Compare commits

..
Author SHA1 Message Date
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
Susmitha Girumala 163ec69771 Update github test workflow permissions to read-only (#76)
Set GITHUB_TOKEN to read-only permission by default
2026-07-14 15:31:58 -04:00
Piotr KazmierczakandHugo e8a0b0bf10 feat(storage): support multipart uploads (#72)
This changeset implements multipart uploads. AWS caps PutObject calls at 5
GiB, effectively causing the plugin to error on any upload larger than that.
Multipart limit is 8 TiB, which should be plenty for most use cases.

Multipart implementation is size-gated: we only switch to multipart uploads once
the file size is greater than multipartThreshold, which is currently set to 8
MiB, and we set the part size to 8 MiB as well (this follows AWS CLI and boto3).
That gives us ~78 GiB max object before we auto-scale, due to 10,000 part cap on
AWS.

---------

Co-authored-by: Hugo <10965479+hugoghx@users.noreply.github.com>
2026-06-26 16:34:32 +02:00
Damian Debkowski 4c8e95fcd8 fix: improve http transport (#73) 2026-06-24 07:12:41 -07:00
Louis Ruch 01f8da94e4 feat(host): add catalog attribute to only sync instance primary addresses (#71)
* feat(host): add catalog attribute to only sync instance primary
addresses
2026-05-27 13:12:34 -07:00
aditya2548 241901319d feat: add ListObjects method & relevant e2e tests 2026-05-14 14:55:01 -05:00
Louis Ruch 56f89a5dcf test(host): update e2e testing to support shared test enviro creds (#69) 2026-05-14 10:59:38 -07:00
hashicorp-copywrite[bot] 5b7b669b41 [COMPLIANCE] Add/Update Copyright Headers (#68)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-05-11 22:37:05 -07:00
dependabot[bot] 7b4ee7f403 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 (#66)
Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.69.0 to 1.97.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.69.0...service/s3/v1.97.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.97.3
  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-04-22 21:14:09 -07:00
dependabot[bot] 916324ee64 build(deps): bump google.golang.org/grpc from 1.68.0 to 1.79.3 (#65)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.68.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.68.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  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-04-22 21:09:52 -07:00
dependabot[bot] 11d3d6d523 build(deps): bump golang.org/x/crypto from 0.36.0 to 0.45.0 (#62)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.36.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-25 22:10:57 -08:00
oss-core-libraries-dashboard[bot]andhashicorp-copywrite[bot] 4758d5d9cd [COMPLIANCE] Update Copyright and License Headers (Batch 1 of 1) (#64)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-01-16 15:56:19 -08:00
Emilia 42e2126217 fix: Allow shared credentials in GenerateCredentialChain (#63)
* allow shared creds

* fix non-const printf format strings for go 1.24

* add .env and .envrc to .gitignore
2025-12-04 12:46:02 -05:00
Katrina Schwark 5d098ac71d Add PCI review checklist to PR template (#61) 2025-06-30 11:57:23 -06:00
dependabot[bot] 949976ec19 build(deps): bump golang.org/x/net from 0.36.0 to 0.38.0 (#60)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.36.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 16:23:17 -07:00
dependabot[bot] 49f692e7a8 build(deps): bump golang.org/x/net from 0.33.0 to 0.36.0 (#59)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-17 09:58:09 -07:00
daniandDanielle Miu a75ac95465 Implement NormalizeStorageBucketData and begin normalizing endpoint URLs (#58)
Co-authored-by: Danielle Miu <29378233+DanielleMiu@users.noreply.github.com>
2025-03-14 15:19:20 +00:00
Irena Rindos 7fd6d3f3e5 chore: update deps (#57) 2025-01-07 12:45:10 -05:00
Louis Ruch bdd27e2562 feat(storage): Add s3Client cache (#55)
* feat(storage): Add s3Client cache

* Update awsutil deps
2024-11-25 21:32:37 -08:00
Johan Brandhorst-Satzkorn f17f05ec82 Update CODEOWNERS (#54)
Make it effective with a wildcard
2024-11-18 16:23:19 -08:00
Damian Debkowski 899c62ce06 feat: support dual stack requests for aws clients (#52)
* feat: enable dual stack in aws clients

* refactor: optionally set custom endpoint resolver

* feat: update sts & iam client to utilize dualstack

* update aws dependencies to remove role policy bug
2024-10-28 11:50:18 -07:00
Louis Ruch ce22c1c46a Refactor error messages to include aws error details (#53)
* refactor error messages to include aws error details

* update deps
2024-10-21 09:55:51 -07:00
Hugo 2f9163ad0e docs(host): Add newly supported attributes 2024-10-03 18:19:49 +01:00
Damian Debkowski 7caff84c06 test: validate ipv6 compatibility (#50)
* test: validate ipv6 compatability

* test: update test to include checking against SBC state
2024-09-25 12:04:00 -07:00
Hugo 5f96422fbf chore(credential): Remove required arg from GetCredentialsConfig 2024-09-23 18:07:32 +01:00
Hugo 4dac4498d3 feat(service): Add support for host plugin AssumeRole
This commit also introduces a new dry run validation step when creating
or updating a host catalog. Additionally, it introduces new tests
related to using AssumeRole credentials, as well as more error and
success state cases.

service/host/plugin.go now closely follows service/storage/plugin.go.
This is by design as to bring both "halves" of the AWS plugin into
parity with each other.
2024-09-23 18:07:32 +01:00
hashicorp-copywrite[bot] 9af0110945 [COMPLIANCE] Add Copyright and License Headers (#47)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2024-07-24 11:50:06 -04:00
Elim Tsiagbey c2e7c94f9c fix: Append list error for permission validation (#48)
* fix: Append list error for permission validation

Instead of returning the error right away, append the error when `ListObject` is successful but the key expected if not found. Set the `Read` permission state as `Unknown` if the response from the list calls does not find the expected key.

This can be caused if the credentials do not have `s3:PutObject` but have `s3:ListBucket`. Without `s3:PutObject`, the object will not be written to S3 so other calls to find the object will not be successful even if they have valid permissions.

This prevents us from returning a `write` error on Boundary because the list call will always fail. Without this, the  Permissions are not wrapped and the SBC states are all set to `unknown` even though we know certain permission failed.
2024-07-24 11:48:14 -04:00
daniandDamian Debkowski 5add0a1b53 Worker storage bucket credential state (#46)
* feat: parse generic aws api errors

* feat: parse s3 api errors

* feat: return credential state for all status errors

* fix status codes on certain http error codes and re-enable two tests

* switch code to FailedPrecondition

* update certain error codes

* update go.mod for new sdk version

---------

Co-authored-by: Damian Debkowski <damian.debkowski@hashicorp.com>
2024-07-17 15:37:27 -04:00
Hugo 88798fb76b fix(plugin): Panic in tests if err variable is nil 2024-07-15 16:27:00 +01:00
Hugo 5626a997c8 fix(plugin): Remove AWS region validation
The check in `PartitionForRegion` was a static check against a list of
known regions. This would cause an error if the SDK was not up-to-date.
2024-07-15 16:27:00 +01:00
Michael Li 6cfccab32a chore: Update to use Node20 version of Actions (#43) 2024-05-03 15:50:39 -04:00
Hugo 282935aa79 chore(README): Fix Endpoint URL attribute 2024-02-07 23:18:49 +00:00
dani 050357f367 add DeleteObjects command to plugin (#38) 2023-12-15 19:12:35 +01:00
dependabot[bot] 86d383c7dd build(deps): bump google.golang.org/grpc from 1.53.0 to 1.56.3
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.53.0 to 1.56.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.53.0...v1.56.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-22 10:02:47 +02:00
dependabot[bot] 5ba86d5b84 build(deps): bump golang.org/x/net from 0.9.0 to 0.17.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.9.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.9.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-22 09:56:51 +02:00
Hugo 39f8f290fc fix(credential): Allow for static AWS credentials in environment
This commit fixes a problem where the plugin/AWS SDK would not recognise
static AWS credentials set in the machine's environment variables (by
setting AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY)

The default `WithSharedCredentials` value is `true` in awsutil (v2) -
This essentially disables the ability to read credentials from the
environment because we set the `default` AWS profile options, making the
SDK go search for a profile that may not exist and ignoring the
environment in the way, so we need to set it to `false` (see
`awsutil@v2.(*CredentialsConfig).generateAwsConfigOptions`) for extra
details.
2023-10-05 14:47:16 +01:00
Damian DebkowskiandHugo fcfe267a3f refactor: switch from awsutil to awsutilv2 (#34)
* fix(storage): Storage Assume Role Support

This commit introduces fixes to enable operators to use AWS dynamic
credentials on the storage side of this plugin, namely validation,
improved credential lifecycle management to allow for storage buckets to
be updated from static to dynamic credentials and vice versa, and a new
way to determine credential types.

It also introduces new tests for dynamic credential usage for the
various functions.

* fix(storage): Static rotated creds being deleted on plugin err

On Storage Bucket update, when we determine if the credentials are to be
updated to new ones, we call `credState.ReplaceCreds`.

This call, if the old credentials were managed by us (rotated at create
time), will delete the old credentials and set the new ones onto
credState.

We called this function without validating if the incoming credentials
are valid. This could get the bucket in a state where where, while we'd
return an update error if the incoming credentials weren't working with
S3 (err in the dry run process that happens at the end of the update),
we'd also silently delete the old credentials the bucket used, rendering
the bucket completely broken.

---------

Co-authored-by: Hugo <hugoamvieira@users.noreply.github.com>
2023-09-22 12:26:08 -07:00
Michael Li f263eefbba doc: Fix links to READMEs (#32) 2023-09-08 14:31:18 -04:00
58 changed files with 9989 additions and 1686 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
## PCI review checklist
<!-- heimdall_github_prtemplate:grc-pci_dss-2024-01-05 -->
- [ ] I have documented a clear reason for, and description of, the change I am making.
- [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
- [ ] If applicable, I've documented the impact of any changes to security controls.
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.
+4 -2
View File
@@ -4,6 +4,8 @@ on:
- workflow_dispatch
- push
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
@@ -13,9 +15,9 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
- name: Set up Go
uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
with:
go-version-file: 'go.mod'
cache: true
+9
View File
@@ -4,3 +4,12 @@ plan.tfplan
*.log
*.out
*.html
# IntelliJ IDEA project files
.idea
*.iprg
.DS_Store
.env
.envrc
+1 -2
View File
@@ -1,5 +1,4 @@
# These owners will be the default owners for everything in
# the repo, unless a later match takes precedence.
@hashicorp/boundary
* @hashicorp/boundary
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2021 HashiCorp, Inc.
Copyright IBM Corp. 2021, 2026
Mozilla Public License Version 2.0
==================================
+6 -4
View File
@@ -17,7 +17,7 @@ and then the given credential will be revoked. In this way, after rotation,
only Boundary knows the client secret in use by this plugin. More information
about AWS static credentials can be found [here](https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.html).
Credential rotation can be turned off by setting the
Credential rotation can be turned off by setting the
`disable_credential_rotation` attribute to `true`.
### Assume Role Credentials
@@ -25,6 +25,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
@@ -48,7 +50,7 @@ The plugin fetches hosts through the
[DescribeInstances](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html)
call.
[Getting Started](https://github.com/hashicorp/boundary-plugin-aws/plugin/service/host/README.md)
[Getting Started](plugin/service/host/README.md)
## Storage Bucket
@@ -60,7 +62,7 @@ these storage bucket resources can in turn be associated to targets within
Boundary.
At creation, update or deletion of a storage bucket of this type, configuration of the
plugin is performed via the attribute/secret values passed to the create, update, or
plugin is performed via the attribute/secret values passed to the create, update, or
delete calls actions. The values passed in to the plugin here are the attributes set
on on a storage bucket in boundary.
@@ -76,4 +78,4 @@ The plugin fetches metadata about the files through the
[HeadObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html)
call.
[Getting Started](https://github.com/hashicorp/boundary-plugin-aws/plugin/service/storage/README.md)
[Getting Started](plugin/service/storage/README.md)
+51 -47
View File
@@ -1,65 +1,69 @@
module github.com/hashicorp/boundary-plugin-aws
go 1.20
go 1.26.0
require (
github.com/aws/aws-sdk-go v1.44.80
github.com/aws/aws-sdk-go-v2 v1.20.1
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2
github.com/aws/smithy-go v1.14.1
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.0
github.com/hashicorp/boundary/sdk v0.0.33
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0
github.com/hashicorp/terraform-json v0.14.0
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
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5
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.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.8.4
google.golang.org/grpc v1.53.0
google.golang.org/protobuf v1.28.1
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
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/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
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/eventlogger v0.1.2-0.20230428153751-cca445805f24 // indirect
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20230428153751-cca445805f24 // indirect
github.com/hashicorp/eventlogger v0.2.11 // indirect
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.9 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.5.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/pointerstructure v1.2.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/zclconf/go-cty v1.10.0 // indirect
golang.org/x/crypto v0.8.0 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/zclconf/go-cty v1.15.0 // 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
)
+154 -198
View File
@@ -1,149 +1,149 @@
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
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/aws/aws-sdk-go v1.44.80 h1:jEXGecSgPdvM5KnyDsSgFhZSm7WwaTp4h544Im4SfhI=
github.com/aws/aws-sdk-go v1.44.80/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2 v1.20.1 h1:rZBf5DWr7YGrnlTK4kgDQGn1ltqOg5orCYb/UhOFZkg=
github.com/aws/aws-sdk-go-v2 v1.20.1/go.mod h1:NU06lETsFm8fUC6ZjhgDpVBcGZTFQ6XM+LZWZxMI4ac=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 h1:lN6L3LrYHeZ6xCxaIYtoWCx4GMLk4nRknsh29OMSqHY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12/go.mod h1:TDCkEAkMTXxTs0oLBGBKpBZbk3NLh8EvAfF0Q3x8/0c=
github.com/aws/aws-sdk-go-v2/config v1.18.33 h1:JKcw5SFxFW/rpM4mOPjv0VQ11E2kxW13F3exWOy7VZU=
github.com/aws/aws-sdk-go-v2/config v1.18.33/go.mod h1:hXO/l9pgY3K5oZJldamP0pbZHdPqqk+4/maa7DSD3cA=
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 h1:lIH1eKPcCY1ylR4B6PkBGRWMHO3aVenOKJHWiS4/G2w=
github.com/aws/aws-sdk-go-v2/credentials v1.13.32/go.mod h1:lL8U3v/Y79YRG69WlAho0OHIKUXCyFvSXaIvfo81sls=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 h1:DK/9C+UN/X+1+Wm8pqaDksQr2tSLzq+8X1/rI/ZxKEQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8/go.mod h1:ce7BgLQfYr5hQFdy67oX2svto3ufGtm6oBvmsHScI1Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 h1:c8ed/T9T2K5I+h/JzmF5tpI46+OODQ74dzmdo+QnaMg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38/go.mod h1:qggunOChCMu9ZF/UkAfhTz25+U2rLVb3ya0Ua6TTfCA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 h1:hNeAAymUY5gu11WrrmFb3CVIp9Dar9hbo44yzzcQpzA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32/go.mod h1:0ZXSqrty4FtQ7p8TEuRde/SZm9X05KT18LAUlR40Ln0=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 h1:fc0ukRAiP1syoSGZYu+DaE+FulSYhTiJ8WpVu5jElU4=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39/go.mod h1:WLAW8PT7+JhjZfLSWe7WEJaJu0GNo0cKc2Zyo003RBs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 h1:vUh7dBFNS3oFCtVv6CiYKh5hP9ls8+kIpKLeFruIBLk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1/go.mod h1:sFMeinkhj/SZKQM8BxtvNtSPjJEo0Xrz+w3g2e4FSKI=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0 h1:NXi4pNJWjAaiI56P1Rl8DC9A4jMNRE00WNBsDua5WRg=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0/go.mod h1:L3ZT0N/vBsw77mOAawXmRnREpEjcHd2v5Hzf7AkIH8M=
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2 h1:DPFxx/6Zwes/MiadlDteVqDKov7yQ5v9vuwfhZuJm1s=
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2/go.mod h1:cQTMNdo/Z5t1DDRsUnx0a2j6cPnytMBidUYZw2zks28=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 h1:iV/W5OMBys+66OeXJi/7xIRrKZNsu0ylsLGu+6nbmQE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13/go.mod h1:ReJb6xYmtGyu9KoFtRreWegbN9dZqvZIIv4vWnhcsyI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 h1:QviNkc+vGSuEHx8P+pVNKOdWLXBPIwMFv7p0fphgE4U=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33/go.mod h1:fABTUmOrAgAalG2i9WJpjBvlnk7UK8YmnYaxN+Q2CwE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 h1:dGAseBFEYxth10V23b5e2mAS+tX7oVbfYHD6dnDdAsg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32/go.mod h1:4jwAWKEkCR0anWk5+1RbfSg1R5Gzld7NLiuaq5bTR/Y=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 h1:PT6PBCycRwhpEW5hJnRiceCeoWJ+r3bdgXtV+VKG7Pk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1/go.mod h1:TqoxCLwT2nrxrBGA+z7t6OWM7LBkgRckK3gOjYE+7JA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 h1:v346f1h8sUBKXnEbrv43L37MTBlFHyKXQPIZHNAaghA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2/go.mod h1:cwCATiyNrXK9P2FsWdZ89g9mpsYv2rhk0UA/KByl5fY=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 h1:A2RlEMo4SJSwbNoUUgkxTAEMduAy/8wG3eB2b2lP4gY=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2/go.mod h1:ju+nNXUunfIFamXUIZQiICjnO/TPlOmWcYhZcSy7xaE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 h1:OJELEgyaT2kmaBGZ+myyZbTTLobfe3ox3FSh5eYK9Qs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2/go.mod h1:ubDBBaDFs1GHijSOTi8ljppML15GLG0HxhILtbjNNYQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 h1:ympg1+Lnq33XLhcK/xTG4yZHPs1Oyxu+6DEWbl7qOzA=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2/go.mod h1:FQ/DQcOfESELfJi5ED+IPPAjI5xC6nxtSolVVB773jM=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.14.1 h1:EFKMUmH/iHMqLiwoEDx2rRjRQpI1YCn5jTysoaDujFs=
github.com/aws/smithy-go v1.14.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
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.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=
github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
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.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.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.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.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.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.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.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.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=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
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.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=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/boundary/sdk v0.0.33 h1:kHaVZ3b9j1QvYlG+vhAgrbXh8/+Rq2qlUIs8fKMQH7k=
github.com/hashicorp/boundary/sdk v0.0.33/go.mod h1:jn9j5mM8v2pOk8aLeJNIszdm7WBf4gRSP68iy8Iu5Q0=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/eventlogger v0.1.2-0.20230428153751-cca445805f24 h1:vtCJgcxwDMFrpUgVMfjtwXY1YqvHbj8pNA7GsXYEoLg=
github.com/hashicorp/eventlogger v0.1.2-0.20230428153751-cca445805f24/go.mod h1://CHt6/j+Q2lc0NlUB5af4aS2M0c0aVBg9/JfcpAyhM=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20230428153751-cca445805f24 h1:mopKPTxSaVr24uSysyRwywTDs85L5b05qqZvEkWa0qc=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20230428153751-cca445805f24/go.mod h1:EQPLoX6CONA9BSYUovTQBHfPGE91g7wOxv03sO29FzY=
github.com/hashicorp/eventlogger v0.2.11 h1:lkK8ARM/DrCeL5deH2yVS6p40337dESi+77p9pIfLqo=
github.com/hashicorp/eventlogger v0.2.11/go.mod h1:Rmc3MEopz7jUnLokZVTWAsXaEP5rqd20ObGS+pcau3U=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 h1:iAb287bq0TaWTnhDYuN/zVqdD2EwanQg9ncVelC60Xc=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0/go.mod h1:tMywUTIvdB/FXhwm6HMTt61C8/eODY6gitCHhXtyojg=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.4 h1:8z+Zi0uLYXONkxwWb/D7O5t1IUucB6Ft3uvSOa950bM=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.9 h1:JpCvi97NMA+saNqO8ovQcGoRbBq6P5ZZlJqvOsW5ick=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.9/go.mod h1:NtMaPhqSlfQ72XWDD2g80o8HI8RKkowIB8/WZHMyPY4=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.8 h1:/GIUjn9GkFXMk/8/irRdbdtmx8CcyeyWdVy/E5LvzyA=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.8/go.mod h1:JDc9UOD4EVRDIwPVethJcT5Ibi/Nas6eQDPtA60iwP0=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 h1:afreZ1WJd0WI7v4NsMZ1aL7V/T59sxPuKmQDgGUja20=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20/go.mod h1:NeK2Ul15t1zutp/dZzt28XQrGZHosbxE/QLNNfaWObM=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 h1:ca5TSI4AgaOncPpyzLDtCGjVEtKukONpeM95vFxXCOQ=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0/go.mod h1:7CUvZtfTp2U0CYQCLzMtS2ngckjAZePSfwrE2aeDP1M=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
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.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/configutil/v2 v2.0.9 h1:YZ01/aiARPneDUryzjiPGvr3pmjCSC+1WBZKI1P7I7k=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.6 h1:rOmnqOSUBn5opme1Kh9OXO8qWd0KztO+f02vvCwCc/w=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.4 h1:IU2iGcvthrJ53rPbQU6B4+iVs/cLPO89ti5dd2XEj3k=
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=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.13/go.mod h1:NDRQ/F3DXTylqjORAP0cA+puH/JFrLlT+NlqxHK2/e8=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10 h1:2iDz+t0JLl1W0tJhvmhsh/UBgT1JgC8Qxz8HxYMWXQo=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10/go.mod h1:eZkXE+osawMrAWR4wJRmyKauUwH6mNGbjFuiDujnbPk=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.8 h1:pgSicufBI+MvaIG9Keykb3k9B3sWtDNjmsoyrl2/8Qw=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.8/go.mod h1:sBcjk+paCXCMR9HHLcrYSfPz2FsskD8MQuotKiyM2aA=
github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1 h1:SMGUnbpAcat8rIKHkBPjfv81yC46a8eCNZ2hsR2l1EI=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1/go.mod h1:Ch/bf00Qnx77MZd49JRgHYqHQjtEmTgGU2faufpVZb0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.2 h1:phcbL8urUzF/kxA/Oj6awENaRwfWsjP59GW7u2qlDyY=
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 h1:xbrxd0U9XQW8qL1BAz2XrAjAF/P2vcqUTAues9c24B8=
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3/go.mod h1:LWq2Sy8UoKKuK4lFuCNWSjJj57MhNNf2zzBWMtkAIX4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.5.0 h1:O293SZ2Eg+AAYijkVK3jR786Am1bhDEh2GHT0tIVE5E=
github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s=
github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W65euzixn7clSzK66puSt1H4I5SC0EmHH4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@@ -152,109 +152,65 @@ github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8oh
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
go.uber.org/goleak v1.0.0 h1:qsup4IcBdlmsnGfqyLl4Ntn3C2XCCuKAE7DwHpScyUo=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.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.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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488 h1:QQF+HdiI4iocoxUjjpLgvTYDHKm99C/VtTBFnfiCJos=
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
+87 -17
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -6,7 +6,6 @@ package credential
import (
"fmt"
"github.com/aws/aws-sdk-go/aws/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
@@ -35,9 +34,10 @@ type CredentialAttributes struct {
}
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
// CredentialsConfig used for configuring an AWS session. An error is returned if
// any unrecognized fields are found in the protobuf struct input.
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, required bool) (*awsutil.CredentialsConfig, error) {
// CredentialsConfig used for configuring an AWS session. An status error is returned
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
// struct input.
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, dualStack bool) (*awsutil.CredentialsConfig, error) {
// initialize secrets if it is nil
// secrets can be nil because static credentials are optional
if secrets == nil {
@@ -49,13 +49,13 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes,
unknownFields := values.StructFields(secrets)
badFields := make(map[string]string)
accessKey, err := values.GetStringValue(secrets, ConstAccessKeyId, required)
accessKey, err := values.GetStringValue(secrets, ConstAccessKeyId, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = err.Error()
}
delete(unknownFields, ConstAccessKeyId)
secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, required)
secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error()
}
@@ -117,13 +117,29 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes,
opts = append(opts, awsutil.WithRoleTags(attrs.RoleTags))
}
return awsutil.NewCredentialsConfig(opts...)
if dualStack {
opts = append(opts, []awsutil.Option{
awsutil.WithIamEndpointResolver(&iamEndpointResolver{
dualStack: dualStack,
}),
awsutil.WithStsEndpointResolver(&stsEndpointResolver{
dualStack: dualStack,
}),
}...)
}
cfg, err := awsutil.NewCredentialsConfig(opts...)
if err != nil {
return nil, errors.BadRequestStatus(err.Error())
}
return cfg, nil
}
// GetCredentialAttributes parses values out of a protobuf struct input and returns a
// CredentialAttributes used for configuring an AWS session. An error is returned if
// any of the following fields are missing from the protobuf struct input or have
// invalid value types: region, disableCredentialRotation
// CredentialAttributes used for configuring an AWS session. An status error is returned
// with an InvalidArgument code if any of the following fields are missing from the protobuf
// struct input or have invalid value types: region, disableCredentialRotation
func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
badFields := make(map[string]string)
@@ -132,12 +148,6 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
}
if region != "" {
if _, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region); !found {
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = fmt.Sprintf("not a valid region: %s", region)
}
}
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
@@ -176,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
}
+243 -25
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -25,13 +25,6 @@ func TestGetCredentialAttributes(t *testing.T) {
in: map[string]any{},
expectedErrContains: "missing required value \"region\"",
},
{
name: "invalid aws region value",
in: map[string]any{
ConstRegion: "dne",
},
expectedErrContains: "not a valid region",
},
{
name: "bad value for disable_credential_rotation",
in: map[string]any{
@@ -108,12 +101,238 @@ 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
secrets *structpb.Struct
required bool
attrs *CredentialAttributes
dualStack bool
expected *awsutil.CredentialsConfig
expectedErrContains string
}{
@@ -144,30 +363,22 @@ func TestGetCredentialsConfig(t *testing.T) {
},
},
{
name: "missing access key",
name: "with dualstack",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
dualStack: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
required: true,
expectedErrContains: "secrets.access_key_id: missing required value",
},
{
name: "missing secret key",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
},
expected: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoobar",
SecretKey: "bazqux",
Region: "us-west-2",
},
required: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expectedErrContains: "secrets.secret_access_key: missing required value",
},
{
name: "unknown fields",
@@ -267,7 +478,7 @@ func TestGetCredentialsConfig(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.required)
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.dualStack)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
@@ -283,6 +494,13 @@ func TestGetCredentialsConfig(t *testing.T) {
require.Equal(tc.expected.RoleExternalId, actual.RoleExternalId)
require.Equal(tc.expected.RoleSessionName, actual.RoleSessionName)
require.Equal(tc.expected.RoleTags, actual.RoleTags)
if tc.dualStack {
require.NotNil(actual.IAMEndpointResolver)
require.NotNil(actual.STSEndpointResolver)
} else {
require.Nil(actual.IAMEndpointResolver)
require.Nil(actual.STSEndpointResolver)
}
})
}
}
+18 -3
View File
@@ -1,13 +1,13 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
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"
)
+89 -23
View File
@@ -1,17 +1,21 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"errors"
stderr "errors"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"google.golang.org/protobuf/types/known/structpb"
@@ -71,7 +75,7 @@ func WithStateTestOpts(opts []awsutil.Option) AwsCredentialPersistedStateOption
func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedStateOption {
return func(s *AwsCredentialPersistedState) error {
if s.CredentialsConfig != nil {
return errors.New("credentials config already set")
return stderr.New("credentials config already set")
}
s.CredentialsConfig = x
@@ -83,7 +87,7 @@ func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedS
func WithCredsLastRotatedTime(t time.Time) AwsCredentialPersistedStateOption {
return func(s *AwsCredentialPersistedState) error {
if !s.CredsLastRotatedTime.IsZero() {
return errors.New("last rotation time already set")
return stderr.New("last rotation time already set")
}
s.CredsLastRotatedTime = t
@@ -106,13 +110,40 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (
// ValidateCreds takes the credentials configuration from the persisted state
// and runs sts.GetCallerIdentity for the current credentials, which is done
// to check that the credentials are valid.
// 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.New("missing credentials config")
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 {
return fmt.Errorf("error validating credentials: %w", err)
st, _ := errors.ParseAWSError("validating credentials", err)
return st.Err()
}
return nil
}
@@ -121,18 +152,19 @@ func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
// then deletes the old access key. If deletion of the old access key is successful, the new access key/secret key are
// written into the credentials config and the persisted state. On any error, the old credentials are not overwritten.
// This ensures that any generated new secret key never leaves this function in case of an error, even though it will
// still result in an extraneous access key existing.
// still result in an extraneous access key existing. This method returns a status error with PluginError details.
func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
return errors.New("invalid credential type")
return errors.BadRequestStatus("invalid credential type")
}
if err := s.CredentialsConfig.RotateKeys(ctx, append([]awsutil.Option{
awsutil.WithValidityCheckTimeout(rotationWaitTimeout),
}, s.testOpts...)...); err != nil {
return fmt.Errorf("error rotating credentials: %w", err)
st, _ := errors.ParseAWSError("rotating credentials", err)
return st.Err()
}
s.CredsLastRotatedTime = time.Now()
return nil
@@ -140,13 +172,14 @@ func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error {
// ReplaceCreds replaces the access key in the state with a new key.
// If the existing key was rotated at any point in time, it is
// deleted first, otherwise it's left alone.
// deleted first, otherwise it's left alone. This method returns a
// status error with PluginError details.
func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credentialsConfig *awsutil.CredentialsConfig) error {
if credentialsConfig == nil {
return errors.New("missing new credentials config")
return errors.BadRequestStatus("missing new credentials config")
}
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
// Delete old/existing credentials. This action is only possible for static credential types.
@@ -166,27 +199,29 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credenti
// DeleteCreds deletes the credentials in the state. The access key
// ID, secret access key, and rotation time fields are zeroed out in
// the state just to ensure that they cannot be re-used after.
// the state just to ensure that they cannot be re-used after. This
// method returns a status error with PluginError details.
func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
return errors.New("invalid credential type")
return errors.BadRequestStatus("invalid credential type")
}
if err := s.CredentialsConfig.DeleteAccessKey(ctx, s.CredentialsConfig.AccessKey, s.testOpts...); err != nil {
// Determine if the deletion error was due to a missing
// resource. If it was, just pass it.
var awsErr *iamTypes.NoSuchEntityException
if errors.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" {
if stderr.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" {
s.CredentialsConfig = nil
s.CredsLastRotatedTime = time.Time{}
return nil
}
// Otherwise treat it as an actual error.
return err
st, _ := errors.ParseAWSError("deleting credentials", err)
return st.Err()
}
s.CredentialsConfig = nil
@@ -224,7 +259,7 @@ func (s *AwsCredentialPersistedState) ToMap() map[string]any {
// AwsCredentialPersistedStateFromProto parses values out of a protobuf struct input
// and returns a AwsCredentialPersistedState used for configuring an AWS session.
func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, dualStack bool, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
// initialize secrets if it is nil
// secrets can be nil because static credentials are optional
if secrets == nil {
@@ -239,17 +274,17 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede
accessKeyId, err := values.GetStringValue(secrets, ConstAccessKeyId, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
secretAccessKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
s, err := NewAwsCredentialPersistedState(opts...)
@@ -279,6 +314,17 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede
if len(attrs.RoleTags) != 0 {
awsOpts = append(awsOpts, awsutil.WithRoleTags(attrs.RoleTags))
}
if dualStack {
awsOpts = append(awsOpts, []awsutil.Option{
awsutil.WithIamEndpointResolver(&iamEndpointResolver{
dualStack: dualStack,
}),
awsutil.WithStsEndpointResolver(&stsEndpointResolver{
dualStack: dualStack,
}),
}...)
}
credentialsConfig, err := awsutil.NewCredentialsConfig(awsOpts...)
if err != nil {
return nil, err
@@ -308,3 +354,23 @@ func GetCredentialType(cc *awsutil.CredentialsConfig) CredentialType {
return Unknown
}
type iamEndpointResolver struct {
dualStack bool
}
func (e *iamEndpointResolver) ResolveEndpoint(ctx context.Context, params iam.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = iam.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
type stsEndpointResolver struct {
dualStack bool
}
func (e *stsEndpointResolver) ResolveEndpoint(ctx context.Context, params sts.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = sts.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
+96 -42
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -14,7 +14,10 @@ import (
"github.com/aws/aws-sdk-go-v2/service/iam"
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
@@ -109,6 +112,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
name string
secrets *structpb.Struct
attrs *CredentialAttributes
dualStack bool
opts []AwsCredentialPersistedStateOption
expected *AwsCredentialPersistedState
expectedErr string
@@ -171,6 +175,26 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
},
},
},
{
name: "with dualstack static credentials",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("foobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
dualStack: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expected: &AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
Region: "us-west-2",
AccessKey: "foobar",
SecretKey: "bazqux",
},
},
},
{
name: "bad last rotated time",
secrets: &structpb.Struct{
@@ -183,7 +207,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expectedErr: "persisted state integrity error: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"",
expectedErr: "persisted state integrity: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"",
},
{
name: "option error",
@@ -315,7 +339,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.opts...)
actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.dualStack, tc.opts...)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
return
@@ -333,6 +357,13 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
require.Equal(tc.expected.CredentialsConfig.RoleExternalId, actual.CredentialsConfig.RoleExternalId)
require.Equal(tc.expected.CredentialsConfig.RoleSessionName, actual.CredentialsConfig.RoleSessionName)
require.Equal(tc.expected.CredentialsConfig.RoleTags, actual.CredentialsConfig.RoleTags)
if tc.dualStack {
require.NotNil(actual.CredentialsConfig.STSEndpointResolver)
require.NotNil(actual.CredentialsConfig.IAMEndpointResolver)
} else {
require.Nil(actual.CredentialsConfig.STSEndpointResolver)
require.Nil(actual.CredentialsConfig.IAMEndpointResolver)
}
}
})
}
@@ -340,14 +371,16 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
cases := []struct {
name string
in *AwsCredentialPersistedState
expectedErr string
name string
in *AwsCredentialPersistedState
expectedStatusCode codes.Code
expectedErr string
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedStatusCode: codes.InvalidArgument,
expectedErr: "missing credentials config",
},
{
name: "validation error",
@@ -365,7 +398,8 @@ func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr),
expectedStatusCode: codes.Unknown,
expectedErr: "aws service unknown: unknown error: validating credentials",
},
{
name: "good",
@@ -383,10 +417,12 @@ func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.ValidateCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
assert.Equal(tc.expectedStatusCode, status.Code(err))
return
}
@@ -402,11 +438,13 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
exepected *awsutil.CredentialsConfig
expectedNonZeroRotatedTime bool
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete dynamic credential type",
@@ -417,7 +455,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete unknown credential type",
@@ -428,7 +467,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "rotation error",
@@ -446,7 +486,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr),
expectedErr: "aws service unknown: unknown error: rotating credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "good",
@@ -496,10 +537,12 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.RotateCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
assert.Equal(tc.expectedStatusCode, status.Code(err))
return
}
@@ -521,17 +564,20 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
expected *awsutil.CredentialsConfig
expectedDeleteCalled bool
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing new credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing new credentials config",
name: "missing new credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing new credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "deletion error",
@@ -549,8 +595,9 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
),
},
},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "aws service unknown: unknown error: deleting credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "good with delete of old rotated",
@@ -604,11 +651,12 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
state.Reset()
err := tc.in.ReplaceCreds(context.Background(), tc.credentialConfig)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
return
}
@@ -623,14 +671,16 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
cases := []struct {
name string
in *AwsCredentialPersistedState
expectedErr string
name string
in *AwsCredentialPersistedState
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete dynamic credential type",
@@ -641,7 +691,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete unknown credential type",
@@ -652,7 +703,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "deletion error",
@@ -669,7 +721,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
expectedErr: "aws service unknown: unknown error: deleting credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "deletion error, but OK because key was just gone",
@@ -708,10 +761,11 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.DeleteCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
return
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
+283 -2
View File
@@ -1,17 +1,96 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"bytes"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/aws/aws-sdk-go-v2/aws/retry"
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"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
// awsErrorAccessDenied is returned when the credentials do not have
// the required permissions to do a specific action on a resource.
// This error will persist until the IAM Role Policy is updated by
// the owner. This error cannot be resolved by retrying the request.
awsErrorAccessDenied = "AccessDenied"
// awsErrorUnauthorizedOperation is returned by EC2 when the caller is not
// authorized to perform the operation. This includes DryRun permission
// checks that fail (the counterpart to DryRunOperation success).
awsErrorUnauthorizedOperation = "UnauthorizedOperation"
// awsErrorInvalidAccessKeyId is returned when the credential does
// not exist. This error will persist until the credentials attached
// to the storage bucket is updated. This error can occur when the
// given credentials are deleted, deactivated, or rotated. This
// error cannot be resolved by retrying the request.
awsErrorInvalidAccessKeyId = "InvalidAccessKeyId"
// awsErrorExpiredToken is returned when the provided credential is
// expired. This error can only be returned by a dynamic credential
// type. This error can resolve itself by retrying the request.
awsErrorExpiredToken = "ExpiredToken"
// NoSuchBucket is returned when the specified S3 storage bucket does
// not exist
awsErrorNoSuchBucket = "NoSuchBucket"
// BadDigest is returned when an S3 PUT OBJECT request's calculated
// checksum does not match the checksum which was sent by the client
awsErrorBadDigest = "BadDigest"
// NoSuchKey is returned when attempting to interact with an S3 object
// whose key does not exist
awsErrorNoSuchKey = "NoSuchKey"
// NotFound is returned when attempting to interact with an S3 object
// whose key does not exist
awsErrorNotFound = "NotFound"
// The InvalidObjectState error is returned when trying to
// access an object that was moved into cold storage.
awsErrorInvalidObjectState = "InvalidObjectState"
// RequestTimeout is returned when an http request takes longer than allowed
awsErrorRequestTimeout = "RequestTimeout"
// 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
@@ -22,5 +101,207 @@ func InvalidArgumentError(msg string, f map[string]string) error {
sort.Strings(fieldMsgs)
msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", "))
}
return status.Error(codes.InvalidArgument, msg)
return BadRequestStatus(msg)
}
// ParseAWSError converts an aws service error into a RPC status. This method
// will handle parsing the following aws error codes: throttling, connections,
// and credentials. This method will fallback to parsing the http status code
// when it cannot match an aws error code. This method does not handle specific
// service type errors such as S3 or EC2.
func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Permission) {
if err == nil {
return nil, nil
}
msg := fmt.Sprintf("%s: %v", op, err)
// find the service name of the aws api
serviceName := "unknown"
var oe *smithy.OperationError
if errors.As(err, &oe) {
serviceName = oe.Service()
}
// The unknown plugin error code will be used
// when the aws service returns an error that
// has failed to be parsed. By default, this
// error code is retryable.
defer func() {
if st == nil {
statusMsg := fmt.Sprintf("aws service %s: unknown error: %s", serviceName, msg)
st = status.New(codes.Unknown, statusMsg)
}
if permission == nil {
permission = &pb.Permission{State: pb.StateType_STATE_TYPE_UNKNOWN, CheckedAt: timestamppb.Now()}
}
}()
// evaluate against aws throttling error codes
throttleErr := retry.ThrottleErrorCode{
Codes: retry.DefaultThrottleErrorCodes,
}
if throttleErr.IsErrorThrottle(err).Bool() {
statusMsg := fmt.Sprintf("aws service %s: throttling: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
}
// match error type against NotFound
var notFoundErr *types.NotFound
if errors.As(err, &notFoundErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against NoSuchkey
var noSuchKeyErr *types.NoSuchKey
if errors.As(err, &noSuchKeyErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against InvalidObjectState
var invalidObjectStateErr *types.InvalidObjectState
if errors.As(err, &invalidObjectStateErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against NoSuchBucket
var noSuchBucketErr *types.NoSuchBucket
if errors.As(err, &noSuchBucketErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: noSuchBucketErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
}
// match error type against AccessDenied
var accessDeniedErr *types.AccessDenied
if errors.As(err, &accessDeniedErr) {
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: accessDeniedErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
}
// fallback to evaluating the smithy APIError code
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case awsErrorAccessDenied:
fallthrough
case awsErrorUnauthorizedOperation:
fallthrough
case awsErrorInvalidAccessKeyId:
fallthrough
case awsErrorExpiredToken:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: apiErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
case awsErrorNoSuchBucket:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: apiErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
case awsErrorBadDigest:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.Aborted, statusMsg), nil
case awsErrorNoSuchKey:
fallthrough
case awsErrorNotFound:
fallthrough
case awsErrorInvalidObjectState:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
case awsErrorRequestTimeout:
fallthrough
case awsErrorRequestTimeoutException:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.DeadlineExceeded, statusMsg), nil
}
}
// fallback to evaluating the http status code
var httpErr *awshttp.ResponseError
if errors.As(err, &httpErr) {
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(httpErr.Response.Body)
if err != nil {
return status.New(codes.Internal, err.Error()), nil
}
defer httpErr.Response.Body.Close()
switch httpErr.HTTPStatusCode() {
case http.StatusBadRequest:
statusMsg := fmt.Sprintf("aws service %s: bad request: %s", serviceName, msg)
return status.New(codes.InvalidArgument, statusMsg), nil
case http.StatusUnauthorized:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: buf.String(),
CheckedAt: timestamppb.Now(),
}
case http.StatusForbidden:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: buf.String(),
CheckedAt: timestamppb.Now(),
}
case http.StatusNotFound:
statusMsg := fmt.Sprintf("aws service %s: resource not found: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
case http.StatusTooManyRequests:
statusMsg := fmt.Sprintf("aws service %s: throttling: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
case http.StatusInternalServerError:
fallthrough
case http.StatusBadGateway:
fallthrough
case http.StatusServiceUnavailable:
statusMsg := fmt.Sprintf("aws service %s: connectivity: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
case http.StatusRequestTimeout:
fallthrough
case http.StatusGatewayTimeout:
statusMsg := fmt.Sprintf("aws service %s: timeout: %s", serviceName, msg)
return status.New(codes.DeadlineExceeded, statusMsg), nil
}
}
return st, nil
}
// BadRequestStatusf returns a status error with an invalid
// argument code and a formatted error message.
func BadRequestStatusf(format string, args ...any) error {
return status.New(codes.InvalidArgument, fmt.Sprintf(format, args...)).Err()
}
// BadRequestStatus returns a status error with an invalid
// argument code and the provided message.
func BadRequestStatus(msg string) error {
return status.New(codes.InvalidArgument, msg).Err()
}
// UnknownStatusf returns a status error with an internal
// error code and a formatted error message.
func UnknownStatusf(format string, args ...any) error {
return status.New(codes.Internal, fmt.Sprintf(format, args...)).Err()
}
// UnknownStatus returns a status error with an internal
// error code and the provided message.
func UnknownStatus(msg string) error {
return status.New(codes.Internal, msg).Err()
}
+478
View File
@@ -0,0 +1,478 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"fmt"
"testing"
"net/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
func Test_ParseAWSError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
expectedStatusCode codes.Code
expectedStatusMsg string
expectedPermission *pb.Permission
}{
{
name: "nil-error",
expectedStatusCode: codes.OK,
},
{
name: "default-to-unknwon",
err: fmt.Errorf("what can this error be?"),
expectedStatusCode: codes.Unknown,
expectedStatusMsg: "aws service unknown: unknown error: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-bad-request",
err: TestAwsHttpResponseError(http.StatusBadRequest, "bad request"),
expectedStatusCode: codes.InvalidArgument,
expectedStatusMsg: "aws service unknown: bad request: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-unauthorized",
err: TestAwsHttpResponseError(http.StatusUnauthorized, "unauthorized request"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "unauthorized request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "http-status-code-forbidden",
err: TestAwsHttpResponseError(http.StatusForbidden, "forbidden request"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "forbidden request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "http-status-code-not-found",
err: TestAwsHttpResponseError(http.StatusNotFound, "not found request"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: resource not found: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-too-many-requests",
err: TestAwsHttpResponseError(http.StatusTooManyRequests, "too many requests"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-request-timeout",
err: TestAwsHttpResponseError(http.StatusRequestTimeout, "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: timeout: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-internal-server-error",
err: TestAwsHttpResponseError(http.StatusInternalServerError, "internal server error"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-bad-gateway",
err: TestAwsHttpResponseError(http.StatusBadGateway, "bad gateway"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-service-unavailable",
err: TestAwsHttpResponseError(http.StatusServiceUnavailable, "service unavailable"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-gateway-timeout",
err: TestAwsHttpResponseError(http.StatusGatewayTimeout, "gateway timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: timeout: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "wrapped-http-status-code-forbidden",
err: fmt.Errorf("wrap: %w", TestAwsHttpResponseError(http.StatusForbidden, "forbidden request")),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "forbidden request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-throttling",
err: TestAwsError("Throttling", "throttling error"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-throttling-exception",
err: TestAwsError("ThrottlingException", "throttling exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-throttled-exception",
err: TestAwsError("ThrottledException", "throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-throttled-exception",
err: TestAwsError("RequestThrottledException", "request throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-too-many-requests-exception",
err: TestAwsError("TooManyRequestsException", "too many requests exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-provisioned-throughput-exceeded-exception",
err: TestAwsError("ProvisionedThroughputExceededException", "provisioned throughput exceeded exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-transaction-in-progress-exception",
err: TestAwsError("TransactionInProgressException", "transaction in progress exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-limit-exceeded",
err: TestAwsError("RequestLimitExceeded", "request limit exceeded"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-bandwidth-limit-exceeded",
err: TestAwsError("BandwidthLimitExceeded", "bandwidth limit exceeded"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-limit-exceeded-exception",
err: TestAwsError("LimitExceededException", "limit exceeded exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-throttled",
err: TestAwsError("RequestThrottled", "request throttled"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-slow-down",
err: TestAwsError("SlowDown", "slow down"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-prior-request-not-complete",
err: TestAwsError("PriorRequestNotComplete", "prior request not complete"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-ec2-throttled-exception",
err: TestAwsError("EC2ThrottledException", "ec2 throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-timeout",
err: TestAwsError("RequestTimeout", "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-timeout-exception",
err: TestAwsError("RequestTimeoutException", "request timeout exception"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-access-denied",
err: TestAwsError(awsErrorAccessDenied, "not authorized to perform action"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "not authorized to perform action",
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."),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "The AWS Access Key Id you provided does not exist in our records.",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-request-expired-token",
err: TestAwsError(awsErrorExpiredToken, "The security token included in the request is expired."),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "The security token included in the request is expired.",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-s3-typed-not-found",
err: TestAwsS3NotFoundError("object was not found"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-no-such-key",
err: TestAwsS3NoSuchKeyError("key does not exist"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-invalid-object-state",
err: TestAwsS3InvalidObjectStateError("object is archived"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-no-such-bucket",
err: TestAwsS3NoSuchBucketError("bucket does not exist"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "bucket does not exist",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-s3-typed-access-denied",
err: TestAwsS3AccessDeniedError("access denied"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "access denied",
CheckedAt: timestamppb.Now(),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
require, assert := require.New(t), assert.New(t)
actualStatus, permission := ParseAWSError("test", tt.err)
if tt.expectedStatusCode == codes.OK {
require.Nil(actualStatus)
return
}
require.Equal(tt.expectedStatusCode, actualStatus.Code())
require.Contains(actualStatus.Message(), tt.expectedStatusMsg)
require.Len(actualStatus.Details(), 0)
CheckSimilarPermission(assert, tt.expectedPermission, permission, true)
})
}
}
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")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.InvalidArgument)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_BadRequestStatus(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := BadRequestStatus("test: hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.InvalidArgument)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_UnknownStatusf(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := UnknownStatusf("test: %s", "hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.Internal)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_UnknownStatus(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := UnknownStatusf("test: hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.Internal)
_, ok := status.FromError(err)
require.True(ok)
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"io"
"net/http"
"strings"
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"
smithyhttp "github.com/aws/smithy-go/transport/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
// TestAwsHttpResponseError returns an aws http response error
func TestAwsHttpResponseError(code int, msg string) error {
return &awshttp.ResponseError{
ResponseError: &smithyhttp.ResponseError{
Response: &smithyhttp.Response{
Response: &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(msg)),
},
},
},
}
}
// TestAwsError returns an generic api error
func TestAwsError(code, msg string) error {
return &smithy.GenericAPIError{
Code: code,
Message: msg,
Fault: smithy.FaultServer,
}
}
// TestAwsS3NotFoundError returns an S3 NotFound error.
func TestAwsS3NotFoundError(msg string) error {
return &types.NotFound{Message: &msg}
}
// TestAwsS3NoSuchKeyError returns an S3 NoSuchKey error.
func TestAwsS3NoSuchKeyError(msg string) error {
return &types.NoSuchKey{Message: &msg}
}
// TestAwsS3InvalidObjectStateError returns an S3 InvalidObjectState error.
func TestAwsS3InvalidObjectStateError(msg string) error {
return &types.InvalidObjectState{Message: &msg}
}
// TestAwsS3NoSuchBucketError returns an S3 NoSuchBucket error.
func TestAwsS3NoSuchBucketError(msg string) error {
return &types.NoSuchBucket{Message: &msg}
}
// TestAwsS3AccessDeniedError returns an S3 AccessDenied error.
func TestAwsS3AccessDeniedError(msg string) error {
return &types.AccessDenied{Message: &msg}
}
func CheckSimilarPermission(assert *assert.Assertions, expected, actual *pb.Permission, substituteNilWithDefaultOk bool) {
if expected == nil && substituteNilWithDefaultOk {
expected = &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
CheckedAt: timestamppb.Now(),
}
}
if expected != nil {
assert.NotNil(actual)
if actual != nil {
assert.Equal(expected.State, actual.State, "StateType mismatch")
assert.Equal(expected.ErrorDetails, actual.ErrorDetails)
assert.NotNil(actual.CheckedAt)
}
} else {
assert.Equal(expected, actual)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package values
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package values
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
@@ -32,6 +32,6 @@ type AwsPlugin struct {
func NewAwsPlugin() *AwsPlugin {
return &AwsPlugin{
HostPlugin: &host.HostPlugin{},
StoragePlugin: &storage.StoragePlugin{},
StoragePlugin: storage.New(),
}
}
+18
View File
@@ -108,6 +108,24 @@ The following `attributes` are valid on an AWS host catalog resource:
be performed. See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information.
- `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 principal `AssumeRole`
provider.
- `role_session_name` (string): The session name configured for the principal `AssumeRole`
provider.
- `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:
+62 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -19,6 +19,17 @@ import (
// map.
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
// InstanceAddressesOnly specifies whether only the instance addresses (private IPv4/DNS,
// public IPv4/DNS, and IPv6) are synced, or all addresses, including those from
// secondary ENIs, are synced.
InstanceAddressesOnly bool
}
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
@@ -31,6 +42,26 @@ 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()
}
delete(unknownFields, ConstAwsDualStack)
instanceAddressesOnly, err := values.GetBoolValue(in, ConstInstanceAddressesOnly, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstInstanceAddressesOnly)] = err.Error()
}
delete(unknownFields, ConstInstanceAddressesOnly)
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
@@ -46,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"
}
@@ -56,7 +97,10 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
}
return &CatalogAttributes{
CredentialAttributes: credAttributes,
CredentialAttributes: credAttributes,
TargetAccount: targetCredAttributes,
DualStack: dualStack,
InstanceAddressesOnly: instanceAddressesOnly,
}, nil
}
@@ -99,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
}
+197 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -43,6 +43,38 @@ func TestGetCatalogAttributes(t *testing.T) {
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "with dual stack",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"dual_stack": structpb.NewBoolValue(true),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &credential.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
},
DualStack: true,
},
},
{
name: "withInstanceAddressesOnly",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"instance_addresses_only": structpb.NewBoolValue(true),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &credential.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
},
InstanceAddressesOnly: true,
},
},
{
name: "default",
in: &structpb.Struct{
@@ -86,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 {
+9 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -10,4 +10,12 @@ const (
// ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2
// instance's name. This field is to be matched literally.
ConstInstanceNameTagKey = "Name"
// ConstAwsDualStack is the key for the dualstack flag in the aws s3 client.
ConstAwsDualStack = "dual_stack"
// ConstInstanceAddressesOnly is the key (instance_addresses_only) that specifies whether
// only the instance addresses (private IPv4/DNS, public IPv4/DNS, and IPv6) are synced,
// or all addresses, including those from secondary ENIs, are synced.
ConstInstanceAddressesOnly = "instance_addresses_only"
)
+385 -166
View File
@@ -1,26 +1,26 @@
package host
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
import (
"context"
"errors"
"fmt"
"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/aws/smithy-go"
"github.com/aws/aws-sdk-go-v2/service/sts"
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
)
// HostPlugin implements the HostPluginServiceServer interface for the
@@ -35,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)
@@ -42,24 +52,19 @@ var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
}
secrets := catalog.GetSecrets()
if secrets == nil {
return nil, status.Error(codes.InvalidArgument, "secrets are required")
return nil, errors.BadRequestStatus("catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "attributes are required")
return nil, errors.BadRequestStatus("attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
if err != nil {
return nil, err
}
credConfig, err := cred.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true)
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes, catalogAttributes.DualStack)
if err != nil {
return nil, err
}
@@ -71,7 +76,23 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
}, p.testCredStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
// Try to rotate AWS static credentials
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS && !catalogAttributes.DisableCredentialRotation {
if err := credState.RotateCreds(ctx); err != nil {
return nil, err
}
}
// 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(
@@ -80,25 +101,29 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if !catalogAttributes.DisableCredentialRotation {
if err := credState.RotateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
}
} else {
// Simply validate if we aren't rotating.
if err := credState.ValidateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err)
}
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
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()
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
}
return &pb.OnCreateCatalogResponse{
@@ -111,43 +136,124 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
// For host catalogs we only need to really validate what is
// currently in the new copy of the host catalog data, so skip
// fetching the new copy to keep things a little less messy.
catalog := req.GetNewCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
currentCatalog := req.GetCurrentCatalog()
if currentCatalog == nil {
return nil, errors.BadRequestStatus("current catalog is required")
}
newCatalog := req.GetNewCatalog()
if newCatalog == nil {
return nil, errors.BadRequestStatus("new catalog is required")
}
var updateSecrets bool
secrets := catalog.GetSecrets()
if secrets != nil {
// We will be updating secrets this run, but what exactly that
// means will be determined later.
updateSecrets = true
oldAttrs := currentCatalog.GetAttributes()
if oldAttrs == nil {
return nil, errors.BadRequestStatus("current catalog attributes are required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
if err != nil {
return nil, err
}
catalogAttributes, err := getCatalogAttributes(attrs)
newAttrs := newCatalog.GetAttributes()
if newAttrs == nil {
return nil, errors.BadRequestStatus("new catalog attributes are required")
}
newCatalogAttributes, err := getCatalogAttributes(newAttrs)
if err != nil {
return nil, err
}
// Get the persisted data.
// NOTE: We might need to change this at a later time. I'm not too
// sure *exactly* what scenarios we might encounter that would
// ultimately mean that we would have to handle an empty or missing
// state in update, but what we are ultimately assuming here
// (implicitly through awsCatalogPersistedStateFromProto) is that
// the state will exist and be populated. Personally I think this
// is fine and important, but this may change in the future.
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
oldCatalogAttributes.CredentialAttributes,
oldCatalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// Verify the incoming credentials are valid and return any errors to the
// user if they're not. Note this doesn't validate the credentials against
// AWS - it only does logical validation on the fields.
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes, newCatalogAttributes.DualStack)
if err != nil {
return nil, err
}
// We will be updating credentials when one of two changes occur:
// 1. the new catalog has secrets for static credentials, in which case we need to delete the old static credentials
// 2. the new catalog has a different roleARN value for dynamic credentials
if newCatalog.GetSecrets() != nil || newCatalogAttributes.RoleArn != oldCatalogAttributes.RoleArn {
// Ensure the incoming credentials are valid for interaction with the S3
// Bucket before replacing them.
newCredState, err := credential.NewAwsCredentialPersistedState(
append([]credential.AwsCredentialPersistedStateOption{
credential.WithCredentialsConfig(updatedCredentials),
}, p.testCredStateOpts...)...,
)
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),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
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.
// This checks the timestamp on the last rotation time as well
// and deletes the credentials if we are managing them
// (ie: if we've rotated them before).
if err := credState.ReplaceCreds(ctx, updatedCredentials); err != nil {
return nil, err
}
}
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if newCatalogAttributes.DisableCredentialRotation && newCatalog.GetSecrets() == nil {
if !credState.CredsLastRotatedTime.IsZero() {
return nil, errors.BadRequestStatus("cannot disable rotation for already-rotated credentials")
}
}
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if !newCatalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
if err := credState.RotateCreds(ctx); err != nil {
return nil, err
}
}
}
catalogState, err := newAwsCatalogPersistedState(
@@ -156,45 +262,29 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if catalogAttributes.DisableCredentialRotation && !updateSecrets {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if !credState.CredsLastRotatedTime.IsZero() {
return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials")
}
}
if updateSecrets {
catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true)
if err != nil {
return nil, err
}
// Replace the credentials. This checks the timestamp on the last
// rotation time as well and deletes the credentials if we are
// managing them (ie: if we've rotated them before).
if err := credState.ReplaceCreds(ctx, catalogSecrets); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err)
}
}
if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if err := credState.RotateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
}
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
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()
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
}
return &pb.OnUpdateCatalogResponse{
@@ -206,12 +296,12 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
@@ -228,9 +318,10 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
_, err = newAwsCatalogPersistedState(
@@ -239,17 +330,17 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// try to delete static credentials
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS {
if !credState.CredsLastRotatedTime.IsZero() {
// Delete old/existing credentials. This is done with the same
// credentials to ensure that it has the proper permissions to do
// it.
if err := credState.DeleteCreds(ctx); err != nil {
return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err)
return nil, err
}
}
}
@@ -288,12 +379,12 @@ func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetD
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -304,9 +395,10 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -315,56 +407,52 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
set := req.GetSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "set is nil")
return nil, errors.BadRequestStatus("set is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
return nil, errors.BadRequestStatus("set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
ec2Client, err := catalogState.EC2Client(ctx)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
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)
}
_, err = ec2Client.DescribeInstances(ctx, input)
if err == nil {
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "DryRunOperation" {
// Success
return &pb.OnCreateSetResponse{}, nil
}
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
return &pb.OnCreateSetResponse{}, nil
}
// OnUpdateSet is called when a dynamic host set is updated.
func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -375,9 +463,10 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -386,46 +475,42 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// As with catalog, we don't need to really look at the old host
// set here, just need to work off of/validate the new set config
set := req.GetNewSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "new set is nil")
return nil, errors.BadRequestStatus("new set is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "new set missing attributes")
return nil, errors.BadRequestStatus("new set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
ec2Client, err := catalogState.EC2Client(ctx)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
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)
}
_, err = ec2Client.DescribeInstances(ctx, input)
if err == nil {
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "DryRunOperation" {
// Success
return &pb.OnUpdateSetResponse{}, nil
}
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
return &pb.OnUpdateSetResponse{}, nil
}
// OnDeleteSet is called when a dynamic host set is deleted.
@@ -439,12 +524,12 @@ func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -455,9 +540,10 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -466,31 +552,24 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
sets := req.GetSets()
if sets == nil {
return nil, status.Error(codes.InvalidArgument, "sets is nil")
return nil, errors.BadRequestStatus("sets are required")
}
// 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
if set.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "set missing id")
return nil, errors.BadRequestStatus("set id is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
return nil, errors.BadRequestStatusf("set %s attributes are required", set.GetId())
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
@@ -499,7 +578,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
input, err := buildDescribeInstancesInput(setAttrs, false)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
return nil, errors.BadRequestStatusf("error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
}
queries[i] = hostSetQuery{
Id: set.GetId(),
@@ -507,28 +586,38 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
}
}
ec2Client, err := catalogState.EC2Client(ctx)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
// 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, status.Errorf(codes.InvalidArgument, "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).
for _, reservation := range output.Reservations {
for _, instance := range reservation.Instances {
host, err := awsInstanceToHost(instance)
host, err := awsInstanceToHost(instance, catalogAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "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)
@@ -577,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]
@@ -610,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{
@@ -624,9 +717,12 @@ func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.Descri
// awsInstanceToHost processes data from an ec2.Instance and returns
// a ListHostsResponseHost with the ID and network addresses
// populated.
func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, error) {
func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) {
if aws.ToString(instance.InstanceId) == "" {
return nil, errors.New("response integrity error: missing instance id")
return nil, fmt.Errorf("response integrity error: missing instance id")
}
if catalogAttributes == nil {
return nil, fmt.Errorf("response integrity error: missing catalog attributes")
}
result := new(pb.ListHostsResponseHost)
@@ -642,10 +738,16 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro
}
}
result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress)
// First let's get the instance's Private IPv4/DNS, Public IPv4/DNS, and IPv6 addresses
result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress, instance.Ipv6Address)
result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName, instance.PublicDnsName)
// Now go through all of the interfaces and log the IP address of
if catalogAttributes.InstanceAddressesOnly {
// return early as we already have the instance addresses
return result, nil
}
// Now go through all of the interfaces and sync the IP address of
// every interface.
for _, iface := range instance.NetworkInterfaces {
// Populate default IP addresses/DNS name similar to how we do
@@ -674,6 +776,123 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro
return result, nil
}
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")
}
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 {
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
}
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 {
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")
}
// 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
// if an element is not nil, empty, and does not exist in slice.
func appendDistinct(slice []string, elems ...*string) []string {
File diff suppressed because it is too large. Load diff
+58 -17
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -7,12 +7,10 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"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"
"google.golang.org/protobuf/types/known/structpb"
@@ -22,17 +20,6 @@ type EC2API interface {
DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
}
var customClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
},
}
type awsCatalogPersistedState struct {
*credential.AwsCredentialPersistedState
@@ -86,7 +73,11 @@ func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
// EC2Client returns a configured EC2 client based on the session
// information stored in the state.
func (s *awsCatalogPersistedState) EC2Client(ctx context.Context) (EC2API, error) {
func (s *awsCatalogPersistedState) EC2Client(ctx context.Context, opt ...ec2Option) (EC2API, error) {
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("error parsing options when fetching EC2 client: %w", err)
}
awsCfg, err := s.GenerateCredentialChain(ctx)
if err != nil {
return nil, fmt.Errorf("error getting AWS configuration when fetching EC2 client: %w", err)
@@ -94,8 +85,58 @@ func (s *awsCatalogPersistedState) EC2Client(ctx context.Context) (EC2API, error
if awsCfg == nil {
return nil, fmt.Errorf("nil aws configuration")
}
var ec2Opts []func(*ec2.Options)
if opts.withDualStack {
ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{
dualStack: opts.withDualStack,
}))
}
if s.testEC2APIFunc != nil {
return s.testEC2APIFunc(*awsCfg)
}
return ec2.NewFromConfig(*awsCfg), nil
return ec2.NewFromConfig(*awsCfg, ec2Opts...), nil
}
// getOpts iterates the inbound ec2Options and returns a struct
func getOpts(opt ...ec2Option) (ec2Options, error) {
opts := getDefaultEc2Options()
for _, o := range opt {
if o == nil {
continue
}
if err := o(&opts); err != nil {
return ec2Options{}, err
}
}
return opts, nil
}
// ec2Option - how ec2Options are passed as arguments
type ec2Option func(*ec2Options) error
// options = how options are represented
type ec2Options struct {
withDualStack bool
}
func getDefaultEc2Options() ec2Options {
return ec2Options{}
}
// WithDualStack sets the dual stack resolver
func WithDualStack(with bool) ec2Option {
return func(o *ec2Options) error {
o.withDualStack = with
return nil
}
}
type endpointResolver struct {
dualStack bool
}
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params ec2.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = ec2.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
+12 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -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 {
+1 -1
View File
@@ -68,7 +68,7 @@ The following `attributes` are valid on an AWS storage bucket resource:
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
be performed.
- `region` (string): The region to configure the storage bucket for.
- `endpoint-url` (string): The endpoint to configure the storage.
- `endpoint_url` (string): The endpoint to configure the storage.
- `role_arn` (string): The role arn configured for the assume role provider.
- `role_external_id` (string): The external id configured for the assume role provider.
- `role_session_name` (string): The session name configured for the assume role provider.
+11 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -19,6 +19,9 @@ type StorageAttributes struct {
// EndpointUrl is used for configuring how the aws client will resolve requests.
EndpointUrl string
// DualStack is used for configuring how the aws client will resolve requests.
DualStack bool
}
func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
@@ -37,6 +40,12 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
}
delete(unknownFields, ConstAwsEndpointUrl)
dualStack, err := values.GetBoolValue(in, ConstAwsDualStack, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstAwsDualStack)] = err.Error()
}
delete(unknownFields, ConstAwsDualStack)
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
@@ -64,5 +73,6 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
return &StorageAttributes{
CredentialAttributes: credAttributes,
EndpointUrl: endpointUrl,
DualStack: dualStack,
}, nil
}
+25 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,6 +7,8 @@ import (
"testing"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -19,6 +21,7 @@ func TestGetStorageAttributes(t *testing.T) {
in *structpb.Struct
expected *StorageAttributes
expectedErrContains string
expectedDetails *pb.StorageBucketCredentialState
}{
{
name: "missing region",
@@ -38,6 +41,21 @@ func TestGetStorageAttributes(t *testing.T) {
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "good w/ dualstack",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"dual_stack": structpb.NewBoolValue(true),
},
},
expected: &StorageAttributes{
CredentialAttributes: &cred.CredentialAttributes{
Region: "us-west-2",
},
DualStack: true,
},
},
{
name: "good w/ endpoint",
in: &structpb.Struct{
@@ -100,13 +118,17 @@ func TestGetStorageAttributes(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
actual, err := getStorageAttributes(tc.in)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(status.Code(err), codes.InvalidArgument)
require.Equal(codes.InvalidArgument, status.Code(err))
st, ok := status.FromError(err)
require.True(ok)
assert.Len(st.Details(), 0)
return
}
+5 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,6 +7,9 @@ const (
// ConstAwsEndpointUrl is the key for the endpoint url in the aws s3 client.
ConstAwsEndpointUrl = "endpoint_url"
// ConstAwsDualStack is the key for the dualstack flag in the aws s3 client.
ConstAwsDualStack = "dual_stack"
// defaultStreamChunkSize is the recommened chunk size for sending data through a stream
defaultStreamChunkSize = 65536 // 64 KiB
defaultStreamChunkSize = 1 << 20 // 1 MiB
)
+75
View File
@@ -0,0 +1,75 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
s3StatusErrMsg = "aws s3 error: %s"
// s3ErrorBadDigest is returned when there is a checksum
// mismatch between the request and the s3 service. This
// error code is returned for the PutObject request.
s3ErrorBadDigest = "BadDigest"
)
func setStatePermission(req any, permission *pb.Permission) *pb.StorageBucketCredentialState {
state := &pb.StorageBucketCredentialState{
State: &pb.Permissions{},
}
switch req.(type) {
case pb.HeadObjectRequest:
state.State.Read = permission
case *pb.HeadObjectRequest:
state.State.Read = permission
case pb.GetObjectRequest:
state.State.Read = permission
case *pb.GetObjectRequest:
state.State.Read = permission
case pb.PutObjectRequest:
state.State.Write = permission
case *pb.PutObjectRequest:
state.State.Write = permission
case pb.DeleteObjectsRequest:
state.State.Delete = permission
case *pb.DeleteObjectsRequest:
state.State.Delete = permission
case pb.ListObjectsRequest:
state.State.Read = permission
case *pb.ListObjectsRequest:
state.State.Read = permission
}
return state
}
// parseS3Error will convert an S3 api error into a RPC status. If the
// error does not match against an s3 error type, it will fallback to
// evaluating against generic aws service errors. This includes throttling,
// credentials, and connectivity error types.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
func parseS3Error(op string, err error, req any) *status.Status {
if err == nil {
return nil
}
st, p := errors.ParseAWSError(op, err)
if p != nil {
state := setStatePermission(req, p)
if st, err = st.WithDetails(state); err != nil {
st = status.New(codes.Internal, err.Error())
}
}
return st
}
func checksumMistmatchStatus() error {
return status.New(codes.Aborted, "mismatched checksum").Err()
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/types/known/timestamppb"
)
func Test_ParseS3Error(t *testing.T) {
t.Parallel()
cases := []struct {
name string
req any
err error
expectedStatusCode codes.Code
expectedStatusMsg string
expectedDetails *pb.StorageBucketCredentialState
}{
{
name: "nil-error",
expectedStatusCode: codes.OK,
},
{
name: "no-such-bucket",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("NoSuchBucket", "GetObject", "no such bucket"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "no such bucket",
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "no-such-key",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("NoSuchKey", "GetObject", "no such key"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "not-found",
req: &pb.GetObjectRequest{},
err: errors.TestAwsHttpResponseError(404, "not found"),
expectedStatusCode: codes.NotFound,
// cannot embed services within mock http errors, therefore unknown service
expectedStatusMsg: "aws service unknown: resource not found: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "invalid-object-state",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("InvalidObjectState", "GetObject", "invalid object state"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "bad-digest",
req: &pb.PutObjectRequest{},
err: TestAwsS3Error(s3ErrorBadDigest, "PutObject", "checksum mismatch"),
expectedStatusCode: codes.Aborted,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-throttle-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("Throttling", "action", "throttling exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service s3: throttling: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-connectivity-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("RequestTimeout", "action", "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-credentials-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("AccessDenied", "action", "access denied"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service s3: invalid credentials: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "access denied",
CheckedAt: timestamppb.Now(),
},
},
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
require, assert := require.New(t), assert.New(t)
actualStatus := parseS3Error("test", tt.err, tt.req)
if tt.expectedStatusCode == codes.OK {
require.Nil(actualStatus)
return
}
require.Equal(tt.expectedStatusCode, actualStatus.Code())
require.Contains(actualStatus.Message(), tt.expectedStatusMsg)
require.Len(actualStatus.Details(), 1)
foundDetail := false
for _, detail := range actualStatus.Details() {
switch errDetail := detail.(type) {
case *pb.StorageBucketCredentialState:
foundDetail = true
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Read, errDetail.State.Read, false)
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Write, errDetail.State.Write, false)
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Delete, errDetail.State.Delete, false)
}
}
require.True(foundDetail, "did not find error details with status")
})
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+84 -21
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,12 +7,13 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/retry"
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"
@@ -25,17 +26,22 @@ type S3API interface {
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
CreateMultipartUpload(ctx context.Context, params *s3.CreateMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error)
UploadPart(ctx context.Context, params *s3.UploadPartInput, optFns ...func(*s3.Options)) (*s3.UploadPartOutput, error)
CompleteMultipartUpload(ctx context.Context, params *s3.CompleteMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error)
AbortMultipartUpload(ctx context.Context, params *s3.AbortMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error)
DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error)
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
Credentials() aws.Credentials
}
var customClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
},
type s3Client struct {
*s3.Client
creds aws.Credentials
}
func (c *s3Client) Credentials() aws.Credentials {
return c.creds
}
type awsStoragePersistedState struct {
@@ -82,7 +88,6 @@ func newAwsStoragePersistedState(opts ...awsStoragePersistedStateOption) (*awsSt
}
func (s *awsStoragePersistedState) toProto() (*storagebuckets.StorageBucketPersisted, error) {
data, err := structpb.NewStruct(s.ToMap())
if err != nil {
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
@@ -90,9 +95,9 @@ func (s *awsStoragePersistedState) toProto() (*storagebuckets.StorageBucketPersi
return &storagebuckets.StorageBucketPersisted{Data: data}, nil
}
// S3Client returns a configured S3 client based on the session
// s3Client returns a configured S3 client based on the session
// information stored in the state.
func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option) (S3API, error) {
func (s *awsStoragePersistedState) s3Client(ctx context.Context, opt ...s3Option) (S3API, error) {
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err)
@@ -105,15 +110,28 @@ func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option
return nil, fmt.Errorf("nil aws configuration")
}
var s3Opts []func(*s3.Options)
if opts.withEndpoint != "" {
if opts.withEndpoint != "" || opts.withDualStack {
s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{
endpoint: opts.withEndpoint,
endpoint: opts.withEndpoint,
dualStack: opts.withDualStack,
}))
}
if s.testS3APIFunc != nil {
return s.testS3APIFunc(*awsCfg)
}
return s3.NewFromConfig(*awsCfg, s3Opts...), nil
// Retrieve the credentials provider from the client
credsProvider := awsCfg.Credentials
creds, err := credsProvider.Retrieve(ctx)
if err != nil {
return nil, fmt.Errorf("failed to retrieve credentials: %v\n", err)
}
err = newTransport(awsCfg)
if err != nil {
return nil, fmt.Errorf("failed to configure new http transport: %v", err)
}
c := s3.NewFromConfig(*awsCfg, s3Opts...)
return &s3Client{Client: c, creds: creds}, nil
}
// getOpts iterates the inbound s3Options and returns a struct
@@ -135,13 +153,23 @@ type s3Option func(*s3Options) error
// options = how options are represented
type s3Options struct {
withEndpoint string
withEndpoint string
withDualStack bool
withCacheRefresh bool
}
func getDefaultS3Options() s3Options {
return s3Options{}
}
// WithCacheRefresh controls if the cache should be forced to refresh
func WithCacheRefresh(refresh bool) s3Option {
return func(o *s3Options) error {
o.withCacheRefresh = refresh
return nil
}
}
// WithEndpoint contains the endpoint to use
func WithEndpoint(with string) s3Option {
return func(o *s3Options) error {
@@ -150,18 +178,27 @@ func WithEndpoint(with string) s3Option {
}
}
// WithDualStack sets the dual stack resolver
func WithDualStack(with bool) s3Option {
return func(o *s3Options) error {
o.withDualStack = with
return nil
}
}
type endpointResolver struct {
endpoint string
endpoint string
dualStack bool
}
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
var uri *url.URL
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
if err != nil {
return
}
if e.endpoint == "" {
// TODO: not sure if we should set an error or ignore the missing endpoint value and continue with the default resolver
return
}
uri, err = url.Parse(e.endpoint)
@@ -169,7 +206,6 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
return
}
if uri == nil {
// TODO: not sure if we should set an error or ignore the missing uri value and continue with the default resolver
return
}
resolver = smithyEndpoints.Endpoint{
@@ -177,3 +213,30 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
}
return
}
// newTransport returns an http Transport object. The http transport object utilizes the
// default transport created by minio. The following transport fields are updated to provide
// more reliable connectivity when handling larger objects:
// MaxResponseHeaderBytes is set to 1MiB to prevent header reads from stalling.
// WriteBufferSize is set to 1MiB, matching the default buffer size used in Boundary storage.
// ReadBufferSize is set to 1MiB, matching the default buffer size used in Boundary storage.
// ForceAttemptHTTP2 is set to true, supporting more resilient multiplexing and avoids HOL blocking on retries.
// IdleConnTimeout is set to 2 minutes.
func newTransport(cfg *aws.Config) error {
if cfg == nil {
return fmt.Errorf("missing aws configuration")
}
cfg.HTTPClient = awshttp.NewBuildableClient().WithTransportOptions(func(transport *http.Transport) {
transport.MaxResponseHeaderBytes = 1 << 20 // 1 MiB prevents header read from stalling
transport.WriteBufferSize = 1 << 20 // 1 MiB write buffer (default is 4 KiB)
transport.ReadBufferSize = 1 << 20 // 1 MiB read buffer (default is 4 KiB)
transport.ForceAttemptHTTP2 = true // enables HTTP/2 where supported more resilient multiplexing, avoids HOL blocking on retries
transport.IdleConnTimeout = 2 * time.Minute // double the idle connection timeout, default is 1 minute
})
cfg.Retryer = func() aws.Retryer {
return retry.NewStandard(func(o *retry.StandardOptions) {
o.MaxAttempts = 20
})
}
return nil
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -64,7 +64,7 @@ func TestAwsStoragePersistedStateS3Client(t *testing.T) {
)
require.NoError(err)
clientRaw, err := state.S3Client(context.Background(), tc.s3Opts...)
clientRaw, err := state.s3Client(context.Background(), tc.s3Opts...)
require.NoError(err)
require.NotNil(clientRaw)
client, ok := clientRaw.(*testMockS3)
+318 -16
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -6,6 +6,8 @@ package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"testing"
@@ -14,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"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"
@@ -28,22 +31,8 @@ const (
testGetUserErr = "test error for GetUser"
testGetCallerIdentityErr = "test error for GetCallerIdentity"
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
testGetObjectErr = "test error for GetObject"
testPutObjectErr = "test error for PutObject"
testHeadObjectErr = "test error for HeadObject"
)
// throttleErr is a mocked error used for testing the aws s3 client
type throttleErr struct{}
func (e *throttleErr) ErrorCode() string {
return "ThrottlingException"
}
func (e *throttleErr) Error() string {
return "ThrottlingException"
}
type testMockS3State struct {
GetObjectCalled bool
GetObjectInputParams *s3.GetObjectInput
@@ -52,8 +41,33 @@ type testMockS3State struct {
PutObjectInputParams *s3.PutObjectInput
PutObjectBody []byte
CreateMultipartUploadCalled bool
CreateMultipartUploadInputParams *s3.CreateMultipartUploadInput
UploadPartCalled bool
UploadPartCallCount int
UploadPartInputParams []*s3.UploadPartInput
// UploadPartBody is the concatenation, in part-number order, of every
// part body received by UploadPart. It can be compared against the full
// object that was expected to be uploaded.
UploadPartBody []byte
CompleteMultipartUploadCalled bool
CompleteMultipartUploadInputParams *s3.CompleteMultipartUploadInput
AbortMultipartUploadCalled bool
AbortMultipartUploadInputParams *s3.AbortMultipartUploadInput
HeadObjectCalled bool
HeadObjectInputParams *s3.HeadObjectInput
ListObjectsV2Called bool
ListObjectsV2InputParams *s3.ListObjectsV2Input
DeleteObjectCalled bool
DeleteObjectInputParams *s3.DeleteObjectInput
DeleteObjectsCalled bool
DeleteObjectsInputParams *s3.DeleteObjectsInput
}
func (s *testMockS3State) Reset() {
@@ -62,8 +76,24 @@ func (s *testMockS3State) Reset() {
s.PutObjectCalled = false
s.PutObjectInputParams = nil
s.PutObjectBody = nil
s.CreateMultipartUploadCalled = false
s.CreateMultipartUploadInputParams = nil
s.UploadPartCalled = false
s.UploadPartCallCount = 0
s.UploadPartInputParams = nil
s.UploadPartBody = nil
s.CompleteMultipartUploadCalled = false
s.CompleteMultipartUploadInputParams = nil
s.AbortMultipartUploadCalled = false
s.AbortMultipartUploadInputParams = nil
s.HeadObjectCalled = false
s.HeadObjectInputParams = nil
s.ListObjectsV2Called = false
s.ListObjectsV2InputParams = nil
s.DeleteObjectCalled = false
s.DeleteObjectInputParams = nil
s.DeleteObjectsCalled = false
s.DeleteObjectsInputParams = nil
}
type testMockS3 struct {
@@ -80,9 +110,34 @@ type testMockS3 struct {
PutObjectOutput *s3.PutObjectOutput
PutObjectErr error
// mocked responses for the multipart upload calls
CreateMultipartUploadOutput *s3.CreateMultipartUploadOutput
CreateMultipartUploadErr error
UploadPartOutput *s3.UploadPartOutput
UploadPartErr error
// UploadPartErrOnPart, when greater than zero, causes UploadPart to return
// UploadPartErr (or a generic error) only for the matching part number.
UploadPartErrOnPart int32
CompleteMultipartUploadOutput *s3.CompleteMultipartUploadOutput
CompleteMultipartUploadErr error
AbortMultipartUploadOutput *s3.AbortMultipartUploadOutput
AbortMultipartUploadErr error
// mocked responses for headObject
HeadObjectOutput *s3.HeadObjectOutput
HeadObjectErr error
// mocked responses for ListObjectsV2 (needed for delete)
ListObjectsV2Output *s3.ListObjectsV2Output
ListObjectsV2Err error
// sometimes we need to create a wildcard output
ListObjectsV2OutputFunc func(*s3.ListObjectsV2Input) *s3.ListObjectsV2Output
// mocked responses for DeleteObject(s)
DeleteObjectOutput *s3.DeleteObjectOutput
DeleteObjectErr error
DeleteObjectsOutput *s3.DeleteObjectsOutput
DeleteObjectsErr error
}
type testMockS3Option func(m *testMockS3) error
@@ -101,6 +156,63 @@ func testMockS3WithPutObjectError(e error) testMockS3Option {
}
}
func testMockS3WithCreateMultipartUploadOutput(o *s3.CreateMultipartUploadOutput) testMockS3Option {
return func(m *testMockS3) error {
m.CreateMultipartUploadOutput = o
return nil
}
}
func testMockS3WithCreateMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.CreateMultipartUploadErr = e
return nil
}
}
func testMockS3WithUploadPartOutput(o *s3.UploadPartOutput) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartOutput = o
return nil
}
}
func testMockS3WithUploadPartError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartErr = e
return nil
}
}
func testMockS3WithUploadPartErrorOnPart(partNumber int32, e error) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartErr = e
m.UploadPartErrOnPart = partNumber
return nil
}
}
func testMockS3WithCompleteMultipartUploadOutput(o *s3.CompleteMultipartUploadOutput) testMockS3Option {
return func(m *testMockS3) error {
m.CompleteMultipartUploadOutput = o
return nil
}
}
func testMockS3WithCompleteMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.CompleteMultipartUploadErr = e
return nil
}
}
func testMockS3WithAbortMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.AbortMultipartUploadErr = e
return nil
}
}
func testMockS3WithGetObjectOutput(o *s3.GetObjectOutput) testMockS3Option {
return func(m *testMockS3) error {
m.GetObjectOutput = o
@@ -129,6 +241,55 @@ func testMockS3WithHeadObjectError(e error) testMockS3Option {
}
}
func testMockS3WithListObjectsV2Output(o *s3.ListObjectsV2Output) testMockS3Option {
return func(m *testMockS3) error {
m.ListObjectsV2Output = o
return nil
}
}
func testMockS3WithListObjectsV2Error(e error) testMockS3Option {
return func(m *testMockS3) error {
m.ListObjectsV2Err = e
return nil
}
}
func testMockS3WithListObjectsV2OutputFunc(o func(*s3.ListObjectsV2Input) *s3.ListObjectsV2Output) testMockS3Option {
return func(m *testMockS3) error {
m.ListObjectsV2OutputFunc = o
return nil
}
}
func testMockS3WithDeleteObjectOutput(o *s3.DeleteObjectOutput) testMockS3Option {
return func(m *testMockS3) error {
m.DeleteObjectOutput = o
return nil
}
}
func testMockS3WithDeleteObjectError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.DeleteObjectErr = e
return nil
}
}
func testMockS3WithDeleteObjectsOutput(o *s3.DeleteObjectsOutput) testMockS3Option {
return func(m *testMockS3) error {
m.DeleteObjectsOutput = o
return nil
}
}
func testMockS3WithDeleteObjectsError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.DeleteObjectsErr = e
return nil
}
}
func newTestMockS3(state *testMockS3State, opts ...testMockS3Option) s3APIFunc {
return func(cfgs ...aws.Config) (S3API, error) {
m := &testMockS3{
@@ -193,8 +354,136 @@ func (m *testMockS3) PutObject(ctx context.Context, params *s3.PutObjectInput, o
return m.PutObjectOutput, nil
}
func (m *testMockS3) CreateMultipartUpload(ctx context.Context, params *s3.CreateMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) {
if m.State != nil {
m.State.CreateMultipartUploadCalled = true
m.State.CreateMultipartUploadInputParams = params
}
if m.CreateMultipartUploadErr != nil {
return nil, m.CreateMultipartUploadErr
}
if m.CreateMultipartUploadOutput != nil {
return m.CreateMultipartUploadOutput, nil
}
return &s3.CreateMultipartUploadOutput{
UploadId: aws.String("test-upload-id"),
}, nil
}
func (m *testMockS3) UploadPart(ctx context.Context, params *s3.UploadPartInput, optFns ...func(*s3.Options)) (*s3.UploadPartOutput, error) {
if m.State != nil {
m.State.UploadPartCalled = true
m.State.UploadPartCallCount++
m.State.UploadPartInputParams = append(m.State.UploadPartInputParams, params)
}
// always drain the body so the part data can be asserted on and so the
// reader is not left dangling.
var data []byte
if params.Body != nil {
var err error
data, err = io.ReadAll(params.Body)
if err != nil {
return nil, err
}
if m.State != nil {
m.State.UploadPartBody = append(m.State.UploadPartBody, data...)
}
}
if m.UploadPartErr != nil {
if m.UploadPartErrOnPart == 0 || (params.PartNumber != nil && *params.PartNumber == m.UploadPartErrOnPart) {
return nil, m.UploadPartErr
}
}
if m.UploadPartOutput != nil {
return m.UploadPartOutput, nil
}
// echo the supplied part checksum so CompleteMultipartUpload can be built
// the way the real service would allow.
sum := sha256.Sum256(data)
return &s3.UploadPartOutput{
ETag: aws.String(fmt.Sprintf("etag-%d", aws.ToInt32(params.PartNumber))),
ChecksumSHA256: aws.String(base64.StdEncoding.EncodeToString(sum[:])),
}, nil
}
func (m *testMockS3) CompleteMultipartUpload(ctx context.Context, params *s3.CompleteMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) {
if m.State != nil {
m.State.CompleteMultipartUploadCalled = true
m.State.CompleteMultipartUploadInputParams = params
}
if m.CompleteMultipartUploadErr != nil {
return nil, m.CompleteMultipartUploadErr
}
if m.CompleteMultipartUploadOutput != nil {
return m.CompleteMultipartUploadOutput, nil
}
return &s3.CompleteMultipartUploadOutput{}, nil
}
func (m *testMockS3) AbortMultipartUpload(ctx context.Context, params *s3.AbortMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) {
if m.State != nil {
m.State.AbortMultipartUploadCalled = true
m.State.AbortMultipartUploadInputParams = params
}
if m.AbortMultipartUploadErr != nil {
return nil, m.AbortMultipartUploadErr
}
if m.AbortMultipartUploadOutput != nil {
return m.AbortMultipartUploadOutput, nil
}
return &s3.AbortMultipartUploadOutput{}, nil
}
func (m *testMockS3) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
if m.State != nil {
m.State.ListObjectsV2Called = true
m.State.ListObjectsV2InputParams = params
}
if m.ListObjectsV2Err != nil {
return nil, m.ListObjectsV2Err
}
if m.ListObjectsV2OutputFunc != nil {
return m.ListObjectsV2OutputFunc(params), nil
}
return m.ListObjectsV2Output, nil
}
func (m *testMockS3) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
return &s3.DeleteObjectOutput{}, nil
if m.State != nil {
m.State.DeleteObjectCalled = true
m.State.DeleteObjectInputParams = params
}
if m.DeleteObjectErr != nil {
return nil, m.DeleteObjectErr
}
return m.DeleteObjectOutput, nil
}
func (m *testMockS3) DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) {
if m.State != nil {
m.State.DeleteObjectsCalled = true
m.State.DeleteObjectsInputParams = params
}
if m.DeleteObjectsErr != nil {
return nil, m.DeleteObjectsErr
}
return m.DeleteObjectsOutput, nil
}
func (m *testMockS3) HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
@@ -297,3 +586,16 @@ func validSTSMock() []credential.AwsCredentialPersistedStateOption {
}),
}
}
// TestAwsS3Error returns an s3 api error
func TestAwsS3Error(code, op, msg string) error {
return &smithy.OperationError{
ServiceID: "s3",
OperationName: op,
Err: &smithy.GenericAPIError{
Code: code,
Message: msg,
Fault: smithy.FaultServer,
},
}
}
+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"
"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"
"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"
"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"
"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)
})
}
}
+197 -296
View File
@@ -1,29 +1,30 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
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"
"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
var expectedTags = []string{"foo", "bar", "baz"}
func TestHostPlugin(t *testing.T) {
region := os.Getenv("AWS_REGION")
if region == "" {
@@ -37,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)
@@ -80,6 +103,14 @@ func TestHostPlugin(t *testing.T) {
require.NoError(err)
require.Len(ec2InstanceTags, expectedEc2InstanceCount)
rawExpectedTags, err := tf.GetOutputSlice("instance_tag_keys")
require.NoError(err)
require.Len(rawExpectedTags, 3)
expectedTags := make([]string, 0, len(rawExpectedTags))
for _, rawExpectedTag := range rawExpectedTags {
expectedTags = append(expectedTags, rawExpectedTag.(string))
}
// Start the workflow now. Set up the host catalog. Note that this
// will cause the state to go out of drift above in the sense that
// the access key ID/secret access key will no longer be valid. We
@@ -131,25 +162,18 @@ 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{
{"foo"},
{"bar"},
{"baz"},
{"foo", "bar"},
{"foo", "baz"},
{"bar", "baz"},
{"foo", "bar", "baz"},
{expectedTags[0]},
{expectedTags[1]},
{expectedTags[2]},
{expectedTags[0], expectedTags[1]},
{expectedTags[0], expectedTags[2]},
{expectedTags[1], expectedTags[2]},
{expectedTags[0], expectedTags[1], expectedTags[2]},
}
for _, tc := range cases {
@@ -159,288 +183,165 @@ func TestHostPlugin(t *testing.T) {
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
// TODO: add OnDeleteSet if it needs to be implemented
}
}
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)
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: !rotate,
// ********************
// * AssumeRole - Happy Path & Error Cases
// ********************
assumeRoleArn, err := tf.GetOutputString("assume_role_arn")
require.NoError(err)
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)
crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn")
require.NoError(err)
targetEc2InstanceIds, err := tf.GetOutputSlice("target_instance_ids")
require.NoError(err)
require.Len(targetEc2InstanceIds, expectedEc2InstanceCount)
targetEc2InstanceTags, err := tf.GetOutputMap("target_instance_tags")
require.NoError(err)
expectedTargetTagInstancesMap := buildExpectedTagInstancesMap(targetEc2InstanceTags, expectedTags)
requireTagInstancesMatchProvisioned(t, targetEc2InstanceIds, expectedTargetTagInstancesMap)
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"},
})
}
}
+320 -8
View File
@@ -1,10 +1,11 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
import (
"context"
"fmt"
"io"
"net"
"os"
@@ -17,12 +18,16 @@ import (
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// NOTE: this test does not vaildate against dynamic credentials because
// this credential type can only be tested on an EC2 instance.
func TestStoragePlugin(t *testing.T) {
region := os.Getenv("AWS_REGION")
if region == "" {
@@ -75,6 +80,18 @@ func TestStoragePlugin(t *testing.T) {
iamSecretAccessKeyMissingPutObject, err := tf.GetOutputString("iam_secret_access_key_missing_put_obj")
require.NoError(err)
iamAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_access_key_missing_delete_obj")
require.NoError(err)
iamSecretAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_secret_access_key_missing_delete_obj")
require.NoError(err)
s3PaginationTestObjectCount, err := tf.GetOutput("s3_pagination_test_object_count")
require.NoError(err)
objectCountFloat, ok := s3PaginationTestObjectCount.(float64)
require.True(ok)
objectCount := int(objectCountFloat)
// Start the workflow now. Set up the storage bucket. Note that this
// will cause the state to go out of drift above in the sense that
// the access key ID/secret access key will no longer be valid. We
@@ -121,22 +138,75 @@ func TestStoragePlugin(t *testing.T) {
testPluginOnDeleteStorageBucket(ctx, t, p, bucketName, region, keyid, secret, true)
// ********************
// * Object Methods: PutObject, GetObject, HeadObject
// * Object Methods: PutObject, GetObject, HeadObject, DeleteObjects
// ********************
//
// Reassign the keyid and secret first.
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
testPluginObjectMethods(ctx, t, p, bucketName, region, keyid, secret)
// ********************
// * ListObjects
// ********************
testListObjects(ctx, t, p, objectCount, bucketName, region, keyid, secret)
// ********************
// * Validate Permissions
// ********************
//
var expectedSBCState *pb.StorageBucketCredentialState
// Validate error is returned for missing get object permission
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingGetObject, iamSecretAccessKeyMissingGetObject)
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
Delete: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingGetObject, iamSecretAccessKeyMissingGetObject, expectedSBCState)
// Validate error is returned for missing put object permission
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingPutObject, iamSecretAccessKeyMissingPutObject)
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
Read: &pb.Permission{
// Since we cannot write an object to S3,
// we cannot test against the ability to read from S3.
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
Delete: &pb.Permission{
// We can still try deleting an object that does
// not exist in the S3 bucket to validate access.
State: pb.StateType_STATE_TYPE_OK,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingPutObject, iamSecretAccessKeyMissingPutObject, expectedSBCState)
// Validate error is returned for missing delete object permission
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Delete: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingDeleteObject, iamSecretAccessKeyMissingDeleteObject, expectedSBCState)
}
func testPluginOnCreateStorageBucket(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
@@ -289,10 +359,12 @@ func testPluginOnDeleteStorageBucket(ctx context.Context, t *testing.T, p *stora
}
}
func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string) {
func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string, expectedState *pb.StorageBucketCredentialState) {
t.Helper()
t.Logf("testing ValidatePermissions")
require := require.New(t)
require, assert := require.New(t), assert.New(t)
require.NotNil(expectedState)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
@@ -315,6 +387,51 @@ func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage
_, err = p.ValidatePermissions(ctx, req)
require.Error(err)
var actualSBCState *pb.StorageBucketCredentialState
if st, ok := status.FromError(err); ok {
for _, detail := range st.Details() {
if statusDetail, ok := detail.(*pb.StorageBucketCredentialState); ok {
actualSBCState = statusDetail
break
}
}
}
require.NotNil(actualSBCState)
require.NotNil(actualSBCState.GetState())
expectedReadState := expectedState.GetState().GetRead()
if expectedReadState != nil {
actualReadState := actualSBCState.GetState().GetRead()
require.NotNil(actualReadState)
assert.Equal(expectedReadState.GetState(), actualReadState.GetState())
assert.NotEmpty(actualReadState.GetCheckedAt())
if expectedReadState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualReadState.GetErrorDetails())
}
}
expectedWriteState := expectedState.GetState().GetWrite()
if expectedWriteState != nil {
actualWriteState := actualSBCState.GetState().GetWrite()
require.NotNil(actualWriteState)
assert.Equal(expectedWriteState.GetState(), actualWriteState.GetState())
assert.NotEmpty(actualWriteState.GetCheckedAt())
if expectedWriteState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualWriteState.GetErrorDetails())
}
}
expectedDeleteState := expectedState.GetState().GetDelete()
if expectedDeleteState != nil {
actualDeleteState := actualSBCState.GetState().GetDelete()
require.NotNil(actualDeleteState)
assert.Equal(expectedDeleteState.GetState(), actualDeleteState.GetState())
assert.NotEmpty(actualDeleteState.GetCheckedAt())
if expectedDeleteState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualDeleteState.GetErrorDetails())
}
}
}
func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string) {
@@ -369,7 +486,7 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
// Need to create a GRPC server to test GetObject
t.Logf("testing GetObject")
lis, err := net.Listen("tcp", "localhost:2030")
lis, err := net.Listen("tcp", "[::1]:2030")
require.NoError(err)
grpcServer := grpc.NewServer()
pb.RegisterStoragePluginServiceServer(grpcServer, &storage.StoragePlugin{})
@@ -377,7 +494,7 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
grpcServer.Serve(lis)
}()
conn, err := grpc.Dial("localhost:2030", grpc.WithTransportCredentials(insecure.NewCredentials()))
conn, err := grpc.Dial("[::1]:2030", grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(err)
defer conn.Close()
client := pb.NewStoragePluginServiceClient(conn)
@@ -409,3 +526,198 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
require.NoError(conn.Close())
grpcServer.Stop()
}
// testListObjects verifies top-level and recursive listing by matching the exact keys and types of returned objects.
func testListObjects(ctx context.Context, t *testing.T, p *storage.StoragePlugin, objectCount int, bucketName, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing ListObjects (bucket=%s, region=%s, expectedObjects=%d, creds=%t)",
bucketName,
region,
objectCount,
accessKeyId != "" && secretAccessKey != "",
)
r := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
})
r.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
r.NoError(err)
bucket := &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: reqAttrs,
Secrets: reqSecrets,
}
// Test Scenario 1: Non-Paginated Listing
t.Run("NonPaginatedListing", func(t *testing.T) {
// Subtest for the non-recursive (top-level) option
t.Run("TopLevel", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a small set with recursive=false")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects/file1.txt", IsDir: false},
{Key: "list-objects/data.json", IsDir: false},
{Key: "list-objects/nested-dir/", IsDir: true},
{Key: "list-objects/empty-nested-dir/", IsDir: true},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Subtest for the recursive option
t.Run("Recursive", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a small set with recursive=true")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects/",
Recursive: true,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects/file1.txt", IsDir: false},
{Key: "list-objects/data.json", IsDir: false},
{Key: "list-objects/nested-dir/file2.txt", IsDir: false},
{Key: "list-objects/nested-dir/", IsDir: true},
{Key: "list-objects/empty-nested-dir/", IsDir: true},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
})
// Test Scenario 2: Paginated Top-Level Listing
t.Run("TopLevelListing", func(t *testing.T) {
// Subtest for the root prefix
t.Run("AtRoot", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=false at root prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
var expectedObjects []*pb.Object
for i := 0; i < objectCount; i++ {
key := fmt.Sprintf("list-objects-paginated/root-file-%d.txt", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: key, IsDir: false})
}
for i := 0; i < objectCount; i++ {
keyEmpty := fmt.Sprintf("list-objects-paginated/empty-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyEmpty, IsDir: true})
keyNested := fmt.Sprintf("list-objects-paginated/nested-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNested, IsDir: true})
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Subtest for a nested prefix
t.Run("AtNestedPrefix", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=false at a nested prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/nested-dir-7/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects-paginated/nested-dir-7/data-file-7.json", IsDir: false},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
})
// Test Scenario 3: Paginated Recursive Listing
t.Run("RecursiveListing", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=true")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/",
Recursive: true,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
var expectedObjects []*pb.Object
for i := 0; i < objectCount; i++ {
keyFile := fmt.Sprintf("list-objects-paginated/root-file-%d.txt", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyFile, IsDir: false})
keyNestedFile := fmt.Sprintf("list-objects-paginated/nested-dir-%d/data-file-%d.json", i, i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedFile, IsDir: false})
}
for i := 0; i < objectCount; i++ {
keyNestedDir := fmt.Sprintf("list-objects-paginated/nested-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedDir, IsDir: true})
keyEmptyDir := fmt.Sprintf("list-objects-paginated/empty-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyEmptyDir, IsDir: true})
keyNestedEmptyDir := fmt.Sprintf("list-objects-paginated/empty-dir-%d/dir-%d/", i, i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedEmptyDir, IsDir: true})
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Test Scenario 4: Edge cases
t.Run("EdgeCases", func(t *testing.T) {
// Subtest for a prefix that doesn't exist
t.Run("NonExistentPrefix", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a non-existent prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "this/prefix/does/not/exist/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
r.Empty(resp.Objects)
})
// Subtest for listing inside a known empty directory
t.Run("InsideEmptyDirectory", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects inside an empty directory")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/empty-dirs/dir-10/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
r.Empty(resp.Objects)
})
})
}
+22 -15
View File
@@ -1,24 +1,20 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
variable "iam_user_prefix" {
default = "boundary-plugin-host-aws-test-iam-user"
}
variable "iam_user_count" {
default = 6
}
resource "random_id" "aws_iam_user_name" {
count = var.iam_user_count
prefix = "${var.iam_user_prefix}-${count.index}"
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
byte_length = 4
}
resource "aws_iam_user" "user" {
count = var.iam_user_count
name = random_id.aws_iam_user_name[count.index].dec
force_destroy = true
count = var.iam_user_count
name = random_id.aws_iam_user_name[count.index].dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_access_key" "user_initial_key" {
@@ -26,8 +22,13 @@ resource "aws_iam_access_key" "user_initial_key" {
user = aws_iam_user.user[count.index].name
}
resource "random_id" "aws_ec2_policy_name" {
prefix = "BoundaryPluginHost"
byte_length = 4
}
resource "aws_iam_policy" "ec2_describeinstances" {
name = "BoundaryPluginHostAwsTestDescribeInstancesPolicy"
name = random_id.aws_ec2_policy_name.dec
policy = <<EOF
{
@@ -51,9 +52,15 @@ resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment
policy_arn = aws_iam_policy.ec2_describeinstances.arn
}
resource "random_id" "aws_iam_policy_name" {
count = var.iam_user_count
prefix = "BoundaryPluginCredentials"
byte_length = 4
}
resource "aws_iam_policy" "user_self_manage_policy" {
count = var.iam_user_count
name = "BoundaryPluginHostAwsTestUserSelfManagePolicy-${count.index}"
name = random_id.aws_iam_policy_name[count.index].dec
policy = <<EOF
{
+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
}
}
+2 -2
View File
@@ -1,11 +1,11 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
version = "~> 6.0"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
data "aws_caller_identity" "current" {}
variable "iam_user_count" {
default = 6
}
# We are using a shared account for testing so make sure the stale ec2
# machine tags don't match for this test run
resource "random_id" "foo_key" {
prefix = "foo"
byte_length = 4
}
resource "random_id" "bar_key" {
prefix = "bar"
byte_length = 4
}
resource "random_id" "baz_key" {
prefix = "baz"
byte_length = 4
}
locals {
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
instance_tags = [
{
"${random_id.foo_key.dec}" = "true"
},
{
"${random_id.foo_key.dec}" = "true"
"${random_id.bar_key.dec}" = "true"
},
{
"${random_id.bar_key.dec}" = "true"
},
{
"${random_id.bar_key.dec}" = "true"
"${random_id.baz_key.dec}" = "true"
},
{
"${random_id.baz_key.dec}" = "true"
},
]
}
+133 -30
View File
@@ -1,28 +1,6 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
variable "instance_tags" {
default = [
{
"foo" = "true",
},
{
"foo" = "true",
"bar" = "true",
},
{
"bar" = "true",
},
{
"bar" = "true",
"baz" = "true",
},
{
"baz" = "true",
},
]
}
data "aws_availability_zones" "azs" {}
data "aws_ami" "ubuntu" {
@@ -42,22 +20,117 @@ data "aws_ami" "ubuntu" {
}
resource "aws_vpc" "vpc" {
cidr_block = "10.0.0.0/16"
cidr_block = "10.0.0.0/16"
assign_generated_ipv6_cidr_block = true
}
resource "aws_subnet" "subnet" {
vpc_id = aws_vpc.vpc.id
cidr_block = aws_vpc.vpc.cidr_block
availability_zone = data.aws_availability_zones.azs.names[0]
vpc_id = aws_vpc.vpc.id
cidr_block = aws_vpc.vpc.cidr_block
ipv6_cidr_block = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 0)
availability_zone = data.aws_availability_zones.azs.names[0]
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
}
# Need to add a gateway for public IP addr assignments
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.vpc.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
route {
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.subnet.id
route_table_id = aws_route_table.public.id
}
resource "aws_instance" "instances" {
count = length(var.instance_tags)
count = length(local.instance_tags)
ami = data.aws_ami.ubuntu.id
instance_type = "t3.nano"
subnet_id = aws_subnet.subnet.id
tags = var.instance_tags[count.index]
tags = local.instance_tags[count.index]
}
resource "aws_instance" "multi_address_instance" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.nano"
subnet_id = aws_subnet.subnet.id
secondary_private_ips = ["10.0.10.10"]
ipv6_address_count = 1
associate_public_ip_address = true
tags = {
Name = "boundary-multi-address-instance"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_network_interface" "multi_address_secondary_eni" {
subnet_id = aws_subnet.subnet.id
private_ips = ["10.0.20.10"]
ipv6_addresses = [cidrhost(aws_subnet.subnet.ipv6_cidr_block, 32)]
tags = {
Name = "boundary-multi-address-secondary-eni"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_network_interface_attachment" "multi_address_secondary_eni" {
instance_id = aws_instance.multi_address_instance.id
network_interface_id = aws_network_interface.multi_address_secondary_eni.id
device_index = 1
}
resource "aws_eip" "multi_address_primary" {
domain = "vpc"
network_interface = aws_instance.multi_address_instance.primary_network_interface_id
associate_with_private_ip = aws_instance.multi_address_instance.private_ip
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public]
tags = {
Name = "boundary-multi-address-primary-eip"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_eip" "multi_address_primary_secondary_private" {
domain = "vpc"
network_interface = aws_instance.multi_address_instance.primary_network_interface_id
associate_with_private_ip = "10.0.10.10"
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_instance.multi_address_instance]
tags = {
Name = "boundary-multi-address-primary-secondary-eip"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_eip" "multi_address_secondary_eni" {
domain = "vpc"
network_interface = aws_network_interface.multi_address_secondary_eni.id
associate_with_private_ip = "10.0.20.10"
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_network_interface_attachment.multi_address_secondary_eni]
tags = {
Name = "boundary-multi-address-secondary-eni-eip"
"boundary-demo" = local.hashicorp_email
}
}
output "instance_ids" {
@@ -74,4 +147,34 @@ output "instance_tags" {
value = {
for _, r in aws_instance.instances : r.id => r.tags
}
}
}
output "instance_tag_keys" {
value = [
random_id.foo_key.dec,
random_id.bar_key.dec,
random_id.baz_key.dec,
]
}
output "multi_address_instance_id" {
value = aws_instance.multi_address_instance.id
}
output "multi_address_primary_addresses" {
value = {
private_ip = aws_instance.multi_address_instance.private_ip
public_ip = aws_eip.multi_address_primary.public_ip
ipv6 = tolist(aws_instance.multi_address_instance.ipv6_addresses)[0]
}
}
output "multi_address_secondary_addresses" {
value = {
primary_eni_private_ip = "10.0.10.10"
primary_eni_public_ip = aws_eip.multi_address_primary_secondary_private.public_ip
secondary_eni_private_ip = aws_network_interface.multi_address_secondary_eni.private_ip
secondary_eni_public_ip = aws_eip.multi_address_secondary_eni.public_ip
secondary_eni_ipv6 = tolist(aws_network_interface.multi_address_secondary_eni.ipv6_addresses)[0]
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "random_id" "foo" {
+104 -17
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "aws_iam_role" "valid" {
@@ -32,10 +32,20 @@ resource "aws_iam_policy" "valid" {
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectAttributes"
"s3:DeleteObject",
"s3:GetObjectAttributes",
"s3:ListBucket",
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
},
{
"Action": [
"s3:ListBucket"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}"
}
]
@@ -79,7 +89,9 @@ resource "aws_iam_policy" "missing_put_obj" {
{
"Action": [
"s3:GetObject",
"s3:GetObjectAttributes"
"s3:GetObjectAttributes",
"s3:ListBucket",
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
@@ -125,7 +137,7 @@ resource "aws_iam_policy" "missing_get_obj" {
{
"Action": [
"s3:PutObject",
"s3:GetObjectAttributes"
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
@@ -141,18 +153,67 @@ resource "aws_iam_role_policy_attachment" "missing_get_obj" {
policy_arn = aws_iam_policy.missing_get_obj.arn
}
resource "aws_iam_role" "missing_delete_obj" {
name = "${random_id.prefix.dec}-missing-delete-obj"
tags = local.tags
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
AWS: data.aws_caller_identity.current.user_id
}
},
]
})
}
resource "aws_iam_policy" "missing_delete_obj" {
name = "${random_id.prefix.dec}-missing-delete-obj"
tags = local.tags
policy = <<EOT
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectAttributes",
"s3:ListBucket"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
}
]
}
EOT
}
resource "aws_iam_role_policy_attachment" "missing_delete_obj" {
role = aws_iam_role.missing_delete_obj.name
policy_arn = aws_iam_policy.missing_delete_obj.arn
}
# Static Credential Testing
resource "random_id" "user_name" {
count = var.iam_user_count
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "test" {
count = var.iam_user_count
name = random_id.user_name[count.index].dec
force_destroy = true
tags = local.tags
count = var.iam_user_count
name = random_id.user_name[count.index].dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "test" {
@@ -195,17 +256,42 @@ resource "aws_iam_user_policy_attachment" "test_credentials" {
policy_arn = aws_iam_policy.credentials[count.index].arn
}
# Edge case for missing DeleteObject permission
resource "random_id" "missing_delete_obj" {
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
byte_length = 4
}
resource "aws_iam_user" "missing_delete_obj" {
name = random_id.missing_delete_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_access_key" "missing_delete_obj" {
user = aws_iam_user.missing_delete_obj.name
}
resource "aws_iam_user_policy_attachment" "missing_delete_obj" {
user = aws_iam_user.missing_delete_obj.name
policy_arn = aws_iam_policy.missing_delete_obj.arn
}
# Edge case for missing GetObject permission
resource "random_id" "missing_get_obj" {
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "missing_get_obj" {
name = random_id.missing_get_obj.dec
force_destroy = true
tags = local.tags
name = random_id.missing_get_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "missing_get_obj" {
@@ -220,14 +306,15 @@ resource "aws_iam_user_policy_attachment" "missing_get_obj" {
# Edge case for missing PutObject permission
resource "random_id" "missing_put_obj" {
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "missing_put_obj" {
name = random_id.missing_put_obj.dec
force_destroy = true
tags = local.tags
name = random_id.missing_put_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "missing_put_obj" {
+2 -2
View File
@@ -1,11 +1,11 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
version = "~> 6.0"
}
}
}
+25 -6
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
output "bucket_name" {
@@ -6,15 +6,19 @@ output "bucket_name" {
}
output "valid_policy_arn" {
value = aws_iam_role.valid.arn
value = aws_iam_role.valid.arn
}
output "missing_put_obj_policy_arn" {
value = aws_iam_role.missing_put_obj.arn
value = aws_iam_role.missing_put_obj.arn
}
output "missing_put_get_policy_arn" {
value = aws_iam_role.missing_get_obj.arn
output "missing_get_obj_policy_arn" {
value = aws_iam_role.missing_get_obj.arn
}
output "missing_delete_obj_policy_arn" {
value = aws_iam_role.missing_delete_obj.arn
}
output "iam_user_names" {
@@ -53,4 +57,19 @@ output "iam_access_key_missing_get_obj" {
output "iam_secret_access_key_missing_get_obj" {
value = aws_iam_access_key.missing_get_obj.secret
sensitive = true
}
}
output "iam_access_key_missing_delete_obj" {
value = aws_iam_access_key.missing_delete_obj.id
sensitive = true
}
output "iam_secret_access_key_missing_delete_obj" {
value = aws_iam_access_key.missing_delete_obj.secret
sensitive = true
}
output "s3_pagination_test_object_count" {
description = "The number of S3 objects created for each pagination test scenario."
value = local.s3_pagination_test_object_count
}
+19 -3
View File
@@ -1,9 +1,25 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "aws_s3_bucket" "test" {
bucket = random_id.prefix.dec
force_destroy = true
acl = "private"
tags = local.tags
}
}
resource "aws_s3_bucket_public_access_block" "secure_access" {
bucket = aws_s3_bucket.test.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_object" "s3_files" {
for_each = local.s3_files
bucket = aws_s3_bucket.test.id
key = each.key
content = each.value
tags = local.tags
}
+47 -5
View File
@@ -1,10 +1,10 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
data "aws_caller_identity" "current" {}
resource "random_id" "prefix" {
prefix = "storage-e2e-test"
prefix = "aws-plugin-e2e-test"
byte_length = 4
}
@@ -13,8 +13,50 @@ variable "iam_user_count" {
}
locals {
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
tags = {
user_id = split(":", data.aws_caller_identity.current.user_id)[1]
repo = "boundary-plugin-aws"
User = split(":", data.aws_caller_identity.current.user_id)[1]
Repo = "boundary-plugin-aws"
Environment = "Demo"
}
}
# total number of objects of each type to create
s3_pagination_test_object_count = 350
# Dynamically build a flat map of S3 objects:
# - root-level text files
# - nested JSON files
# - placeholder "empty directories"
# - some static files in list-objects directory
# This map is consumed directly by an aws_s3_object resource
s3_files = merge(
# Root-level text files
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/root-file-${i}.txt" => "This is a root-level file #${i}."
},
# Nested JSON files inside sub-directories
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/nested-dir-${i}/data-file-${i}.json" =>
jsonencode({
id = i,
message = "This is a nested JSON file #${i}."
})
},
# "Empty" directories (in S3 these are just placeholder objects ending with `/`)
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/empty-dir-${i}/dir-${i}/" => "" # zero-length object to mimic directory
},
# Static example files in list-objects directory
{
"list-objects/file1.txt" = "This is the content for file 1."
"list-objects/data.json" = jsonencode({ key = "value", items = [1, 2, 3] })
"list-objects/nested-dir/file2.txt" = "This is a nested file."
"list-objects/empty-nested-dir/" = ""
}
)
}
+30 -8
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
@@ -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 {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing