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
38 changed files with 3070 additions and 1314 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.
+11
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
@@ -25,6 +34,8 @@ Credential rotation can be turned off by setting the
This plugin will attempt to assume a role when a `role_arn` is supplied through the
`attributes` object. More information about assume an AWS role can be found [here](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). This feature only works when the plugin is running on a self managed Boundary [worker](https://developer.hashicorp.com/boundary/tutorials/hcp-administration/hcp-manage-workers).
To discover hosts across multiple AWS accounts, supply a `target_role_arn` in addition to `role_arn`. The plugin uses the principal credentials to assume the target role via a two-hop `AssumeRole` chain, enabling cross-account host discovery from a single worker. See [Attributes and Secrets](plugin/service/host/README.md#attributes-and-secrets) for the full list of `target_*` attributes.
### Environment Credentials
This plugin will attempt to retrieve credentials from environment variables. More
+12 -12
View File
@@ -1,9 +1,10 @@
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
github.com/aws/aws-sdk-go-v2/credentials v1.17.46
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
@@ -11,22 +12,21 @@ 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 (
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
@@ -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=
+62 -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"
)
@@ -186,3 +186,63 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
RoleTags: roleTags,
}, nil
}
// GetTargetCredentialAttributes parses values out of a protobuf struct input and returns a CredentialAttributes used
// for configuring a second AWS session. target_role_arn is required when parsing target credentials.
// A status error is returned with an InvalidArgument code when target account attributes are missing
// required values or have invalid value types.
func GetTargetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
badFields := make(map[string]string)
// Parse the role ARN of the target account to AssumeRole into.
targetRoleArn, err := values.GetStringValue(in, ConstTargetRoleArn, true)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleArn)] = err.Error()
}
// try parsing target_region, fallback to region, adds target_region to bad fields only when malformed since not required
targetRegion, err := values.GetStringValue(in, ConstTargetRegion, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetRegion)] = err.Error()
}
if targetRegion == "" {
targetRegion, err = values.GetStringValue(in, ConstRegion, true) // region is required for processing
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
}
}
// the remaining fields are optional and only raise errors when malformed
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
}
targetRoleExternalId, err := values.GetStringValue(in, ConstTargetRoleExternalId, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleExternalId)] = err.Error()
}
targetRoleSessionName, err := values.GetStringValue(in, ConstTargetRoleSessionName, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleSessionName)] = err.Error()
}
targetRoleTags, err := values.GetMapStringString(in, ConstTargetRoleTags, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleTags)] = err.Error()
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
}
return &CredentialAttributes{
Region: targetRegion,
DisableCredentialRotation: disableCredentialRotation,
RoleArn: targetRoleArn,
RoleExternalId: targetRoleExternalId,
RoleSessionName: targetRoleSessionName,
RoleTags: targetRoleTags,
}, nil
}
+226
View File
@@ -101,6 +101,232 @@ func TestGetCredentialAttributes(t *testing.T) {
}
}
func TestGetTargetCredentialAttributes(t *testing.T) {
const targetRoleArn = "arn:aws:iam::222222222222:role/Target"
cases := []struct {
name string
in map[string]any
expected *CredentialAttributes
expectedErrContains string
}{
{
name: "missing target_role_arn",
in: map[string]any{
ConstRegion: "us-west-2",
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "missing target_role_arn and region",
in: map[string]any{},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "empty target_role_arn",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: "",
},
expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty",
},
{
name: "wrong type target_role_arn",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: true,
},
expectedErrContains: "attributes.target_role_arn: unexpected type for value \"target_role_arn\": want string, got bool",
},
{
name: "target_region without target_role_arn",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRegion: "eu-west-1",
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "other target fields without target_role_arn",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleExternalId: "ext",
ConstTargetRoleSessionName: "sess",
ConstTargetRoleTags: map[string]any{"k": "v"},
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "target_role_arn uses region when target_region omitted",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
},
expected: &CredentialAttributes{
Region: "us-west-2",
RoleArn: targetRoleArn,
},
},
{
name: "target_role_arn without target_region or region",
in: map[string]any{
ConstTargetRoleArn: targetRoleArn,
},
expectedErrContains: "missing required value \"region\"",
},
{
name: "target_region overrides region",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRegion: "eu-west-1",
ConstTargetRoleArn: targetRoleArn,
},
expected: &CredentialAttributes{
Region: "eu-west-1",
RoleArn: targetRoleArn,
},
},
{
name: "empty target_region falls back to region",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRegion: "",
ConstTargetRoleArn: targetRoleArn,
},
expected: &CredentialAttributes{
Region: "us-west-2",
RoleArn: targetRoleArn,
},
},
{
name: "all optional target fields",
in: map[string]any{
ConstRegion: "us-west-2",
ConstDisableCredentialRotation: true,
ConstTargetRoleArn: targetRoleArn,
ConstTargetRegion: "eu-west-1",
ConstTargetRoleExternalId: "target-ext",
ConstTargetRoleSessionName: "target-sess",
ConstTargetRoleTags: map[string]any{"env": "target"},
},
expected: &CredentialAttributes{
Region: "eu-west-1",
DisableCredentialRotation: true,
RoleArn: targetRoleArn,
RoleExternalId: "target-ext",
RoleSessionName: "target-sess",
RoleTags: map[string]string{"env": "target"},
},
},
{
name: "optional target fields omitted do not copy principal role fields",
in: map[string]any{
ConstRegion: "us-west-2",
ConstRoleArn: "arn:aws:iam::111111111111:role/Principal",
ConstRoleExternalId: "principal-ext",
ConstRoleSessionName: "principal-sess",
ConstRoleTags: map[string]any{"env": "principal"},
ConstTargetRoleArn: targetRoleArn,
},
expected: &CredentialAttributes{
Region: "us-west-2",
RoleArn: targetRoleArn,
},
},
{
name: "disable_credential_rotation false is copied",
in: map[string]any{
ConstRegion: "us-west-2",
ConstDisableCredentialRotation: false,
ConstTargetRoleArn: targetRoleArn,
},
expected: &CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
RoleArn: targetRoleArn,
},
},
{
name: "wrong type target_region",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstTargetRegion: true,
},
expectedErrContains: "attributes.target_region: unexpected type for value \"target_region\": want string, got bool",
},
{
name: "wrong type target_role_external_id",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstTargetRoleExternalId: true,
},
expectedErrContains: "attributes.target_role_external_id: unexpected type for value \"target_role_external_id\": want string, got bool",
},
{
name: "wrong type target_role_session_name",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstTargetRoleSessionName: true,
},
expectedErrContains: "attributes.target_role_session_name: unexpected type for value \"target_role_session_name\": want string, got bool",
},
{
name: "wrong type target_role_tags",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstTargetRoleTags: true,
},
expectedErrContains: "attributes.target_role_tags: unexpected type for value \"target_role_tags\": want map[string]string, got bool",
},
{
name: "wrong type target_role_tag value",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstTargetRoleTags: map[string]any{
"env": true,
},
},
expectedErrContains: `unexpected type for value in map["env"]: want string, got bool`,
},
{
name: "wrong type disable_credential_rotation",
in: map[string]any{
ConstRegion: "us-west-2",
ConstTargetRoleArn: targetRoleArn,
ConstDisableCredentialRotation: "sure",
},
expectedErrContains: "attributes.disable_credential_rotation: unexpected type for value \"disable_credential_rotation\": want bool, got string",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
input, err := structpb.NewStruct(tc.in)
require.NoError(err)
actual, err := GetTargetCredentialAttributes(input)
if tc.expectedErrContains != "" {
require.Error(err)
require.Nil(actual)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(codes.InvalidArgument, status.Code(err))
return
}
require.NoError(err)
require.Equal(tc.expected, actual)
})
}
}
func TestGetCredentialsConfig(t *testing.T) {
cases := []struct {
name string
+17 -2
View File
@@ -4,10 +4,10 @@
package credential
const (
// ConstAccessKey is the key for the region in the aws credentials.
// ConstRegion is the key for the region in the aws credentials.
ConstRegion = "region"
// ConstAccessKey is the key for the access key id in the aws credentials.
// ConstAccessKeyId is the key for the access key id in the aws credentials.
ConstAccessKeyId = "access_key_id"
// ConstSecretAccessKey is the key for the secret access key in the aws credentials.
@@ -30,4 +30,19 @@ const (
// ConstRoleTags is the key for the tags used for assuming a IAM role.
ConstRoleTags = "role_tags"
// ConstTargetRegion is the key for the region of the target account in the aws credentials.
ConstTargetRegion = "target_region"
// ConstTargetRoleArn is the key for assuming a IAM role for the target account.
ConstTargetRoleArn = "target_role_arn"
// ConstTargetRoleExternalId is the key for the external id used for assuming a IAM role for the target account.
ConstTargetRoleExternalId = "target_role_external_id"
// ConstTargetRoleSessionName is the key for the session name used for assuming a IAM role of the target account.
ConstTargetRoleSessionName = "target_role_session_name"
// ConstTargetRoleTags is the key for the tags used for assuming a IAM role of the target account.
ConstTargetRoleTags = "target_role_tags"
)
@@ -1,172 +0,0 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
// TestGetCredentialsConfigRoleArnFallback documents all credential input
// combinations and the CredentialsConfig they produce. The key finding is
// in the "role_arn + no static keys" case: GetCredentialsConfig succeeds and
// populates CredentialsConfig.RoleARN, but no STS call is ever made during
// config construction. At runtime, awsutil.GenerateCredentialChain silently
// falls back to the ambient credential chain (env vars / instance profile)
// without calling sts:AssumeRole, so a bogus or unreachable ARN is never
// detected.
//
// Run this test to confirm the root cause:
//
// go test ./internal/credential/... -run TestGetCredentialsConfigRoleArnFallback -v
import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
func TestGetCredentialsConfigRoleArnFallback(t *testing.T) {
cases := []struct {
name string
secrets *structpb.Struct
attrs *CredentialAttributes
expectedAccessKey string
expectedSecretKey string
expectedRoleARN string
expectedType CredentialType
expectedErrContains string
}{
{
// NOTE: No role_arn and no static keys. GetCredentialsConfig
// succeeds and returns a CredentialsConfig with no credentials set
// at all. GetCredentialType returns Unknown because there is no
// AccessKey prefix to classify. The AWS SDK will use whatever
// ambient credentials it finds at runtime (env vars, instance
// profile, etc.).
name: "ambient only — no role_arn, no static keys",
attrs: &CredentialAttributes{
Region: "us-east-1",
DisableCredentialRotation: true,
},
expectedAccessKey: "",
expectedSecretKey: "",
expectedRoleARN: "",
expectedType: Unknown,
},
{
// NOTE: The normal static-key path. Both keys are provided; no
// role_arn. GetCredentialType returns StaticAWS because the
// AccessKey starts with "AKIA".
name: "static keys only",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{
Region: "us-east-1",
DisableCredentialRotation: true,
},
expectedAccessKey: "AKIAfoobar",
expectedSecretKey: "bazqux",
expectedRoleARN: "",
expectedType: StaticAWS,
},
{
// NOTE: THE BUG SCENARIO. role_arn is set (to a clearly bogus
// value) and disable_credential_rotation is true, but no static
// keys are provided. GetCredentialsConfig succeeds — the ARN is
// stored in CredentialsConfig.RoleARN. GetCredentialType returns
// DynamicAWS. However, no sts:AssumeRole call is ever made here
// or during GenerateCredentialChain: the awsutil library skips the
// assume-role path when AccessKey is empty and falls back to the
// ambient credential chain instead. The bogus ARN is never
// validated or exercised.
name: "role_arn + no static keys (THE BUG SCENARIO)",
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:sts::000000000000:assumed-role/fake/whoever",
DisableCredentialRotation: true,
},
expectedAccessKey: "",
expectedSecretKey: "",
expectedRoleARN: "arn:aws:sts::000000000000:assumed-role/fake/whoever",
expectedType: DynamicAWS,
},
{
// NOTE: role_arn is set but disable_credential_rotation is false
// (the default). This is caught by the validation switch and
// returns an explicit error. Validation works correctly here.
name: "role_arn without disable_credential_rotation — validation error",
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:iam::123456789012:role/MyRole",
},
expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn",
},
{
// NOTE: Both role_arn and static keys are provided together. This
// is caught by the validation switch and returns an error listing
// all three conflicting fields. Validation works correctly here.
name: "role_arn + static keys — conflict error",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:iam::123456789012:role/MyRole",
},
expectedErrContains: "conflicts with role_arn value",
},
{
// NOTE: access_key_id is provided but secret_access_key is
// missing. The validation switch catches the incomplete pair.
name: "static keys — missing secret_access_key",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
},
},
attrs: &CredentialAttributes{Region: "us-east-1"},
expectedErrContains: "secrets.secret_access_key: missing required value",
},
{
// NOTE: secret_access_key is provided but access_key_id is
// missing. The validation switch catches the incomplete pair.
name: "static keys — missing access_key_id",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{Region: "us-east-1"},
expectedErrContains: "secrets.access_key_id: missing required value",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
cfg, err := GetCredentialsConfig(tc.secrets, tc.attrs, false)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(codes.InvalidArgument, status.Code(err))
return
}
require.NoError(err)
require.Equal(tc.expectedAccessKey, cfg.AccessKey)
require.Equal(tc.expectedSecretKey, cfg.SecretKey)
require.Equal(tc.expectedRoleARN, cfg.RoleARN)
require.Equal(tc.expectedType, GetCredentialType(cfg),
"credential type mismatch — see NOTE above the test case for why this matters")
})
}
}
+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 -1
View File
@@ -18,7 +18,7 @@ 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"
)
@@ -29,6 +29,11 @@ const (
// 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
@@ -66,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
@@ -172,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:
+14 -4
View File
@@ -109,13 +109,23 @@ The following `attributes` are valid on an AWS host catalog resource:
- `region` (string): The region to configure the host catalog for. All host sets
in this catalog will be configured for this region.
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
provided, `disable_credential_rotation` must be also set to `true.
- `role_external_id` (string): The external id configured for the `AssumeRole`
provided, `disable_credential_rotation` must be also set to `true`.
- `role_external_id` (string): The external ID configured for the principal `AssumeRole`
provider.
- `role_session_name` (string): The session name configured for the `AssumeRole`
- `role_session_name` (string): The session name configured for the principal `AssumeRole`
provider.
- `role_tags` (object): The key-value pair tags configured for the `AssumeRole`
- `role_tags` (object): The key-value pair tags configured for the principal `AssumeRole`
provider.
- `target_role_arn` (string): The AWS role ARN of the target IAM role to assume for
cross-account discovery. Required if any other `target_*` attributes are configured.
- `target_region` (string): The region where the target hosts reside. Defaults to the
principal account `region` if omitted.
- `target_role_external_id` (string): The external ID configured for the target
`AssumeRole` provider.
- `target_role_session_name` (string): The session name configured for the target
`AssumeRole` provider.
- `target_role_tags` (object): The key-value pair tags configured for the target
`AssumeRole` provider.
The following `secrets` are required on an AWS host catalog resource:
+41 -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"
@@ -20,6 +20,9 @@ import (
type CatalogAttributes struct {
*cred.CredentialAttributes
// TargetAccount optionally holds the credentials used to AssumeRole into a second, affiliated account when provided.
TargetAccount *cred.CredentialAttributes
// DualStack is used for configuring how the aws client will resolve requests.
DualStack bool
@@ -39,6 +42,14 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
return nil, err
}
var targetCredAttributes *cred.CredentialAttributes
if hasTargetAccountAttributes(in) {
targetCredAttributes, err = cred.GetTargetCredentialAttributes(in)
if err != nil {
return nil, err
}
}
dualStack, err := values.GetBoolValue(in, ConstAwsDualStack, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstAwsDualStack)] = err.Error()
@@ -66,6 +77,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
continue
case cred.ConstRoleTags:
continue
case cred.ConstTargetRegion:
continue
case cred.ConstTargetRoleArn:
continue
case cred.ConstTargetRoleExternalId:
continue
case cred.ConstTargetRoleSessionName:
continue
case cred.ConstTargetRoleTags:
continue
default:
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
}
@@ -77,6 +98,7 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
return &CatalogAttributes{
CredentialAttributes: credAttributes,
TargetAccount: targetCredAttributes,
DualStack: dualStack,
InstanceAddressesOnly: instanceAddressesOnly,
}, nil
@@ -121,3 +143,19 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
return &setAttrs, nil
}
func hasTargetAccountAttributes(in *structpb.Struct) bool {
fields := in.GetFields()
_, hasTargetRegion := fields[cred.ConstTargetRegion]
_, hasTargetRoleArn := fields[cred.ConstTargetRoleArn]
_, hasTargetRoleExternalID := fields[cred.ConstTargetRoleExternalId]
_, hasTargetRoleSessionName := fields[cred.ConstTargetRoleSessionName]
_, hasTargetRoleTags := fields[cred.ConstTargetRoleTags]
return hasTargetRegion ||
hasTargetRoleArn ||
hasTargetRoleExternalID ||
hasTargetRoleSessionName ||
hasTargetRoleTags
}
+167 -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"
@@ -118,6 +118,170 @@ func TestGetCatalogAttributes(t *testing.T) {
},
},
},
{
name: "target_region without target_role_arn",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"target_region": structpb.NewStringValue("eu-west-1"),
},
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "target role field without target_role_arn",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"target_role_external_id": structpb.NewStringValue("target-ext"),
},
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "other target role fields without target_role_arn",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"target_role_session_name": structpb.NewStringValue("target-session"),
"target_role_tags": structpb.NewStructValue(&structpb.Struct{
Fields: map[string]*structpb.Value{
"env": structpb.NewStringValue("target"),
},
}),
},
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
},
{
name: "empty target_role_arn with target_region",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"target_role_arn": structpb.NewStringValue(""),
"target_region": structpb.NewStringValue("eu-west-1"),
},
},
expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty",
},
{
name: "target_role_arn without target_region uses region; principal sts fields are not copied",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"disable_credential_rotation": structpb.NewBoolValue(true),
"role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"),
"role_external_id": structpb.NewStringValue("principal-ext"),
"role_session_name": structpb.NewStringValue("principal-sess"),
"role_tags": structpb.NewStructValue(&structpb.Struct{
Fields: map[string]*structpb.Value{
"env": structpb.NewStringValue("principal"),
},
}),
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &cred.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: true,
RoleArn: "arn:aws:iam::111111111111:role/Principal",
RoleExternalId: "principal-ext",
RoleSessionName: "principal-sess",
RoleTags: map[string]string{
"env": "principal",
},
},
TargetAccount: &cred.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: true,
RoleArn: "arn:aws:iam::222222222222:role/Target",
},
},
},
{
name: "target attributes are independent of principal",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"disable_credential_rotation": structpb.NewBoolValue(true),
"role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"),
"role_external_id": structpb.NewStringValue("principal-ext"),
"role_session_name": structpb.NewStringValue("principal-sess"),
"role_tags": structpb.NewStructValue(&structpb.Struct{
Fields: map[string]*structpb.Value{
"env": structpb.NewStringValue("principal"),
},
}),
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
"target_region": structpb.NewStringValue("eu-west-1"),
"target_role_external_id": structpb.NewStringValue("target-ext"),
"target_role_session_name": structpb.NewStringValue("target-sess"),
"target_role_tags": structpb.NewStructValue(&structpb.Struct{
Fields: map[string]*structpb.Value{
"env": structpb.NewStringValue("target"),
},
}),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &cred.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: true,
RoleArn: "arn:aws:iam::111111111111:role/Principal",
RoleExternalId: "principal-ext",
RoleSessionName: "principal-sess",
RoleTags: map[string]string{
"env": "principal",
},
},
TargetAccount: &cred.CredentialAttributes{
Region: "eu-west-1",
DisableCredentialRotation: true,
RoleArn: "arn:aws:iam::222222222222:role/Target",
RoleExternalId: "target-ext",
RoleSessionName: "target-sess",
RoleTags: map[string]string{
"env": "target",
},
},
},
},
{
name: "catalog dual_stack and instance_addresses_only with target",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"dual_stack": structpb.NewBoolValue(true),
"instance_addresses_only": structpb.NewBoolValue(true),
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &credential.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
},
TargetAccount: &cred.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
RoleArn: "arn:aws:iam::222222222222:role/Target",
},
DualStack: true,
InstanceAddressesOnly: true,
},
},
{
name: "unknown field with target_role_arn",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
"foo": structpb.NewBoolValue(true),
},
},
expectedErrContains: "attributes.foo: unrecognized field",
},
}
for _, tc := range cases {
+219 -55
View File
@@ -9,11 +9,14 @@ import (
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/aws/aws-sdk-go-v2/service/sts"
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
"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"
@@ -32,6 +35,16 @@ type HostPlugin struct {
testCatalogStateOpts []awsCatalogPersistedStateOption
}
type hostSetQuery struct {
Id string
Input *ec2.DescribeInstancesInput
Output *ec2.DescribeInstancesOutput
OutputHosts []*pb.ListHostsResponseHost
}
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)
@@ -73,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),
@@ -82,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()
@@ -163,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),
@@ -171,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.
@@ -216,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()
@@ -366,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
}
@@ -431,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
@@ -495,13 +561,6 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
}
// Build all the queries in advance.
type hostSetQuery struct {
Id string
Input *ec2.DescribeInstancesInput
Output *ec2.DescribeInstancesOutput
OutputHosts []*pb.ListHostsResponseHost
}
queries := make([]hostSetQuery, len(sets))
for i, set := range sets {
// Validate Id since we use it in output
@@ -531,20 +590,26 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
ec2Client, err := catalogState.EC2Client(ctx, opts...)
if err != nil {
return nil, errors.BadRequestStatusf("error getting EC2 client: %s", err)
}
// Run all queries now and assemble output.
var maxLen int
for i, query := range queries {
output, err := ec2Client.DescribeInstances(ctx, query.Input)
output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
if err != nil {
return nil, errors.BadRequestStatusf("error running DescribeInstances for host set id %q: %s", query.Id, err)
// Mapped AWS failures are already gRPC statuses from checkHosts.
if _, ok := status.FromError(err); ok {
return nil, 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
if output == nil {
continue
}
// Process the output here, we will normalize this into a single
// set of hosts afterwards (possibly removing duplicates).
@@ -552,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)
@@ -601,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]
@@ -634,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{
@@ -707,26 +776,121 @@ func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttrib
return result, nil
}
// 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.
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")
func checkHosts(ctx context.Context,
principalCatalogState *awsCatalogPersistedState,
input *ec2.DescribeInstancesInput,
opts []ec2Option,
target *credential.CredentialAttributes,
) (*ec2.DescribeInstancesOutput, error) {
if principalCatalogState == nil {
return nil, fmt.Errorf("unable to create EC2 client from catalog state")
}
ec2Client, err := state.EC2Client(ctx, ec2Opts...)
var ec2Client EC2API
var err error
if target == nil {
// 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 {
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
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)
}
return nil, err
}
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
return output, nil
}
// ec2ClientForTarget builds an EC2 client to query the target account.
// The hop is a continuation of the principal session: STS AssumeRole is called
// as the principal to get to the target, then EC2 uses the resulting temporary credentials.
// This is not a second worker-level credential chain on the target attributes.
func ec2ClientForTarget(
ctx context.Context,
principalCatalogState *awsCatalogPersistedState,
target *credential.CredentialAttributes,
opts []ec2Option) (EC2API, error) {
// Reuse the principal SDK config (HTTP client, retries, logger, and the
// principal identity).
principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx)
if err != nil {
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
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 {
return nil, fmt.Errorf("nil principal AWS configuration while configuring EC2 client for target account")
}
return nil
// STS must run as the principal so the target role's trust policy sees that
// identity, not the worker's. Session name, external ID, and tags are
// forwarded from attrs when set so the AssumeRole matches the target role's configuration.
stsClient := sts.NewFromConfig(*principalCfg)
provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) {
if target.RoleSessionName != "" {
o.RoleSessionName = target.RoleSessionName
}
if target.RoleExternalId != "" {
o.ExternalID = &target.RoleExternalId
}
for k, v := range target.RoleTags {
o.Tags = append(o.Tags, stsTypes.Tag{
Key: aws.String(k),
Value: aws.String(v),
})
}
})
// Same worker SDK environment as the principal; only identity and region change.
targetCfg := principalCfg.Copy()
targetCfg.Credentials = aws.NewCredentialsCache(provider)
if target.Region != "" {
targetCfg.Region = target.Region
}
parsed, err := getOpts(opts...)
if err != nil {
return nil, fmt.Errorf("EC2 client options are invalid: %w", err)
}
// Dual-stack is the worker's path to AWS endpoints, not per-account. Apply
// it on the hopped client the same way EC2Client does for the principal.
var ec2Opts []func(*ec2.Options)
if parsed.withDualStack {
ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{
dualStack: parsed.withDualStack,
}))
}
if principalCatalogState.testEC2APIFunc != nil {
return principalCatalogState.testEC2APIFunc(targetCfg)
}
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
}
// appendDistinct will append the elements to the slice
@@ -1,516 +0,0 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
// This file contains integration tests that demonstrate the role_arn silent
// fallback bug at the plugin level.
//
// THE BUG IN ONE SENTENCE:
// When a host catalog is configured with role_arn but no static credentials,
// the plugin never calls sts:AssumeRole. It silently falls back to the
// ambient AWS credential chain (env vars / instance profile), so a bogus or
// unreachable ARN produces no error and returns the same hosts as if no ARN
// were specified at all.
//
// HOW TO READ THESE TESTS:
// Each test function covers one plugin operation (OnCreateCatalog or
// ListHosts). Within each function, the cases are ordered to tell a
// progressive story:
// 1. Ambient baseline — no role_arn, works fine
// 2. Static-key path — explicit keys, works fine
// 3. Bug case — bogus role_arn + no keys, silently succeeds
// 4. Desired behavior — bogus role_arn + STS error injected, SHOULD fail
// 5. Validation errors — combinations that are correctly rejected
//
// Cases marked "BUG" in the soft-fail block currently pass (no error is
// returned). Once the bug is fixed those tests will start asserting correctly
// and the t.Fail() path will no longer be reached.
//
// Run both test functions:
//
// go test ./plugin/service/host/... -run TestPluginRoleArnFallback -v
import (
"context"
"errors"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"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"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// ambientInstance is the single fake EC2 instance returned by every mock in
// this file. Seeing this instance in the response means the ambient credential
// chain was used, regardless of what role_arn was configured.
var ambientInstance = ec2types.Instance{
InstanceId: aws.String("i-ambientinstance"),
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
NetworkInterfaces: []ec2types.InstanceNetworkInterface{
{
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
PrivateIpAddresses: []ec2types.InstancePrivateIpAddress{
{
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
},
},
},
},
}
// ambientInstanceOutput is a DescribeInstancesOutput that returns ambientInstance.
// All mock EC2 clients in this file are configured with this output so that
// success cases always return the same identifiable host.
var ambientInstanceOutput = &ec2.DescribeInstancesOutput{
Reservations: []ec2types.Reservation{
{Instances: []ec2types.Instance{ambientInstance}},
},
}
// catalogAttrsStruct builds a catalog attributes struct for the given region.
// disableRotation is always written explicitly — false must be set to prevent
// credential rotation from being attempted (which would require IAM mocks).
// roleArn is written only when non-empty.
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}
}
// ─────────────────────────────────────────────────────────────────────────────
// OnCreateCatalog
// ─────────────────────────────────────────────────────────────────────────────
func TestPluginRoleArnFallback_OnCreateCatalog(t *testing.T) {
// bogusARN is the ARN used in bug-scenario cases. It contains a fake
// account ID (000000000000) that does not correspond to any real AWS
// account. A correct implementation would fail when it attempts to call
// sts:AssumeRole with this ARN. Currently it is silently ignored.
const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever"
tests := []struct {
name string
req *pb.OnCreateCatalogRequest
credOpts []credential.AwsCredentialPersistedStateOption
catalogOpts []awsCatalogPersistedStateOption
// wantErr is set for cases that should return an error.
wantErr bool
wantErrContains string
wantErrCode codes.Code
// wantEmptySecrets is true when the persisted secrets should be empty
// (dynamic / ambient credentials are not stored in the database).
wantEmptySecrets bool
}{
{
// NOTE: No role_arn, no static keys. OnCreateCatalog succeeds using
// whatever ambient credentials are available. The persisted secrets
// are empty because there are no static keys to store. This is the
// baseline — it shows that ambient credentials work on their own.
name: "1. ambient only — no role_arn, no static keys (baseline)",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: true,
},
{
// NOTE: Explicit static keys, rotation disabled. OnCreateCatalog
// succeeds and persists the key pair. This is the normal static-key
// path — no role assumption involved. No credOpts are needed here:
// with disable_credential_rotation=true and StaticAWS keys, the
// dry-run only calls the mocked EC2 DescribeInstances and does not
// make any real AWS calls (same pattern as the existing
// TestPluginOnCreateCatalogSuccess/usingStaticCredentials test).
name: "2. static keys only (baseline)",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: false, // key pair is stored
},
{
// NOTE: THE BUG — bogus role_arn, no static keys. OnCreateCatalog
// succeeds even though the ARN points to a nonexistent role. The
// dry-run DescribeInstances call is mocked out, so the bad ARN is
// never presented to STS. The persisted secrets are empty (dynamic
// credentials are not stored), which is correct — but the absence
// of an error is not.
name: "3. bogus role_arn + no static keys — BUG: succeeds silently",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: true,
},
{
// NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn,
// but we inject an STS mock that returns an error when AssumeRole
// is called. A correct implementation would propagate that error
// and OnCreateCatalog would fail. Currently it succeeds because
// sts:AssumeRole is never called — the STS mock is never reached.
// The soft-fail below documents this: if err == nil it means the
// bug is still present.
name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
},
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(
awsutil.NewMockSTS(
awsutil.WithAssumeRoleError(
errors.New("simulated STS AssumeRole failure: no such role"),
),
),
),
}),
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
// We do NOT set wantErr here — the soft-fail logic below handles
// this case explicitly so we can log a clear BUG message.
},
{
// NOTE: Providing both role_arn and static keys is correctly
// rejected at validation time. The switch in GetCredentialsConfig
// catches this combination before any AWS call is attempted.
name: "5. role_arn + static keys — correctly rejected (conflict error)",
wantErr: true,
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
},
wantErrContains: "conflicts with role_arn value",
wantErrCode: codes.InvalidArgument,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
p := &HostPlugin{
testCredStateOpts: tt.credOpts,
testCatalogStateOpts: tt.catalogOpts,
}
rsp, err := p.OnCreateCatalog(context.Background(), tt.req)
// Case 4 — desired behavior, soft-fail.
if tt.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" {
if err == nil {
t.Log("BUG: expected OnCreateCatalog to fail when sts:AssumeRole returns an error,",
"but it succeeded — the role_arn is being silently ignored and sts:AssumeRole",
"is never called. This test will pass once the bug is fixed.")
t.Fail()
}
return
}
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErrContains)
require.Equal(t, tt.wantErrCode.String(), status.Code(err).String())
return
}
require.NoError(t, err)
secrets := rsp.GetPersisted().GetSecrets().GetFields()
// Remove the rotation timestamp before checking emptiness.
delete(secrets, credential.ConstCredsLastRotatedTime)
if tt.wantEmptySecrets {
require.Empty(t, secrets,
"expected empty persisted secrets (dynamic/ambient credentials are not stored)")
} else {
require.NotEmpty(t, secrets,
"expected persisted secrets to contain the static key pair")
}
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ListHosts
// ─────────────────────────────────────────────────────────────────────────────
// listHostsRequest builds a minimal ListHostsRequest. The persisted secrets
// are always an empty struct because the bug scenario uses dynamic credentials
// (nothing is persisted for ambient or role-based auth).
func listHostsRequest(attrs *structpb.Struct) *pb.ListHostsRequest {
return &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: attrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
}
}
func TestPluginRoleArnFallback_ListHosts(t *testing.T) {
// bogusARN is a clearly invalid ARN: a real AWS account ID has 12 digits,
// and "fake/whoever" is not a valid role path. A correct implementation
// would call sts:AssumeRole with this ARN and fail. Currently it is ignored.
const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever"
// ec2WithAmbientInstance is the mock EC2 factory used by all three
// narrative cases. It always returns the same ambientInstance regardless
// of what credentials were resolved. Seeing "i-ambientinstance" in the
// response from case 3 (bogus role_arn) proves the ARN was ignored and
// the same ambient identity was used as in case 1.
ec2WithAmbientInstance := []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(ambientInstanceOutput),
)),
}
cases := []struct {
name string
req *pb.ListHostsRequest
credOpts []credential.AwsCredentialPersistedStateOption
catalogOpts []awsCatalogPersistedStateOption
// wantHosts is the expected number of hosts in a successful response.
wantHosts int
wantErr bool
wantErrContains string
wantErrCode codes.Code
}{
{
// NOTE: No role_arn, no static keys. ListHosts succeeds using
// ambient credentials and returns i-ambientinstance (10.0.0.1).
// This is the baseline — the "normal" ambient credential flow.
name: "1. ambient only — no role_arn, no static keys (baseline)",
req: listHostsRequest(catalogAttrsStruct("us-east-1", "", true)),
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
{
// NOTE: Explicit static keys. ListHosts succeeds and returns
// i-ambientinstance. The same mock EC2 is used here just for
// consistency; in a real scenario static keys and role-assumed
// keys would return different instances.
name: "2. static keys only (baseline)",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
},
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
{
// NOTE: THE BUG — bogus role_arn is set, but ListHosts succeeds
// and returns the SAME i-ambientinstance (10.0.0.1) as the ambient
// baseline in case 1. The two responses are identical. This proves
// the role_arn was completely ignored: the ambient credential chain
// was used instead of calling sts:AssumeRole with the bogus ARN.
name: "3. bogus role_arn + no static keys — BUG: same hosts as ambient baseline",
req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)),
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1, // same count AND same instance ID as case 1 — the ARN was ignored
},
{
// NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn,
// but an STS mock is injected that returns an error when AssumeRole
// is called. A correct implementation would propagate that STS
// error and ListHosts would return an error. Currently ListHosts
// succeeds because sts:AssumeRole is never called — the injected
// error has no effect. The soft-fail below makes the bug visible
// without causing the full test run to abort.
name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail",
req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)),
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(
awsutil.NewMockSTS(
awsutil.WithAssumeRoleError(
errors.New("simulated STS AssumeRole failure: no such role"),
),
),
),
}),
},
catalogOpts: ec2WithAmbientInstance,
// wantErr deliberately not set — handled by soft-fail logic below.
},
{
// NOTE: Providing both role_arn and static keys is correctly
// rejected at validation time, before any AWS call is made.
// This case is here to show that not all role_arn combinations
// are silently ignored — only the "no static keys" path is affected.
name: "5. role_arn + static keys — correctly rejected (conflict error)",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
},
// ListHosts calls AwsCredentialPersistedStateFromProto which does
// NOT use GetCredentialsConfig — it reconstructs from persisted
// state. With both a role_arn in attrs and keys in persisted
// secrets, the state is built with both set, which results in the
// DynamicAWS path. No conflict error is raised at this layer.
// We still include this case for completeness, expecting success
// (the conflict is only checked in GetCredentialsConfig, which is
// called during OnCreateCatalog, not during ListHosts).
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := &HostPlugin{
testCredStateOpts: tc.credOpts,
testCatalogStateOpts: tc.catalogOpts,
}
rsp, err := p.ListHosts(context.Background(), tc.req)
// Case 4 — desired behavior, soft-fail.
if tc.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" {
if err == nil {
t.Log("BUG: expected ListHosts to fail when sts:AssumeRole returns an error,",
"but it succeeded — the role_arn is being silently ignored and sts:AssumeRole",
"is never called. Compare the response with case 1 (ambient baseline):",
"both return i-ambientinstance, proving the same ambient credentials were",
"used in both cases. This test will assert correctly once the bug is fixed.")
t.Fail()
}
return
}
if tc.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErrContains)
require.Equal(t, tc.wantErrCode.String(), status.Code(err).String())
return
}
require.NoError(t, err)
require.Len(t, rsp.GetHosts(), tc.wantHosts,
"unexpected number of hosts returned — see case NOTE for what this proves")
if tc.wantHosts > 0 {
require.Equal(t, "i-ambientinstance", rsp.GetHosts()[0].GetExternalId(),
"case 3 returning i-ambientinstance (same as case 1) proves the bogus ARN was ignored")
}
})
}
}
+682 -76
View File
@@ -16,11 +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"
"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"
@@ -28,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 {
@@ -138,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),
},
},
},
@@ -158,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{}}},
},
}
@@ -315,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{}},
},
@@ -421,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{
@@ -742,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,
},
}
@@ -994,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",
@@ -1030,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",
@@ -1211,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,
},
}
@@ -1565,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,
},
}
@@ -1828,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,
},
}
@@ -1849,6 +2133,162 @@ func TestPluginOnUpdateSetErr(t *testing.T) {
}
}
func testListHostsSet(id, filter string) *hostsets.HostSet {
return &hostsets.HostSet{
Id: id,
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstDescribeInstancesFilters: structpb.NewListValue(
&structpb.ListValue{
Values: []*structpb.Value{
structpb.NewStringValue(filter),
},
},
),
},
},
},
}
}
func testListHostsPersisted() *pb.HostCatalogPersisted {
return &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
}
}
func testExpectedDescribeInput(filterKey, filterValue string) *ec2.DescribeInstancesInput {
return &ec2.DescribeInstancesInput{
DryRun: aws.Bool(false),
Filters: []types.Filter{
{
Name: aws.String(filterKey),
Values: []string{filterValue},
},
{
Name: aws.String("instance-state-name"),
Values: []string{string(types.InstanceStateNameRunning)},
},
},
}
}
func TestPluginListHosts(t *testing.T) {
describeOutput := &ec2.DescribeInstancesOutput{
Reservations: []types.Reservation{
{
Instances: []types.Instance{
{
InstanceId: aws.String("i-shared"),
PrivateIpAddress: aws.String("10.0.0.1"),
},
},
},
},
}
cases := []struct {
name string
catalogFields map[string]*structpb.Value
sets []*hostsets.HostSet
expectedRegion string
expectedHosts []*pb.ListHostsResponseHost
expectedInputs []*ec2.DescribeInstancesInput
expectedDescribe int
}{
{
name: "principal account lists hosts",
catalogFields: map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
},
sets: []*hostsets.HostSet{
testListHostsSet("set-1", "tag-key=foo"),
},
expectedRegion: "us-west-2",
expectedHosts: []*pb.ListHostsResponseHost{
{
ExternalId: "i-shared",
SetIds: []string{"set-1"},
},
},
expectedInputs: []*ec2.DescribeInstancesInput{
testExpectedDescribeInput("tag-key", "foo"),
},
expectedDescribe: 1,
},
{
name: "target account lists hosts without querying principal",
catalogFields: 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"),
},
sets: []*hostsets.HostSet{
testListHostsSet("set-1", "tag-key=foo"),
testListHostsSet("set-2", "tag-key=bar"),
},
expectedRegion: "eu-west-1",
expectedHosts: []*pb.ListHostsResponseHost{
{
ExternalId: "i-shared",
SetIds: []string{"set-1", "set-2"},
},
},
expectedInputs: []*ec2.DescribeInstancesInput{
testExpectedDescribeInput("tag-key", "foo"),
testExpectedDescribeInput("tag-key", "bar"),
},
expectedDescribe: 2,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
state := &testMockEC2State{}
p := &HostPlugin{
testCatalogStateOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
state,
testMockEC2WithDescribeInstancesOutput(describeOutput),
)),
},
}
resp, err := p.ListHosts(context.Background(), &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: &structpb.Struct{Fields: tc.catalogFields},
},
},
Persisted: testListHostsPersisted(),
Sets: tc.sets,
})
require.NoError(err)
require.NotNil(resp)
require.Len(resp.GetHosts(), len(tc.expectedHosts))
for i, expected := range tc.expectedHosts {
require.Equal(expected.ExternalId, resp.Hosts[i].ExternalId)
require.Equal(expected.SetIds, resp.Hosts[i].SetIds)
}
require.Equal(tc.expectedDescribe, state.DescribeInstancesCallCount)
require.Len(state.ClientRegions, tc.expectedDescribe)
for _, region := range state.ClientRegions {
require.Equal(tc.expectedRegion, region)
}
require.Equal(tc.expectedInputs, state.DescribeInstancesInputs)
})
}
}
func TestPluginListHostsErr(t *testing.T) {
cases := []struct {
name string
@@ -2130,9 +2570,103 @@ func TestPluginListHostsErr(t *testing.T) {
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: fmt.Sprintf("error running DescribeInstances for host set id \"foobar\": %s", testDescribeInstancesError),
expectedErrContains: fmt.Sprintf("error retrieving host results for host set id \"foobar\": %s", testDescribeInstancesError),
expectedErrCode: codes.Unknown,
},
{
name: "target attributes missing target_role_arn",
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.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
},
},
},
},
},
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
expectedErrCode: codes.InvalidArgument,
},
{
name: "target EC2 client error",
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: &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"),
},
},
},
Sets: []*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(func(...aws.Config) (EC2API, error) {
return nil, errors.New("test EC2 client error")
}),
},
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: test EC2 client error",
expectedErrCode: codes.Unknown,
},
{
name: "target DescribeInstances error",
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(errors.New(testDescribeInstancesError)),
)),
},
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 DescribeInstances failed for target account: DescribeInstances error",
expectedErrCode: codes.Unknown,
},
{
name: "awsInstanceToHost error",
req: &pb.ListHostsRequest{
@@ -2192,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,
},
}
@@ -2383,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) {
@@ -2821,54 +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)
})
}
+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"
)
+11
View File
@@ -22,11 +22,17 @@ const (
type testMockEC2State struct {
DescribeInstancesCalled bool
DescribeInstancesInputParams *ec2.DescribeInstancesInput
DescribeInstancesCallCount int
DescribeInstancesInputs []*ec2.DescribeInstancesInput
ClientRegions []string
}
func (s *testMockEC2State) Reset() {
s.DescribeInstancesCalled = false
s.DescribeInstancesInputParams = nil
s.DescribeInstancesCallCount = 0
s.DescribeInstancesInputs = nil
s.ClientRegions = nil
}
type testMockEC2 struct {
@@ -72,7 +78,10 @@ func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFu
if cfg.Region != "" {
m.Region = cfg.Region
}
}
if state != nil {
state.ClientRegions = append(state.ClientRegions, m.Region)
}
return m, nil
@@ -83,6 +92,8 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
if m.State != nil {
m.State.DescribeInstancesCalled = true
m.State.DescribeInstancesInputParams = input
m.State.DescribeInstancesCallCount++
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
}
if m.DescribeInstancesError != nil {
+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 {