Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Sonnet 5.5 01b9b76239 Rename module and SDK import paths for Threshold
Fork of hashicorp/boundary-plugin-aws (MPL-2.0). Module path and Boundary SDK
imports now point at repo.anhonesthost.net/CyberCoveLLC. Adds NOTICE and an
About this fork section to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:28:17 -07:00
Matt Romanelli 4db0decc5c bug(go) gRPC status codes were getting overwritten by the handlers (#94)
* bug(go) gRPC status codes were getting overwritten by the handlers

**Problem**
* If the gRPC handlers received an error with a gRPC status code from checkHosts, it got overwritten by a plain error value.

**Solution**
* Added a check to each checkHosts call that passes on the error received (with its gRPC status)

**Testing**
* Added new unit tests for when the handlers receive an error from checkHosts with a status code.
* Existing unit tests and terraform tests passed
2026-09-29 15:15:40 -05:00
Matt Romanelli 13f9d0a70a feat(tests) Add Terraform testing to support Cross-account discovery (#89)
* feat(tests) Add Terraform testing to support Cross-account discovery

**Problem**
* To support cross-account DHC discovery, we need to add terraform configurations and tests to test against live AWS data.
* However, there was nothing in place to support any AssumeRole testing, irrespective of cross-account.

**Solution**
* Added a new terraform file, iam_assume_role.tf, to contain the new terraform configurations for single-account AssumeRole and cross-role
* Added a second terraform file, iam_cross_account.tf, to contain the new terraform configurations for cross-account
* Refactored how the tests are organized. The previous static test cases used a system where a separate function was created for each test. This was moved to a dedicated file, e2e_host_static_test.go.
* For AssumeRole tests (single account, cross role, and cross account), this system wasn't ideal since there are so many test cases and they are all extremely similar. A table driven system was better that passed in a set of variables to shared test helpers. A dedicated file, e2e_host_assumerole_test.go, was created to contain the testing infrastructure for AssumeRole tests.
* e2e_host_test.go remains the main test runner that then calls e2e_host_assumerole_test.go and e2e_host_static_test.go
* The terraform tests work by setting environment variables for the AWS account that you want to configure the terraform for. The existing setup worked for single-account and cross-role. However, to support cross-account, support had to be added for a second set of credentials. The second set of environment variables can now be set with the TARGET_ prefix. Some configuration in testing.go had to be added to support creating/destroying multiple terraform setups.

**Testing**
* The single account tests pass as expected. However, a bug was discovered during this process that will be addressed later.
* The cross-role and cross-account tests failed as expected because this featured has not yet been delivered.
* It was noticed that some of the existing static tests were flaky. The problem and fix were identified and will be addressed later.
2026-09-29 15:15:40 -05:00
Delaine Rogers 1c87d178a2 fix(host): fix role_arn fallback bug (#82)
* Fix role arn fallthrough bug

* add unit test

* bump awsutil version

* fix tests
2026-09-29 15:15:40 -05:00
Ryan Flynn ca7b5ff389 feat(cli) Update error handling for checkHosts, Target EC2 client logic (#91)
* feat(cli) Update error handling for checkHosts, Target EC2 client logic

* feat(cli) Add UnauthorizedOperation handling to errors.go
2026-09-29 15:15:40 -05:00
rebecca-yohandryanflynn9299 24119fc1a1 feat(host): replace dryRunValidation with checkHosts in catalog and set handlers (ICU-19979) (#87)
* temporary commit

* feat(host): replace dryRunValidation with checkHosts in catalog and set handlers

* refactor(host): clean up comments and fix typos in dry-run validation blocks

* refactor(host): address PR feedback on dry-run validation blocks

* refactor(host): remove dead dryRunValidation function and its tests

* refactor(host): extract dry run error message as a constant

* refactor(host): propagate BadRequestStatusf from buildFilters through buildDescribeInstancesInput

---------

Co-authored-by: ryanflynn9299 <ryan.flynn001@gmail.com>
2026-09-29 15:15:40 -05:00
Ryan Flynn 5607603fbf feat(cli) Implement infra to support cross account AssumeRole for DHC on ListHosts (#86) 2026-09-29 15:15:40 -05:00
Ryan Flynn 1eed21718b feat(cli) Implement parsing of target-prefixed attrs for cross account dynamic host catalogs (#84) 2026-09-29 15:15:40 -05:00
Delaine RogersandDelaine Rogers e5b28b919d feat(host): Allow nil setAttributes for buildDescribeInstancesInput (#79)
* Allow nil setAttributes

* move the nil check

---------

Co-authored-by: Delaine Rogers <delainerogers@dhcp-9-16-190-160.sjs-us.ibm.com>
2026-09-29 15:15:40 -05:00
Matt Romanelli fab6d011c3 Updated the READMEs for cross-account discovery 2026-09-29 15:15:40 -05:00
richardtrinh1 18d10e659a bump golang.org/x/crypto to v0.56.0 (#92) 2026-09-24 10:39:18 -05:00
dependabot[bot] ccd70c45d5 build(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 (#88)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.1 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 15:40:20 -04:00
dependabot[bot] 7814d48854 build(deps): bump google.golang.org/grpc from 1.79.3 to 1.83.1 (#80)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 15:34:05 -04:00
33 changed files with 2180 additions and 752 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
This repository is a community fork of hashicorp/boundary-plugin-aws
(https://github.com/hashicorp/boundary-plugin-aws), maintained for Threshold.
The original work is licensed under the Mozilla Public License 2.0 (see LICENSE).
Original copyright IBM Corp. / HashiCorp, Inc. is retained in all source file
headers and in LICENSE. Modifications are limited to the Go module path and the
import paths of the Boundary SDK/API packages.
This fork is not affiliated with, endorsed by, or supported by HashiCorp or IBM.
+9
View File
@@ -1,3 +1,12 @@
## About this fork
This is a community fork of
[hashicorp/boundary-plugin-aws](https://github.com/hashicorp/boundary-plugin-aws)
for use with Threshold. It is licensed under the MPL-2.0 (see `LICENSE`), and
the original copyright notices are retained. It is not affiliated with,
endorsed by, or supported by HashiCorp or IBM. Changes from upstream are limited
to the Go module path and SDK import paths; see `NOTICE`.
# AWS Plugin for HashiCorp Boundary
This repo contains the aws plugin for [HashiCorp
+11 -11
View File
@@ -1,6 +1,6 @@
module github.com/hashicorp/boundary-plugin-aws
module repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws
go 1.25.7
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.7
@@ -12,15 +12,15 @@ require (
github.com/aws/smithy-go v1.25.1
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/hashicorp/boundary/sdk v0.0.59
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0
github.com/hashicorp/terraform-json v0.23.0
github.com/mitchellh/mapstructure v1.5.0
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.79.3
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
repo.anhonesthost.net/CyberCoveLLC/threshold/sdk v0.1.0
)
require (
@@ -59,11 +59,11 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/zclconf/go-cty v1.15.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+28 -52
View File
@@ -8,12 +8,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE=
github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
@@ -22,42 +18,26 @@ github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgb
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8 h1:1/bT9kDdLQzfZ1e6J6hpW+SfNDd6xrV8F3M2CuGyUz8=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8/go.mod h1:RbdwTONAIi59ej/+1H+QzZORt5bcyAtbrS7FQb2pvz0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0 h1:fTLR6dLDTGChAjecRPlVrKeznT0rVdzR4yn9Z68MTGk=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0/go.mod h1:V0jbRy1/IPapnkqgXSwVOFB+u5pnCwd9S+R3pKWULC4=
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6 h1:EWehQXACWr+6hzfZPwZChlfoVhiUCfLHE0Xh3kAfzWQ=
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6/go.mod h1:qRXgEBWPIltrWHQwU+HkyBvwh1QgeigFcaCGCIVrWk0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8 h1:tIN8MFT1z5STK5kTdOT1TCfMN/bn5fSEnlKsTL8qBOU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8/go.mod h1:VKS56txtNWjKI8FqD/hliL0BcshyF4ZaLBa1rm2Y+5s=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8 h1:AgYCo1Rb8XChJXA871BXHDNxNWOTAr6V5YdsRIBbgv0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8/go.mod h1:Au9dvIGm1Hbqnt29d3VakOCQuN9l0WrkDDTRq8biWS4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2 h1:T7b3qniouutV5Wwa9B1q7gW+Y8s1B3g9RE9qa7zLBIM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2/go.mod h1:tW9TsLb6t1eaTdBE6LITyJW1m/+DjQPU78Q/jT2FJu8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
@@ -66,8 +46,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
@@ -93,8 +71,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/boundary/sdk v0.0.59 h1:XNniO0rwgihLdF1+4qDHKN3gdta3AU1JteZResCJ8Oc=
github.com/hashicorp/boundary/sdk v0.0.59/go.mod h1:VYwVGAEaHTzihRXpzKZL6EhWBhll59Iul3PGLVL63ao=
github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU=
github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -116,8 +92,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2 h1:KlDrZWx/wYpnUcFPB0s9uhPjyNZoeIfhrV2MN/Ly76s=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3 h1:mLe7IMysLSedhuu00W7AXsbfb90AA7UKfZmw3OkJ8n4=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.13 h1:TayxZ5drfMP0G6T++WvnLESGLOWeHtdWDWWTAi2e3Qk=
@@ -195,40 +171,40 @@ github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ
github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+2 -2
View File
@@ -6,8 +6,8 @@ package credential
import (
"fmt"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"google.golang.org/protobuf/types/known/structpb"
)
+27 -2
View File
@@ -15,8 +15,8 @@ import (
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"google.golang.org/protobuf/types/known/structpb"
)
@@ -112,10 +112,35 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (
// and runs sts.GetCallerIdentity for the current credentials, which is done
// to check that the credentials are valid. This method returns a status error
// with PluginError details.
//
// When RoleARN is set and no static key is present (ambient role path),
// credentials are eagerly retrieved before calling GetCallerIdentity. This
// forces sts:AssumeRole to be called immediately so that any role errors
// (e.g. no such role, access denied) surface here rather than being deferred
// to the first real AWS API call.
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.BadRequestStatus("missing credentials config")
}
// For the ambient role path (RoleARN set, no static key), eagerly resolve
// the credential chain so that sts:AssumeRole is called now. Without this,
// the AssumeRoleProvider inside the chain is lazy and GetCallerIdentity
// would succeed even with an invalid role ARN (it uses the mock/ambient
// identity directly rather than the assumed one).
cc := s.CredentialsConfig
if cc.RoleARN != "" && cc.AccessKey == "" {
cfg, err := s.GenerateCredentialChain(ctx)
if err != nil {
st, _ := errors.ParseAWSError("validating credentials", err)
return st.Err()
}
if _, err := cfg.Credentials.Retrieve(ctx); err != nil {
st, _ := errors.ParseAWSError("validating credentials", err)
return st.Err()
}
}
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
st, _ := errors.ParseAWSError("validating credentials", err)
return st.Err()
+26 -6
View File
@@ -18,22 +18,22 @@ import (
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
// AwsErrorAuthFailure is returned by EC2 when credentials exist in IAM
// but have not yet propagated to EC2. This is a transient eventual
// consistency issue that can resolve itself by retrying the request.
AwsErrorAuthFailure = "AuthFailure"
// awsErrorAccessDenied is returned when the credentials do not have
// the required permissions to do a specific action on a resource.
// This error will persist until the IAM Role Policy is updated by
// the owner. This error cannot be resolved by retrying the request.
awsErrorAccessDenied = "AccessDenied"
// awsErrorUnauthorizedOperation is returned by EC2 when the caller is not
// authorized to perform the operation. This includes DryRun permission
// checks that fail (the counterpart to DryRunOperation success).
awsErrorUnauthorizedOperation = "UnauthorizedOperation"
// awsErrorInvalidAccessKeyId is returned when the credential does
// not exist. This error will persist until the credentials attached
// to the storage bucket is updated. This error can occur when the
@@ -71,8 +71,26 @@ const (
// RequestTimeoutException is returned when an http request takes longer than allowed
awsErrorRequestTimeoutException = "RequestTimeoutException"
// dryRunOperationErrorCode is returned by AWS when a DryRun request would have
// succeeded. It is a success signal disguised as an API error (typically HTTP 412).
dryRunOperationErrorCode = "DryRunOperation"
)
// IsDryRunSuccess reports whether err is the AWS DryRunOperation success signal.
// A true result means the dry-run validated credentials, permissions, and parameters;
// it must not be treated as a failure.
func IsDryRunSuccess(err error) bool {
if err == nil {
return false
}
var apiErr smithy.APIError
if !errors.As(err, &apiErr) {
return false
}
return apiErr.ErrorCode() == dryRunOperationErrorCode
}
// InvalidArgumentError returns an grpc invalid argument status error.
func InvalidArgumentError(msg string, f map[string]string) error {
var fieldMsgs []string
@@ -177,6 +195,8 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
switch apiErr.ErrorCode() {
case awsErrorAccessDenied:
fallthrough
case awsErrorUnauthorizedOperation:
fallthrough
case awsErrorInvalidAccessKeyId:
fallthrough
case awsErrorExpiredToken:
+52 -1
View File
@@ -9,7 +9,7 @@ import (
"net/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
@@ -300,6 +300,17 @@ func Test_ParseAWSError(t *testing.T) {
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-request-unauthorized-operation",
err: TestAwsError(awsErrorUnauthorizedOperation, "You are not authorized to perform this operation."),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "You are not authorized to perform this operation.",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-request-invalid-access-key-id",
err: TestAwsError(awsErrorInvalidAccessKeyId, "The AWS Access Key Id you provided does not exist in our records."),
@@ -390,6 +401,46 @@ func Test_ParseAWSError(t *testing.T) {
}
}
func Test_IsDryRunSuccess(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{
name: "nil",
want: false,
},
{
name: "plain error",
err: fmt.Errorf("not an aws error"),
want: false,
},
{
name: "other api error",
err: TestAwsError(awsErrorAccessDenied, "not authorized"),
want: false,
},
{
name: "dry run operation",
err: TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set"),
want: true,
},
{
name: "wrapped dry run operation",
err: fmt.Errorf("describe instances: %w", TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set")),
want: true,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, IsDryRunSuccess(tt.err))
})
}
}
func Test_BadRequestStatusf(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := BadRequestStatusf("test: %s", "hello world")
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
+3 -3
View File
@@ -4,9 +4,9 @@
package plugin
import (
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
"github.com/hashicorp/boundary-plugin-aws/plugin/service/storage"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/host"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/storage"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
)
// Ensure that AWSPlugin implementings the following services:
+3 -3
View File
@@ -6,9 +6,9 @@ package host
import (
"fmt"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
cred "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/values"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
cred "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+110 -98
View File
@@ -5,10 +5,8 @@ package host
import (
"context"
goerrors "errors"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
@@ -16,10 +14,9 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
"github.com/aws/smithy-go"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
@@ -45,8 +42,8 @@ type hostSetQuery struct {
OutputHosts []*pb.ListHostsResponseHost
}
const defaultSessionName = "boundary-default"
const dryRunOperationErrorCode = "DryRunOperation"
const errBuildDescribeInstancesInput = "error building DescribeInstances input: %s"
const errDryRunFailed = "error performing DescribeInstances dry run: %s"
// Ensure that we are implementing HostPluginServiceServer
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
@@ -89,6 +86,15 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
}
}
// Validate assumed-role credentials at catalog-creation time so that an
// invalid role_arn is rejected immediately rather than silently falling
// back to ambient credentials.
if credential.GetCredentialType(credState.CredentialsConfig) == credential.DynamicAWS {
if err := credState.ValidateCreds(ctx); err != nil {
return nil, err
}
}
catalogState, err := newAwsCatalogPersistedState(
append([]awsCatalogPersistedStateOption{
withCredentials(credState),
@@ -98,13 +104,21 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf(errDryRunFailed, err)
}
persistedProto, err := catalogState.toProto()
@@ -179,6 +193,15 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
if err != nil {
return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err)
}
// Validate assumed-role credentials when the role_arn changes so that
// an invalid ARN is caught before replacing the existing credential state.
if credential.GetCredentialType(newCredState.CredentialsConfig) == credential.DynamicAWS {
if err := newCredState.ValidateCreds(ctx); err != nil {
return nil, err
}
}
newCatalogState, err := newAwsCatalogPersistedState(
append([]awsCatalogPersistedStateOption{
withCredentials(newCredState),
@@ -187,12 +210,22 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
if err != nil {
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, newCatalogState, opts); st != nil {
return nil, st.Err()
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, newCatalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf(errDryRunFailed, err)
}
// Replace the existing credential state.
@@ -232,13 +265,21 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf(errDryRunFailed, err)
}
persistedProto, err := catalogState.toProto()
@@ -382,18 +423,23 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
return nil, err
}
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf(errDryRunFailed, err)
}
return &pb.OnCreateSetResponse{}, nil
}
@@ -447,17 +493,21 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
return nil, err
}
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
}
// Run a DescribeInstances request with DryRun set to true to ensure
// we can interact with AWS resources as expected.
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf(errDryRunFailed, err)
}
return &pb.OnUpdateSetResponse{}, nil
@@ -546,10 +596,14 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
for i, query := range queries {
output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
if err != nil {
if catalogAttributes.TargetAccount != nil {
return nil, errors.BadRequestStatusf("unable to list hosts for host set %q: %s", query.Id, err)
// Mapped AWS failures are already gRPC statuses from checkHosts.
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, errors.BadRequestStatusf("error retrieving host results for host set id %q: %s", query.Id, err)
if catalogAttributes.TargetAccount != nil {
return nil, status.Errorf(codes.Unknown, "unable to list hosts for host set %q: %v", query.Id, err)
}
return nil, status.Errorf(codes.Unknown, "error retrieving host results for host set id %q: %v", query.Id, err)
}
queries[i].Output = output
@@ -563,7 +617,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
for _, instance := range reservation.Instances {
host, err := awsInstanceToHost(instance, catalogAttributes)
if err != nil {
return nil, errors.BadRequestStatusf("error processing host results for host set id %q: %s", query.Id, err)
return nil, status.Errorf(codes.Unknown, "error processing host results for host set id %q: %v", query.Id, err)
}
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
@@ -612,11 +666,11 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
splitFilter := strings.Split(filterAttr, "=")
switch {
case len(splitFilter) != 2:
return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr)
return nil, errors.BadRequestStatusf("expected filter %q to contain a single equal sign", filterAttr)
case len(splitFilter[0]) == 0:
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
return nil, errors.BadRequestStatusf("filter %q contains an empty filter key", filterAttr)
case len(splitFilter[1]) == 0:
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
return nil, errors.BadRequestStatusf("filter %q contains an empty value", filterAttr)
}
filterKey, filterValue := splitFilter[0], splitFilter[1]
@@ -645,9 +699,13 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
}
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
if attrs == nil {
attrs = &SetAttributes{}
}
filters, err := buildFilters(attrs)
if err != nil {
return nil, fmt.Errorf("error building filters: %w", err)
return nil, err
}
return &ec2.DescribeInstancesInput{
@@ -734,25 +792,31 @@ func checkHosts(ctx context.Context,
// use the principal account to execute the DescribeInstances command
ec2Client, err = principalCatalogState.EC2Client(ctx, opts...)
if err != nil {
if st, _ := errors.ParseAWSError("EC2 client setup", err); st.Code() != codes.Unknown {
return nil, st.Err()
}
return nil, err
}
} else {
// use the target account to execute the DescribeInstances command
ec2Client, err = ec2ClientForTarget(ctx, principalCatalogState, target, opts)
if err != nil {
if _, ok := status.FromError(err); ok {
return nil, err
}
return nil, fmt.Errorf("EC2 client setup failed for target account: %w", err)
}
}
output, err := ec2Client.DescribeInstances(ctx, input)
if err != nil {
var awsErr smithy.APIError
ok := goerrors.As(err, &awsErr)
if ok && awsErr.ErrorCode() == dryRunOperationErrorCode {
// successful DryRun validation, no response necessary, no error thrown
if errors.IsDryRunSuccess(err) {
// DryRunOperation: request would have succeeded; not a failure.
return nil, nil
}
if st, _ := errors.ParseAWSError("DescribeInstances", err); st.Code() != codes.Unknown {
return nil, st.Err()
}
if target != nil {
return nil, fmt.Errorf("EC2 DescribeInstances failed for target account: %w", err)
}
@@ -775,6 +839,9 @@ func ec2ClientForTarget(
// principal identity).
principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx)
if err != nil {
if st, _ := errors.ParseAWSError("EC2 client setup for target account", err); st.Code() != codes.Unknown {
return nil, st.Err()
}
return nil, fmt.Errorf("principal AWS configuration could not be loaded: %w", err)
}
if principalCfg == nil {
@@ -788,9 +855,6 @@ func ec2ClientForTarget(
provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) {
if target.RoleSessionName != "" {
o.RoleSessionName = target.RoleSessionName
} else {
// STS may reject the AssumeRole request if session name is not provided.
o.RoleSessionName = defaultSessionName
}
if target.RoleExternalId != "" {
o.ExternalID = &target.RoleExternalId
@@ -829,58 +893,6 @@ func ec2ClientForTarget(
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
}
// dryRunValidationTimeout is the maximum time to wait for credentials to
// propagate across AWS services after rotation before giving up.
const dryRunValidationTimeout = 30 * time.Second
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
// credentials, the host listing functionality as well as the filters, if any
// are passed in. This function can therefore be used for both host catalog and
// host set validation.
//
// After credential rotation, IAM keys may be valid for STS (GetCallerIdentity)
// but not yet propagated to EC2. This function retries on AuthFailure to
// tolerate that eventual consistency window.
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
if state == nil {
return status.New(codes.InvalidArgument, "persisted state is required")
}
ec2Client, err := state.EC2Client(ctx, ec2Opts...)
if err != nil {
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
}
deadline := time.Now().Add(dryRunValidationTimeout)
delay := time.Second
for {
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
if err == nil {
return nil
}
// AuthFailure indicates the credentials exist but haven't propagated
// to EC2 yet — a transient eventual consistency issue after rotation.
// Retry until the deadline.
if strings.Contains(err.Error(), errors.AwsErrorAuthFailure) && time.Now().Before(deadline) {
select {
case <-time.After(delay):
// Wait before retrying to give credentials time to propagate.
case <-ctx.Done():
// The caller cancelled — break out of the select.
break
}
if ctx.Err() != nil {
break
}
continue
}
break
}
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
}
// appendDistinct will append the elements to the slice
// if an element is not nil, empty, and does not exist in slice.
func appendDistinct(slice []string, elems ...*string) []string {
+434 -141
View File
@@ -16,12 +16,14 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
pluginErrors "github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
awserrors "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostcatalogs"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostsets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
@@ -29,6 +31,38 @@ import (
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/structpb"
)
// testRoleARN is a syntactically valid ARN used for role_arn test cases.
// All STS calls against it are handled by injected mocks — no real AWS
// account is contacted.
const testRoleARN = "arn:aws:iam::123456789012:role/BoundaryTestRole"
// validAssumeRoleOutput returns a mock AssumeRoleOutput with synthetic
// temporary credentials for use with WithSTSAPIFunc.
func validAssumeRoleOutput() *sts.AssumeRoleOutput {
return &sts.AssumeRoleOutput{
Credentials: &stsTypes.Credentials{
AccessKeyId: aws.String("ASIAmockassumedkey"),
SecretAccessKey: aws.String("mockassumedsecret"),
SessionToken: aws.String("mockassumedsessiontoken"),
Expiration: aws.Time(time.Now().Add(time.Hour)),
},
}
}
// catalogAttrsStruct builds a catalog attributes structpb for the given
// region. roleArn is written only when non-empty. disableRotation must be
// set explicitly to avoid implicit rotation behaviour in tests.
func catalogAttrsStruct(region, roleArn string, disableRotation bool) *structpb.Struct {
fields := map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue(region),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(disableRotation),
}
if roleArn != "" {
fields[credential.ConstRoleArn] = structpb.NewStringValue(roleArn)
}
return &structpb.Struct{Fields: fields}
}
func TestPluginOnCreateCatalogSuccess(t *testing.T) {
tests := []struct {
@@ -139,17 +173,13 @@ func TestPluginOnCreateCatalogSuccess(t *testing.T) {
},
},
{
// role_arn set, no static keys. AssumeRole mock succeeds.
// Persisted secrets are empty — dynamic credentials are not stored.
name: "usingAssumeRole",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-east-1"),
credential.ConstRoleArn: structpb.NewStringValue("arn:0123:test:rolearn"),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
},
},
Attributes: catalogAttrsStruct("us-east-1", testRoleARN, true),
},
},
},
@@ -159,6 +189,13 @@ func TestPluginOnCreateCatalogSuccess(t *testing.T) {
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
awsutil.WithAssumeRoleOutput(validAssumeRoleOutput()),
)),
}),
},
expRsp: &pb.OnCreateCatalogResponse{Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}}},
},
}
@@ -316,6 +353,20 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) {
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{
Credentials: &stsTypes.Credentials{
AccessKeyId: aws.String("ASIAfoobar"),
SecretAccessKey: aws.String("secretkeyfoobar"),
SessionToken: aws.String("sessiontokenfoobar"),
Expiration: aws.Time(time.Now().Add(time.Hour)),
},
}),
)),
}),
},
expRsp: &pb.OnUpdateCatalogResponse{
Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}},
},
@@ -422,6 +473,16 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) {
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithIAMAPIFunc(awsutil.NewMockIAM()),
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{
Credentials: &stsTypes.Credentials{
AccessKeyId: aws.String("ASIAfoobar"),
SecretAccessKey: aws.String("secretkeyfoobar"),
SessionToken: aws.String("sessiontokenfoobar"),
Expiration: aws.Time(time.Now().Add(time.Hour)),
},
}),
)),
}),
},
expRsp: &pb.OnUpdateCatalogResponse{
@@ -743,8 +804,72 @@ func TestPluginOnCreateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
// checkHosts returns the DescribeInstances error as-is rather than
// wrapping it in a gRPC FailedPrecondition status like the old
// dryRunValidation did. A plain Go error maps to codes.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
},
{
name: "dry run status error preserves gRPC code",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
},
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
// checkHosts wraps the AWS error via ParseAWSError into a gRPC
// PermissionDenied status. OnCreateCatalog's dry-run error handler
// should preserve that status code, not re-wrap it as Unknown.
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
{
// BUG FIX: role_arn set, no static keys. AssumeRole mock returns
// an error. ValidateCreds now eagerly calls Retrieve() which forces
// sts:AssumeRole, so the error surfaces here at creation time.
// Previously this succeeded silently via the ambient credential chain.
name: "role_arn AssumeRole error — correctly rejected",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", testRoleARN, true),
},
},
},
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
awsutil.WithAssumeRoleError(errors.New("simulated AssumeRole failure")),
)),
}),
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
expectedErrContains: "simulated AssumeRole failure",
expectedErrCode: codes.Unknown,
},
}
@@ -995,8 +1120,11 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
//checkHosts returns the DesribeInstances errror as-is rather than wrapping it
// in a gRpc FailedPrecodndition status like the old dryRunValidiation did.
// A plain Go error maps to codeds.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
},
{
name: "new incoming dynamic credential update dry run error",
@@ -1031,14 +1159,27 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
},
},
},
// ValidateCreds is called for DynamicAWS credentials before the dry-run
// DescribeInstances. Provide a mock STS that returns a successful AssumeRole
// so the validate step passes and execution reaches the EC2 mock.
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(
awsutil.WithAssumeRoleOutput(validAssumeRoleOutput()),
)),
}),
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: "aws describe instances failed: DescribeInstances error",
expectedErrCode: codes.FailedPrecondition,
// checkHosts returns the DescribeInstances error as-is rather than wrapping it
// in a gRPC FailedPrecondition status like the old dryRunValidiation did.
// A plain Go error maps to codes.Unknown.
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
},
{
name: "replace creds error",
@@ -1212,8 +1353,53 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(fmt.Errorf("oops there was an error")),
)),
},
expectedErrContains: "aws describe instances failed: oops there was an error",
expectedErrCode: codes.FailedPrecondition,
// checkHosts returns the DescribeInstances error as-is rather than wrapping it
// in a gRPC FailedPrecondition status like the old dryRunValidiation did.
// A plain Go error maps to codes.Unknown.
expectedErrContains: "oops there was an error",
expectedErrCode: codes.Unknown,
},
{
name: "final dry run gRPC status error preserves code",
req: &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
},
},
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
},
},
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
// error handler should preserve that gRPC status, not wrap it as Unknown.
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
}
@@ -1566,8 +1752,57 @@ func TestPluginOnCreateSetErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
expectedErrCode: codes.FailedPrecondition,
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
},
{
name: "dry run gRPC status error preserves code",
req: &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
},
},
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
},
Set: &hostsets.HostSet{
Id: "foobar",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstDescribeInstancesFilters: structpb.NewListValue(
&structpb.ListValue{
Values: []*structpb.Value{
structpb.NewStringValue("tag-key=foo"),
},
},
),
},
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
// error handler should preserve that gRPC status, not wrap it as Unknown.
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
}
@@ -1829,8 +2064,56 @@ func TestPluginOnUpdateSetErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: fmt.Sprintf("aws describe instances failed: %s", testDescribeInstancesError),
expectedErrCode: codes.FailedPrecondition,
expectedErrContains: testDescribeInstancesError,
expectedErrCode: codes.Unknown,
},
{
name: "dry run gRPC status error preserves code",
req: &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true),
},
},
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstDescribeInstancesFilters: structpb.NewListValue(
&structpb.ListValue{
Values: []*structpb.Value{
structpb.NewStringValue("tag-key=foo"),
},
},
),
},
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
// ParseAWSError maps AccessDenied to PermissionDenied. The dry-run
// error handler should preserve that gRPC status, not wrap it as Unknown.
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
}
@@ -2288,7 +2571,7 @@ func TestPluginListHostsErr(t *testing.T) {
)),
},
expectedErrContains: fmt.Sprintf("error retrieving host results for host set id \"foobar\": %s", testDescribeInstancesError),
expectedErrCode: codes.InvalidArgument,
expectedErrCode: codes.Unknown,
},
{
name: "target attributes missing target_role_arn",
@@ -2354,7 +2637,7 @@ func TestPluginListHostsErr(t *testing.T) {
}),
},
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: test EC2 client error",
expectedErrCode: codes.InvalidArgument,
expectedErrCode: codes.Unknown,
},
{
name: "target DescribeInstances error",
@@ -2382,7 +2665,7 @@ func TestPluginListHostsErr(t *testing.T) {
)),
},
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 DescribeInstances failed for target account: DescribeInstances error",
expectedErrCode: codes.InvalidArgument,
expectedErrCode: codes.Unknown,
},
{
name: "awsInstanceToHost error",
@@ -2443,7 +2726,115 @@ func TestPluginListHostsErr(t *testing.T) {
)),
},
expectedErrContains: "error processing host results for host set id \"foobar\": response integrity error: missing instance id",
expectedErrCode: codes.InvalidArgument,
expectedErrCode: codes.Unknown,
},
{
name: "DescribeInstances AccessDenied",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
},
},
},
},
Persisted: testListHostsPersisted(),
Sets: []*hostsets.HostSet{
testListHostsSet("foobar", "tag-key=foo"),
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
{
name: "DescribeInstances UnauthorizedOperation",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
},
},
},
},
Persisted: testListHostsPersisted(),
Sets: []*hostsets.HostSet{
testListHostsSet("foobar", "tag-key=foo"),
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("UnauthorizedOperation", "You are not authorized to perform this operation.")),
)),
},
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
{
name: "target DescribeInstances AccessDenied",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
},
},
},
},
Persisted: testListHostsPersisted(),
Sets: []*hostsets.HostSet{
testListHostsSet("foobar", "tag-key=foo"),
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesError(awserrors.TestAwsError("AccessDenied", "not authorized to perform ec2:DescribeInstances")),
)),
},
expectedErrContains: "invalid credentials",
expectedErrCode: codes.PermissionDenied,
},
{
name: "target EC2 client AccessDenied",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
},
},
},
},
Persisted: testListHostsPersisted(),
Sets: []*hostsets.HostSet{
testListHostsSet("foobar", "tag-key=foo"),
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(func(...aws.Config) (EC2API, error) {
return nil, awserrors.TestAwsError("AccessDenied", "not authorized to assume role")
}),
},
// ec2ClientForTarget returns testEC2APIFunc errors unparsed; checkHosts
// only preserves existing gRPC statuses, so this surfaces as Unknown.
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: api error AccessDenied: not authorized to assume role",
expectedErrCode: codes.Unknown,
},
}
@@ -2634,6 +3025,21 @@ func TestBuildDescribeInstancesInput(t *testing.T) {
require.Equal(tc.expected, actual)
})
}
t.Run("nil attrs, dry run", func(t *testing.T) {
require := require.New(t)
actual, err := buildDescribeInstancesInput(nil, true)
require.NoError(err)
require.Equal(&ec2.DescribeInstancesInput{
DryRun: aws.Bool(true),
Filters: []types.Filter{
{
Name: aws.String("instance-state-name"),
Values: []string{string(types.InstanceStateNameRunning)},
},
},
}, actual)
})
}
func TestAwsInstanceToHost(t *testing.T) {
@@ -3072,116 +3478,3 @@ func TestAppendDistinct(t *testing.T) {
})
}
}
func TestDryRunValidation(t *testing.T) {
t.Run("nil credential state", func(t *testing.T) {
st := dryRunValidation(context.Background(), nil, nil)
require.NotNil(t, st)
require.Equal(t, codes.InvalidArgument.String(), st.Code().String())
require.Equal(t, "persisted state is required", st.Message())
})
t.Run("ec2ClientErr", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{},
},
testEC2APIFunc: func(...aws.Config) (EC2API, error) {
return nil, fmt.Errorf("oops ec2 client err")
},
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.InvalidArgument.String(), st.Code().String())
require.Equal(t, "error getting EC2 client: oops ec2 client err", st.Message())
})
t.Run("describeInstancesErr", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(nil, testMockEC2WithDescribeInstancesError(fmt.Errorf("oops describe instances error"))),
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
require.Equal(t, "aws describe instances failed: oops describe instances error", st.Message())
})
t.Run("success", func(t *testing.T) {
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{})),
}, []ec2Option{})
require.Nil(t, st)
})
t.Run("authFailureThenSuccess", func(t *testing.T) {
// Simulates the IAM eventual consistency window: EC2 returns AuthFailure
// twice before the credentials propagate and the call succeeds.
state := &testMockEC2State{}
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(state,
testMockEC2WithDescribeInstancesErrorSequence(
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure),
),
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
),
}, []ec2Option{})
require.Nil(t, st)
require.Equal(t, 3, state.DescribeInstancesCallCount)
})
t.Run("authFailureContextCancelled", func(t *testing.T) {
// If the context is cancelled while waiting to retry, the function should
// return a FailedPrecondition error immediately rather than hanging.
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately so the first retry select hits ctx.Done()
st := dryRunValidation(ctx, &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(nil,
testMockEC2WithDescribeInstancesError(fmt.Errorf("%s: credentials not yet propagated", pluginErrors.AwsErrorAuthFailure)),
),
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
})
t.Run("nonAuthFailureNoRetry", func(t *testing.T) {
// A non-AuthFailure error should fail immediately without retrying.
state := &testMockEC2State{}
st := dryRunValidation(context.Background(), &awsCatalogPersistedState{
AwsCredentialPersistedState: &credential.AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoo",
SecretKey: "baz",
},
},
testEC2APIFunc: newTestMockEC2(state,
testMockEC2WithDescribeInstancesError(fmt.Errorf("UnauthorizedOperation: access denied")),
),
}, []ec2Option{})
require.NotNil(t, st)
require.Equal(t, codes.FailedPrecondition.String(), st.Code().String())
require.Equal(t, 1, state.DescribeInstancesCallCount)
})
}
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"google.golang.org/protobuf/types/known/structpb"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"context"
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
)
-20
View File
@@ -43,10 +43,6 @@ type testMockEC2 struct {
DescribeInstancesOutput *ec2.DescribeInstancesOutput
DescribeInstancesError error
// DescribeInstancesErrorSequence, if set, returns each error in order on
// successive calls. Once exhausted, DescribeInstancesOutput is returned.
DescribeInstancesErrorSequence []error
}
type testMockEC2Option func(m *testMockEC2) error
@@ -65,16 +61,6 @@ func testMockEC2WithDescribeInstancesError(e error) testMockEC2Option {
}
}
// testMockEC2WithDescribeInstancesErrorSequence sets a sequence of errors to
// return on successive DescribeInstances calls. Once the sequence is exhausted,
// the mock returns DescribeInstancesOutput (nil error).
func testMockEC2WithDescribeInstancesErrorSequence(errs ...error) testMockEC2Option {
return func(m *testMockEC2) error {
m.DescribeInstancesErrorSequence = errs
return nil
}
}
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
return func(cfgs ...aws.Config) (EC2API, error) {
m := &testMockEC2{
@@ -110,12 +96,6 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
}
if len(m.DescribeInstancesErrorSequence) > 0 {
err := m.DescribeInstancesErrorSequence[0]
m.DescribeInstancesErrorSequence = m.DescribeInstancesErrorSequence[1:]
return nil, err
}
if m.DescribeInstancesError != nil {
return nil, m.DescribeInstancesError
}
+3 -3
View File
@@ -6,9 +6,9 @@ package storage
import (
"fmt"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
cred "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/values"
"google.golang.org/protobuf/types/known/structpb"
)
+2 -2
View File
@@ -6,8 +6,8 @@ package storage
import (
"testing"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
cred "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
+2 -2
View File
@@ -4,8 +4,8 @@
package storage
import (
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
+2 -2
View File
@@ -6,8 +6,8 @@ package storage
import (
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
+4 -4
View File
@@ -21,10 +21,10 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/google/uuid"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
cred "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/storagebuckets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/go-secure-stdlib/parseutil"
"google.golang.org/grpc/codes"
+5 -5
View File
@@ -23,11 +23,11 @@ import (
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/aws/aws-sdk-go-v2/service/sts/types"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
internal "github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
"github.com/hashicorp/boundary/sdk/pbs/plugin"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
internal "repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/errors"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/storagebuckets"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+2 -2
View File
@@ -16,8 +16,8 @@ import (
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/storagebuckets"
"google.golang.org/protobuf/types/known/structpb"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"context"
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
)
+3 -3
View File
@@ -17,9 +17,9 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/aws/smithy-go"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/storagebuckets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
+304
View File
@@ -0,0 +1,304 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
// This file contains table-driven test infrastructure for AssumeRole and
// cross-role (two-hop) Dynamic Host Catalog scenarios. Each plugin operation
// has a corresponding case type and runner function. The orchestrator in
// e2e_host_test.go populates the case tables using the Terraform outputs.
import (
"context"
"fmt"
"strings"
"testing"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/host"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostcatalogs"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostsets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// ---------------------------------------------------------------------------
// Attr builders
// ---------------------------------------------------------------------------
// roleArnAttrs builds catalog attributes for a single-hop AssumeRole catalog.
func roleArnAttrs(region, roleArn string) map[string]any {
return map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
credential.ConstRoleArn: roleArn,
}
}
// crossRoleAttrs builds catalog attributes for a two-hop cross-role catalog.
func crossRoleAttrs(region, principalRoleArn, targetRoleArn string) map[string]any {
return map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
credential.ConstRoleArn: principalRoleArn,
credential.ConstTargetRoleArn: targetRoleArn,
}
}
// tagFilterSetAttrs builds set attributes with a single tag-key filter.
func tagFilterSetAttrs(tags []string) map[string]any {
return map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
}
}
// requireTagInstancesMatchProvisioned asserts that every instance ID in tagInstancesMap
// is present in provisionedIds (the raw []any slice from a Terraform output).
func requireTagInstancesMatchProvisioned(t *testing.T, provisionedIds []any, tagInstancesMap map[string][]string) {
t.Helper()
idSet := make(map[string]struct{}, len(provisionedIds))
for _, id := range provisionedIds {
idSet[id.(string)] = struct{}{}
}
for tag, ids := range tagInstancesMap {
for _, id := range ids {
require.Contains(t, idSet, id, "instance ID %s in tag bucket %q was not provisioned by Terraform", id, tag)
}
}
}
// buildExpectedTagInstancesMap builds a map of tag key to instance IDs from a Terraform instance_tags output map.
func buildExpectedTagInstancesMap(rawTags map[string]any, expectedTags []string) map[string][]string {
tagMap := make(map[string][]string)
for instanceId, instanceTags := range rawTags {
for tagKey := range instanceTags.(map[string]any) {
for _, expectedTag := range expectedTags {
if tagKey == expectedTag {
tagMap[tagKey] = append(tagMap[tagKey], instanceId)
}
}
}
}
return tagMap
}
// ---------------------------------------------------------------------------
// Table-driven runners
// ---------------------------------------------------------------------------
type onCreateCatalogCase struct {
name string
catalogAttrs map[string]any
wantErr string // empty = expect success with empty persisted secrets
}
func testOnCreateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onCreateCatalogCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
attrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
resp, err := p.OnCreateCatalog(ctx, &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: attrs},
},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
// Dynamic credentials are never stored — persisted secrets should be empty.
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
})
}
}
type onUpdateCatalogCase struct {
name string
catalogAttrs map[string]any // used for both current and new when currentAttrs/newAttrs are nil
currentAttrs map[string]any // optional: override current catalog attrs
newAttrs map[string]any // optional: override new catalog attrs
wantErr string
}
func testOnUpdateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onUpdateCatalogCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
currentRaw := tc.catalogAttrs
if tc.currentAttrs != nil {
currentRaw = tc.currentAttrs
}
newRaw := tc.catalogAttrs
if tc.newAttrs != nil {
newRaw = tc.newAttrs
}
currentAttrs, err := structpb.NewStruct(currentRaw)
require.NoError(err)
newAttrs, err := structpb.NewStruct(newRaw)
require.NoError(err)
resp, err := p.OnUpdateCatalog(ctx, &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: currentAttrs},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: newAttrs},
},
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
// Dynamic credentials are never stored — persisted secrets should be empty.
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
})
}
}
type onSetCase struct {
name string
catalogAttrs map[string]any
tags []string // nil = no filter (empty set attrs)
wantErr string
}
func testOnCreateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
if len(tc.tags) > 0 {
setRaw = tagFilterSetAttrs(tc.tags)
}
setAttrs, err := structpb.NewStruct(setRaw)
require.NoError(err)
resp, err := p.OnCreateSet(ctx, &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
Set: &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}},
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
})
}
}
func testOnUpdateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
if len(tc.tags) > 0 {
setRaw = tagFilterSetAttrs(tc.tags)
}
setAttrs, err := structpb.NewStruct(setRaw)
require.NoError(err)
set := &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}}
resp, err := p.OnUpdateSet(ctx, &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
CurrentSet: set,
NewSet: set,
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
})
}
}
type listHostsCase struct {
name string
catalogAttrs map[string]any
tags []string
expected map[string][]string // nil = don't assert instances (error cases)
wantErr string
}
func testListHostsCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []listHostsCase) {
t.Helper()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Helper()
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
require.NoError(err)
sets := make([]*hostsets.HostSet, len(tc.tags))
for i, tag := range tc.tags {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}
}
resp, err := p.ListHosts(ctx, &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{},
})
if tc.wantErr != "" {
require.Error(err)
st, ok := status.FromError(err)
require.True(ok)
require.Contains(st.Message(), tc.wantErr)
return
}
require.NoError(err)
require.NotNil(resp)
expectedInstances := make(map[string][]string)
for i, tag := range tc.tags {
for _, instanceId := range tc.expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
actualInstances := make(map[string][]string)
for _, h := range resp.GetHosts() {
actualInstances[h.ExternalId] = h.SetIds
}
require.Equal(expectedInstances, actualInstances)
t.Logf("success: %d hosts matched", len(actualInstances))
})
}
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
// This file contains helper functions for the static-credential (access key /
// secret access key) test paths exercised by TestHostPlugin in e2e_host_test.go.
import (
"context"
"fmt"
"strings"
"testing"
"time"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/host"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostcatalogs"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/hostsets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
t.Helper()
t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context, t *testing.T, p *host.HostPlugin,
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
rotated, rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqNewAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
var reqSecrets *structpb.Struct
if newAccessKeyId != "" && newSecretAccessKey != "" {
reqSecrets, err = structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: newAccessKeyId,
credential.ConstSecretAccessKey: newSecretAccessKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: currentAccessKeyId,
credential.ConstSecretAccessKey: currentSecretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
}
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) {
t.Helper()
t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return time.Now().Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, accessKeyId, secretAccessKey)
}
}
func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) {
t.Helper()
t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
createRequest := &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) {
t.Helper()
t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
sets := make([]*hostsets.HostSet, len(tags))
for i, tag := range tags {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, tag := range tags {
for _, instanceId := range expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
}
func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing testInstanceAddressesOnlyListHosts")
// Get the instance details from tf output
instanceID, err := tf.GetOutputString("multi_address_instance_id")
require.NoError(t, err)
primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses")
require.NoError(t, err)
secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses")
require.NoError(t, err)
var primary, allAddresses []string
for _, addr := range primaryAddresses {
primary = append(primary, addr.(string))
}
allAddresses = append(allAddresses, primary...)
for _, addr := range secondaryAddresses {
allAddresses = append(allAddresses, addr.(string))
}
cases := []struct {
name string
instanceAddressesOnly bool
expectedIPs []string
}{
{
name: "all addresses",
expectedIPs: allAddresses,
},
{
name: "instance addresses only",
instanceAddressesOnly: true,
expectedIPs: primary,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
catalogAttrMap := map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
}
if tc.instanceAddressesOnly {
catalogAttrMap[host.ConstInstanceAddressesOnly] = true
}
catalogAttrs, err := structpb.NewStruct(catalogAttrMap)
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: []*hostsets.HostSet{{
Id: "hostset-0",
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.Len(response.GetHosts(), 1)
hostResp := response.GetHosts()[0]
require.Equal(instanceID, hostResp.ExternalId)
require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs)
})
}
}
+172 -369
View File
@@ -3,21 +3,24 @@
package testing
// This file contains the TestHostPlugin orchestrator. It deploys the Terraform
// workspace, collects outputs, and calls the per-credential-type helper
// functions defined in e2e_host_static_test.go and e2e_host_assumerole_test.go.
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/host"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
const (
envTargetAccessKeyId = "TARGET_AWS_ACCESS_KEY_ID"
envTargetSecretAccessKey = "TARGET_AWS_SECRET_ACCESS_KEY"
envTargetSessionToken = "TARGET_AWS_SESSION_TOKEN"
envTargetRegion = "TARGET_AWS_REGION"
)
const expectedEc2InstanceCount = 5
@@ -35,7 +38,29 @@ func TestHostPlugin(t *testing.T) {
}
require := require.New(t)
tf, err := NewTestTerraformer("testdata/host")
// Collect cross-account credentials up front so they can be passed as
// Terraform variables. The cross-account section is skipped (not failed)
// when these are absent, but we need the values available before Deploy.
targetAccessKeyId := os.Getenv(envTargetAccessKeyId)
targetSecretAccessKey := os.Getenv(envTargetSecretAccessKey)
targetSessionToken := os.Getenv(envTargetSessionToken)
targetRegion := os.Getenv(envTargetRegion)
if targetRegion == "" {
targetRegion = region
}
tfVars := map[string]string{}
if targetAccessKeyId != "" && targetSecretAccessKey != "" {
tfVars["target_access_key_id"] = targetAccessKeyId
tfVars["target_secret_access_key"] = targetSecretAccessKey
tfVars["target_region"] = targetRegion
if targetSessionToken != "" {
tfVars["target_session_token"] = targetSessionToken
}
}
tf, err := NewTestTerraformer("testdata/host", tfVars)
require.NoError(err)
require.NotNil(tf)
@@ -137,16 +162,9 @@ func TestHostPlugin(t *testing.T) {
// Reassign the keyid and secret first.
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
// Process the collection of instances and index by expected tag names.
expectedTagInstancesMap := make(map[string][]string)
for instanceId, instanceTags := range ec2InstanceTags {
for tagKey := range instanceTags.(map[string]any) {
for _, expectedTag := range expectedTags {
if tagKey == expectedTag {
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
}
}
}
}
expectedTagInstancesMap := buildExpectedTagInstancesMap(ec2InstanceTags, expectedTags)
requireTagInstancesMatchProvisioned(t, ec2InstanceIds, expectedTagInstancesMap)
cases := [][]string{
{expectedTags[0]},
@@ -168,377 +186,162 @@ func TestHostPlugin(t *testing.T) {
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
}
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
t.Helper()
t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate)
require := require.New(t)
// ********************
// * AssumeRole - Happy Path & Error Cases
// ********************
assumeRoleArn, err := tf.GetOutputString("assume_role_arn")
require.NoError(err)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
noTrustRoleArn, err := tf.GetOutputString("assume_role_no_trust_arn")
require.NoError(err)
missingPermRoleArn, err := tf.GetOutputString("assume_role_no_ec2_permission_arn")
require.NoError(err)
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", currentAttrs: roleArnAttrs(region, assumeRoleArn), newAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context, t *testing.T, p *host.HostPlugin,
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
rotated, rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqNewAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
var reqSecrets *structpb.Struct
if newAccessKeyId != "" && newSecretAccessKey != "" {
reqSecrets, err = structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: newAccessKeyId,
credential.ConstSecretAccessKey: newSecretAccessKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: currentAccessKeyId,
credential.ConstSecretAccessKey: currentSecretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
}
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) {
t.Helper()
t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated)
require := require.New(t)
// ********************
// * Cross-Role AssumeRole - Happy Path & Error Cases
// ********************
// These tests exercise the two-hop role chain within a single AWS account.
// The principal role has no ec2:DescribeInstances; the target role
// (assume_role) does. The plugin must assume the principal first, then use
// those credentials to assume the target role before calling DescribeInstances.
// This validates the plugin's chained credential logic without requiring a
// second AWS account.
crossRolePrincipalArn, err := tf.GetOutputString("cross_role_principal_arn")
require.NoError(err)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotated,
crossRolePrincipalNoTrustArn, err := tf.GetOutputString("cross_role_principal_no_trust_arn")
require.NoError(err)
crossRoleTargetNoTrustArn, err := tf.GetOutputString("cross_role_target_no_trust_arn")
require.NoError(err)
crossRoleTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_role_target_no_ec2_permission_arn")
require.NoError(err)
// assume_role_arn is reused as the cross-role target — it has ec2:DescribeInstances
// and its trust policy allows the cross-role principal to assume it.
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return time.Now().Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
// ********************
// * Cross-Account AssumeRole - Happy Path & Error Cases
// ********************
// These tests require credentials for a second AWS account supplied via
// TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for
// temporary credentials) TARGET_AWS_SESSION_TOKEN. They are skipped
// (not failed) when the access key ID or secret access key are absent.
if targetAccessKeyId == "" || targetSecretAccessKey == "" {
t.Log("skipping cross-account tests: set TARGET_AWS_ACCESS_KEY_ID and TARGET_AWS_SECRET_ACCESS_KEY to enable")
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, accessKeyId, secretAccessKey)
}
}
func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) {
t.Helper()
t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
createRequest := &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) {
t.Helper()
t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
sets := make([]*hostsets.HostSet, len(tags))
for i, tag := range tags {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
})
crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn")
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, tag := range tags {
for _, instanceId := range expected[tag] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn")
require.NoError(err)
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn")
require.NoError(err)
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
}
crossAccountTargetNoTrustArn, err := tf.GetOutputString("cross_account_target_no_trust_arn")
require.NoError(err)
func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing testInstanceAddressesOnlyListHosts")
crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn")
require.NoError(err)
// Get the instance details from tf output
instanceID, err := tf.GetOutputString("multi_address_instance_id")
require.NoError(t, err)
primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses")
require.NoError(t, err)
secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses")
require.NoError(t, err)
targetEc2InstanceIds, err := tf.GetOutputSlice("target_instance_ids")
require.NoError(err)
require.Len(targetEc2InstanceIds, expectedEc2InstanceCount)
var primary, allAddresses []string
for _, addr := range primaryAddresses {
primary = append(primary, addr.(string))
}
allAddresses = append(allAddresses, primary...)
for _, addr := range secondaryAddresses {
allAddresses = append(allAddresses, addr.(string))
}
targetEc2InstanceTags, err := tf.GetOutputMap("target_instance_tags")
require.NoError(err)
expectedTargetTagInstancesMap := buildExpectedTagInstancesMap(targetEc2InstanceTags, expectedTags)
cases := []struct {
name string
instanceAddressesOnly bool
expectedIPs []string
}{
{
name: "all addresses",
expectedIPs: allAddresses,
},
{
name: "instance addresses only",
instanceAddressesOnly: true,
expectedIPs: primary,
},
}
requireTagInstancesMatchProvisioned(t, targetEc2InstanceIds, expectedTargetTagInstancesMap)
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
catalogAttrMap := map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
}
if tc.instanceAddressesOnly {
catalogAttrMap[host.ConstInstanceAddressesOnly] = true
}
catalogAttrs, err := structpb.NewStruct(catalogAttrMap)
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: []*hostsets.HostSet{{
Id: "hostset-0",
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.Len(response.GetHosts(), 1)
hostResp := response.GetHosts()[0]
require.Equal(instanceID, hostResp.ExternalId)
require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs)
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnCreateSetCases(ctx, t, p, []onSetCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testOnUpdateSetCases(ctx, t, p, []onSetCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
})
testListHostsCases(ctx, t, p, []listHostsCase{
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0], expected: expectedTargetTagInstancesMap},
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
})
}
}
+4 -4
View File
@@ -14,10 +14,10 @@ import (
"time"
"github.com/google/uuid"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/plugin/service/storage"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/plugin/service/storage"
"repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/controller/api/resources/storagebuckets"
pb "repo.anhonesthost.net/CyberCoveLLC/threshold/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
+206
View File
@@ -0,0 +1,206 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "random_id" "assume_role_name" {
prefix = "BoundaryPluginAssumeRole"
byte_length = 4
}
resource "aws_iam_role" "assume_role" {
name = random_id.assume_role_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
# Also trusted by the cross-role principal role so it can be used
# as the target in two-hop AssumeRole tests.
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = aws_iam_role.cross_role_principal.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_role_policy_attachment" "assume_role_ec2_describeinstances" {
role = aws_iam_role.assume_role.name
policy_arn = aws_iam_policy.ec2_describeinstances.arn
}
output "assume_role_arn" {
value = aws_iam_role.assume_role.arn
}
# Error case: role exists with ec2:DescribeInstances but trust policy
# does not allow the caller to assume it.
resource "random_id" "assume_role_no_trust_name" {
prefix = "BoundaryPluginNoTrust"
byte_length = 4
}
resource "aws_iam_role" "assume_role_no_trust" {
name = random_id.assume_role_no_trust_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Deny"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "assume_role_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: role exists and the caller can assume it, but has no
# ec2:DescribeInstances permission.
# Also trusted by cross_role_principal so this same role can be reused as the
# no-EC2-permission target in two-hop (cross-role) error case tests.
resource "random_id" "assume_role_no_ec2_permission_name" {
prefix = "BoundaryPluginNoEC2Perm"
byte_length = 4
}
resource "aws_iam_role" "assume_role_no_ec2_permission" {
name = random_id.assume_role_no_ec2_permission_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
# Also trusted by the cross-role principal so it can serve as the
# no-EC2-permission target in two-hop error case tests.
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = aws_iam_role.cross_role_principal.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "assume_role_no_ec2_permission_arn" {
value = aws_iam_role.assume_role_no_ec2_permission.arn
}
# Cross-role (two-hop) principal role.
# The test runner can assume this role. It has permission to AssumeRole into
# aws_iam_role.assume_role (the target role) but has no ec2:DescribeInstances
# of its own. This simulates the principal side of a two-hop role chain: the
# plugin must assume the principal first, then use those credentials to assume
# the target role before calling DescribeInstances.
# Note: both roles are in the same AWS account. This tests the plugin's
# two-hop credential chain logic without requiring a second account.
resource "random_id" "cross_role_principal_name" {
prefix = "BoundaryPluginCRPrincipal"
byte_length = 4
}
resource "aws_iam_role" "cross_role_principal" {
name = random_id.cross_role_principal_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = {
AWS = data.aws_caller_identity.current.arn
}
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
# Single policy granting cross_role_principal permission to assume all target
# roles it needs for both happy path and error case tests.
resource "aws_iam_policy" "cross_role_principal_assume_targets" {
name = "${random_id.cross_role_principal_name.dec}-assume-targets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Resource = [
aws_iam_role.assume_role.arn,
aws_iam_role.assume_role_no_ec2_permission.arn,
]
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_role_principal_assume_targets" {
role = aws_iam_role.cross_role_principal.name
policy_arn = aws_iam_policy.cross_role_principal_assume_targets.arn
}
output "cross_role_principal_arn" {
value = aws_iam_role.cross_role_principal.arn
}
# Error case: caller cannot assume the principal role (first hop denied).
# Reuses assume_role_no_trust — it already has a Deny trust policy for the
# caller, so it also serves as an unassumable principal in two-hop tests.
output "cross_role_principal_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: principal can be assumed, but the target role's trust policy
# does not allow the principal to assume it (second hop denied).
# Reuses assume_role_no_trust as the target — its Deny trust policy blocks
# all callers including cross_role_principal.
output "cross_role_target_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# Error case: both hops succeed but the target has no ec2:DescribeInstances.
# Reuses assume_role_no_ec2_permission — its trust policy allows
# cross_role_principal (see above), and it has no EC2 permission.
output "cross_role_target_no_ec2_permission_arn" {
value = aws_iam_role.assume_role_no_ec2_permission.arn
}
+322
View File
@@ -0,0 +1,322 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
# Cross-account Dynamic Host Catalog test infrastructure.
#
# This file provisions the IAM resources needed to test two-hop AssumeRole
# across two separate AWS accounts:
#
# Account A (principal account) — the default provider
# * cross_account_principal: the plugin assumes this role first.
# It has sts:AssumeRole permission on the target role in Account B.
#
# Account B (target account) — the "target" provider alias
# * cross_account_target: holds ec2:DescribeInstances; its trust policy
# allows the Account A principal role to assume it. (happy path)
# * cross_account_target_no_ec2_permission: same trust policy but no
# ec2:DescribeInstances. (error case)
# * cross_account_target_no_trust: exists in Account B but its trust
# policy does not allow the Account A principal. (error case)
#
# Prerequisites:
# TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for temporary
# credentials) TARGET_AWS_SESSION_TOKEN must be set to credentials that have
# sufficient IAM permissions in Account B. The test runner skips this
# section when the access key ID or secret access key are absent.
# ---------------------------------------------------------------------------
# Provider for Account B (target account)
# ---------------------------------------------------------------------------
provider "aws" {
alias = "target"
region = var.target_region
access_key = var.target_access_key_id
secret_key = var.target_secret_access_key
token = var.target_session_token
}
data "aws_caller_identity" "target" {
provider = aws.target
}
# ---------------------------------------------------------------------------
# Variables for Account B credentials and region
# ---------------------------------------------------------------------------
variable "target_access_key_id" {
description = "AWS access key ID for the target account (Account B)."
type = string
sensitive = true
default = ""
}
variable "target_secret_access_key" {
description = "AWS secret access key for the target account (Account B)."
type = string
sensitive = true
default = ""
}
variable "target_session_token" {
description = "AWS session token for the target account (Account B). Required when using temporary (STS) credentials."
type = string
sensitive = true
default = ""
}
variable "target_region" {
description = "AWS region for the target account (Account B). Defaults to the same region as Account A."
type = string
default = ""
}
# ---------------------------------------------------------------------------
# Account A: cross_account_principal role
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_principal_name" {
prefix = "BoundaryPluginCAPrincipal"
byte_length = 4
}
resource "aws_iam_role" "cross_account_principal" {
name = random_id.cross_account_principal_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = data.aws_caller_identity.current.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
# Allow the principal role to assume the happy-path and no-EC2-permission
# target roles in Account B.
resource "aws_iam_policy" "cross_account_principal_assume_targets" {
name = "${random_id.cross_account_principal_name.dec}-assume-targets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Resource = [
aws_iam_role.cross_account_target.arn,
aws_iam_role.cross_account_target_no_ec2_permission.arn,
]
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_account_principal_assume_targets" {
role = aws_iam_role.cross_account_principal.name
policy_arn = aws_iam_policy.cross_account_principal_assume_targets.arn
}
output "cross_account_principal_arn" {
value = aws_iam_role.cross_account_principal.arn
}
# Error case: caller cannot assume the principal role (first hop denied).
# Reuses assume_role_no_trust from iam_assume_role.tf — its Deny trust policy
# also blocks the test runner from assuming it as a principal.
output "cross_account_principal_no_trust_arn" {
value = aws_iam_role.assume_role_no_trust.arn
}
# ---------------------------------------------------------------------------
# Account B: happy-path target role
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_name" {
prefix = "BoundaryPluginCATarget"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target" {
provider = aws.target
name = random_id.cross_account_target_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_policy" "cross_account_target_ec2_describeinstances" {
provider = aws.target
name = "${random_id.cross_account_target_name.dec}-ec2"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["ec2:DescribeInstances"]
Effect = "Allow"
Resource = "*"
},
]
})
}
resource "aws_iam_role_policy_attachment" "cross_account_target_ec2_describeinstances" {
provider = aws.target
role = aws_iam_role.cross_account_target.name
policy_arn = aws_iam_policy.cross_account_target_ec2_describeinstances.arn
}
output "cross_account_target_arn" {
value = aws_iam_role.cross_account_target.arn
}
# ---------------------------------------------------------------------------
# Account B: error case — both hops succeed but target has no ec2:DescribeInstances
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_no_ec2_permission_name" {
prefix = "BoundaryPluginCATargetNoEC2"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target_no_ec2_permission" {
provider = aws.target
name = random_id.cross_account_target_no_ec2_permission_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Allow"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "cross_account_target_no_ec2_permission_arn" {
value = aws_iam_role.cross_account_target_no_ec2_permission.arn
}
# ---------------------------------------------------------------------------
# Account B: error case — principal cannot assume this target (second hop denied)
# ---------------------------------------------------------------------------
resource "random_id" "cross_account_target_no_trust_name" {
prefix = "BoundaryPluginCATargetNoTrust"
byte_length = 4
}
resource "aws_iam_role" "cross_account_target_no_trust" {
provider = aws.target
name = random_id.cross_account_target_no_trust_name.dec
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
Effect = "Deny"
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
},
]
})
tags = {
"boundary-demo" = local.hashicorp_email
}
}
output "cross_account_target_no_trust_arn" {
value = aws_iam_role.cross_account_target_no_trust.arn
}
# ---------------------------------------------------------------------------
# Account B: EC2 instance for cross-account host discovery validation
# ---------------------------------------------------------------------------
data "aws_availability_zones" "target_azs" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
}
data "aws_ami" "target_ubuntu" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
resource "aws_vpc" "target_vpc" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
cidr_block = "10.1.0.0/16"
assign_generated_ipv6_cidr_block = true
}
resource "aws_subnet" "target_subnet" {
count = var.target_access_key_id != "" ? 1 : 0
provider = aws.target
vpc_id = aws_vpc.target_vpc[0].id
cidr_block = aws_vpc.target_vpc[0].cidr_block
ipv6_cidr_block = cidrsubnet(aws_vpc.target_vpc[0].ipv6_cidr_block, 8, 0)
availability_zone = data.aws_availability_zones.target_azs[0].names[0]
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
}
resource "aws_instance" "target_instances" {
count = var.target_access_key_id != "" ? length(local.instance_tags) : 0
provider = aws.target
ami = data.aws_ami.target_ubuntu[0].id
instance_type = "t3.nano"
subnet_id = aws_subnet.target_subnet[0].id
tags = local.instance_tags[count.index]
}
output "target_instance_ids" {
value = aws_instance.target_instances.*.id
}
output "target_instance_tags" {
value = {
for _, r in aws_instance.target_instances : r.id => r.tags
}
}
+31 -9
View File
@@ -14,8 +14,8 @@ import (
"time"
"github.com/aws/smithy-go"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/credential"
"repo.anhonesthost.net/CyberCoveLLC/threshold-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/require"
@@ -39,6 +39,10 @@ type TestTerraformer struct {
// The working directory for Terraform commands.
workDir string
// vars holds optional key/value pairs passed as -var flags to every
// Terraform command that accepts them (apply, destroy, plan).
vars map[string]string
// The Terraform state. Use the Output method to look up particular
// outputs from the Terraform state.
state *tfjson.State
@@ -53,8 +57,9 @@ type TestTerraformer struct {
// NewTestTerraformer initializes a Terraform directory. It just
// returns an initialized project, it does not perform any actions on
// it.
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
// it. An optional vars map may be provided; each entry is passed as a
// -var flag to Terraform commands.
func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerraformer, error) {
tfPath, err := exec.LookPath("terraform")
if err != nil {
return nil, fmt.Errorf("LookPath error: %w", err)
@@ -64,10 +69,24 @@ func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
return nil, errors.New("workDir is empty")
}
return &TestTerraformer{
tf := &TestTerraformer{
tfPath: tfPath,
workDir: workDir,
}, nil
}
if len(vars) > 0 {
tf.vars = vars[0]
}
return tf, nil
}
// varArgs converts the stored vars map into a slice of -var flags
// suitable for appending to a Terraform command's argument list.
func (t *TestTerraformer) varArgs() []string {
args := make([]string, 0, len(t.vars)*2)
for k, v := range t.vars {
args = append(args, "-var", fmt.Sprintf("%s=%s", k, v))
}
return args
}
// Deploy runs the following commands, in order: init, apply, plan,
@@ -214,7 +233,8 @@ func (t *TestTerraformer) init() error {
// apply runs "terraform apply" in the project's working directory.
func (t *TestTerraformer) apply() error {
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
@@ -226,7 +246,8 @@ func (t *TestTerraformer) apply() error {
// destroy runs "terraform destroy" in the project's working directory.
func (t *TestTerraformer) destroy() error {
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
@@ -242,7 +263,8 @@ func (t *TestTerraformer) destroy() error {
// it's run to get the data for for importing the state via
// terraform-json. The apply method ignores the plan generated here.
func (t *TestTerraformer) plan() error {
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...)
cmd := exec.Command(t.tfPath, args...)
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {