Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f9e80b81 | ||
|
|
9e1fb04057 | ||
|
|
4656760fb8 |
No files matched your search
@@ -1,11 +0,0 @@
|
|||||||
## 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.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
name: test
|
|
||||||
|
|
||||||
on:
|
|
||||||
- workflow_dispatch
|
|
||||||
- push
|
|
||||||
|
|
||||||
permissions: read-all
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GO111MODULE: on
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
|
|
||||||
with:
|
|
||||||
go-version-file: 'go.mod'
|
|
||||||
cache: true
|
|
||||||
- name: Run Tests
|
|
||||||
run: go test -v ./plugin/... ./internal/...
|
|
||||||
@@ -4,12 +4,3 @@ plan.tfplan
|
|||||||
*.log
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.html
|
*.html
|
||||||
|
|
||||||
# IntelliJ IDEA project files
|
|
||||||
.idea
|
|
||||||
*.iprg
|
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
.env
|
|
||||||
.envrc
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# These owners will be the default owners for everything in
|
|
||||||
# the repo, unless a later match takes precedence.
|
|
||||||
|
|
||||||
* @hashicorp/boundary
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
Create a new package when adding support to a new service in the aws plugin. The package name should be named after the service name found [here](https://github.com/hashicorp/boundary/tree/main/internal/proto/plugin/v1).
|
|
||||||
|
|
||||||
## Service Packages
|
|
||||||
|
|
||||||
- A service package should contain an `attributes.go` file, which contains all attributes associated to the service in addition to `CredentialAttributes`, which is defined in `internal/credential/attributes.go`.
|
|
||||||
- A service package should contain an `state.go` file, which contains the logic and definition of the service's persisted state.
|
|
||||||
- A service package should contain an `plugin.go` file, which contains the implemented method defined the the services proto definition found [here](https://github.com/hashicorp/boundary/tree/main/internal/proto/plugin/v1).
|
|
||||||
|
|
||||||
## Credential Package
|
|
||||||
|
|
||||||
This package contains all logic used for authorizing aws sessions. Any changes applied to this package will affect how all services handle aws sessions.
|
|
||||||
|
|
||||||
## Plugin.go
|
|
||||||
|
|
||||||
Found in `plugin/plugin.go`. This file contains the AwsPlugin struct definition, which contains the struct composition of all services that the plugin supports. Add your newly defined plugin service struct as a struct composition to the AwsPlugins struct.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Please add end to end tests here in `testing/e2e_{service}_test.go`. Any aws terraform files should be stored in `testing/testdata/{service}/*.tf`
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
Copyright IBM Corp. 2021, 2026
|
|
||||||
|
|
||||||
Mozilla Public License Version 2.0
|
Mozilla Public License Version 2.0
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +1,155 @@
|
|||||||
# AWS Plugin for HashiCorp Boundary
|
# AWS Host Plugin for HashiCorp Boundary
|
||||||
|
|
||||||
This repo contains the aws plugin for [HashiCorp
|
This repo contains a Host-type plugin for [HashiCorp
|
||||||
Boundary](https://www.boundaryproject.io/).
|
Boundary](https://www.boundaryproject.io/) allowing dynamically sourcing hosts
|
||||||
|
from Amazon EC2.
|
||||||
## Credential Rotation
|
|
||||||
|
|
||||||
This is the following priority for the credential chain:
|
|
||||||
static, assume role, environment variables.
|
|
||||||
|
|
||||||
### Static Credentials
|
|
||||||
|
|
||||||
Although static credentials are stored encrypted within Boundary, by default this
|
|
||||||
plugin will attempt to rotate credentials when they are supplied through the
|
|
||||||
`secrets` object. The given credentials will be used to create a new credential,
|
|
||||||
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
|
|
||||||
`disable_credential_rotation` attribute to `true`.
|
|
||||||
|
|
||||||
### Assume Role Credentials
|
|
||||||
|
|
||||||
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
|
|
||||||
information about environment variables for AWS credentials can be found [here](https://docs.aws.amazon.com/sdkref/latest/guide/feature-static-credentials.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).
|
|
||||||
|
|
||||||
## Dynamic Hosts
|
|
||||||
|
|
||||||
This plugin supports dynamically sourcing hosts from Amazon EC2.
|
|
||||||
|
|
||||||
Host sets created with this plugin define filters which select and group like
|
Host sets created with this plugin define filters which select and group like
|
||||||
instances within AWS; these host sets can in turn be added to targets within
|
instances within AWS; these host sets can in turn be added to targets within
|
||||||
Boundary as host sources.
|
Boundary as host sources.
|
||||||
|
|
||||||
At creation, update or deletion of a host catalog of this type, configuration of the
|
At creation or update of a host catalog 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 or
|
||||||
delete calls actions. The values passed in to the plugin here are the attributes set
|
update calls actions. These values are input as JSON objects; the expected types
|
||||||
on on a host catalog in boundary.
|
are indicated below with the valid fields.
|
||||||
|
|
||||||
The plugin fetches hosts through the
|
The plugin fetches hosts through the
|
||||||
[DescribeInstances](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html)
|
[DescribeInstances](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html)
|
||||||
call.
|
call.
|
||||||
|
|
||||||
[Getting Started](plugin/service/host/README.md)
|
## Credential Rotation
|
||||||
|
|
||||||
## Storage Bucket
|
Although credentials are stored encrypted within Boundary, by default this
|
||||||
|
plugin will attempt to rotate credentials when they are supplied through the
|
||||||
|
`secrets` object on a create or update call to the host catalog resource. The
|
||||||
|
given credentials will be used to create a new credential, and then the given
|
||||||
|
credential will be revoked. In this way, after rotation, only Boundary knows the
|
||||||
|
client secret in use by this plugin.
|
||||||
|
|
||||||
This plugin supports storing and fetching objects from Amazon S3.
|
## Getting Started
|
||||||
|
|
||||||
Files created with this plugin are stored as objects defined by the bucket
|
Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on
|
||||||
name and bucket prefix values configured in the storage bucket resource;
|
configuration options for host catalogs and sets.
|
||||||
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
|
To create a host catalog (using default scope created by `boundary dev`):
|
||||||
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.
|
|
||||||
|
|
||||||
The plugin fetches files through the
|
```
|
||||||
[GetObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html)
|
boundary host-catalogs create plugin \
|
||||||
call.
|
-scope-id p_1234567890 \
|
||||||
|
-name "Example Plugin-Based Host Catalog" \
|
||||||
|
-description "Description for plugin-based host catalog" \
|
||||||
|
-plugin-name aws \
|
||||||
|
-attr region=REGION \
|
||||||
|
-secret access_key_id='KEY' \
|
||||||
|
-secret secret_access_key='SECRET'
|
||||||
|
```
|
||||||
|
|
||||||
The plugin stores files through the
|
To create a host set, filtering the host set based on tag keys `foo` or `bar`
|
||||||
[PutObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html)
|
(either tag can be present), ensuring that any targets set to this host set only
|
||||||
call.
|
connect to external addresses in the `54.0.0.0/8` class A subnet:
|
||||||
|
|
||||||
The plugin fetches metadata about the files through the
|
```
|
||||||
[HeadObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html)
|
boundary host-sets create plugin \
|
||||||
call.
|
-host-catalog-id HOST_CATALOG_ID \
|
||||||
|
-name "Example Plugin-Based Host Set" \
|
||||||
|
-description "Description for plugin-based host set" \
|
||||||
|
-attr filters=tag-key=foo,bar \
|
||||||
|
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||||
|
```
|
||||||
|
|
||||||
[Getting Started](plugin/service/storage/README.md)
|
As above, but instances must have both tags (both `foo` and `bar` *must* be
|
||||||
|
present):
|
||||||
|
|
||||||
|
```
|
||||||
|
boundary host-sets create plugin \
|
||||||
|
-host-catalog-id HOST_CATALOG_ID \
|
||||||
|
-name "Example Plugin-Based Host Set" \
|
||||||
|
-description "Description for plugin-based host set" \
|
||||||
|
-attr filters=tag-key=foo \
|
||||||
|
-attr filters=tag-key=bar \
|
||||||
|
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||||
|
```
|
||||||
|
|
||||||
|
As above, but matching on tag key and launch date:
|
||||||
|
|
||||||
|
```
|
||||||
|
boundary host-sets create plugin \
|
||||||
|
-host-catalog-id HOST_CATALOG_ID \
|
||||||
|
-name "Example Plugin-Based Host Set" \
|
||||||
|
-description "Description for plugin-based host set" \
|
||||||
|
-attr filters=tag-key=foo \
|
||||||
|
-attr filters=launch-time=2022-01-04T* \
|
||||||
|
-preferred-endpoint "cidr:54.0.0.0/8"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required IAM Privileges
|
||||||
|
|
||||||
|
The following IAM privileges, at the very least, are required to be attached to
|
||||||
|
a configured IAM user for this provider:
|
||||||
|
|
||||||
|
* `ec2:DescribeInstances`, configured to `*` (`DescribeInstances` cannot be
|
||||||
|
scoped to a resource ARN). Example policy:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Action": [
|
||||||
|
"ec2:DescribeInstances"
|
||||||
|
],
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to
|
||||||
|
the IAM user to allow credential rotation. Example policy:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Action": [
|
||||||
|
"iam:DeleteAccessKey",
|
||||||
|
"iam:GetUser",
|
||||||
|
"iam:CreateAccessKey"
|
||||||
|
],
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Resource": "arn:aws:iam::123456789012:user/JohnDoe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Attributes and Secrets
|
||||||
|
|
||||||
|
### Host Catalog
|
||||||
|
|
||||||
|
The following `attributes` are valid on an AWS host catalog resource:
|
||||||
|
|
||||||
|
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
|
||||||
|
be performed. See the [Credential Rotation](#credential-rotation) section
|
||||||
|
above.
|
||||||
|
- `region` (string): The region to configure the host catalog for. All host sets
|
||||||
|
in this catalog will be configured for this region.
|
||||||
|
|
||||||
|
The following `secrets` are valid on an AWS host catalog resource:
|
||||||
|
|
||||||
|
- `access_key_id` (string): The access key ID for the IAM user to use with this
|
||||||
|
host catalog.
|
||||||
|
- `secret_access_key` (string): The secret access key for the IAM user to use
|
||||||
|
with this host catalog.
|
||||||
|
|
||||||
|
### Host Set
|
||||||
|
|
||||||
|
The following attributes are valid on an AWS host Set resource:
|
||||||
|
|
||||||
|
- `filters` (array of strings): An array of string filters in the format
|
||||||
|
`key=val1,val2`. The key corresponds to a filter option, and the value(s) are
|
||||||
|
a comma-separated list. For a list of filter options, check out
|
||||||
|
[describe-instances in the AWS CLI
|
||||||
|
reference](https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html).
|
||||||
@@ -1,69 +1,44 @@
|
|||||||
module github.com/hashicorp/boundary-plugin-aws
|
module github.com/hashicorp/boundary-plugin-host-aws
|
||||||
|
|
||||||
go 1.25.7
|
go 1.17
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
github.com/aws/aws-sdk-go v1.42.2
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.46
|
github.com/hashicorp/boundary/sdk v0.0.12-0.20211112235128-46a1edcb3b7b
|
||||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0
|
github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6
|
github.com/hashicorp/terraform-json v0.13.0
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
github.com/mitchellh/mapstructure v1.4.2
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/aws/smithy-go v1.25.1
|
google.golang.org/protobuf v1.27.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.11.1
|
|
||||||
google.golang.org/grpc v1.79.3
|
|
||||||
google.golang.org/protobuf v1.36.11
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/eventlogger v0.2.11 // indirect
|
github.com/hashicorp/eventlogger v0.1.1-0.20211106154408-4ff8da3a890c // indirect
|
||||||
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
|
github.com/hashicorp/eventlogger/filters/encrypt v0.1.6-0.20211027211326-5db60a48f239 // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
github.com/hashicorp/go-hclog v0.16.2 // indirect
|
||||||
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 // indirect
|
github.com/hashicorp/go-kms-wrapping v0.6.6 // indirect
|
||||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
github.com/hashicorp/go-uuid v1.0.2 // indirect
|
||||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
github.com/hashicorp/go-version v1.3.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/mattn/go-colorable v0.1.11 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/pointerstructure v1.2.1 // indirect
|
github.com/mitchellh/pointerstructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
github.com/zclconf/go-cty v1.9.1 // indirect
|
||||||
github.com/zclconf/go-cty v1.15.0 // indirect
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/sys v0.0.0-20211004093028-2c5d950f24ef // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/text v0.3.6 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
google.golang.org/grpc v1.40.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CredentialAttributes contains attributes used for AWS credentials
|
|
||||||
type CredentialAttributes struct {
|
|
||||||
// Region is the region associated with the aws credentials
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// DisableCredentialRotation disables the rotation of aws secrets associated with the plugin
|
|
||||||
DisableCredentialRotation bool
|
|
||||||
|
|
||||||
// RoleArn is the role arn associated with the aws credentials
|
|
||||||
RoleArn string
|
|
||||||
|
|
||||||
// RoleExternalId is the external id associated with the aws credentials
|
|
||||||
RoleExternalId string
|
|
||||||
|
|
||||||
// RoleSessionName is the session name associated with the aws credentials
|
|
||||||
RoleSessionName string
|
|
||||||
|
|
||||||
// RoleTags is the tags associated with the aws credentials
|
|
||||||
RoleTags map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
|
|
||||||
// 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 {
|
|
||||||
secrets = &structpb.Struct{
|
|
||||||
Fields: make(map[string]*structpb.Value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unknownFields := values.StructFields(secrets)
|
|
||||||
badFields := make(map[string]string)
|
|
||||||
|
|
||||||
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, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error()
|
|
||||||
}
|
|
||||||
delete(unknownFields, ConstSecretAccessKey)
|
|
||||||
|
|
||||||
// the creds_last_rotated_time field will be found in the input struct for
|
|
||||||
// persisted secrets, this value is not needed for creating the CredentialsConfig
|
|
||||||
delete(unknownFields, ConstCredsLastRotatedTime)
|
|
||||||
for s := range unknownFields {
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []awsutil.Option{}
|
|
||||||
// logic for parsing the credential type
|
|
||||||
// supported types:
|
|
||||||
// - user static credential
|
|
||||||
// - ec2 assume role dynamic credential
|
|
||||||
// - enviornment variables credential
|
|
||||||
switch {
|
|
||||||
// static credentials and dynamic credentials cannot be used together
|
|
||||||
case accessKey != "" && secretKey != "" && attrs.RoleArn != "":
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "conflicts with role_arn value"
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = "conflicts with role_arn value"
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRoleArn)] = "conflicts with access_key_id and secret_access_key values"
|
|
||||||
// static credential is missing it's secret_access_key
|
|
||||||
case accessKey != "" && secretKey == "":
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = "missing required value"
|
|
||||||
// static credential is missing it's access_key_id
|
|
||||||
case accessKey == "" && secretKey != "":
|
|
||||||
badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "missing required value"
|
|
||||||
// dynamic credentials and credential rotation is not supported
|
|
||||||
case len(attrs.RoleArn) > 0 && !attrs.DisableCredentialRotation:
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = "disable_credential_rotation attribute is required when providing a role_arn"
|
|
||||||
// add static credentials
|
|
||||||
case accessKey != "" && secretKey != "":
|
|
||||||
opts = append(opts,
|
|
||||||
awsutil.WithAccessKey(accessKey),
|
|
||||||
awsutil.WithSecretKey(secretKey),
|
|
||||||
)
|
|
||||||
// add dynamic credentials
|
|
||||||
case attrs.RoleArn != "":
|
|
||||||
opts = append(opts, awsutil.WithRoleArn(attrs.RoleArn))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(badFields) > 0 {
|
|
||||||
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
if attrs.Region != "" {
|
|
||||||
opts = append(opts, awsutil.WithRegion(attrs.Region))
|
|
||||||
}
|
|
||||||
if attrs.RoleExternalId != "" {
|
|
||||||
opts = append(opts, awsutil.WithRoleExternalId(attrs.RoleExternalId))
|
|
||||||
}
|
|
||||||
if attrs.RoleSessionName != "" {
|
|
||||||
opts = append(opts, awsutil.WithRoleSessionName(attrs.RoleSessionName))
|
|
||||||
}
|
|
||||||
if len(attrs.RoleTags) != 0 {
|
|
||||||
opts = append(opts, awsutil.WithRoleTags(attrs.RoleTags))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
region, err := values.GetStringValue(in, ConstRegion, true)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
roleArn, err := values.GetStringValue(in, ConstRoleArn, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRoleArn)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
roleExternalId, err := values.GetStringValue(in, ConstRoleExternalId, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRoleExternalId)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
roleSessionName, err := values.GetStringValue(in, ConstRoleSessionName, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRoleSessionName)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
roleTags, err := values.GetMapStringString(in, ConstRoleTags, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstRoleTags)] = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(badFields) > 0 {
|
|
||||||
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CredentialAttributes{
|
|
||||||
Region: region,
|
|
||||||
DisableCredentialRotation: disableCredentialRotation,
|
|
||||||
RoleArn: roleArn,
|
|
||||||
RoleExternalId: roleExternalId,
|
|
||||||
RoleSessionName: roleSessionName,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetCredentialAttributes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in map[string]any
|
|
||||||
expected *CredentialAttributes
|
|
||||||
expectedErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing region",
|
|
||||||
in: map[string]any{},
|
|
||||||
expectedErrContains: "missing required value \"region\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad value for disable_credential_rotation",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstRegion: "us-west-2",
|
|
||||||
ConstDisableCredentialRotation: "sure",
|
|
||||||
},
|
|
||||||
expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstRegion: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with disable_credential_rotation",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstRegion: "us-west-2",
|
|
||||||
ConstDisableCredentialRotation: true,
|
|
||||||
},
|
|
||||||
expected: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with assume role",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstRegion: "us-west-2",
|
|
||||||
ConstRoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
ConstRoleExternalId: "1234567890",
|
|
||||||
ConstRoleSessionName: "test-session",
|
|
||||||
ConstRoleTags: map[string]interface{}{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: false,
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := GetCredentialAttributes(input)
|
|
||||||
if tc.expectedErrContains != "" {
|
|
||||||
require.Error(err)
|
|
||||||
require.Contains(err.Error(), tc.expectedErrContains)
|
|
||||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.EqualValues(tc.expected.Region, actual.Region)
|
|
||||||
require.EqualValues(tc.expected.DisableCredentialRotation, actual.DisableCredentialRotation)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
attrs *CredentialAttributes
|
|
||||||
dualStack bool
|
|
||||||
expected *awsutil.CredentialsConfig
|
|
||||||
expectedErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no credentials",
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with static credentials",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown fields",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
"foo": structpb.NewBoolValue(true),
|
|
||||||
"bar": structpb.NewBoolValue(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expectedErrContains: "secrets.bar: unrecognized field, secrets.foo: unrecognized field",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid ignore creds_last_rotated_time",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with assume role",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
DisableCredentialRotation: true,
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleARN: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with static credential & assume role",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErrContains: "attributes.role_arn: conflicts with access_key_id and secret_access_key values, secrets.access_key_id: conflicts with role_arn value, secrets.secret_access_key: conflicts with role_arn value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with dynamic credentials and no disable credential rotation",
|
|
||||||
secrets: &structpb.Struct{Fields: map[string]*structpb.Value{}},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErrContains: "attributes.disable_credential_rotation: disable_credential_rotation attribute is required when providing a role_arn",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.dualStack)
|
|
||||||
if tc.expectedErrContains != "" {
|
|
||||||
require.Error(err)
|
|
||||||
require.Contains(err.Error(), tc.expectedErrContains)
|
|
||||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected.AccessKey, actual.AccessKey)
|
|
||||||
require.Equal(tc.expected.SecretKey, actual.SecretKey)
|
|
||||||
require.Equal(tc.expected.Region, actual.Region)
|
|
||||||
require.Equal(tc.expected.RoleARN, actual.RoleARN)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ConstRegion is the key for the region in the aws credentials.
|
|
||||||
ConstRegion = "region"
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
ConstSecretAccessKey = "secret_access_key"
|
|
||||||
|
|
||||||
// ConstDisableCredentialRotation is the key for the disable credential rotation in the aws credentials.
|
|
||||||
ConstDisableCredentialRotation = "disable_credential_rotation"
|
|
||||||
|
|
||||||
// ConstCredsLastRotatedTime is the key for the last rotated time in the aws credentials.
|
|
||||||
ConstCredsLastRotatedTime = "creds_last_rotated_time"
|
|
||||||
|
|
||||||
// ConstRoleArn is the key for assuming a IAM role.
|
|
||||||
ConstRoleArn = "role_arn"
|
|
||||||
|
|
||||||
// ConstRoleExternalId is the key for the external id used for assuming a IAM role.
|
|
||||||
ConstRoleExternalId = "role_external_id"
|
|
||||||
|
|
||||||
// ConstRoleSessionName is the key for the session name used for assuming a IAM role.
|
|
||||||
ConstRoleSessionName = "role_session_name"
|
|
||||||
|
|
||||||
// ConstRoleTags is the key for the tags used for assuming a IAM role.
|
|
||||||
ConstRoleTags = "role_tags"
|
|
||||||
|
|
||||||
// ConstTargetRegion is the key for the region of the target account in the aws credentials.
|
|
||||||
ConstTargetRegion = "target_region"
|
|
||||||
|
|
||||||
// ConstTargetRoleArn is the key for assuming a IAM role for the target account.
|
|
||||||
ConstTargetRoleArn = "target_role_arn"
|
|
||||||
|
|
||||||
// ConstTargetRoleExternalId is the key for the external id used for assuming a IAM role for the target account.
|
|
||||||
ConstTargetRoleExternalId = "target_role_external_id"
|
|
||||||
|
|
||||||
// ConstTargetRoleSessionName is the key for the session name used for assuming a IAM role of the target account.
|
|
||||||
ConstTargetRoleSessionName = "target_role_session_name"
|
|
||||||
|
|
||||||
// ConstTargetRoleTags is the key for the tags used for assuming a IAM role of the target account.
|
|
||||||
ConstTargetRoleTags = "target_role_tags"
|
|
||||||
)
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CredentialType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// StaticAWS denotes an Access Key Id that begins with "AKIA". These are
|
|
||||||
// long-term access keys, provided by AWS, for an IAM user or an AWS account
|
|
||||||
// root user.
|
|
||||||
// https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys
|
|
||||||
StaticAWS CredentialType = iota
|
|
||||||
|
|
||||||
// DynamicAWS denotes the presence of a RoleARN, or an Access Key Id that
|
|
||||||
// begins with "ASIA". The latter are temporary credentials access keys that
|
|
||||||
// are created using AWS STS operations.
|
|
||||||
DynamicAWS
|
|
||||||
|
|
||||||
// StaticOther denotes the presence of an Access Key Id that does not follow
|
|
||||||
// the AKIA/ASIA convention outlined by AWS.
|
|
||||||
StaticOther
|
|
||||||
|
|
||||||
// Unknown is a catch-all for everything else.
|
|
||||||
Unknown
|
|
||||||
)
|
|
||||||
|
|
||||||
// rotationWaitTimeout controls the time we wait for credential rotation to
|
|
||||||
// succeed. This is important to ensure that rotated credentials can be used
|
|
||||||
// right away.
|
|
||||||
const rotationWaitTimeout = time.Second * 30
|
|
||||||
|
|
||||||
type AwsCredentialPersistedStateOption func(s *AwsCredentialPersistedState) error
|
|
||||||
|
|
||||||
// AwsCredentialPersistedState is the persisted state for the AWS credential.
|
|
||||||
type AwsCredentialPersistedState struct {
|
|
||||||
// CredentialsConfig is the credential configuration for the AWS credential.
|
|
||||||
CredentialsConfig *awsutil.CredentialsConfig
|
|
||||||
// CredsLastRotatedTime is the last rotation of aws secrets for the AWS credential.
|
|
||||||
CredsLastRotatedTime time.Time
|
|
||||||
|
|
||||||
// testOpts are options that should be used for testing only
|
|
||||||
testOpts []awsutil.Option
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithStateTestOpts enables unit testing different edge cases
|
|
||||||
// when using CredentialsConfig. This should never be used in
|
|
||||||
// production code. This should only be used in unit tests.
|
|
||||||
func WithStateTestOpts(opts []awsutil.Option) AwsCredentialPersistedStateOption {
|
|
||||||
return func(s *AwsCredentialPersistedState) error {
|
|
||||||
s.testOpts = opts
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithCredentialsConfig sets the value for CredentialsConfig in the credential persisted state.
|
|
||||||
func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedStateOption {
|
|
||||||
return func(s *AwsCredentialPersistedState) error {
|
|
||||||
if s.CredentialsConfig != nil {
|
|
||||||
return stderr.New("credentials config already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.CredentialsConfig = x
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithCredsLastRotatedTime sets the value for CredsLastRotatedTime in the credential persisted state.
|
|
||||||
func WithCredsLastRotatedTime(t time.Time) AwsCredentialPersistedStateOption {
|
|
||||||
return func(s *AwsCredentialPersistedState) error {
|
|
||||||
if !s.CredsLastRotatedTime.IsZero() {
|
|
||||||
return stderr.New("last rotation time already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.CredsLastRotatedTime = t
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAwsCredentialPersistedState returns a AwsCredentialPersistedState.
|
|
||||||
// Supported options include: WithAccessKeyId, WithSecretAccessKey
|
|
||||||
// WithCredsLastRotatedTime, & WithRegion.
|
|
||||||
func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
|
|
||||||
s := new(AwsCredentialPersistedState)
|
|
||||||
for _, opt := range opts {
|
|
||||||
if err := opt(s); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. This method returns a status error
|
|
||||||
// with PluginError details.
|
|
||||||
//
|
|
||||||
// When RoleARN is set and no static key is present (ambient role path),
|
|
||||||
// credentials are eagerly retrieved before calling GetCallerIdentity. This
|
|
||||||
// forces sts:AssumeRole to be called immediately so that any role errors
|
|
||||||
// (e.g. no such role, access denied) surface here rather than being deferred
|
|
||||||
// to the first real AWS API call.
|
|
||||||
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
|
|
||||||
if s.CredentialsConfig == nil {
|
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
|
||||||
}
|
|
||||||
|
|
||||||
// For the ambient role path (RoleARN set, no static key), eagerly resolve
|
|
||||||
// the credential chain so that sts:AssumeRole is called now. Without this,
|
|
||||||
// the AssumeRoleProvider inside the chain is lazy and GetCallerIdentity
|
|
||||||
// would succeed even with an invalid role ARN (it uses the mock/ambient
|
|
||||||
// identity directly rather than the assumed one).
|
|
||||||
cc := s.CredentialsConfig
|
|
||||||
if cc.RoleARN != "" && cc.AccessKey == "" {
|
|
||||||
cfg, err := s.GenerateCredentialChain(ctx)
|
|
||||||
if err != nil {
|
|
||||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
|
||||||
return st.Err()
|
|
||||||
}
|
|
||||||
if _, err := cfg.Credentials.Retrieve(ctx); err != nil {
|
|
||||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
|
||||||
return st.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
|
|
||||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
|
||||||
return st.Err()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotateCreds takes the access key and secret key from the persisted state and creates a new access/secret key,
|
|
||||||
// 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. This method returns a status error with PluginError details.
|
|
||||||
func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error {
|
|
||||||
if s.CredentialsConfig == nil {
|
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
|
||||||
}
|
|
||||||
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
|
|
||||||
return errors.BadRequestStatus("invalid credential type")
|
|
||||||
}
|
|
||||||
if err := s.CredentialsConfig.RotateKeys(ctx, append([]awsutil.Option{
|
|
||||||
awsutil.WithValidityCheckTimeout(rotationWaitTimeout),
|
|
||||||
}, s.testOpts...)...); err != nil {
|
|
||||||
st, _ := errors.ParseAWSError("rotating credentials", err)
|
|
||||||
return st.Err()
|
|
||||||
}
|
|
||||||
s.CredsLastRotatedTime = time.Now()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. 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.BadRequestStatus("missing new credentials config")
|
|
||||||
}
|
|
||||||
if s.CredentialsConfig == nil {
|
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old/existing credentials. This action is only possible for static credential types.
|
|
||||||
// This is done with the same credentials to ensure that it has the proper permissions to do it.
|
|
||||||
ct := GetCredentialType(s.CredentialsConfig)
|
|
||||||
if !s.CredsLastRotatedTime.IsZero() && ct == StaticAWS {
|
|
||||||
if err := s.DeleteCreds(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the new attributes and clear the rotated time.
|
|
||||||
s.CredentialsConfig = credentialsConfig
|
|
||||||
s.CredsLastRotatedTime = time.Time{}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. This
|
|
||||||
// method returns a status error with PluginError details.
|
|
||||||
func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error {
|
|
||||||
if s.CredentialsConfig == nil {
|
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
|
||||||
}
|
|
||||||
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
|
|
||||||
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 stderr.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" {
|
|
||||||
s.CredentialsConfig = nil
|
|
||||||
s.CredsLastRotatedTime = time.Time{}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise treat it as an actual error.
|
|
||||||
st, _ := errors.ParseAWSError("deleting credentials", err)
|
|
||||||
return st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.CredentialsConfig = nil
|
|
||||||
s.CredsLastRotatedTime = time.Time{}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateCredentialChain returns a AWS configuration for the credentials in the state.
|
|
||||||
func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Context) (*aws.Config, error) {
|
|
||||||
return s.CredentialsConfig.GenerateCredentialChain(ctx, s.testOpts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToMap returns a map of the credentials stored in the persisted state.
|
|
||||||
// ToMap will return an empty map for temporary credentials. ToMap will
|
|
||||||
// return a map for long-term credentials with following keys:
|
|
||||||
// access_key_id, secret_access_key & creds_last_rotated_time
|
|
||||||
func (s *AwsCredentialPersistedState) ToMap() map[string]any {
|
|
||||||
ct := GetCredentialType(s.CredentialsConfig)
|
|
||||||
// Dynamic AWS credentials are temporary credentials that expire within an hour.
|
|
||||||
// ToMap() returns an empty map here so that Boundary does not store temporary
|
|
||||||
// credentials into the database.
|
|
||||||
//
|
|
||||||
// Uknown credentials are not aws compatiable secrets that may have been provided
|
|
||||||
// for a non-aws s3 compatiable service. These credentials will not be returned
|
|
||||||
// because the plugin does not know how to manage non-aws credentials.
|
|
||||||
if ct == DynamicAWS || ct == Unknown {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
ConstAccessKeyId: s.CredentialsConfig.AccessKey,
|
|
||||||
ConstSecretAccessKey: s.CredentialsConfig.SecretKey,
|
|
||||||
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, dualStack bool, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
|
|
||||||
// initialize secrets if it is nil
|
|
||||||
// secrets can be nil because static credentials are optional
|
|
||||||
if secrets == nil {
|
|
||||||
secrets = &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, fmt.Errorf("missing credential attributes")
|
|
||||||
}
|
|
||||||
|
|
||||||
accessKeyId, err := values.GetStringValue(secrets, ConstAccessKeyId, false)
|
|
||||||
if err != nil {
|
|
||||||
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: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("persisted state integrity: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := NewAwsCredentialPersistedState(opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
awsOpts := append([]awsutil.Option{}, s.testOpts...)
|
|
||||||
if accessKeyId != "" && secretAccessKey != "" {
|
|
||||||
awsOpts = append(awsOpts,
|
|
||||||
awsutil.WithAccessKey(accessKeyId),
|
|
||||||
awsutil.WithSecretKey(secretAccessKey),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if attrs.Region != "" {
|
|
||||||
awsOpts = append(awsOpts, awsutil.WithRegion(attrs.Region))
|
|
||||||
}
|
|
||||||
if attrs.RoleArn != "" {
|
|
||||||
awsOpts = append(awsOpts, awsutil.WithRoleArn(attrs.RoleArn))
|
|
||||||
}
|
|
||||||
if attrs.RoleExternalId != "" {
|
|
||||||
awsOpts = append(awsOpts, awsutil.WithRoleExternalId(attrs.RoleExternalId))
|
|
||||||
}
|
|
||||||
if attrs.RoleSessionName != "" {
|
|
||||||
awsOpts = append(awsOpts, awsutil.WithRoleSessionName(attrs.RoleSessionName))
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
s.CredentialsConfig = credentialsConfig
|
|
||||||
s.CredsLastRotatedTime = credsLastRotatedTime
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCredentialType returns the credential type based on the given
|
|
||||||
// AccessKey/RoleARN. See CredentialType definition for more information.
|
|
||||||
func GetCredentialType(cc *awsutil.CredentialsConfig) CredentialType {
|
|
||||||
if cc == nil {
|
|
||||||
return Unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cc.RoleARN) > 0 || strings.HasPrefix(cc.AccessKey, "ASIA") {
|
|
||||||
return DynamicAWS
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(cc.AccessKey, "AKIA") {
|
|
||||||
return StaticAWS
|
|
||||||
}
|
|
||||||
if len(cc.AccessKey) > 0 {
|
|
||||||
return StaticOther
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,897 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"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/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"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewAwsCredentialPersistedState(t *testing.T) {
|
|
||||||
staticTime := time.Now()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
opts []AwsCredentialPersistedStateOption
|
|
||||||
expected *AwsCredentialPersistedState
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "error",
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
func(s *AwsCredentialPersistedState) error {
|
|
||||||
return errors.New(testOptionErr)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErr: testOptionErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with credentials config",
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
WithCredentialsConfig(
|
|
||||||
&awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rotation time",
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
WithCredsLastRotatedTime(staticTime),
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredsLastRotatedTime: staticTime,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double set credentials config",
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
WithCredentialsConfig(&awsutil.CredentialsConfig{}),
|
|
||||||
WithCredentialsConfig(&awsutil.CredentialsConfig{}),
|
|
||||||
},
|
|
||||||
expectedErr: "credentials config already set",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double set rotation time",
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
WithCredsLastRotatedTime(staticTime),
|
|
||||||
WithCredsLastRotatedTime(time.Now()),
|
|
||||||
},
|
|
||||||
expectedErr: "last rotation time already set",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
actual, err := NewAwsCredentialPersistedState(tc.opts...)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
|
|
||||||
if tc.expected.CredentialsConfig != nil {
|
|
||||||
require.NotNil(actual.CredentialsConfig)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.AccessKey, actual.CredentialsConfig.AccessKey)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.SecretKey, actual.CredentialsConfig.SecretKey)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.Region, actual.CredentialsConfig.Region)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
secrets *structpb.Struct
|
|
||||||
attrs *CredentialAttributes
|
|
||||||
dualStack bool
|
|
||||||
opts []AwsCredentialPersistedStateOption
|
|
||||||
expected *AwsCredentialPersistedState
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing credential attributes",
|
|
||||||
expectedErr: "missing credential attributes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing secrets",
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing long-term access key id",
|
|
||||||
secrets: &structpb.Struct{},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing long-term secret access key",
|
|
||||||
secrets: &structpb.Struct{},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with long-term static credentials",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue("notatime"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
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",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
opts: []AwsCredentialPersistedStateOption{
|
|
||||||
func(s *AwsCredentialPersistedState) error {
|
|
||||||
return errors.New(testOptionErr)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErr: testOptionErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good with non-zero timestamp",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: func() time.Time {
|
|
||||||
t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good with zero timestamp",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue((time.Time{}).Format(time.RFC3339Nano)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Time{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with assume role",
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleARN: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with assume role & static credentials",
|
|
||||||
secrets: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
ConstCredsLastRotatedTime: structpb.NewStringValue((time.Time{}).Format(time.RFC3339Nano)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attrs: &CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
RoleARN: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Time{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.dualStack, tc.opts...)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(actual)
|
|
||||||
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
|
|
||||||
if tc.expected.CredentialsConfig != nil {
|
|
||||||
require.NotNil(actual.CredentialsConfig)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.AccessKey, actual.CredentialsConfig.AccessKey)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.SecretKey, actual.CredentialsConfig.SecretKey)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.Region, actual.CredentialsConfig.Region)
|
|
||||||
require.Equal(tc.expected.CredentialsConfig.RoleARN, actual.CredentialsConfig.RoleARN)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *AwsCredentialPersistedState
|
|
||||||
expectedStatusCode codes.Code
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing credentials config",
|
|
||||||
in: &AwsCredentialPersistedState{},
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
expectedErr: "missing credentials config",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "validation error",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithSTSAPIFunc(
|
|
||||||
awsutil.NewMockSTS(
|
|
||||||
awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedStatusCode: codes.Unknown,
|
|
||||||
expectedErr: "aws service unknown: unknown error: validating credentials",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
err := tc.in.ValidateCreds(context.Background())
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.Error(err)
|
|
||||||
assert.ErrorContains(err, tc.expectedErr)
|
|
||||||
assert.Equal(tc.expectedStatusCode, status.Code(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *AwsCredentialPersistedState
|
|
||||||
exepected *awsutil.CredentialsConfig
|
|
||||||
expectedNonZeroRotatedTime bool
|
|
||||||
expectedErr string
|
|
||||||
expectedStatusCode codes.Code
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing credentials config",
|
|
||||||
in: &AwsCredentialPersistedState{},
|
|
||||||
expectedErr: "missing credentials config",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cannot delete dynamic credential type",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "ASIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
},
|
|
||||||
expectedErr: "invalid credential type",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cannot delete unknown credential type",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "DNEfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
},
|
|
||||||
expectedErr: "invalid credential type",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rotation error",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(
|
|
||||||
awsutil.WithGetUserError(errors.New(testGetUserErr)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErr: "aws service unknown: unknown error: rotating credentials",
|
|
||||||
expectedStatusCode: codes.Unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithSTSAPIFunc(
|
|
||||||
awsutil.NewMockSTS(),
|
|
||||||
),
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(
|
|
||||||
awsutil.WithGetUserOutput(
|
|
||||||
&iam.GetUserOutput{
|
|
||||||
User: &iamTypes.User{
|
|
||||||
Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"),
|
|
||||||
UserId: aws.String("AIDAJQABLZS4A3QDU576Q"),
|
|
||||||
UserName: aws.String("JohnDoe"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
awsutil.WithCreateAccessKeyOutput(
|
|
||||||
&iam.CreateAccessKeyOutput{
|
|
||||||
AccessKey: &iamTypes.AccessKey{
|
|
||||||
AccessKeyId: aws.String("one"),
|
|
||||||
SecretAccessKey: aws.String("two"),
|
|
||||||
UserName: aws.String("JohnDoe"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
exepected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "one",
|
|
||||||
SecretKey: "two",
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
expectedNonZeroRotatedTime: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
err := tc.in.RotateCreds(context.Background())
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.Error(err)
|
|
||||||
assert.ErrorContains(err, tc.expectedErr)
|
|
||||||
assert.Equal(tc.expectedStatusCode, status.Code(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.exepected.AccessKey, tc.in.CredentialsConfig.AccessKey)
|
|
||||||
require.Equal(tc.exepected.SecretKey, tc.in.CredentialsConfig.SecretKey)
|
|
||||||
require.Equal(tc.expectedNonZeroRotatedTime, !tc.in.CredsLastRotatedTime.IsZero())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
|
|
||||||
// NOTE: Not safe to run this test in parallel
|
|
||||||
state := new(testMockIAMState)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *AwsCredentialPersistedState
|
|
||||||
credentialConfig *awsutil.CredentialsConfig
|
|
||||||
expected *awsutil.CredentialsConfig
|
|
||||||
expectedDeleteCalled bool
|
|
||||||
expectedErr string
|
|
||||||
expectedStatusCode codes.Code
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deletion error",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(
|
|
||||||
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
credentialConfig: &awsutil.CredentialsConfig{},
|
|
||||||
expectedErr: "aws service unknown: unknown error: deleting credentials",
|
|
||||||
expectedStatusCode: codes.Unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good with delete of old rotated",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
newTestMockIAM(state),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
credentialConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "one",
|
|
||||||
SecretKey: "two",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "one",
|
|
||||||
SecretKey: "two",
|
|
||||||
},
|
|
||||||
expectedDeleteCalled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good without delete of old rotated",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
newTestMockIAM(state),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
credentialConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "one",
|
|
||||||
SecretKey: "two",
|
|
||||||
},
|
|
||||||
expected: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "one",
|
|
||||||
SecretKey: "two",
|
|
||||||
},
|
|
||||||
expectedDeleteCalled: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
state.Reset()
|
|
||||||
err := tc.in.ReplaceCreds(context.Background(), tc.credentialConfig)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.Error(err)
|
|
||||||
assert.ErrorContains(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected.AccessKey, tc.in.CredentialsConfig.AccessKey)
|
|
||||||
require.Equal(tc.expected.SecretKey, tc.in.CredentialsConfig.SecretKey)
|
|
||||||
require.Zero(tc.in.CredsLastRotatedTime)
|
|
||||||
require.Equal(tc.expectedDeleteCalled, state.DeleteAccessKeyCalled)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *AwsCredentialPersistedState
|
|
||||||
expectedErr string
|
|
||||||
expectedStatusCode codes.Code
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing credentials config",
|
|
||||||
in: &AwsCredentialPersistedState{},
|
|
||||||
expectedErr: "missing credentials config",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cannot delete dynamic credential type",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "ASIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
},
|
|
||||||
expectedErr: "invalid credential type",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cannot delete unknown credential type",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "DNEfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
CredsLastRotatedTime: time.Now(),
|
|
||||||
},
|
|
||||||
expectedErr: "invalid credential type",
|
|
||||||
expectedStatusCode: codes.InvalidArgument,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deletion error",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(
|
|
||||||
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedErr: "aws service unknown: unknown error: deleting credentials",
|
|
||||||
expectedStatusCode: codes.Unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deletion error, but OK because key was just gone",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(
|
|
||||||
awsutil.WithDeleteAccessKeyError(
|
|
||||||
&iamTypes.NoSuchEntityException{},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIAfoobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
err := tc.in.DeleteCreds(context.Background())
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.Error(err)
|
|
||||||
assert.ErrorContains(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Nil(tc.in.CredentialsConfig)
|
|
||||||
require.Zero(tc.in.CredsLastRotatedTime)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedState_GenerateCredentialChain(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *AwsCredentialPersistedState
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "error",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
|
||||||
},
|
|
||||||
expectedErr: fmt.Sprintf("error reading options in GenerateCredentialChain: %s", testOptionErr),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "static credentials",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: &awsutil.CredentialsConfig{
|
|
||||||
AccessKey: "AKIA_foobar",
|
|
||||||
SecretKey: "bazqux",
|
|
||||||
},
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assume role",
|
|
||||||
in: &AwsCredentialPersistedState{
|
|
||||||
CredentialsConfig: func() *awsutil.CredentialsConfig {
|
|
||||||
config, err := awsutil.NewCredentialsConfig(
|
|
||||||
awsutil.WithRegion("us-west-2"),
|
|
||||||
awsutil.WithRoleArn("arn:aws:iam::123456789012:role/S3Access"),
|
|
||||||
awsutil.WithRoleExternalId("1234567890"),
|
|
||||||
awsutil.WithRoleSessionName("assume-role"),
|
|
||||||
awsutil.WithRoleTags(map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
return config
|
|
||||||
}(),
|
|
||||||
testOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := tc.in.GenerateCredentialChain(context.Background())
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCredentialType(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
credConfig *awsutil.CredentialsConfig
|
|
||||||
expectedCredType CredentialType
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil credential config",
|
|
||||||
credConfig: nil,
|
|
||||||
expectedCredType: Unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty credential config",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{},
|
|
||||||
expectedCredType: Unknown,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "static aws",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{AccessKey: "AKIAfoobar"},
|
|
||||||
expectedCredType: StaticAWS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "static other",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{AccessKey: "cK3kNFa24foobar"},
|
|
||||||
expectedCredType: StaticOther,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dynamic aws - role arn",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{RoleARN: "arn:aws:iam::123456789012:role/S3Access"},
|
|
||||||
expectedCredType: DynamicAWS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dynamic aws - access key",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{AccessKey: "ASIAfoobar"},
|
|
||||||
expectedCredType: DynamicAWS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dynamic aws - role arn and access key",
|
|
||||||
credConfig: &awsutil.CredentialsConfig{AccessKey: "ASIAfoobar", RoleARN: "arn:aws:iam::123456789012:role/S3Access"},
|
|
||||||
expectedCredType: DynamicAWS,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
ct := GetCredentialType(tc.credConfig)
|
|
||||||
require.Equal(t, tc.expectedCredType, ct)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/iam"
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
testOptionErr = "test option error"
|
|
||||||
testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
|
||||||
testGetUserErr = "test error for GetUser"
|
|
||||||
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testMockIAMState struct {
|
|
||||||
DeleteAccessKeyCalled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testMockIAMState) Reset() {
|
|
||||||
s.DeleteAccessKeyCalled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
type testMockIAM struct {
|
|
||||||
awsutil.IAMClient
|
|
||||||
|
|
||||||
State *testMockIAMState
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *testMockIAM) DeleteAccessKey(ctx context.Context, input *iam.DeleteAccessKeyInput, opts ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) {
|
|
||||||
m.State.DeleteAccessKeyCalled = true
|
|
||||||
return m.IAMClient.DeleteAccessKey(ctx, input, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc {
|
|
||||||
return func(awsConfig *aws.Config) (awsutil.IAMClient, error) {
|
|
||||||
m := &testMockIAM{
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
f := awsutil.NewMockIAM(opts...)
|
|
||||||
var err error
|
|
||||||
|
|
||||||
m.IAMClient, err = f(awsConfig)
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockStaticCredentialSecrets returns a *structpb.Struct that contains two
|
|
||||||
// key pair values: (access_key_id, AKIA_foobar) & (secret_access_key, bazqux)
|
|
||||||
func MockStaticCredentialSecrets() *structpb.Struct {
|
|
||||||
return &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
|
||||||
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockAssumeRoleAttributes returns a *structpb.Struct that contains six key pair values:
|
|
||||||
//
|
|
||||||
// (region, region)
|
|
||||||
// (disable_credential_rotation, disableRotate)
|
|
||||||
// (role_arn, arn:aws:iam::123456789012:role/S3Access)
|
|
||||||
// (role_external_id, 1234567890)
|
|
||||||
// (role_session_name, ec2-assume-role-provider)
|
|
||||||
// (role_tags, struct{foo:bar})
|
|
||||||
func MockAssumeRoleAttributes(region string, disableRotate bool) *structpb.Struct {
|
|
||||||
return &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
ConstRegion: structpb.NewStringValue(region),
|
|
||||||
ConstDisableCredentialRotation: structpb.NewBoolValue(disableRotate),
|
|
||||||
ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"),
|
|
||||||
ConstRoleExternalId: structpb.NewStringValue("1234567890"),
|
|
||||||
ConstRoleSessionName: structpb.NewStringValue("ec2-assume-role-provider"),
|
|
||||||
ConstRoleTags: structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStringValue("bar"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// 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
|
|
||||||
for field, val := range f {
|
|
||||||
fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val))
|
|
||||||
}
|
|
||||||
if len(fieldMsgs) > 0 {
|
|
||||||
sort.Strings(fieldMsgs)
|
|
||||||
msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", "))
|
|
||||||
}
|
|
||||||
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, ¬FoundErr) {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// 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,123 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package values
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetStringValue returns a string value and no error if the given key
|
|
||||||
// is found in the provided proto struct input. An error is returned
|
|
||||||
// if the key is not found or the value type is not a string. An error is
|
|
||||||
// returned if the parameter argument "required" is set to true
|
|
||||||
// and the value from the proto struct input is empty.
|
|
||||||
func GetStringValue(in *structpb.Struct, k string, required bool) (string, error) {
|
|
||||||
mv := in.GetFields()
|
|
||||||
v, ok := mv[k]
|
|
||||||
if !ok {
|
|
||||||
if required {
|
|
||||||
return "", fmt.Errorf("missing required value %q", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s, ok := v.AsInterface().(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
|
||||||
}
|
|
||||||
|
|
||||||
if s == "" && required {
|
|
||||||
return "", fmt.Errorf("value %q cannot be empty", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBoolValue returns a boolean value and no error if the given key
|
|
||||||
// is found in the provided proto struct input. An error is returned
|
|
||||||
// if the key is not found or the value type is not a bool.
|
|
||||||
func GetBoolValue(in *structpb.Struct, k string, required bool) (bool, error) {
|
|
||||||
mv := in.GetFields()
|
|
||||||
v, ok := mv[k]
|
|
||||||
if !ok {
|
|
||||||
if required {
|
|
||||||
return false, fmt.Errorf("missing required value %q", k)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b, ok := v.AsInterface().(bool)
|
|
||||||
if !ok {
|
|
||||||
return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface())
|
|
||||||
}
|
|
||||||
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTimeValue returns a time.Time value and no error if the given key
|
|
||||||
// is found in the provided proto struct input. An error is returned
|
|
||||||
// if the key is not found or the value type is not a parsable. The
|
|
||||||
// expected time layout is RFC3339Nano.
|
|
||||||
func GetTimeValue(in *structpb.Struct, k string) (time.Time, error) {
|
|
||||||
mv := in.GetFields()
|
|
||||||
v, ok := mv[k]
|
|
||||||
if !ok {
|
|
||||||
return time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tRaw, ok := v.AsInterface().(string)
|
|
||||||
if !ok {
|
|
||||||
return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := time.Parse(time.RFC3339Nano, tRaw)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMapStringString returns a map[string]string value and no error if the given key
|
|
||||||
// is found in the provided proto struct input. An error is returned if the key
|
|
||||||
// is not found or the value type is not map[string]string.
|
|
||||||
func GetMapStringString(in *structpb.Struct, k string, required bool) (map[string]string, error) {
|
|
||||||
mv := in.GetFields()
|
|
||||||
v, ok := mv[k]
|
|
||||||
if !ok {
|
|
||||||
if required {
|
|
||||||
return nil, fmt.Errorf("missing required value %q", k)
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
result := map[string]string{}
|
|
||||||
m, ok := v.AsInterface().(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected type for value %q: want map[string]string, got %T", k, v.AsInterface())
|
|
||||||
}
|
|
||||||
for mapKey, mapValue := range m {
|
|
||||||
e, ok := mapValue.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected type for value in map[%q]: want string, got %T", mapKey, mapValue)
|
|
||||||
}
|
|
||||||
result[mapKey] = e
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StructFields returns a map[string]struct{} of the
|
|
||||||
// proto struct input.
|
|
||||||
func StructFields(s *structpb.Struct) map[string]struct{} {
|
|
||||||
m := make(map[string]struct{}, len(s.GetFields()))
|
|
||||||
for k := range s.GetFields() {
|
|
||||||
m[k] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package values
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStringValue(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in map[string]any
|
|
||||||
key string
|
|
||||||
required bool
|
|
||||||
expected string
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "required missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
key: "foo",
|
|
||||||
required: true,
|
|
||||||
expectedErr: "missing required value \"foo\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "optional missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
key: "foo",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-string value",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": 1,
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "required empty",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": "",
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
required: true,
|
|
||||||
expectedErr: "value \"foo\" cannot be empty",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expected: "bar",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := GetStringValue(input, tc.key, tc.required)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetBoolValue(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in map[string]any
|
|
||||||
key string
|
|
||||||
required bool
|
|
||||||
expected bool
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "required missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
key: "foo",
|
|
||||||
required: true,
|
|
||||||
expectedErr: "missing required value \"foo\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "optional missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
key: "foo",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-bool value",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "unexpected type for value \"foo\": want bool, got string",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": true,
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := GetBoolValue(input, tc.key, tc.required)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetTimeValue(t *testing.T) {
|
|
||||||
staticTime := time.Now()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in map[string]any
|
|
||||||
key string
|
|
||||||
expected time.Time
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
key: "foo",
|
|
||||||
expected: time.Time{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-time value",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": 1,
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad parse",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": staticTime.Format(time.RFC3339Nano),
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expected: func() time.Time {
|
|
||||||
u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return u
|
|
||||||
}(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := GetTimeValue(input, tc.key)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetMapStringString(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *structpb.Struct
|
|
||||||
key string
|
|
||||||
required bool
|
|
||||||
expected map[string]string
|
|
||||||
expectedErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "required missing",
|
|
||||||
in: &structpb.Struct{},
|
|
||||||
key: "foo",
|
|
||||||
required: true,
|
|
||||||
expectedErr: "missing required value \"foo\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "optional missing",
|
|
||||||
in: &structpb.Struct{},
|
|
||||||
key: "foo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-map[string]string value",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewBoolValue(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "unexpected type for value \"foo\": want map[string]string, got bool",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty map",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expected: map[string]string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-string value in map",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"bar": structpb.NewBoolValue(true),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expectedErr: "unexpected type for value in map[\"bar\"]: want string, got bool",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"bar": structpb.NewStringValue("zoo"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
key: "foo",
|
|
||||||
expected: map[string]string{
|
|
||||||
"bar": "zoo",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
actual, err := GetMapStringString(tc.in, tc.key, tc.required)
|
|
||||||
if tc.expectedErr != "" {
|
|
||||||
require.EqualError(err, tc.expectedErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func invalidArgumentError(msg string, f map[string]string) error {
|
||||||
|
var fieldMsgs []string
|
||||||
|
for field, val := range f {
|
||||||
|
fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val))
|
||||||
|
}
|
||||||
|
if len(fieldMsgs) > 0 {
|
||||||
|
sort.Strings(fieldMsgs)
|
||||||
|
msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", "))
|
||||||
|
}
|
||||||
|
return status.Error(codes.InvalidArgument, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogAttributes is a Go-native representation of the Attributes
|
||||||
|
// map.
|
||||||
|
type CatalogAttributes struct {
|
||||||
|
Region string
|
||||||
|
DisableCredentialRotation bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||||
|
unknownFields := structFields(in)
|
||||||
|
result := new(CatalogAttributes)
|
||||||
|
badFields := make(map[string]string)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
result.Region, err = getStringValue(in, constRegion, true)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", constRegion)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, constRegion)
|
||||||
|
|
||||||
|
result.DisableCredentialRotation, err = getBoolValue(in, constDisableCredentialRotation, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", constDisableCredentialRotation)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, constDisableCredentialRotation)
|
||||||
|
|
||||||
|
for s := range unknownFields {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(badFields) > 0 {
|
||||||
|
return nil, invalidArgumentError("Invalid arguments in catalog attributes", badFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogSecrets is a Go-native representation of the Secrets map.
|
||||||
|
type CatalogSecrets struct {
|
||||||
|
AccessKeyId string
|
||||||
|
SecretAccessKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCatalogSecrets(in *structpb.Struct) (*CatalogSecrets, error) {
|
||||||
|
unknownFields := structFields(in)
|
||||||
|
result := new(CatalogSecrets)
|
||||||
|
badFields := make(map[string]string)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
result.AccessKeyId, err = getStringValue(in, constAccessKeyId, true)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", constAccessKeyId)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, constAccessKeyId)
|
||||||
|
|
||||||
|
result.SecretAccessKey, err = getStringValue(in, constSecretAccessKey, true)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", constSecretAccessKey)] = err.Error()
|
||||||
|
}
|
||||||
|
delete(unknownFields, constSecretAccessKey)
|
||||||
|
|
||||||
|
for s := range unknownFields {
|
||||||
|
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(badFields) > 0 {
|
||||||
|
return nil, invalidArgumentError("Error in the secrets provided", badFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAttributes is a Go-native representation of the Attributes map that can be
|
||||||
|
// used for decoding the incoming map via mapstructure.
|
||||||
|
type SetAttributes struct {
|
||||||
|
Filters []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||||
|
var setAttrs SetAttributes
|
||||||
|
|
||||||
|
// Quick validation to ensure that there's no non-filter attributes
|
||||||
|
// here for now. Make this more complex if we add more attributes
|
||||||
|
// to host sets.
|
||||||
|
unknownFields := structFields(in)
|
||||||
|
badFields := make(map[string]string)
|
||||||
|
delete(unknownFields, constDescribeInstancesFilters)
|
||||||
|
for a := range unknownFields {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
|
||||||
|
}
|
||||||
|
if len(badFields) > 0 {
|
||||||
|
return nil, invalidArgumentError("Error in the attributes provided", badFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapstructure complains if it expects a slice as output and sees a scalar
|
||||||
|
// value. Rather than use WeakDecode and risk unintended consequences, I'm
|
||||||
|
// manually making this change if necessary.
|
||||||
|
inMap := in.AsMap()
|
||||||
|
if filtersRaw, ok := inMap[constDescribeInstancesFilters]; ok {
|
||||||
|
switch filterVal := filtersRaw.(type) {
|
||||||
|
case string:
|
||||||
|
inMap[constDescribeInstancesFilters] = []string{filterVal}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mapstructure.Decode(inMap, &setAttrs); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &setAttrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringValue(in *structpb.Struct, k string, required bool) (string, error) {
|
||||||
|
mv := in.GetFields()
|
||||||
|
v, ok := mv[k]
|
||||||
|
if !ok {
|
||||||
|
if required {
|
||||||
|
return "", fmt.Errorf("missing required value %q", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := v.AsInterface().(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == "" && required {
|
||||||
|
return "", fmt.Errorf("value %q cannot be empty", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoolValue(in *structpb.Struct, k string, required bool) (bool, error) {
|
||||||
|
mv := in.GetFields()
|
||||||
|
v, ok := mv[k]
|
||||||
|
if !ok {
|
||||||
|
if required {
|
||||||
|
return false, fmt.Errorf("missing required value %q", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := v.AsInterface().(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface())
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeValue(in *structpb.Struct, k string) (time.Time, error) {
|
||||||
|
mv := in.GetFields()
|
||||||
|
v, ok := mv[k]
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tRaw, ok := v.AsInterface().(string)
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface())
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, tRaw)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func structFields(s *structpb.Struct) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{}, len(s.GetFields()))
|
||||||
|
for k := range s.GetFields() {
|
||||||
|
m[k] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysAsString(m map[string]struct{}) string {
|
||||||
|
s := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
s = append(s, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(s)
|
||||||
|
return strings.Join(s, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetCatalogAttributes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
expected *CatalogAttributes
|
||||||
|
expectedErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing region",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
expectedErrContains: "missing required value \"region\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad value for disable_credential_rotation",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constRegion: "us-west-2",
|
||||||
|
constDisableCredentialRotation: "sure",
|
||||||
|
}),
|
||||||
|
expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constRegion: "us-west-2",
|
||||||
|
"foo": true,
|
||||||
|
"bar": true,
|
||||||
|
}),
|
||||||
|
expectedErrContains: "attributes.bar: Unrecognized field, attributes.foo: Unrecognized field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constRegion: "us-west-2",
|
||||||
|
}),
|
||||||
|
expected: &CatalogAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with disable_credential_rotation",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constRegion: "us-west-2",
|
||||||
|
constDisableCredentialRotation: true,
|
||||||
|
}),
|
||||||
|
expected: &CatalogAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getCatalogAttributes(tc.in)
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCatalogSecrets(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
expected *CatalogSecrets
|
||||||
|
expectedErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing access_key_id",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
expectedErrContains: "missing required value \"access_key_id\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing secret_access_key",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
}),
|
||||||
|
expectedErrContains: "missing required value \"secret_access_key\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
"foo": true,
|
||||||
|
"bar": true,
|
||||||
|
}),
|
||||||
|
expectedErrContains: "secrets.bar: Unrecognized field, secrets.foo: Unrecognized field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
}),
|
||||||
|
expected: &CatalogSecrets{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getCatalogSecrets(tc.in)
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSetAttributes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
expected *SetAttributes
|
||||||
|
expectedErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
expected: &SetAttributes{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-string-slice value",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constDescribeInstancesFilters: "zip=foo,bar",
|
||||||
|
}),
|
||||||
|
expected: &SetAttributes{
|
||||||
|
Filters: []string{"zip=foo,bar"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad filter element value",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constDescribeInstancesFilters: []interface{}{1},
|
||||||
|
}),
|
||||||
|
expectedErrContains: "expected type 'string', got unconvertible type 'float64'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": true,
|
||||||
|
"bar": true,
|
||||||
|
}),
|
||||||
|
expectedErrContains: "attributes.bar: Unrecognized field, attributes.foo: Unrecognized field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
constDescribeInstancesFilters: []interface{}{
|
||||||
|
"foo=bar",
|
||||||
|
"zip=zap",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expected: &SetAttributes{
|
||||||
|
Filters: []string{"foo=bar", "zip=zap"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getSetAttributes(tc.in)
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStringValue(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
key string
|
||||||
|
required bool
|
||||||
|
expected string
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "required missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
key: "foo",
|
||||||
|
required: true,
|
||||||
|
expectedErr: "missing required value \"foo\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
key: "foo",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-string value",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": 1,
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "required empty",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": "",
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
required: true,
|
||||||
|
expectedErr: "value \"foo\" cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": "bar",
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expected: "bar",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getStringValue(tc.in, tc.key, tc.required)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBoolValue(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
key string
|
||||||
|
required bool
|
||||||
|
expected bool
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "required missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
key: "foo",
|
||||||
|
required: true,
|
||||||
|
expectedErr: "missing required value \"foo\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
key: "foo",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-bool value",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": "bar",
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expectedErr: "unexpected type for value \"foo\": want bool, got string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": true,
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getBoolValue(tc.in, tc.key, tc.required)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTimeValue(t *testing.T) {
|
||||||
|
staticTime := time.Now()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *structpb.Struct
|
||||||
|
key string
|
||||||
|
expected time.Time
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing",
|
||||||
|
in: mustStruct(map[string]interface{}{}),
|
||||||
|
key: "foo",
|
||||||
|
expected: time.Time{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-time value",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": 1,
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expectedErr: "unexpected type for value \"foo\": want string, got float64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad parse",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": "bar",
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: mustStruct(map[string]interface{}{
|
||||||
|
"foo": staticTime.Format(time.RFC3339Nano),
|
||||||
|
}),
|
||||||
|
key: "foo",
|
||||||
|
expected: func() time.Time {
|
||||||
|
u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return u
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := getTimeValue(tc.in, tc.key)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRegion(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid region",
|
||||||
|
in: "foobar",
|
||||||
|
expectedErr: "not a valid region: foobar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: "us-west-2",
|
||||||
|
expectedErr: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
err := validateRegion(tc.in)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.Contains(err.Error(), tc.expectedErr)
|
||||||
|
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
const (
|
||||||
|
constRegion = "region"
|
||||||
|
constAccessKeyId = "access_key_id"
|
||||||
|
constSecretAccessKey = "secret_access_key"
|
||||||
|
constDisableCredentialRotation = "disable_credential_rotation"
|
||||||
|
constCredsLastRotatedTime = "creds_last_rotated_time"
|
||||||
|
constDescribeInstancesFilters = "filters"
|
||||||
|
)
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
pluginTesting "github.com/hashicorp/boundary-plugin-host-aws/testing"
|
||||||
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
||||||
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||||
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedIamUserCount = 6
|
||||||
|
const expectedEc2InstanceCount = 5
|
||||||
|
|
||||||
|
var expectedTags = []string{"foo", "bar", "baz"}
|
||||||
|
|
||||||
|
func TestPlugin(t *testing.T) {
|
||||||
|
region := os.Getenv("AWS_REGION")
|
||||||
|
if region == "" {
|
||||||
|
t.Skip("set AWS_REGION to use this test")
|
||||||
|
}
|
||||||
|
if envAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID"); envAccessKeyId == "" {
|
||||||
|
t.Skip("set AWS_ACCESS_KEY_ID to use this test")
|
||||||
|
}
|
||||||
|
if envSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY"); envSecretAccessKey == "" {
|
||||||
|
t.Skip("set AWS_SECRET_ACCESS_KEY to use this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
require := require.New(t)
|
||||||
|
tf, err := pluginTesting.NewTestTerraformer("testdata/basic")
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(tf)
|
||||||
|
|
||||||
|
t.Log("===== deploying test Terraform workspace =====")
|
||||||
|
err = tf.Deploy()
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
t.Log("===== destroying test Terraform workspace =====")
|
||||||
|
if err := tf.Destroy(); err != nil {
|
||||||
|
t.Logf("WARNING: could not run Terraform destroy: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
iamUserNames, err := tf.GetOutputSlice("iam_user_names")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(iamUserNames, expectedIamUserCount)
|
||||||
|
|
||||||
|
iamUserArns, err := tf.GetOutputSlice("iam_user_arns")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(iamUserArns, expectedIamUserCount)
|
||||||
|
|
||||||
|
iamAccessKeyIds, err := tf.GetOutputSlice("iam_access_key_ids")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(iamAccessKeyIds, expectedIamUserCount)
|
||||||
|
|
||||||
|
iamSecretAccessKeys, err := tf.GetOutputSlice("iam_secret_access_keys")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(iamSecretAccessKeys, expectedIamUserCount)
|
||||||
|
|
||||||
|
ec2InstanceIds, err := tf.GetOutputSlice("instance_ids")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(ec2InstanceIds, expectedEc2InstanceCount)
|
||||||
|
|
||||||
|
ec2InstanceAddrs, err := tf.GetOutputMap("instance_addrs")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(ec2InstanceAddrs, expectedEc2InstanceCount)
|
||||||
|
|
||||||
|
ec2InstanceTags, err := tf.GetOutputMap("instance_tags")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(ec2InstanceTags, expectedEc2InstanceCount)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// will assert this through the returned state.
|
||||||
|
p := new(AwsPlugin)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// * OnCreateCatalog
|
||||||
|
// ********************
|
||||||
|
//
|
||||||
|
// Test non-rotation (using primary user).
|
||||||
|
keyid, secret := testPluginOnCreateCatalog(ctx, t, p, region, iamAccessKeyIds[0].(string), iamSecretAccessKeys[0].(string), false)
|
||||||
|
// Test rotation next.
|
||||||
|
keyid, secret = testPluginOnCreateCatalog(ctx, t, p, region, keyid, secret, true)
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// * OnUpdateCatalog
|
||||||
|
// ********************
|
||||||
|
//
|
||||||
|
// Test no-op non-rotation.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, false)
|
||||||
|
// Switch to rotation.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, true)
|
||||||
|
// Test no-op with rotation.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", true, true)
|
||||||
|
// Switch credentials to next user. Don't rotate.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[1].(string), iamSecretAccessKeys[1].(string), true, false)
|
||||||
|
// Switch credentials to next user. Add rotation.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[2].(string), iamSecretAccessKeys[2].(string), false, true)
|
||||||
|
// Switch to next user, with rotation disabled.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[3].(string), iamSecretAccessKeys[3].(string), true, false)
|
||||||
|
// Last case - switch to another user and keep rotation off.
|
||||||
|
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[4].(string), iamSecretAccessKeys[4].(string), false, false)
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// * OnDeleteCatalog
|
||||||
|
// ********************
|
||||||
|
//
|
||||||
|
// Test non-rotated.
|
||||||
|
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, false)
|
||||||
|
// Test as if we had rotated these credentials (note that this
|
||||||
|
// makes this test set unusable).
|
||||||
|
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, true)
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// * Host set stuff
|
||||||
|
// ********************
|
||||||
|
// 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]interface{}) {
|
||||||
|
for _, expectedTag := range expectedTags {
|
||||||
|
if tagKey == expectedTag {
|
||||||
|
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := [][]string{
|
||||||
|
{"foo"},
|
||||||
|
{"bar"},
|
||||||
|
{"baz"},
|
||||||
|
{"foo", "bar"},
|
||||||
|
{"foo", "baz"},
|
||||||
|
{"bar", "baz"},
|
||||||
|
{"foo", "bar", "baz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
// Test create/update in one step
|
||||||
|
testPluginOnCreateUpdateSet(ctx, t, p, region, keyid, secret, tc)
|
||||||
|
// Test ListHosts
|
||||||
|
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 *AwsPlugin, 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]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
constDisableCredentialRotation: !rotate,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: accessKeyId,
|
||||||
|
constSecretAccessKey: secretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
request := &pb.OnCreateCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attributes: reqAttrs,
|
||||||
|
Secrets: reqSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, err := p.OnCreateCatalog(ctx, request)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(response)
|
||||||
|
persisted := response.GetPersisted()
|
||||||
|
require.NotNil(persisted)
|
||||||
|
persistedSecrets := persisted.GetSecrets()
|
||||||
|
require.NotNil(persistedSecrets)
|
||||||
|
persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotZero(persistedAccessKeyId)
|
||||||
|
if rotate {
|
||||||
|
require.NotEqual(accessKeyId, persistedAccessKeyId)
|
||||||
|
} else {
|
||||||
|
require.Equal(accessKeyId, persistedAccessKeyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotZero(persistedSecretAccessKey)
|
||||||
|
if rotate {
|
||||||
|
require.NotEqual(secretAccessKey, persistedSecretAccessKey)
|
||||||
|
} else {
|
||||||
|
require.Equal(secretAccessKey, persistedSecretAccessKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime)
|
||||||
|
require.NoError(err)
|
||||||
|
if rotate {
|
||||||
|
require.NotZero(persistedCredsLastRotatedTime)
|
||||||
|
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
|
||||||
|
} else {
|
||||||
|
require.Zero(persistedCredsLastRotatedTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedAccessKeyId, persistedSecretAccessKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginOnUpdateCatalog(
|
||||||
|
ctx context.Context, t *testing.T, p *AwsPlugin,
|
||||||
|
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]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
constDisableCredentialRotation: !rotated,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqNewAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
constDisableCredentialRotation: !rotate,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
var reqSecrets *structpb.Struct
|
||||||
|
if newAccessKeyId != "" && newSecretAccessKey != "" {
|
||||||
|
reqSecrets, err = structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: newAccessKeyId,
|
||||||
|
constSecretAccessKey: newSecretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
}
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: currentAccessKeyId,
|
||||||
|
constSecretAccessKey: currentSecretAccessKey,
|
||||||
|
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{
|
||||||
|
Attributes: reqCurrentAttrs,
|
||||||
|
},
|
||||||
|
NewCatalog: &hostcatalogs.HostCatalog{
|
||||||
|
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)
|
||||||
|
persistedSecrets := persisted.GetSecrets()
|
||||||
|
require.NotNil(persistedSecrets)
|
||||||
|
|
||||||
|
// Complex checks based on the scenarios.
|
||||||
|
persistedAccessKeyId, err := getStringValue(persistedSecrets, constAccessKeyId, true)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotZero(persistedAccessKeyId)
|
||||||
|
persistedSecretAccessKey, err := getStringValue(persistedSecrets, constSecretAccessKey, true)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotZero(persistedSecretAccessKey)
|
||||||
|
persistedCredsLastRotatedTime, err := getTimeValue(persistedSecrets, constCredsLastRotatedTime)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// Our test scenarios are complex due the multi-dimensional nature
|
||||||
|
// of criteria, so we lay them out in a switch below.
|
||||||
|
switch {
|
||||||
|
case newAccessKeyId != "" && rotated && rotate:
|
||||||
|
// The new access key ID was provided, we had previously rotated
|
||||||
|
// the credentials before, and the new credential set is to be
|
||||||
|
// rotated as well. In this case, the old credentials should have
|
||||||
|
// been deleted, and the new credentials should have been rotated,
|
||||||
|
// hence, should not match the new credentials initially
|
||||||
|
// provided. Rotation time should be non-zero and updated.
|
||||||
|
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
||||||
|
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
||||||
|
require.NotZero(persistedCredsLastRotatedTime)
|
||||||
|
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||||
|
|
||||||
|
case newAccessKeyId != "" && rotated && !rotate:
|
||||||
|
// The new access key ID was provided, we had previously rotated
|
||||||
|
// the credentials before, and the new credential is *not*
|
||||||
|
// rotated. In this case, the old credentials should have
|
||||||
|
// been deleted, But the new credentials should have not been
|
||||||
|
// rotated, and hence should be the same. Rotation time should be
|
||||||
|
// zero.
|
||||||
|
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
||||||
|
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
||||||
|
require.Zero(persistedCredsLastRotatedTime)
|
||||||
|
|
||||||
|
case newAccessKeyId != "" && !rotated && rotate:
|
||||||
|
// The new access key ID was provided, we *have not* previously
|
||||||
|
// rotated the credentials, and the new credential set is to be
|
||||||
|
// rotated. In this case, the old credentials should have been
|
||||||
|
// left alone, and the new credentials should have been rotated,
|
||||||
|
// hence, should not match the new credentials initially
|
||||||
|
// provided. Rotation time should be non-zero, but updated.
|
||||||
|
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
||||||
|
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
||||||
|
require.NotZero(persistedCredsLastRotatedTime)
|
||||||
|
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||||
|
|
||||||
|
case newAccessKeyId != "" && !rotated && !rotate:
|
||||||
|
// The new access key ID was provided, but we have not rotated
|
||||||
|
// the credentials previously and we still don't plan on rotating
|
||||||
|
// them. In this case, the old credentials should still be valid,
|
||||||
|
// and the persisted ones should match the new ones provided.
|
||||||
|
// Rotation time should be zero.
|
||||||
|
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
||||||
|
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
||||||
|
require.Zero(persistedCredsLastRotatedTime)
|
||||||
|
|
||||||
|
case newAccessKeyId == "" && rotated && rotate:
|
||||||
|
// No new credentials have been provided, but we have previously
|
||||||
|
// rotated and are still rotating credentials. This is a no-op.
|
||||||
|
// Existing credentials should still be valid and match the ones
|
||||||
|
// persisted to state. Rotation time should be identical since
|
||||||
|
// no new rotation occurred.
|
||||||
|
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
||||||
|
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
||||||
|
require.NotZero(persistedCredsLastRotatedTime)
|
||||||
|
require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime))
|
||||||
|
|
||||||
|
case newAccessKeyId == "" && rotated && !rotate:
|
||||||
|
// No new credentials have been provided, and we have previously
|
||||||
|
// rotated the credentials. This is actually an error, but we
|
||||||
|
// don't test it here; it's covered in unit testing (see
|
||||||
|
// plugin_test.go).
|
||||||
|
require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper")
|
||||||
|
|
||||||
|
case newAccessKeyId == "" && !rotated && rotate:
|
||||||
|
// No new credentials have been provided, and while we did not
|
||||||
|
// rotate before, we want to switch to rotation. In this case,
|
||||||
|
// the existing persisted credentials should have been rotated,
|
||||||
|
// with a new non-zero timestamp.
|
||||||
|
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.NotEqual(persistedAccessKeyId, currentAccessKeyId)
|
||||||
|
require.NotEqual(persistedSecretAccessKey, currentSecretAccessKey)
|
||||||
|
require.NotZero(persistedCredsLastRotatedTime)
|
||||||
|
|
||||||
|
case newAccessKeyId == "" && !rotated && !rotate:
|
||||||
|
// No new credentials have been provided and we have not, nor do
|
||||||
|
// not, plan on rotating the credentials. This is a no-op.
|
||||||
|
// Existing credentials should still be valid and match the ones
|
||||||
|
// persisted to state. Rotation time should remain at zero.
|
||||||
|
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
||||||
|
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
||||||
|
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
||||||
|
require.Zero(persistedCredsLastRotatedTime)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Scenario was reached that was not covered by this function.
|
||||||
|
require.FailNow("unknown test scenario")
|
||||||
|
}
|
||||||
|
|
||||||
|
return persistedAccessKeyId, persistedSecretAccessKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *AwsPlugin, 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]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
constDisableCredentialRotation: !rotated,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: accessKeyId,
|
||||||
|
constSecretAccessKey: secretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: accessKeyId,
|
||||||
|
constSecretAccessKey: secretAccessKey,
|
||||||
|
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{
|
||||||
|
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 *AwsPlugin, 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]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
constDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
setAttrs, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: accessKeyId,
|
||||||
|
constSecretAccessKey: secretAccessKey,
|
||||||
|
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
createRequest := &pb.OnCreateSetRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attributes: catalogAttrs,
|
||||||
|
},
|
||||||
|
Set: &hostsets.HostSet{
|
||||||
|
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{
|
||||||
|
Attributes: catalogAttrs,
|
||||||
|
},
|
||||||
|
CurrentSet: &hostsets.HostSet{
|
||||||
|
Attributes: setAttrs,
|
||||||
|
},
|
||||||
|
NewSet: &hostsets.HostSet{
|
||||||
|
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 *AwsPlugin, 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]interface{}{
|
||||||
|
constRegion: region,
|
||||||
|
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]interface{}{
|
||||||
|
constDescribeInstancesFilters: []interface{}{fmt.Sprintf("tag-key=%s", tag)},
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
sets[i] = &hostsets.HostSet{
|
||||||
|
Id: fmt.Sprintf("hostset-%d", i),
|
||||||
|
Attributes: setAttrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: accessKeyId,
|
||||||
|
constSecretAccessKey: secretAccessKey,
|
||||||
|
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
request := &pb.ListHostsRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
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 requireCredentialsInvalid(t *testing.T, accessKeyId, secretAccessKey string) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
c, err := awsutil.NewCredentialsConfig(
|
||||||
|
awsutil.WithAccessKey(accessKeyId),
|
||||||
|
awsutil.WithSecretKey(secretAccessKey),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// We need to wait for invalidation as while awsutil waits for
|
||||||
|
// credential creation, deletion of the old credentials returns
|
||||||
|
// immediately.
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
waitErr:
|
||||||
|
for {
|
||||||
|
_, err = c.GetCallerIdentity()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
// pass
|
||||||
|
|
||||||
|
case <-timeoutCtx.Done():
|
||||||
|
break waitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(err)
|
||||||
|
awsErr, ok := err.(awserr.Error)
|
||||||
|
require.True(ok)
|
||||||
|
require.Equal("InvalidClientTokenId", awsErr.Code())
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
c, err := awsutil.NewCredentialsConfig(
|
||||||
|
awsutil.WithAccessKey(accessKeyId),
|
||||||
|
awsutil.WithSecretKey(secretAccessKey),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
_, err = c.GetCallerIdentity()
|
||||||
|
require.NoError(err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam/iamiface"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testDescribeInstancesError = "DescribeInstances error"
|
||||||
|
|
||||||
|
type testMockIAMState struct {
|
||||||
|
DeleteAccessKeyCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testMockIAMState) Reset() {
|
||||||
|
s.DeleteAccessKeyCalled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockIAM struct {
|
||||||
|
iamiface.IAMAPI
|
||||||
|
|
||||||
|
State *testMockIAMState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsutil.IAMAPIFunc {
|
||||||
|
return func(sess *session.Session) (iamiface.IAMAPI, error) {
|
||||||
|
m := &testMockIAM{
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
f := awsutil.NewMockIAM(opts...)
|
||||||
|
var err error
|
||||||
|
|
||||||
|
m.IAMAPI, err = f(sess)
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *testMockIAM) DeleteAccessKey(input *iam.DeleteAccessKeyInput) (*iam.DeleteAccessKeyOutput, error) {
|
||||||
|
m.State.DeleteAccessKeyCalled = true
|
||||||
|
return m.IAMAPI.DeleteAccessKey(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockEC2State struct {
|
||||||
|
DescribeInstancesCalled bool
|
||||||
|
DescribeInstancesInputParams *ec2.DescribeInstancesInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testMockEC2State) Reset() {
|
||||||
|
s.DescribeInstancesCalled = false
|
||||||
|
s.DescribeInstancesInputParams = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockEC2 struct {
|
||||||
|
ec2iface.EC2API
|
||||||
|
|
||||||
|
State *testMockEC2State
|
||||||
|
Region string
|
||||||
|
DescribeInstancesOutput *ec2.DescribeInstancesOutput
|
||||||
|
DescribeInstancesError error
|
||||||
|
}
|
||||||
|
|
||||||
|
type testMockEC2Option func(m *testMockEC2) error
|
||||||
|
|
||||||
|
func testMockEC2WithDescribeInstancesOutput(o *ec2.DescribeInstancesOutput) testMockEC2Option {
|
||||||
|
return func(m *testMockEC2) error {
|
||||||
|
m.DescribeInstancesOutput = o
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMockEC2WithDescribeInstancesError(e error) testMockEC2Option {
|
||||||
|
return func(m *testMockEC2) error {
|
||||||
|
m.DescribeInstancesError = e
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
|
||||||
|
return func(sess *session.Session, cfgs ...*aws.Config) (ec2iface.EC2API, error) {
|
||||||
|
m := &testMockEC2{
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
if err := opt(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cfg := range cfgs {
|
||||||
|
// Last region takes precedence
|
||||||
|
m.Region = *cfg.Region
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *testMockEC2) DescribeInstances(input *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) {
|
||||||
|
if m.State != nil {
|
||||||
|
m.State.DescribeInstancesCalled = true
|
||||||
|
m.State.DescribeInstancesInputParams = input
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.DescribeInstancesError != nil {
|
||||||
|
return nil, m.DescribeInstancesError
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.DescribeInstancesOutput, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustStruct(in map[string]interface{}) *structpb.Struct {
|
||||||
|
out, err := structpb.NewStruct(in)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type ec2FilterSorter []*ec2.Filter
|
||||||
|
|
||||||
|
func (s ec2FilterSorter) Len() int { return len(s) }
|
||||||
|
func (s ec2FilterSorter) Less(i, j int) bool { return *s[i].Name < *s[j].Name }
|
||||||
|
func (s ec2FilterSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||||
+617
-24
@@ -1,37 +1,630 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
"context"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/storage"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/endpoints"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure that AWSPlugin implementings the following services:
|
// rotationWaitTimeout controls the time we wait for credential rotation to
|
||||||
//
|
// succeed. This is important to ensure that rotated credentials can be used
|
||||||
// HostPluginServiceServer
|
// right away.
|
||||||
// StoragePluginServiceServer
|
const rotationWaitTimeout = time.Second * 30
|
||||||
var (
|
|
||||||
_ pb.HostPluginServiceServer = (*host.HostPlugin)(nil)
|
|
||||||
_ pb.StoragePluginServiceServer = (*storage.StoragePlugin)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AwsPlugin contains a collection of all aws plugin services.
|
// AwsPlugin implements the HostPluginServiceServer interface for the
|
||||||
|
// AWS plugin.
|
||||||
type AwsPlugin struct {
|
type AwsPlugin struct {
|
||||||
// HostPlugin implements the HostPluginServiceServer interface for
|
pb.UnimplementedHostPluginServiceServer
|
||||||
// supporting dynamically sourcing hosts from Amazon EC2
|
|
||||||
*host.HostPlugin
|
|
||||||
|
|
||||||
// StoragePlugin implements the StoragePluginServiceServer interface for
|
// testStateOpts are passed in to the stored state to control test behavior
|
||||||
// supporting storing and retrieving BSR files from Amazon S3
|
testStateOpts []awsCatalogPersistedStateOption
|
||||||
*storage.StoragePlugin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAwsPlugin() *AwsPlugin {
|
// Ensure that we are implementing HostPluginServiceServer
|
||||||
return &AwsPlugin{
|
var (
|
||||||
HostPlugin: &host.HostPlugin{},
|
_ pb.HostPluginServiceServer = (*AwsPlugin)(nil)
|
||||||
StoragePlugin: storage.New(),
|
)
|
||||||
|
|
||||||
|
func (p *AwsPlugin) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := catalog.GetAttributes()
|
||||||
|
if attrs == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
catalogSecrets, err := getCatalogSecrets(secrets)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the creds in our persisted state.
|
||||||
|
state, err := newAwsCatalogPersistedState(append([]awsCatalogPersistedStateOption{
|
||||||
|
withAccessKeyId(catalogSecrets.AccessKeyId),
|
||||||
|
withSecretAccessKey(catalogSecrets.SecretAccessKey),
|
||||||
|
}, p.testStateOpts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to rotate the credentials if we're not skipping them.
|
||||||
|
if !catalogAttributes.DisableCredentialRotation {
|
||||||
|
if err := state.RotateCreds(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simply validate if we aren't rotating.
|
||||||
|
if err := state.ValidateCreds(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
persistedProto, err := state.ToProto()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.OnCreateCatalogResponse{
|
||||||
|
Persisted: persistedProto,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := catalog.GetAttributes()
|
||||||
|
if attrs == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRegion(catalogAttributes.Region); 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.
|
||||||
|
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), p.testStateOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !state.CredsLastRotatedTime.IsZero() {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateSecrets {
|
||||||
|
catalogSecrets, err := getCatalogSecrets(secrets)
|
||||||
|
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 := state.ReplaceCreds(catalogSecrets.AccessKeyId, catalogSecrets.SecretAccessKey); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !catalogAttributes.DisableCredentialRotation && state.CredsLastRotatedTime.IsZero() {
|
||||||
|
// If we're enabling rotation now but didn't before, or have
|
||||||
|
// freshly replaced credentials, we can rotate here.
|
||||||
|
if err := state.RotateCreds(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// That's it!
|
||||||
|
persistedProto, err := state.ToProto()
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.OnUpdateCatalogResponse{
|
||||||
|
Persisted: persistedProto,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
||||||
|
// Get the persisted data.
|
||||||
|
// NOTE: We return on error here, blocking the delete. This may or
|
||||||
|
// may not be an overzealous approach to maintaining database/state
|
||||||
|
// integrity. May need to be changed at later time if there are
|
||||||
|
// scenarios where we might be deleting things and any secret state
|
||||||
|
// may be corrupt/and or legitimately missing.
|
||||||
|
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), p.testStateOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.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 := state.DeleteCreds(); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.OnDeleteCatalogResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttrsRaw := catalog.GetAttributes()
|
||||||
|
if catalogAttrsRaw == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), p.testStateOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
set := req.GetSet()
|
||||||
|
if set == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "set is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set.GetAttributes() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||||
|
}
|
||||||
|
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ec2Client, err := state.EC2Client(catalogAttributes.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ec2Client.DescribeInstances(input)
|
||||||
|
if err == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||||
|
// Success
|
||||||
|
return &pb.OnCreateSetResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttrsRaw := catalog.GetAttributes()
|
||||||
|
if catalogAttrsRaw == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), p.testStateOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set.GetAttributes() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "new set missing attributes")
|
||||||
|
}
|
||||||
|
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ec2Client, err := state.EC2Client(catalogAttributes.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ec2Client.DescribeInstances(input)
|
||||||
|
if err == nil {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == "DryRunOperation" {
|
||||||
|
// Success
|
||||||
|
return &pb.OnUpdateSetResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
||||||
|
// No-op, AWS host set does not maintain anything that requires
|
||||||
|
// cleanup
|
||||||
|
return &pb.OnDeleteSetResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AwsPlugin) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttrsRaw := catalog.GetAttributes()
|
||||||
|
if catalogAttrsRaw == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateRegion(catalogAttributes.Region); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := awsCatalogPersistedStateFromProto(req.GetPersisted(), p.testStateOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sets := req.GetSets()
|
||||||
|
if sets == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "sets is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if set.GetAttributes() == nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||||
|
}
|
||||||
|
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
queries[i] = hostSetQuery{
|
||||||
|
Id: set.GetId(),
|
||||||
|
Input: input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ec2Client, err := state.EC2Client(catalogAttributes.Region)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all queries now and assemble output.
|
||||||
|
var maxLen int
|
||||||
|
for i, query := range queries {
|
||||||
|
output, err := ec2Client.DescribeInstances(query.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances for host set id %q: %s", query.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queries[i].Output = output
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||||
|
maxLen++ // Increment maximum counter for allocation later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now de-duplicate the hosts for the output. Maintain two sets:
|
||||||
|
// * A slice of hosts that will be used in the output
|
||||||
|
// * A map of hosts indexed by their external ID
|
||||||
|
//
|
||||||
|
// The map will is used in de-duplication to determine whether or
|
||||||
|
// not we've seen the host before to simply add the query's set ID
|
||||||
|
// to the list of set IDs that the host was seen in.
|
||||||
|
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
||||||
|
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
||||||
|
for _, query := range queries {
|
||||||
|
for _, host := range query.OutputHosts {
|
||||||
|
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
||||||
|
// Existing host, just add the set ID to the list of seen IDs
|
||||||
|
// and continue
|
||||||
|
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will be the first seen entry, so append the set ID to
|
||||||
|
// this host, and add it.
|
||||||
|
host.SetIds = append(host.SetIds, query.Id)
|
||||||
|
hostResultSlice = append(hostResultSlice, host)
|
||||||
|
hostResultMap[host.ExternalId] = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done!
|
||||||
|
return &pb.ListHostsResponse{
|
||||||
|
Hosts: hostResultSlice,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegion(region string) error {
|
||||||
|
_, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region)
|
||||||
|
if !found {
|
||||||
|
return status.Errorf(codes.InvalidArgument, "not a valid region: %s", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFilters(attrs *SetAttributes) ([]*ec2.Filter, error) {
|
||||||
|
var filters []*ec2.Filter
|
||||||
|
var foundStateFilter bool
|
||||||
|
for _, filterAttr := range attrs.Filters {
|
||||||
|
// Each filter arrives in "k=v1,v2" format. First, split on equal sign.
|
||||||
|
splitFilter := strings.Split(filterAttr, "=")
|
||||||
|
switch {
|
||||||
|
case len(splitFilter) != 2:
|
||||||
|
return nil, fmt.Errorf("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)
|
||||||
|
case len(splitFilter[1]) == 0:
|
||||||
|
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
|
||||||
|
}
|
||||||
|
|
||||||
|
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
||||||
|
if filterKey == "instance-state-code" || filterKey == "instance-state-name" {
|
||||||
|
foundStateFilter = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterValues []*string
|
||||||
|
for _, val := range strings.Split(filterValue, ",") {
|
||||||
|
filterValues = append(filterValues, aws.String(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
filters = append(filters, &ec2.Filter{
|
||||||
|
Name: aws.String(filterKey),
|
||||||
|
Values: filterValues,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundStateFilter {
|
||||||
|
// if there is no explicit instance state filter, add the
|
||||||
|
// instance-state-name = ["running"] to the filter set. This
|
||||||
|
// ensures that we filter on running instances only at the API
|
||||||
|
// side, saving time when processing results.
|
||||||
|
filters = append(filters, &ec2.Filter{
|
||||||
|
Name: aws.String("instance-state-name"),
|
||||||
|
Values: aws.StringSlice([]string{ec2.InstanceStateNameRunning}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
||||||
|
filters, err := buildFilters(attrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error building filters: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ec2.DescribeInstancesInput{
|
||||||
|
DryRun: aws.Bool(dryRun),
|
||||||
|
Filters: filters,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// awsInstanceToHost processes data from an ec2.Instance and returns
|
||||||
|
// a ListHostsResponseHost with the ID and network addresses
|
||||||
|
// populated.
|
||||||
|
func awsInstanceToHost(instance *ec2.Instance) (*pb.ListHostsResponseHost, error) {
|
||||||
|
// Integrity check: some fields should always be non-nil. Check
|
||||||
|
// those.
|
||||||
|
if instance == nil {
|
||||||
|
return nil, errors.New("response integrity error: missing instance entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if aws.StringValue(instance.InstanceId) == "" {
|
||||||
|
return nil, errors.New("response integrity error: missing instance id")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := new(pb.ListHostsResponseHost)
|
||||||
|
|
||||||
|
// External ID is the instance ID.
|
||||||
|
result.ExternalId = aws.StringValue(instance.InstanceId)
|
||||||
|
|
||||||
|
// First IP address/dns name are always the private fields if they
|
||||||
|
// are populated
|
||||||
|
if aws.StringValue(instance.PrivateIpAddress) != "" {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PrivateIpAddress))
|
||||||
|
}
|
||||||
|
if aws.StringValue(instance.PrivateDnsName) != "" {
|
||||||
|
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PrivateDnsName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public IP address/dns names are next
|
||||||
|
if aws.StringValue(instance.PublicIpAddress) != "" {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(instance.PublicIpAddress))
|
||||||
|
}
|
||||||
|
if aws.StringValue(instance.PublicDnsName) != "" {
|
||||||
|
result.DnsNames = append(result.DnsNames, aws.StringValue(instance.PublicDnsName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now go through all of the interfaces and log the IP address of
|
||||||
|
// every interface.
|
||||||
|
for _, iface := range instance.NetworkInterfaces {
|
||||||
|
if iface == nil {
|
||||||
|
// Probably will never happen, but just in case
|
||||||
|
return nil, errors.New("response integrity error: interface entry is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate default IP addresses/DNS name similar to how we do
|
||||||
|
// for the entire instance.
|
||||||
|
if aws.StringValue(iface.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress)) {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(iface.PrivateIpAddress))
|
||||||
|
}
|
||||||
|
if aws.StringValue(iface.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(iface.PrivateDnsName)) {
|
||||||
|
result.DnsNames = append(result.DnsNames, aws.StringValue(iface.PrivateDnsName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through the private IP addresses and log the
|
||||||
|
// information.
|
||||||
|
for _, addr := range iface.PrivateIpAddresses {
|
||||||
|
if addr == nil {
|
||||||
|
return nil, errors.New("response integrity error: interface address entry is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add private address/dns name if they have not been added yet
|
||||||
|
if aws.StringValue(addr.PrivateIpAddress) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress)) {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.PrivateIpAddress))
|
||||||
|
}
|
||||||
|
if aws.StringValue(addr.PrivateDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.PrivateDnsName)) {
|
||||||
|
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.PrivateDnsName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public address/dns name if they have not been added yet
|
||||||
|
if addr.Association != nil && aws.StringValue(addr.Association.PublicIp) != "" && !stringInSlice(result.IpAddresses, aws.StringValue(addr.Association.PublicIp)) {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Association.PublicIp))
|
||||||
|
}
|
||||||
|
if addr.Association != nil && aws.StringValue(addr.Association.PublicDnsName) != "" && !stringInSlice(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName)) {
|
||||||
|
result.DnsNames = append(result.DnsNames, aws.StringValue(addr.Association.PublicDnsName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the IPv6 addresses.
|
||||||
|
for _, addr := range iface.Ipv6Addresses {
|
||||||
|
if addr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr.Ipv6Address != nil && aws.StringValue(addr.Ipv6Address) != "" {
|
||||||
|
result.IpAddresses = append(result.IpAddresses, aws.StringValue(addr.Ipv6Address))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringInSlice(s []string, x string) bool {
|
||||||
|
for _, y := range s {
|
||||||
|
if x == y {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,147 +0,0 @@
|
|||||||
## Getting Started
|
|
||||||
|
|
||||||
Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on
|
|
||||||
configuration options for host catalogs and sets.
|
|
||||||
|
|
||||||
To create a host catalog (using default scope created by `boundary dev`):
|
|
||||||
|
|
||||||
```
|
|
||||||
boundary host-catalogs create plugin \
|
|
||||||
-scope-id p_1234567890 \
|
|
||||||
-name "Example Plugin-Based Host Catalog" \
|
|
||||||
-description "Description for plugin-based host catalog" \
|
|
||||||
-plugin-name aws \
|
|
||||||
-attr region=REGION \
|
|
||||||
-secret access_key_id='KEY' \
|
|
||||||
-secret secret_access_key='SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
To create a host set, filtering the host set based on tag keys `foo` or `bar`
|
|
||||||
(either tag can be present), ensuring that any targets set to this host set only
|
|
||||||
connect to external addresses in the `54.0.0.0/8` class A subnet:
|
|
||||||
|
|
||||||
```
|
|
||||||
boundary host-sets create plugin \
|
|
||||||
-host-catalog-id HOST_CATALOG_ID \
|
|
||||||
-name "Example Plugin-Based Host Set" \
|
|
||||||
-description "Description for plugin-based host set" \
|
|
||||||
-attr filters=tag-key=foo,bar \
|
|
||||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
|
||||||
```
|
|
||||||
|
|
||||||
As above, but instances must have both tags (both `foo` and `bar` *must* be
|
|
||||||
present):
|
|
||||||
|
|
||||||
```
|
|
||||||
boundary host-sets create plugin \
|
|
||||||
-host-catalog-id HOST_CATALOG_ID \
|
|
||||||
-name "Example Plugin-Based Host Set" \
|
|
||||||
-description "Description for plugin-based host set" \
|
|
||||||
-attr filters=tag-key=foo \
|
|
||||||
-attr filters=tag-key=bar \
|
|
||||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
|
||||||
```
|
|
||||||
|
|
||||||
As above, but matching on tag key named "development" with value "prod" or "dev"
|
|
||||||
and launch date:
|
|
||||||
|
|
||||||
```
|
|
||||||
boundary host-sets create plugin \
|
|
||||||
-host-catalog-id HOST_CATALOG_ID \
|
|
||||||
-name "Example Plugin-Based Host Set" \
|
|
||||||
-description "Description for plugin-based host set" \
|
|
||||||
-attr filters=tag:development=prod,dev \
|
|
||||||
-attr filters=launch-time=2022-01-04T* \
|
|
||||||
-preferred-endpoint "cidr:54.0.0.0/8"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required IAM Privileges
|
|
||||||
|
|
||||||
The following IAM privileges, at the very least, are required to be attached to
|
|
||||||
a configured IAM user for this provider:
|
|
||||||
|
|
||||||
* `ec2:DescribeInstances`, configured to `*` (`DescribeInstances` cannot be
|
|
||||||
scoped to a resource ARN). Example policy:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"ec2:DescribeInstances"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to
|
|
||||||
the IAM user to allow credential rotation. Example policy:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"iam:DeleteAccessKey",
|
|
||||||
"iam:GetUser",
|
|
||||||
"iam:CreateAccessKey"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "arn:aws:iam::123456789012:user/JohnDoe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Attributes and Secrets
|
|
||||||
|
|
||||||
### Host Catalog
|
|
||||||
|
|
||||||
The following `attributes` are valid on an AWS host catalog resource:
|
|
||||||
|
|
||||||
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
|
|
||||||
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:
|
|
||||||
|
|
||||||
- `access_key_id` (string): The access key ID for the IAM user to use with this
|
|
||||||
host catalog.
|
|
||||||
- `secret_access_key` (string): The secret access key for the IAM user to use
|
|
||||||
with this host catalog.
|
|
||||||
|
|
||||||
See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information.
|
|
||||||
|
|
||||||
### Host Set
|
|
||||||
|
|
||||||
The following attributes are valid on an AWS host Set resource:
|
|
||||||
|
|
||||||
- `filters` (array of strings): An array of string filters in the format
|
|
||||||
`key=val1,val2`. The key corresponds to a filter option, and the value(s) are
|
|
||||||
a comma-separated list. For a list of filter options, check out
|
|
||||||
[describe-instances in the AWS CLI
|
|
||||||
reference](https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html).
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/values"
|
|
||||||
"github.com/mitchellh/mapstructure"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CatalogAttributes is a Go-native representation of the Attributes
|
|
||||||
// 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) {
|
|
||||||
unknownFields := values.StructFields(in)
|
|
||||||
badFields := make(map[string]string)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
credAttributes, err := cred.GetCredentialAttributes(in)
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
case cred.ConstRegion:
|
|
||||||
continue
|
|
||||||
case cred.ConstDisableCredentialRotation:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleArn:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleExternalId:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleSessionName:
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(badFields) > 0 {
|
|
||||||
return nil, errors.InvalidArgumentError("Invalid arguments in catalog attributes", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CatalogAttributes{
|
|
||||||
CredentialAttributes: credAttributes,
|
|
||||||
TargetAccount: targetCredAttributes,
|
|
||||||
DualStack: dualStack,
|
|
||||||
InstanceAddressesOnly: instanceAddressesOnly,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAttributes is a Go-native representation of the Attributes map that can be
|
|
||||||
// used for decoding the incoming map via mapstructure.
|
|
||||||
type SetAttributes struct {
|
|
||||||
Filters []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
|
||||||
var setAttrs SetAttributes
|
|
||||||
|
|
||||||
// Quick validation to ensure that there's no non-filter attributes
|
|
||||||
// here for now. Make this more complex if we add more attributes
|
|
||||||
// to host sets.
|
|
||||||
unknownFields := values.StructFields(in)
|
|
||||||
badFields := make(map[string]string)
|
|
||||||
delete(unknownFields, ConstDescribeInstancesFilters)
|
|
||||||
for a := range unknownFields {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
|
|
||||||
}
|
|
||||||
if len(badFields) > 0 {
|
|
||||||
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapstructure complains if it expects a slice as output and sees a scalar
|
|
||||||
// value. Rather than use WeakDecode and risk unintended consequences, I'm
|
|
||||||
// manually making this change if necessary.
|
|
||||||
inMap := in.AsMap()
|
|
||||||
if filtersRaw, ok := inMap[ConstDescribeInstancesFilters]; ok {
|
|
||||||
switch filterVal := filtersRaw.(type) {
|
|
||||||
case string:
|
|
||||||
inMap[ConstDescribeInstancesFilters] = []string{filterVal}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mapstructure.Decode(inMap, &setAttrs); err != nil {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/testing/protocmp"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetCatalogAttributes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *structpb.Struct
|
|
||||||
expected *CatalogAttributes
|
|
||||||
expectedErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing region",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: make(map[string]*structpb.Value),
|
|
||||||
},
|
|
||||||
expectedErrContains: "missing required value \"region\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown fields",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"region": structpb.NewStringValue("us-west-2"),
|
|
||||||
"foo": structpb.NewBoolValue(true),
|
|
||||||
"bar": structpb.NewBoolValue(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"region": structpb.NewStringValue("us-west-2"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &CatalogAttributes{
|
|
||||||
CredentialAttributes: &credential.CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "credential attributes",
|
|
||||||
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::123456789012:role/S3Access"),
|
|
||||||
"role_external_id": structpb.NewStringValue("1234567890"),
|
|
||||||
"role_session_name": structpb.NewStringValue("test-session"),
|
|
||||||
"role_tags": structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStringValue("bar"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &CatalogAttributes{
|
|
||||||
CredentialAttributes: &cred.CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: true,
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
actual, err := getCatalogAttributes(tc.in)
|
|
||||||
if tc.expectedErrContains != "" {
|
|
||||||
require.Error(err)
|
|
||||||
require.Contains(err.Error(), tc.expectedErrContains)
|
|
||||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSetAttributes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in map[string]any
|
|
||||||
normalized map[string]any
|
|
||||||
expected *SetAttributes
|
|
||||||
expectedErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing",
|
|
||||||
in: map[string]any{},
|
|
||||||
expected: &SetAttributes{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-string-slice value",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstDescribeInstancesFilters: "zip=foo,bar",
|
|
||||||
},
|
|
||||||
expected: &SetAttributes{
|
|
||||||
Filters: []string{"zip=foo,bar"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad filter element value",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstDescribeInstancesFilters: []any{1},
|
|
||||||
},
|
|
||||||
expectedErrContains: "expected type 'string', got unconvertible type 'float64'",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown fields",
|
|
||||||
in: map[string]any{
|
|
||||||
"foo": true,
|
|
||||||
"bar": true,
|
|
||||||
},
|
|
||||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstDescribeInstancesFilters: []any{
|
|
||||||
"foo=bar",
|
|
||||||
"zip=zap",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &SetAttributes{
|
|
||||||
Filters: []string{"foo=bar", "zip=zap"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good with filter transform",
|
|
||||||
in: map[string]any{
|
|
||||||
ConstDescribeInstancesFilters: "foo=bar",
|
|
||||||
},
|
|
||||||
normalized: map[string]any{
|
|
||||||
ConstDescribeInstancesFilters: []any{"foo=bar"},
|
|
||||||
},
|
|
||||||
expected: &SetAttributes{
|
|
||||||
Filters: []string{"foo=bar"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
p := new(HostPlugin)
|
|
||||||
normalizedOut, err := p.NormalizeSetData(context.Background(), &plugin.NormalizeSetDataRequest{Attributes: input})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
if tc.normalized != nil {
|
|
||||||
normalized, err := structpb.NewStruct(tc.normalized)
|
|
||||||
require.NoError(err)
|
|
||||||
require.Empty(cmp.Diff(normalized, normalizedOut.Attributes, protocmp.Transform()))
|
|
||||||
}
|
|
||||||
input = normalizedOut.Attributes
|
|
||||||
actual, err := getSetAttributes(input)
|
|
||||||
if tc.expectedErrContains != "" {
|
|
||||||
require.Error(err)
|
|
||||||
require.Contains(err.Error(), tc.expectedErrContains)
|
|
||||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ConstDescribeInstancesFilters is the key for the filter in the dynamic host set.
|
|
||||||
ConstDescribeInstancesFilters = "filters"
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
)
|
|
||||||
@@ -1,917 +0,0 @@
|
|||||||
package host
|
|
||||||
|
|
||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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/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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HostPlugin implements the HostPluginServiceServer interface for the
|
|
||||||
// AWS host service plugin.
|
|
||||||
type HostPlugin struct {
|
|
||||||
pb.UnimplementedHostPluginServiceServer
|
|
||||||
|
|
||||||
// testCredStateOpts are passed in to the stored state to control test behavior
|
|
||||||
testCredStateOpts []credential.AwsCredentialPersistedStateOption
|
|
||||||
|
|
||||||
// testCatalogStateOpts are passed in to the stored state to control test behavior
|
|
||||||
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)
|
|
||||||
|
|
||||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
|
||||||
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
|
||||||
catalog := req.GetCatalog()
|
|
||||||
if catalog == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := catalog.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes, catalogAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := credential.NewAwsCredentialPersistedState(
|
|
||||||
append([]credential.AwsCredentialPersistedStateOption{
|
|
||||||
credential.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
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(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error setting up persisted state: %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, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnCreateCatalogResponse{
|
|
||||||
Persisted: persistedProto,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnUpdateCatalog is called when a dynamic host catalog is updated.
|
|
||||||
func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
|
||||||
// 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.
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldAttrs := currentCatalog.GetAttributes()
|
|
||||||
if oldAttrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("current catalog attributes are required")
|
|
||||||
}
|
|
||||||
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetSecrets(),
|
|
||||||
oldCatalogAttributes.CredentialAttributes,
|
|
||||||
oldCatalogAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
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(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, 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, 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, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnUpdateCatalogResponse{
|
|
||||||
Persisted: persistedProto,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
|
|
||||||
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
|
||||||
catalog := req.GetCatalog()
|
|
||||||
if catalog == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := catalog.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog attributes are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the persisted data.
|
|
||||||
// NOTE: We return on error here, blocking the delete. This may or
|
|
||||||
// may not be an overzealous approach to maintaining database/state
|
|
||||||
// integrity. May need to be changed at later time if there are
|
|
||||||
// scenarios where we might be deleting things and any secret state
|
|
||||||
// may be corrupt/and or legitimately missing.
|
|
||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetSecrets(),
|
|
||||||
catalogAttributes.CredentialAttributes,
|
|
||||||
catalogAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to delete static credentials
|
|
||||||
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, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnDeleteCatalogResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeSetData currently ensures that "filters" is an array value, even
|
|
||||||
// though it's accepted as a string value for CLI UX reasons
|
|
||||||
func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
|
|
||||||
if req.Attributes == nil {
|
|
||||||
return new(pb.NormalizeSetDataResponse), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
val := req.Attributes.Fields["filters"]
|
|
||||||
if val == nil {
|
|
||||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
|
||||||
}
|
|
||||||
stringVal, ok := val.Kind.(*structpb.Value_StringValue)
|
|
||||||
if !ok {
|
|
||||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
|
|
||||||
retAttrs.Fields["filters"] = structpb.NewListValue(
|
|
||||||
&structpb.ListValue{
|
|
||||||
Values: []*structpb.Value{
|
|
||||||
structpb.NewStringValue(stringVal.StringValue),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnCreateSet is called when a dynamic host set is created.
|
|
||||||
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
|
||||||
catalog := req.GetCatalog()
|
|
||||||
if catalog == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttrsRaw := catalog.GetAttributes()
|
|
||||||
if catalogAttrsRaw == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog attributes are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetSecrets(),
|
|
||||||
catalogAttributes.CredentialAttributes,
|
|
||||||
catalogAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
set := req.GetSet()
|
|
||||||
if set == nil {
|
|
||||||
return nil, errors.BadRequestStatus("set is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if set.GetAttributes() == nil {
|
|
||||||
return nil, errors.BadRequestStatus("set attributes are required")
|
|
||||||
}
|
|
||||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
|
||||||
if err != nil {
|
|
||||||
return nil, 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.Unknown, errBuildDescribeInstancesInput, err)
|
|
||||||
}
|
|
||||||
// Run a DescribeInstances request with DryRun set to true to ensure
|
|
||||||
// we can interact with AWS resources as expected.
|
|
||||||
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
|
|
||||||
if _, ok := status.FromError(err); ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnCreateSetResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, errors.BadRequestStatus("catalog is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttrsRaw := catalog.GetAttributes()
|
|
||||||
if catalogAttrsRaw == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog attributes are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetSecrets(),
|
|
||||||
catalogAttributes.CredentialAttributes,
|
|
||||||
catalogAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
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, errors.BadRequestStatus("new set is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if set.GetAttributes() == nil {
|
|
||||||
return nil, errors.BadRequestStatus("new set attributes are required")
|
|
||||||
}
|
|
||||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
|
||||||
if err != nil {
|
|
||||||
return nil, 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.Unknown, errBuildDescribeInstancesInput, err)
|
|
||||||
}
|
|
||||||
// Run a DescribeInstances request with DryRun set to true to ensure
|
|
||||||
// we can interact with AWS resources as expected.
|
|
||||||
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
|
|
||||||
if _, ok := status.FromError(err); ok {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf(errDryRunFailed, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnUpdateSetResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnDeleteSet is called when a dynamic host set is deleted.
|
|
||||||
func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
|
||||||
// No-op, AWS host set does not maintain anything that requires
|
|
||||||
// cleanup
|
|
||||||
return &pb.OnDeleteSetResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListHosts returns the list of ec2 hosts and their descriptions.
|
|
||||||
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
|
||||||
catalog := req.GetCatalog()
|
|
||||||
if catalog == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttrsRaw := catalog.GetAttributes()
|
|
||||||
if catalogAttrsRaw == nil {
|
|
||||||
return nil, errors.BadRequestStatus("catalog attributes are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetSecrets(),
|
|
||||||
catalogAttributes.CredentialAttributes,
|
|
||||||
catalogAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sets := req.GetSets()
|
|
||||||
if sets == nil {
|
|
||||||
return nil, errors.BadRequestStatus("sets are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build all the queries in advance.
|
|
||||||
queries := make([]hostSetQuery, len(sets))
|
|
||||||
for i, set := range sets {
|
|
||||||
// Validate Id since we use it in output
|
|
||||||
if set.GetId() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("set id is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if set.GetAttributes() == nil {
|
|
||||||
return nil, errors.BadRequestStatusf("set %s attributes are required", set.GetId())
|
|
||||||
}
|
|
||||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
input, err := buildDescribeInstancesInput(setAttrs, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatusf("error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
|
|
||||||
}
|
|
||||||
queries[i] = hostSetQuery{
|
|
||||||
Id: set.GetId(),
|
|
||||||
Input: input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
|
|
||||||
if err != nil {
|
|
||||||
// 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, catalogAttributes)
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
maxLen++ // Increment maximum counter for allocation later
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now de-duplicate the hosts for the output. Maintain two sets:
|
|
||||||
// * A slice of hosts that will be used in the output
|
|
||||||
// * A map of hosts indexed by their external ID
|
|
||||||
//
|
|
||||||
// The map will is used in de-duplication to determine whether or
|
|
||||||
// not we've seen the host before to simply add the query's set ID
|
|
||||||
// to the list of set IDs that the host was seen in.
|
|
||||||
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
|
||||||
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
|
||||||
for _, query := range queries {
|
|
||||||
for _, host := range query.OutputHosts {
|
|
||||||
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
|
||||||
// Existing host, just add the set ID to the list of seen IDs
|
|
||||||
// and continue
|
|
||||||
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will be the first seen entry, so append the set ID to
|
|
||||||
// this host, and add it.
|
|
||||||
host.SetIds = append(host.SetIds, query.Id)
|
|
||||||
hostResultSlice = append(hostResultSlice, host)
|
|
||||||
hostResultMap[host.ExternalId] = host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done!
|
|
||||||
return &pb.ListHostsResponse{
|
|
||||||
Hosts: hostResultSlice,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
|
|
||||||
var filters []types.Filter
|
|
||||||
var foundStateFilter bool
|
|
||||||
for _, filterAttr := range attrs.Filters {
|
|
||||||
// Each filter arrives in "k=v1,v2" format. First, split on equal sign.
|
|
||||||
splitFilter := strings.Split(filterAttr, "=")
|
|
||||||
switch {
|
|
||||||
case len(splitFilter) != 2:
|
|
||||||
return nil, errors.BadRequestStatusf("expected filter %q to contain a single equal sign", filterAttr)
|
|
||||||
case len(splitFilter[0]) == 0:
|
|
||||||
return nil, errors.BadRequestStatusf("filter %q contains an empty filter key", filterAttr)
|
|
||||||
case len(splitFilter[1]) == 0:
|
|
||||||
return nil, errors.BadRequestStatusf("filter %q contains an empty value", filterAttr)
|
|
||||||
}
|
|
||||||
|
|
||||||
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
|
||||||
if filterKey == "instance-state-code" || filterKey == "instance-state-name" {
|
|
||||||
foundStateFilter = true
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = append(filters, types.Filter{
|
|
||||||
Name: aws.String(filterKey),
|
|
||||||
Values: strings.Split(filterValue, ","),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundStateFilter {
|
|
||||||
// if there is no explicit instance state filter, add the
|
|
||||||
// instance-state-name = ["running"] to the filter set. This
|
|
||||||
// ensures that we filter on running instances only at the API
|
|
||||||
// side, saving time when processing results.
|
|
||||||
filters = append(filters, types.Filter{
|
|
||||||
Name: aws.String("instance-state-name"),
|
|
||||||
Values: []string{string(types.InstanceStateNameRunning)},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return filters, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
|
||||||
if attrs == nil {
|
|
||||||
attrs = &SetAttributes{}
|
|
||||||
}
|
|
||||||
|
|
||||||
filters, err := buildFilters(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ec2.DescribeInstancesInput{
|
|
||||||
DryRun: aws.Bool(dryRun),
|
|
||||||
Filters: filters,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// awsInstanceToHost processes data from an ec2.Instance and returns
|
|
||||||
// a ListHostsResponseHost with the ID and network addresses
|
|
||||||
// populated.
|
|
||||||
func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) {
|
|
||||||
if aws.ToString(instance.InstanceId) == "" {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// External ID is the instance ID.
|
|
||||||
result.ExternalId = aws.ToString(instance.InstanceId)
|
|
||||||
|
|
||||||
// External Name is the AWS "Name" tag, if it exists.
|
|
||||||
for _, t := range instance.Tags {
|
|
||||||
if aws.ToString(t.Key) == ConstInstanceNameTagKey {
|
|
||||||
result.ExternalName = aws.ToString(t.Value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
// for the entire instance.
|
|
||||||
result.IpAddresses = appendDistinct(result.IpAddresses, iface.PrivateIpAddress)
|
|
||||||
result.DnsNames = appendDistinct(result.DnsNames, iface.PrivateDnsName)
|
|
||||||
|
|
||||||
// Iterate through the private IP addresses and log the
|
|
||||||
// information.
|
|
||||||
for _, addr := range iface.PrivateIpAddresses {
|
|
||||||
result.IpAddresses = appendDistinct(result.IpAddresses, addr.PrivateIpAddress)
|
|
||||||
result.DnsNames = appendDistinct(result.DnsNames, addr.PrivateDnsName)
|
|
||||||
if addr.Association != nil {
|
|
||||||
result.IpAddresses = appendDistinct(result.IpAddresses, addr.Association.PublicIp)
|
|
||||||
result.DnsNames = appendDistinct(result.DnsNames, addr.Association.PublicDnsName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the IPv6 addresses.
|
|
||||||
for _, addr := range iface.Ipv6Addresses {
|
|
||||||
result.IpAddresses = appendDistinct(result.IpAddresses, addr.Ipv6Address)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Done
|
|
||||||
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 {
|
|
||||||
for _, e := range elems {
|
|
||||||
value := aws.ToString(e)
|
|
||||||
if value == "" || stringInSlice(slice, value) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slice = append(slice, value)
|
|
||||||
}
|
|
||||||
return slice
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringInSlice returns true if the slice contains the given element.
|
|
||||||
func stringInSlice(s []string, x string) bool {
|
|
||||||
for _, y := range s {
|
|
||||||
if x == y {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,142 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EC2API interface {
|
|
||||||
DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type awsCatalogPersistedState struct {
|
|
||||||
*credential.AwsCredentialPersistedState
|
|
||||||
|
|
||||||
// testEC2APIFunc provides a way to provide a factory for a mock EC2 client
|
|
||||||
testEC2APIFunc ec2APIFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
type ec2APIFunc func(...aws.Config) (EC2API, error)
|
|
||||||
|
|
||||||
type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error
|
|
||||||
|
|
||||||
func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption {
|
|
||||||
return func(s *awsCatalogPersistedState) error {
|
|
||||||
if s.testEC2APIFunc != nil {
|
|
||||||
return errors.New("test API function already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.testEC2APIFunc = f
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withCredentials(x *credential.AwsCredentialPersistedState) awsCatalogPersistedStateOption {
|
|
||||||
return func(s *awsCatalogPersistedState) error {
|
|
||||||
if s.AwsCredentialPersistedState != nil {
|
|
||||||
return errors.New("aws credentials already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.AwsCredentialPersistedState = x
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
|
||||||
s := new(awsCatalogPersistedState)
|
|
||||||
for _, opt := range opts {
|
|
||||||
if err := opt(s); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
|
||||||
data, err := structpb.NewStruct(s.ToMap())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
|
||||||
}
|
|
||||||
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EC2Client returns a configured EC2 client based on the session
|
|
||||||
// information stored in the state.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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, 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,39 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAwsCatalogPersistedStateEC2Client(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
cred, err := credential.NewAwsCredentialPersistedState(
|
|
||||||
credential.WithCredentialsConfig(&awsutil.CredentialsConfig{
|
|
||||||
Region: "us-west-2",
|
|
||||||
AccessKey: "foobar",
|
|
||||||
SecretKey: "barfoo",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
state, err := newAwsCatalogPersistedState(
|
|
||||||
withCredentials(cred),
|
|
||||||
withTestEC2APIFunc(newTestMockEC2(nil)),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
clientRaw, err := state.EC2Client(context.Background())
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(clientRaw)
|
|
||||||
client, ok := clientRaw.(*testMockEC2)
|
|
||||||
require.True(ok)
|
|
||||||
require.NotNil(client)
|
|
||||||
require.Equal("us-west-2", client.Region)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package host
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
testOptionErr = "test option error"
|
|
||||||
testGetUserErr = "test error for GetUser"
|
|
||||||
testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
|
||||||
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
|
||||||
testDescribeInstancesError = "DescribeInstances error"
|
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
EC2API
|
|
||||||
|
|
||||||
State *testMockEC2State
|
|
||||||
Region string
|
|
||||||
|
|
||||||
DescribeInstancesOutput *ec2.DescribeInstancesOutput
|
|
||||||
DescribeInstancesError error
|
|
||||||
}
|
|
||||||
|
|
||||||
type testMockEC2Option func(m *testMockEC2) error
|
|
||||||
|
|
||||||
func testMockEC2WithDescribeInstancesOutput(o *ec2.DescribeInstancesOutput) testMockEC2Option {
|
|
||||||
return func(m *testMockEC2) error {
|
|
||||||
m.DescribeInstancesOutput = o
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMockEC2WithDescribeInstancesError(e error) testMockEC2Option {
|
|
||||||
return func(m *testMockEC2) error {
|
|
||||||
m.DescribeInstancesError = e
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFunc {
|
|
||||||
return func(cfgs ...aws.Config) (EC2API, error) {
|
|
||||||
m := &testMockEC2{
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
if err := opt(m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cfg := range cfgs {
|
|
||||||
// Last region takes precedence
|
|
||||||
if cfg.Region != "" {
|
|
||||||
m.Region = cfg.Region
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state != nil {
|
|
||||||
state.ClientRegions = append(state.ClientRegions, m.Region)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
|
|
||||||
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 {
|
|
||||||
return nil, m.DescribeInstancesError
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.DescribeInstancesOutput, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ec2FilterSorter []types.Filter
|
|
||||||
|
|
||||||
func (s ec2FilterSorter) Len() int { return len(s) }
|
|
||||||
func (s ec2FilterSorter) Less(i, j int) bool { return *s[i].Name < *s[j].Name }
|
|
||||||
func (s ec2FilterSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
## Getting Started
|
|
||||||
|
|
||||||
To create a storage bucket (using default scope created by `boundary dev`):
|
|
||||||
|
|
||||||
```
|
|
||||||
boundary storage-buckets create \
|
|
||||||
-scope-id p_1234567890 \
|
|
||||||
-name "Example Plugin-Based Storage Bucket" \
|
|
||||||
-description "Description for plugin-based storage bucket" \
|
|
||||||
-plugin-name aws \
|
|
||||||
-bucket-name="session_recording_storage" \
|
|
||||||
-bucket-prefix="foo/bar/zoo" \
|
|
||||||
-worker-filter ‘“aws-access” in “/tags/type”’ \
|
|
||||||
-attr region=REGION \
|
|
||||||
-attr endpoint_url=0.0.0.0 \
|
|
||||||
-secret access_key_id='KEY' \
|
|
||||||
-secret secret_access_key='SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required IAM Privileges
|
|
||||||
|
|
||||||
The following IAM privileges, at the very least, are required to be attached to
|
|
||||||
a configured IAM user for this provider:
|
|
||||||
|
|
||||||
Example policy:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"s3:PutObject",
|
|
||||||
"s3:GetObject",
|
|
||||||
"s3:GetObjectAttributes"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "arn:aws:s3:::session_recording_storage/foo/bar/zoo/*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to
|
|
||||||
the IAM user to allow static credential rotation. Example policy:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"iam:DeleteAccessKey",
|
|
||||||
"iam:GetUser",
|
|
||||||
"iam:CreateAccessKey"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "arn:aws:iam::123456789012:user/JohnDoe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attributes
|
|
||||||
|
|
||||||
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.
|
|
||||||
- `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.
|
|
||||||
- `role_tags` (object): The key-value pair tags configured for the assume role provider.
|
|
||||||
|
|
||||||
An example of how to utilize the [endpoint attribute](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html)
|
|
||||||
See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information.
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
|
|
||||||
The following `secrets` are optional on an AWS storage bucket resource:
|
|
||||||
|
|
||||||
- `access_key_id` (string): The access key ID for the IAM user to use with this
|
|
||||||
storage bucket.
|
|
||||||
- `secret_access_key` (string): The secret access key for the IAM user to use
|
|
||||||
with this storage bucket.
|
|
||||||
|
|
||||||
See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/values"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StorageAttributes is a Go-native representation of the Attributes
|
|
||||||
// map.
|
|
||||||
type StorageAttributes struct {
|
|
||||||
*cred.CredentialAttributes
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
unknownFields := values.StructFields(in)
|
|
||||||
badFields := make(map[string]string)
|
|
||||||
|
|
||||||
var err error
|
|
||||||
credAttributes, err := cred.GetCredentialAttributes(in)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointUrl, err := values.GetStringValue(in, ConstAwsEndpointUrl, false)
|
|
||||||
if err != nil {
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", ConstAwsEndpointUrl)] = err.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
|
|
||||||
case cred.ConstRegion:
|
|
||||||
continue
|
|
||||||
case cred.ConstDisableCredentialRotation:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleArn:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleExternalId:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleSessionName:
|
|
||||||
continue
|
|
||||||
case cred.ConstRoleTags:
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(badFields) > 0 {
|
|
||||||
return nil, errors.InvalidArgumentError("Invalid arguments in storage bucket attributes", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &StorageAttributes{
|
|
||||||
CredentialAttributes: credAttributes,
|
|
||||||
EndpointUrl: endpointUrl,
|
|
||||||
DualStack: dualStack,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
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"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStorageAttributes(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
in *structpb.Struct
|
|
||||||
expected *StorageAttributes
|
|
||||||
expectedErrContains string
|
|
||||||
expectedDetails *pb.StorageBucketCredentialState
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "missing region",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: make(map[string]*structpb.Value),
|
|
||||||
},
|
|
||||||
expectedErrContains: "missing required value \"region\"",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown fields",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"region": structpb.NewStringValue("us-west-2"),
|
|
||||||
"foo": structpb.NewBoolValue(true),
|
|
||||||
"bar": structpb.NewBoolValue(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"region": structpb.NewStringValue("us-west-2"),
|
|
||||||
"endpoint_url": structpb.NewStringValue("0.0.0.0"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &StorageAttributes{
|
|
||||||
CredentialAttributes: &cred.CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
EndpointUrl: "0.0.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good w/o endpoint",
|
|
||||||
in: &structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"region": structpb.NewStringValue("us-west-2"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &StorageAttributes{
|
|
||||||
CredentialAttributes: &cred.CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "credential attributes",
|
|
||||||
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::123456789012:role/S3Access"),
|
|
||||||
"role_external_id": structpb.NewStringValue("1234567890"),
|
|
||||||
"role_session_name": structpb.NewStringValue("test-session"),
|
|
||||||
"role_tags": structpb.NewStructValue(&structpb.Struct{
|
|
||||||
Fields: map[string]*structpb.Value{
|
|
||||||
"foo": structpb.NewStringValue("bar"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: &StorageAttributes{
|
|
||||||
CredentialAttributes: &cred.CredentialAttributes{
|
|
||||||
Region: "us-west-2",
|
|
||||||
DisableCredentialRotation: true,
|
|
||||||
RoleArn: "arn:aws:iam::123456789012:role/S3Access",
|
|
||||||
RoleExternalId: "1234567890",
|
|
||||||
RoleSessionName: "test-session",
|
|
||||||
RoleTags: map[string]string{
|
|
||||||
"foo": "bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.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(codes.InvalidArgument, status.Code(err))
|
|
||||||
|
|
||||||
st, ok := status.FromError(err)
|
|
||||||
require.True(ok)
|
|
||||||
assert.Len(st.Details(), 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(err)
|
|
||||||
require.Equal(tc.expected, actual)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
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 = 1 << 20 // 1 MiB
|
|
||||||
)
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
// 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
@@ -1,242 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"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"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type S3API interface {
|
|
||||||
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type s3Client struct {
|
|
||||||
*s3.Client
|
|
||||||
creds aws.Credentials
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *s3Client) Credentials() aws.Credentials {
|
|
||||||
return c.creds
|
|
||||||
}
|
|
||||||
|
|
||||||
type awsStoragePersistedState struct {
|
|
||||||
*credential.AwsCredentialPersistedState
|
|
||||||
|
|
||||||
// testS3APIFunc provides a way to provide a factory for a mock EC2 client
|
|
||||||
testS3APIFunc s3APIFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
type s3APIFunc func(...aws.Config) (S3API, error)
|
|
||||||
|
|
||||||
type awsStoragePersistedStateOption func(s *awsStoragePersistedState) error
|
|
||||||
|
|
||||||
func withTestS3APIFunc(f s3APIFunc) awsStoragePersistedStateOption {
|
|
||||||
return func(s *awsStoragePersistedState) error {
|
|
||||||
if s.testS3APIFunc != nil {
|
|
||||||
return errors.New("test API function already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.testS3APIFunc = f
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withCredentials(x *credential.AwsCredentialPersistedState) awsStoragePersistedStateOption {
|
|
||||||
return func(s *awsStoragePersistedState) error {
|
|
||||||
if s.AwsCredentialPersistedState != nil {
|
|
||||||
return errors.New("aws credentials already set")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.AwsCredentialPersistedState = x
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAwsStoragePersistedState(opts ...awsStoragePersistedStateOption) (*awsStoragePersistedState, error) {
|
|
||||||
s := new(awsStoragePersistedState)
|
|
||||||
for _, opt := range opts {
|
|
||||||
if err := opt(s); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return &storagebuckets.StorageBucketPersisted{Data: data}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
opts, err := getOpts(opt...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err)
|
|
||||||
}
|
|
||||||
awsCfg, err := s.GenerateCredentialChain(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error getting AWS configuration when fetching S3 client: %w", err)
|
|
||||||
}
|
|
||||||
if awsCfg == nil {
|
|
||||||
return nil, fmt.Errorf("nil aws configuration")
|
|
||||||
}
|
|
||||||
var s3Opts []func(*s3.Options)
|
|
||||||
if opts.withEndpoint != "" || opts.withDualStack {
|
|
||||||
s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{
|
|
||||||
endpoint: opts.withEndpoint,
|
|
||||||
dualStack: opts.withDualStack,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if s.testS3APIFunc != nil {
|
|
||||||
return s.testS3APIFunc(*awsCfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
func getOpts(opt ...s3Option) (s3Options, error) {
|
|
||||||
opts := getDefaultS3Options()
|
|
||||||
for _, o := range opt {
|
|
||||||
if o == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := o(&opts); err != nil {
|
|
||||||
return s3Options{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return opts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// s3Option - how s3Options are passed as arguments
|
|
||||||
type s3Option func(*s3Options) error
|
|
||||||
|
|
||||||
// options = how options are represented
|
|
||||||
type s3Options struct {
|
|
||||||
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 {
|
|
||||||
o.withEndpoint = with
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
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 == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uri, err = url.Parse(e.endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if uri == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolver = smithyEndpoints.Endpoint{
|
|
||||||
URI: *uri,
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAwsStoragePersistedStateS3Client(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
creds *awsutil.CredentialsConfig
|
|
||||||
opts []awsStoragePersistedStateOption
|
|
||||||
awsOpts []awsutil.Option
|
|
||||||
s3Opts []s3Option
|
|
||||||
expectedRegion string
|
|
||||||
expectedEndpoint string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "static credentials",
|
|
||||||
creds: func() *awsutil.CredentialsConfig {
|
|
||||||
creds, err := awsutil.NewCredentialsConfig(
|
|
||||||
awsutil.WithRegion("us-west-2"),
|
|
||||||
awsutil.WithAccessKey("foobar"),
|
|
||||||
awsutil.WithSecretKey("barfoo"),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return creds
|
|
||||||
}(),
|
|
||||||
awsOpts: []awsutil.Option{
|
|
||||||
awsutil.WithIAMAPIFunc(
|
|
||||||
awsutil.NewMockIAM(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
opts: []awsStoragePersistedStateOption{
|
|
||||||
withTestS3APIFunc(newTestMockS3(nil)),
|
|
||||||
},
|
|
||||||
s3Opts: []s3Option{
|
|
||||||
WithEndpoint("0.0.0.0"),
|
|
||||||
},
|
|
||||||
expectedRegion: "us-west-2",
|
|
||||||
expectedEndpoint: "0.0.0.0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
cred, err := credential.NewAwsCredentialPersistedState(
|
|
||||||
credential.WithCredentialsConfig(tc.creds),
|
|
||||||
credential.WithStateTestOpts(tc.awsOpts),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
state, err := newAwsStoragePersistedState(
|
|
||||||
append(tc.opts, withCredentials(cred))...,
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
clientRaw, err := state.s3Client(context.Background(), tc.s3Opts...)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(clientRaw)
|
|
||||||
client, ok := clientRaw.(*testMockS3)
|
|
||||||
require.True(ok)
|
|
||||||
require.NotNil(client)
|
|
||||||
require.Equal(tc.expectedRegion, client.Region)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,601 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
testOptionErr = "test option error"
|
|
||||||
testGetUserErr = "test error for GetUser"
|
|
||||||
testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
|
||||||
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testMockS3State struct {
|
|
||||||
GetObjectCalled bool
|
|
||||||
GetObjectInputParams *s3.GetObjectInput
|
|
||||||
|
|
||||||
PutObjectCalled bool
|
|
||||||
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() {
|
|
||||||
s.GetObjectCalled = false
|
|
||||||
s.GetObjectInputParams = nil
|
|
||||||
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 {
|
|
||||||
S3API
|
|
||||||
|
|
||||||
State *testMockS3State
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// mocked responses for getObject
|
|
||||||
GetObjectOutput *s3.GetObjectOutput
|
|
||||||
GetObjectErr error
|
|
||||||
|
|
||||||
// mocked responses for putObject
|
|
||||||
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
|
|
||||||
|
|
||||||
func testMockS3WithPutObjectOutput(o *s3.PutObjectOutput) testMockS3Option {
|
|
||||||
return func(m *testMockS3) error {
|
|
||||||
m.PutObjectOutput = o
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMockS3WithPutObjectError(e error) testMockS3Option {
|
|
||||||
return func(m *testMockS3) error {
|
|
||||||
m.PutObjectErr = e
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMockS3WithGetObjectError(e error) testMockS3Option {
|
|
||||||
return func(m *testMockS3) error {
|
|
||||||
m.GetObjectErr = e
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMockS3WithHeadObjectOutput(o *s3.HeadObjectOutput) testMockS3Option {
|
|
||||||
return func(m *testMockS3) error {
|
|
||||||
m.HeadObjectOutput = o
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMockS3WithHeadObjectError(e error) testMockS3Option {
|
|
||||||
return func(m *testMockS3) error {
|
|
||||||
m.HeadObjectErr = e
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, opt := range opts {
|
|
||||||
if err := opt(m); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cfg := range cfgs {
|
|
||||||
// Last region takes precedence
|
|
||||||
if cfg.Region != "" {
|
|
||||||
m.Region = cfg.Region
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *testMockS3) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
|
|
||||||
if m.State != nil {
|
|
||||||
m.State.GetObjectCalled = true
|
|
||||||
m.State.GetObjectInputParams = params
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.GetObjectErr != nil {
|
|
||||||
return nil, m.GetObjectErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.GetObjectOutput, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *testMockS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
|
|
||||||
if m.State != nil {
|
|
||||||
m.State.PutObjectCalled = true
|
|
||||||
m.State.PutObjectInputParams = params
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.PutObjectErr != nil {
|
|
||||||
return nil, m.PutObjectErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads and Writes on the pipe are matched one to one except when multiple Reads are needed to consume a single Write.
|
|
||||||
// Therefore, we need to read the entire body to ensure that the pipe is not blocked.
|
|
||||||
if params.Body != nil {
|
|
||||||
data, err := io.ReadAll(params.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if m.State == nil || m.State.PutObjectBody == nil {
|
|
||||||
return m.PutObjectOutput, nil
|
|
||||||
}
|
|
||||||
if !bytes.Equal(data, m.State.PutObjectBody) {
|
|
||||||
return nil, fmt.Errorf("expected body %q, got %q", m.State.PutObjectBody, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
if m.State != nil {
|
|
||||||
m.State.HeadObjectCalled = true
|
|
||||||
m.State.HeadObjectInputParams = params
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.HeadObjectErr != nil {
|
|
||||||
return nil, m.HeadObjectErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.HeadObjectOutput, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type getObjectStreamMock struct {
|
|
||||||
grpc.ServerStream
|
|
||||||
ctx context.Context
|
|
||||||
err error
|
|
||||||
data *pb.GetObjectResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *getObjectStreamMock) Context() context.Context {
|
|
||||||
return s.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send streams a response message to the client.
|
|
||||||
// This function should only be called on the server side.
|
|
||||||
func (g *getObjectStreamMock) Send(req *pb.GetObjectResponse) error {
|
|
||||||
if req == nil {
|
|
||||||
return fmt.Errorf(`parameter arg "resp GetObjectResponse" cannot be nil.`)
|
|
||||||
}
|
|
||||||
g.data = req
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recv streams a response message from the server.
|
|
||||||
// This function should only be called on the client side.
|
|
||||||
func (g *getObjectStreamMock) Recv() (*pb.GetObjectResponse, error) {
|
|
||||||
if g.data == nil {
|
|
||||||
if g.err != nil {
|
|
||||||
return nil, g.err
|
|
||||||
}
|
|
||||||
return nil, io.EOF
|
|
||||||
}
|
|
||||||
resp := &pb.GetObjectResponse{
|
|
||||||
FileChunk: append([]byte{}, g.data.FileChunk...),
|
|
||||||
}
|
|
||||||
g.data = nil
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGetObjectStreamMock() *getObjectStreamMock {
|
|
||||||
return &getObjectStreamMock{
|
|
||||||
ctx: context.Background(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTime(t *testing.T, timestamp string) time.Time {
|
|
||||||
require := require.New(t)
|
|
||||||
testTime, err := time.Parse(time.RFC3339, timestamp)
|
|
||||||
require.NoError(err)
|
|
||||||
return testTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func deepCopyPutObjectRequest(v *pb.PutObjectRequest) *pb.PutObjectRequest {
|
|
||||||
deepCopy := &pb.PutObjectRequest{
|
|
||||||
Key: v.Key,
|
|
||||||
Path: v.Path,
|
|
||||||
}
|
|
||||||
if v.Bucket != nil {
|
|
||||||
deepCopy.Bucket = &storagebuckets.StorageBucket{
|
|
||||||
BucketName: v.Bucket.BucketName,
|
|
||||||
BucketPrefix: v.Bucket.BucketPrefix,
|
|
||||||
}
|
|
||||||
if v.Bucket.Attributes != nil {
|
|
||||||
attrs, _ := structpb.NewStruct(v.Bucket.Attributes.AsMap())
|
|
||||||
deepCopy.Bucket.Attributes = attrs
|
|
||||||
}
|
|
||||||
if v.Bucket.Secrets != nil {
|
|
||||||
secrets, _ := structpb.NewStruct(v.Bucket.Secrets.AsMap())
|
|
||||||
deepCopy.Bucket.Secrets = secrets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deepCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
func validSTSMock() []credential.AwsCredentialPersistedStateOption {
|
|
||||||
return []credential.AwsCredentialPersistedStateOption{
|
|
||||||
credential.WithStateTestOpts([]awsutil.Option{
|
|
||||||
awsutil.WithSTSAPIFunc(
|
|
||||||
awsutil.NewMockSTS(
|
|
||||||
awsutil.WithGetCallerIdentityOutput(&sts.GetCallerIdentityOutput{
|
|
||||||
Account: aws.String("0123456789"),
|
|
||||||
Arn: aws.String("arn:aws:iam::0123456789:user/test"),
|
|
||||||
UserId: aws.String("test"),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ec2"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ec2APIFunc func(*session.Session, ...*aws.Config) (ec2iface.EC2API, error)
|
||||||
|
|
||||||
|
type awsCatalogPersistedStateOption func(s *awsCatalogPersistedState) error
|
||||||
|
|
||||||
|
func withStateTestOpts(opts []awsutil.Option) awsCatalogPersistedStateOption {
|
||||||
|
return func(s *awsCatalogPersistedState) error {
|
||||||
|
s.testOpts = opts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTestEC2APIFunc(f ec2APIFunc) awsCatalogPersistedStateOption {
|
||||||
|
return func(s *awsCatalogPersistedState) error {
|
||||||
|
if s.testEC2APIFunc != nil {
|
||||||
|
return errors.New("test API function already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.testEC2APIFunc = f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAccessKeyId(x string) awsCatalogPersistedStateOption {
|
||||||
|
return func(s *awsCatalogPersistedState) error {
|
||||||
|
if s.AccessKeyId != "" {
|
||||||
|
return errors.New("access key id already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AccessKeyId = x
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSecretAccessKey(x string) awsCatalogPersistedStateOption {
|
||||||
|
return func(s *awsCatalogPersistedState) error {
|
||||||
|
if s.SecretAccessKey != "" {
|
||||||
|
return errors.New("secret access key already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SecretAccessKey = x
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCredsLastRotatedTime(t time.Time) awsCatalogPersistedStateOption {
|
||||||
|
return func(s *awsCatalogPersistedState) error {
|
||||||
|
if !s.CredsLastRotatedTime.IsZero() {
|
||||||
|
return errors.New("last rotation time already set")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.CredsLastRotatedTime = t
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type awsCatalogPersistedState struct {
|
||||||
|
AccessKeyId string
|
||||||
|
SecretAccessKey string
|
||||||
|
CredsLastRotatedTime time.Time
|
||||||
|
|
||||||
|
// testOpts are options that should be used for testing only
|
||||||
|
testOpts []awsutil.Option
|
||||||
|
|
||||||
|
// testEC2APIFunc provides a way to provide a factory for a mock EC2 client
|
||||||
|
testEC2APIFunc ec2APIFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAwsCatalogPersistedState(opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
||||||
|
s := new(awsCatalogPersistedState)
|
||||||
|
for _, opt := range opts {
|
||||||
|
if err := opt(s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func awsCatalogPersistedStateFromProto(in *pb.HostCatalogPersisted, opts ...awsCatalogPersistedStateOption) (*awsCatalogPersistedState, error) {
|
||||||
|
data := in.GetSecrets()
|
||||||
|
if data == nil {
|
||||||
|
return nil, errors.New("missing persisted secrets")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKeyId, err := getStringValue(data, constAccessKeyId, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretAccessKey, err := getStringValue(data, constSecretAccessKey, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credsLastRotatedTime, err := getTimeValue(data, constCredsLastRotatedTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := newAwsCatalogPersistedState(opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AccessKeyId = accessKeyId
|
||||||
|
s.SecretAccessKey = secretAccessKey
|
||||||
|
s.CredsLastRotatedTime = credsLastRotatedTime
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awsCatalogPersistedState) ToProto() (*pb.HostCatalogPersisted, error) {
|
||||||
|
data, err := structpb.NewStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: s.AccessKeyId,
|
||||||
|
constSecretAccessKey: s.SecretAccessKey,
|
||||||
|
constCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awsCatalogPersistedState) ValidateCreds() error {
|
||||||
|
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||||
|
awsutil.WithAccessKey(s.AccessKeyId),
|
||||||
|
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||||
|
}, s.testOpts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.GetCallerIdentity(s.testOpts...); err != nil {
|
||||||
|
return fmt.Errorf("error validating credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *awsCatalogPersistedState) RotateCreds() error {
|
||||||
|
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||||
|
awsutil.WithAccessKey(s.AccessKeyId),
|
||||||
|
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||||
|
}, s.testOpts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.RotateKeys(append([]awsutil.Option{
|
||||||
|
awsutil.WithValidityCheckTimeout(rotationWaitTimeout),
|
||||||
|
}, s.testOpts...)...); err != nil {
|
||||||
|
return fmt.Errorf("error rotating credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AccessKeyId = c.AccessKey
|
||||||
|
s.SecretAccessKey = c.SecretKey
|
||||||
|
s.CredsLastRotatedTime = time.Now()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (s *awsCatalogPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error {
|
||||||
|
if accessKeyId == "" {
|
||||||
|
return errors.New("access key id cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if secretAccessKey == "" {
|
||||||
|
return errors.New("secret access key cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if accessKeyId == s.AccessKeyId {
|
||||||
|
return errors.New("attempting to replace access key with the same one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.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 := s.DeleteCreds(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the new attributes and clear the rotated time.
|
||||||
|
s.AccessKeyId = accessKeyId
|
||||||
|
s.SecretAccessKey = secretAccessKey
|
||||||
|
s.CredsLastRotatedTime = time.Time{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (s *awsCatalogPersistedState) DeleteCreds() error {
|
||||||
|
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||||
|
awsutil.WithAccessKey(s.AccessKeyId),
|
||||||
|
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||||
|
}, s.testOpts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.DeleteAccessKey(s.AccessKeyId, s.testOpts...); err != nil {
|
||||||
|
// Determine if the deletion error was due to a missing
|
||||||
|
// resource. If it was, just pass it.
|
||||||
|
var awsErr awserr.Error
|
||||||
|
if errors.As(err, &awsErr) {
|
||||||
|
if awsErr.Code() == iam.ErrCodeNoSuchEntityException {
|
||||||
|
s.AccessKeyId = ""
|
||||||
|
s.SecretAccessKey = ""
|
||||||
|
s.CredsLastRotatedTime = time.Time{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise treat it as an actual error.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AccessKeyId = ""
|
||||||
|
s.SecretAccessKey = ""
|
||||||
|
s.CredsLastRotatedTime = time.Time{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession returns a configured AWS session for the credentials in
|
||||||
|
// the state.
|
||||||
|
func (s *awsCatalogPersistedState) GetSession() (*session.Session, error) {
|
||||||
|
c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{
|
||||||
|
awsutil.WithAccessKey(s.AccessKeyId),
|
||||||
|
awsutil.WithSecretKey(s.SecretAccessKey),
|
||||||
|
}, s.testOpts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EC2Client returns a configured EC2 client based on the session
|
||||||
|
// information stored in the state.
|
||||||
|
func (s *awsCatalogPersistedState) EC2Client(region string) (ec2iface.EC2API, error) {
|
||||||
|
sess, err := s.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting AWS session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.testEC2APIFunc != nil {
|
||||||
|
return s.testEC2APIFunc(sess, aws.NewConfig().WithRegion(region))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ec2.New(sess, aws.NewConfig().WithRegion(region)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
"github.com/aws/aws-sdk-go/service/iam"
|
||||||
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/awsutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testOptionErr = "test option error"
|
||||||
|
const testGetCallerIdentityErr = "test error for GetCallerIdentity"
|
||||||
|
const testGetUserErr = "test error for GetUser"
|
||||||
|
const testDeleteAccessKeyErr = "test error for DeleteAccessKey"
|
||||||
|
|
||||||
|
func TestNewAwsCatalogPersistedState(t *testing.T) {
|
||||||
|
staticTime := time.Now()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
opts []awsCatalogPersistedStateOption
|
||||||
|
expected *awsCatalogPersistedState
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "error",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
func(s *awsCatalogPersistedState) error {
|
||||||
|
return errors.New(testOptionErr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErr: testOptionErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "access key id",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withAccessKeyId("foobar"),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "secret access key",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withSecretAccessKey("bazqux"),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rotation time",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withCredsLastRotatedTime(staticTime),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
CredsLastRotatedTime: staticTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double set access key id",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withAccessKeyId("foobar"),
|
||||||
|
withAccessKeyId("onetwo"),
|
||||||
|
},
|
||||||
|
expectedErr: "access key id already set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "secret access key",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withSecretAccessKey("bazqux"),
|
||||||
|
withSecretAccessKey("threefour"),
|
||||||
|
},
|
||||||
|
expectedErr: "secret access key already set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rotation time",
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withCredsLastRotatedTime(staticTime),
|
||||||
|
withCredsLastRotatedTime(time.Now()),
|
||||||
|
},
|
||||||
|
expectedErr: "last rotation time already set",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := newAwsCatalogPersistedState(tc.opts...)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateFromProto(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *pb.HostCatalogPersisted
|
||||||
|
opts []awsCatalogPersistedStateOption
|
||||||
|
expected *awsCatalogPersistedState
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no secrets",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: nil,
|
||||||
|
},
|
||||||
|
expectedErr: "missing persisted secrets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing access key ID",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(nil),
|
||||||
|
},
|
||||||
|
expectedErr: "persisted state integrity error: missing required value \"access_key_id\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing secret access key",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
expectedErr: "persisted state integrity error: missing required value \"secret_access_key\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad last rotated time",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: "notatime",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
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\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "option error",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
func(s *awsCatalogPersistedState) error {
|
||||||
|
return errors.New(testOptionErr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErr: testOptionErr,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good with non-zero timestamp",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: func() time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good with zero timestamp",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: time.Time{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good (ignoring non-test options)",
|
||||||
|
in: &pb.HostCatalogPersisted{
|
||||||
|
// Nil should be the same as empty map here
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
opts: []awsCatalogPersistedStateOption{
|
||||||
|
withAccessKeyId("ignored"),
|
||||||
|
},
|
||||||
|
expected: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: time.Time{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := awsCatalogPersistedStateFromProto(tc.in, tc.opts...)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateToProto(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *awsCatalogPersistedState
|
||||||
|
expected *pb.HostCatalogPersisted
|
||||||
|
expectedErr string // NOTE: Here for scaffolding but hard to test deep structpb err right now
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "good with non-zero timestamp",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: func() time.Time {
|
||||||
|
t, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
expected: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good with zero timestamp",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: time.Time{},
|
||||||
|
},
|
||||||
|
expected: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: mustStruct(map[string]interface{}{
|
||||||
|
constAccessKeyId: "foobar",
|
||||||
|
constSecretAccessKey: "bazqux",
|
||||||
|
constCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
actual, err := tc.in.ToProto()
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *awsCatalogPersistedState
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "could not load credentials",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "validation error",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr))))},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
err := tc.in.ValidateCreds()
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *awsCatalogPersistedState
|
||||||
|
expectedAccessKeyId string
|
||||||
|
expectedSecretAccessKey string
|
||||||
|
expectedNonZeroRotatedTime bool
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "could not load credentials",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rotation error",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(
|
||||||
|
awsutil.WithGetUserError(errors.New(testGetUserErr)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling aws.GetUser: %s", testGetUserErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithSTSAPIFunc(
|
||||||
|
awsutil.NewMockSTS(),
|
||||||
|
),
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(
|
||||||
|
awsutil.WithGetUserOutput(
|
||||||
|
&iam.GetUserOutput{
|
||||||
|
User: &iam.User{
|
||||||
|
Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"),
|
||||||
|
UserId: aws.String("AIDAJQABLZS4A3QDU576Q"),
|
||||||
|
UserName: aws.String("JohnDoe"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
awsutil.WithCreateAccessKeyOutput(
|
||||||
|
&iam.CreateAccessKeyOutput{
|
||||||
|
AccessKey: &iam.AccessKey{
|
||||||
|
AccessKeyId: aws.String("one"),
|
||||||
|
SecretAccessKey: aws.String("two"),
|
||||||
|
UserName: aws.String("JohnDoe"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedAccessKeyId: "one",
|
||||||
|
expectedSecretAccessKey: "two",
|
||||||
|
expectedNonZeroRotatedTime: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
err := tc.in.RotateCreds()
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expectedAccessKeyId, tc.in.AccessKeyId)
|
||||||
|
require.Equal(tc.expectedSecretAccessKey, tc.in.SecretAccessKey)
|
||||||
|
require.Equal(tc.expectedNonZeroRotatedTime, !tc.in.CredsLastRotatedTime.IsZero())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) {
|
||||||
|
// NOTE: Not safe to run this test in parallel
|
||||||
|
state := new(testMockIAMState)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *awsCatalogPersistedState
|
||||||
|
accessKeyId string
|
||||||
|
secretAccessKey string
|
||||||
|
expectedAccessKeyId string
|
||||||
|
expectedSecretAccessKey string
|
||||||
|
expectedDeleteCalled bool
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing access key id",
|
||||||
|
in: &awsCatalogPersistedState{},
|
||||||
|
expectedErr: "access key id cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing access key id",
|
||||||
|
in: &awsCatalogPersistedState{},
|
||||||
|
accessKeyId: "one",
|
||||||
|
expectedErr: "secret access key cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical access key id",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
},
|
||||||
|
accessKeyId: "foobar",
|
||||||
|
secretAccessKey: "bazqux",
|
||||||
|
expectedErr: "attempting to replace access key with the same one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deletion error",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: time.Now(),
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(
|
||||||
|
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessKeyId: "one",
|
||||||
|
secretAccessKey: "two",
|
||||||
|
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good with delete of old rotated",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
CredsLastRotatedTime: time.Now(),
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
newTestMockIAM(state),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessKeyId: "one",
|
||||||
|
secretAccessKey: "two",
|
||||||
|
expectedAccessKeyId: "one",
|
||||||
|
expectedSecretAccessKey: "two",
|
||||||
|
expectedDeleteCalled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good without delete of old rotated",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
newTestMockIAM(state),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessKeyId: "one",
|
||||||
|
secretAccessKey: "two",
|
||||||
|
expectedAccessKeyId: "one",
|
||||||
|
expectedSecretAccessKey: "two",
|
||||||
|
expectedDeleteCalled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
state.Reset()
|
||||||
|
err := tc.in.ReplaceCreds(tc.accessKeyId, tc.secretAccessKey)
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expectedAccessKeyId, tc.in.AccessKeyId)
|
||||||
|
require.Equal(tc.expectedSecretAccessKey, tc.in.SecretAccessKey)
|
||||||
|
require.Zero(tc.in.CredsLastRotatedTime)
|
||||||
|
require.Equal(tc.expectedDeleteCalled, state.DeleteAccessKeyCalled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in *awsCatalogPersistedState
|
||||||
|
expectedErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "could not load credentials",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deletion error",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(
|
||||||
|
awsutil.WithDeleteAccessKeyError(errors.New(testDeleteAccessKeyErr)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deletion error, but OK because key was just gone",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(
|
||||||
|
awsutil.WithDeleteAccessKeyError(
|
||||||
|
awserr.New(iam.ErrCodeNoSuchEntityException, "", nil),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
in: &awsCatalogPersistedState{
|
||||||
|
AccessKeyId: "foobar",
|
||||||
|
SecretAccessKey: "bazqux",
|
||||||
|
testOpts: []awsutil.Option{
|
||||||
|
awsutil.WithIAMAPIFunc(
|
||||||
|
awsutil.NewMockIAM(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
err := tc.in.DeleteCreds()
|
||||||
|
if tc.expectedErr != "" {
|
||||||
|
require.EqualError(err, tc.expectedErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Empty(tc.in.AccessKeyId)
|
||||||
|
require.Empty(tc.in.SecretAccessKey)
|
||||||
|
require.Zero(tc.in.CredsLastRotatedTime)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateGetSessionErr(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
state := &awsCatalogPersistedState{
|
||||||
|
testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := state.GetSession()
|
||||||
|
require.EqualError(err, fmt.Sprintf("error reading options in NewCredentialsConfig: %s", testOptionErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateEC2ClientErr(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
state, err := newAwsCatalogPersistedState(
|
||||||
|
withStateTestOpts([]awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
_, err = state.EC2Client("someregion")
|
||||||
|
require.EqualError(err, fmt.Sprintf("error getting AWS session: error reading options in NewCredentialsConfig: %s", testOptionErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwsCatalogPersistedStateEC2ClientGood(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
state, err := newAwsCatalogPersistedState(
|
||||||
|
withTestEC2APIFunc(newTestMockEC2(nil)),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
clientRaw, err := state.EC2Client("someregion")
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(clientRaw)
|
||||||
|
client, ok := clientRaw.(*testMockEC2)
|
||||||
|
require.True(ok)
|
||||||
|
require.NotNil(client)
|
||||||
|
require.Equal("someregion", client.Region)
|
||||||
|
}
|
||||||
+16
-20
@@ -1,9 +1,14 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
variable "iam_user_prefix" {
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
default = "boundary-plugin-host-aws-test-iam-user"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "iam_user_count" {
|
||||||
|
default = 6
|
||||||
|
}
|
||||||
|
|
||||||
resource "random_id" "aws_iam_user_name" {
|
resource "random_id" "aws_iam_user_name" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
|
prefix = "${var.iam_user_prefix}-${count.index}"
|
||||||
byte_length = 4
|
byte_length = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,10 +16,8 @@ resource "aws_iam_user" "user" {
|
|||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
name = random_id.aws_iam_user_name[count.index].dec
|
name = random_id.aws_iam_user_name[count.index].dec
|
||||||
force_destroy = true
|
force_destroy = true
|
||||||
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
|
|
||||||
tags = {
|
tags = var.project_path_tags
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_access_key" "user_initial_key" {
|
resource "aws_iam_access_key" "user_initial_key" {
|
||||||
@@ -22,13 +25,8 @@ resource "aws_iam_access_key" "user_initial_key" {
|
|||||||
user = aws_iam_user.user[count.index].name
|
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" {
|
resource "aws_iam_policy" "ec2_describeinstances" {
|
||||||
name = random_id.aws_ec2_policy_name.dec
|
name = "BoundaryPluginHostAwsTestDescribeInstancesPolicy"
|
||||||
|
|
||||||
policy = <<EOF
|
policy = <<EOF
|
||||||
{
|
{
|
||||||
@@ -44,6 +42,8 @@ resource "aws_iam_policy" "ec2_describeinstances" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment" {
|
resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment" {
|
||||||
@@ -52,15 +52,9 @@ resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment
|
|||||||
policy_arn = aws_iam_policy.ec2_describeinstances.arn
|
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" {
|
resource "aws_iam_policy" "user_self_manage_policy" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
name = random_id.aws_iam_policy_name[count.index].dec
|
name = "BoundaryPluginHostAwsTestUserSelfManagePolicy-${count.index}"
|
||||||
|
|
||||||
policy = <<EOF
|
policy = <<EOF
|
||||||
{
|
{
|
||||||
@@ -78,6 +72,8 @@ resource "aws_iam_policy" "user_self_manage_policy" {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "user_self_manage_policy_attachment" {
|
resource "aws_iam_user_policy_attachment" "user_self_manage_policy_attachment" {
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_path_tags" {
|
||||||
|
default = {
|
||||||
|
"project_path" = "hashicorp/boundary-plugin-host-aws/test/basic"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+78
@@ -0,0 +1,78 @@
|
|||||||
|
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" {
|
||||||
|
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" "vpc" {
|
||||||
|
cidr_block = "10.0.0.0/16"
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_instance" "instances" {
|
||||||
|
count = length(var.instance_tags)
|
||||||
|
ami = data.aws_ami.ubuntu.id
|
||||||
|
instance_type = "t3.nano"
|
||||||
|
subnet_id = aws_subnet.subnet.id
|
||||||
|
|
||||||
|
tags = merge(var.instance_tags[count.index], var.project_path_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
output "instance_ids" {
|
||||||
|
value = aws_instance.instances.*.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "instance_addrs" {
|
||||||
|
value = {
|
||||||
|
for _, r in aws_instance.instances : r.id => r.private_ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "instance_tags" {
|
||||||
|
value = {
|
||||||
|
for _, r in aws_instance.instances : r.id => r.tags
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
// 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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
// 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"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
func TestHostPlugin(t *testing.T) {
|
|
||||||
region := os.Getenv("AWS_REGION")
|
|
||||||
if region == "" {
|
|
||||||
t.Skip("set AWS_REGION to use this test")
|
|
||||||
}
|
|
||||||
if envAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID"); envAccessKeyId == "" {
|
|
||||||
t.Skip("set AWS_ACCESS_KEY_ID to use this test")
|
|
||||||
}
|
|
||||||
if envSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY"); envSecretAccessKey == "" {
|
|
||||||
t.Skip("set AWS_SECRET_ACCESS_KEY to use this test")
|
|
||||||
}
|
|
||||||
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
t.Log("===== deploying test Terraform workspace =====")
|
|
||||||
err = tf.Deploy()
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
t.Log("===== destroying test Terraform workspace =====")
|
|
||||||
if err := tf.Destroy(); err != nil {
|
|
||||||
t.Logf("WARNING: could not run Terraform destroy: %s", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
iamUserNames, err := tf.GetOutputSlice("iam_user_names")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamUserNames, expectedIamUserCount)
|
|
||||||
|
|
||||||
iamUserArns, err := tf.GetOutputSlice("iam_user_arns")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamUserArns, expectedIamUserCount)
|
|
||||||
|
|
||||||
iamAccessKeyIds, err := tf.GetOutputSlice("iam_access_key_ids")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamAccessKeyIds, expectedIamUserCount)
|
|
||||||
|
|
||||||
iamSecretAccessKeys, err := tf.GetOutputSlice("iam_secret_access_keys")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamSecretAccessKeys, expectedIamUserCount)
|
|
||||||
|
|
||||||
ec2InstanceIds, err := tf.GetOutputSlice("instance_ids")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(ec2InstanceIds, expectedEc2InstanceCount)
|
|
||||||
|
|
||||||
ec2InstanceAddrs, err := tf.GetOutputMap("instance_addrs")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(ec2InstanceAddrs, expectedEc2InstanceCount)
|
|
||||||
|
|
||||||
ec2InstanceTags, err := tf.GetOutputMap("instance_tags")
|
|
||||||
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
|
|
||||||
// will assert this through the returned state.
|
|
||||||
p := new(host.HostPlugin)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnCreateCatalog
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test non-rotation (using primary user).
|
|
||||||
keyid, secret := testPluginOnCreateCatalog(ctx, t, p, region, iamAccessKeyIds[0].(string), iamSecretAccessKeys[0].(string), false)
|
|
||||||
// Test rotation next.
|
|
||||||
keyid, secret = testPluginOnCreateCatalog(ctx, t, p, region, keyid, secret, true)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnUpdateCatalog
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test no-op non-rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, false)
|
|
||||||
// Switch to rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, true)
|
|
||||||
// Test no-op with rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", true, true)
|
|
||||||
// Switch credentials to next user. Don't rotate.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[1].(string), iamSecretAccessKeys[1].(string), true, false)
|
|
||||||
// Switch credentials to next user. Add rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[2].(string), iamSecretAccessKeys[2].(string), false, true)
|
|
||||||
// Switch to next user, with rotation disabled.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[3].(string), iamSecretAccessKeys[3].(string), true, false)
|
|
||||||
// Last case - switch to another user and keep rotation off.
|
|
||||||
keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[4].(string), iamSecretAccessKeys[4].(string), false, false)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnDeleteCatalog
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test non-rotated.
|
|
||||||
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, false)
|
|
||||||
// Test as if we had rotated these credentials (note that this
|
|
||||||
// makes this test set unusable).
|
|
||||||
testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, true)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * Host set stuff
|
|
||||||
// ********************
|
|
||||||
// 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 := buildExpectedTagInstancesMap(ec2InstanceTags, expectedTags)
|
|
||||||
|
|
||||||
requireTagInstancesMatchProvisioned(t, ec2InstanceIds, expectedTagInstancesMap)
|
|
||||||
|
|
||||||
cases := [][]string{
|
|
||||||
{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 {
|
|
||||||
// Test create/update in one step
|
|
||||||
testPluginOnCreateUpdateSet(ctx, t, p, region, keyid, secret, tc)
|
|
||||||
// Test ListHosts
|
|
||||||
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
|
|
||||||
// TODO: add OnDeleteSet if it needs to be implemented
|
|
||||||
}
|
|
||||||
|
|
||||||
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
|
|
||||||
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * 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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * 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)
|
|
||||||
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
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 {
|
|
||||||
crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
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"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/storage"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
||||||
"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 == "" {
|
|
||||||
t.Skip("set AWS_REGION to use this test")
|
|
||||||
}
|
|
||||||
if envAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID"); envAccessKeyId == "" {
|
|
||||||
t.Skip("set AWS_ACCESS_KEY_ID to use this test")
|
|
||||||
}
|
|
||||||
if envSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY"); envSecretAccessKey == "" {
|
|
||||||
t.Skip("set AWS_SECRET_ACCESS_KEY to use this test")
|
|
||||||
}
|
|
||||||
|
|
||||||
require := require.New(t)
|
|
||||||
tf, err := NewTestTerraformer("testdata/storage")
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(tf)
|
|
||||||
|
|
||||||
t.Log("===== deploying test Terraform workspace =====")
|
|
||||||
err = tf.Deploy()
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
t.Log("===== destroying test Terraform workspace =====")
|
|
||||||
if err := tf.Destroy(); err != nil {
|
|
||||||
t.Logf("WARNING: could not run Terraform destroy: %s", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
bucketName, err := tf.GetOutputString("bucket_name")
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotEmpty(bucketName)
|
|
||||||
|
|
||||||
iamAccessKeyIds, err := tf.GetOutputSlice("iam_access_key_ids")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamAccessKeyIds, expectedIamUserCount)
|
|
||||||
|
|
||||||
iamSecretAccessKeys, err := tf.GetOutputSlice("iam_secret_access_keys")
|
|
||||||
require.NoError(err)
|
|
||||||
require.Len(iamSecretAccessKeys, expectedIamUserCount)
|
|
||||||
|
|
||||||
iamAccessKeyMissingGetObject, err := tf.GetOutputString("iam_access_key_missing_get_obj")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
iamSecretAccessKeyMissingGetObject, err := tf.GetOutputString("iam_secret_access_key_missing_get_obj")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
iamAccessKeyMissingPutObject, err := tf.GetOutputString("iam_access_key_missing_put_obj")
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
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
|
|
||||||
// will assert this through the returned state.
|
|
||||||
p := new(storage.StoragePlugin)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnCreateStorageBucket
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test non-rotation (using primary user).
|
|
||||||
keyid, secret := testPluginOnCreateStorageBucket(ctx, t, p, bucketName, region, iamAccessKeyIds[0].(string), iamSecretAccessKeys[0].(string), false)
|
|
||||||
// Test rotation next.
|
|
||||||
keyid, secret = testPluginOnCreateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, true)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnUpdateStorageBucket
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test no-op non-rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, "", "", false, false)
|
|
||||||
// Switch to rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, "", "", false, true)
|
|
||||||
// Test no-op with rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, "", "", true, true)
|
|
||||||
// Switch credentials to next user. Don't rotate.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, iamAccessKeyIds[1].(string), iamSecretAccessKeys[1].(string), true, false)
|
|
||||||
// Switch credentials to next user. Add rotation.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, iamAccessKeyIds[2].(string), iamSecretAccessKeys[2].(string), false, true)
|
|
||||||
// Switch to next user, with rotation disabled.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, iamAccessKeyIds[3].(string), iamSecretAccessKeys[3].(string), true, false)
|
|
||||||
// Last case - switch to another user and keep rotation off.
|
|
||||||
keyid, secret = testPluginOnUpdateStorageBucket(ctx, t, p, bucketName, region, keyid, secret, iamAccessKeyIds[4].(string), iamSecretAccessKeys[4].(string), false, false)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * OnDeleteStorageBucket
|
|
||||||
// ********************
|
|
||||||
//
|
|
||||||
// Test non-rotated.
|
|
||||||
testPluginOnDeleteStorageBucket(ctx, t, p, bucketName, region, keyid, secret, false)
|
|
||||||
// Test as if we had rotated these credentials (note that this
|
|
||||||
// makes this test set unusable).
|
|
||||||
testPluginOnDeleteStorageBucket(ctx, t, p, bucketName, region, keyid, secret, true)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * 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
|
|
||||||
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
|
|
||||||
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) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing OnCreateStorageBucket (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.OnCreateStorageBucketRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.OnCreateStorageBucket(ctx, request)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(response)
|
|
||||||
persisted := response.GetPersisted()
|
|
||||||
require.NotNil(persisted)
|
|
||||||
return validatePersistedSecrets(t, persisted.GetData(), accessKeyId, secretAccessKey, rotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnUpdateStorageBucket(
|
|
||||||
ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName,
|
|
||||||
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
|
|
||||||
rotated, rotate bool,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing OnUpdateStorageBucket (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.OnUpdateStorageBucketRequest{
|
|
||||||
CurrentBucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqCurrentAttrs,
|
|
||||||
},
|
|
||||||
NewBucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqNewAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Persisted: &storagebuckets.StorageBucketPersisted{
|
|
||||||
Data: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.OnUpdateStorageBucket(ctx, request)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(response)
|
|
||||||
persisted := response.GetPersisted()
|
|
||||||
require.NotNil(persisted)
|
|
||||||
return validateUpdateSecrets(t, persisted.GetData(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnDeleteStorageBucket(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string, rotated bool) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing OnDeleteStorageBucket (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.OnDeleteStorageBucketRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Persisted: &storagebuckets.StorageBucketPersisted{
|
|
||||||
Data: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.OnDeleteStorageBucket(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 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, assert := require.New(t), assert.New(t)
|
|
||||||
|
|
||||||
require.NotNil(expectedState)
|
|
||||||
|
|
||||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstRegion: region,
|
|
||||||
credential.ConstDisableCredentialRotation: true,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstAccessKeyId: accessKeyId,
|
|
||||||
credential.ConstSecretAccessKey: secretAccessKey,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
req := &pb.ValidatePermissionsRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, 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) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing PutObject")
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
testDataPath, err := filepath.Abs("./testdata/storage/test_object_data")
|
|
||||||
require.NoError(err)
|
|
||||||
require.FileExists(testDataPath)
|
|
||||||
|
|
||||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstRegion: region,
|
|
||||||
credential.ConstDisableCredentialRotation: true,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstAccessKeyId: accessKeyId,
|
|
||||||
credential.ConstSecretAccessKey: secretAccessKey,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
objectKey := uuid.New().String()
|
|
||||||
putObjReq := &pb.PutObjectRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Key: objectKey,
|
|
||||||
Path: testDataPath,
|
|
||||||
}
|
|
||||||
putObjResp, err := p.PutObject(ctx, putObjReq)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(putObjResp)
|
|
||||||
require.NotEmpty(putObjResp.GetChecksumSha_256())
|
|
||||||
|
|
||||||
t.Logf("testing HeadObject")
|
|
||||||
headObjReq := &pb.HeadObjectRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Key: objectKey,
|
|
||||||
}
|
|
||||||
headObjResp, err := p.HeadObject(ctx, headObjReq)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(headObjResp)
|
|
||||||
require.NotEmpty(headObjResp.GetContentLength())
|
|
||||||
require.NotEmpty(headObjResp.GetLastModified())
|
|
||||||
|
|
||||||
// Need to create a GRPC server to test GetObject
|
|
||||||
t.Logf("testing GetObject")
|
|
||||||
lis, err := net.Listen("tcp", "[::1]:2030")
|
|
||||||
require.NoError(err)
|
|
||||||
grpcServer := grpc.NewServer()
|
|
||||||
pb.RegisterStoragePluginServiceServer(grpcServer, &storage.StoragePlugin{})
|
|
||||||
go func() {
|
|
||||||
grpcServer.Serve(lis)
|
|
||||||
}()
|
|
||||||
|
|
||||||
conn, err := grpc.Dial("[::1]:2030", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
require.NoError(err)
|
|
||||||
defer conn.Close()
|
|
||||||
client := pb.NewStoragePluginServiceClient(conn)
|
|
||||||
|
|
||||||
stream, err := client.GetObject(ctx, &pb.GetObjectRequest{
|
|
||||||
Bucket: &storagebuckets.StorageBucket{
|
|
||||||
BucketName: bucketName,
|
|
||||||
Attributes: reqAttrs,
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Key: objectKey,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
var actualData []byte
|
|
||||||
expectedData, err := os.ReadFile(testDataPath)
|
|
||||||
require.NoError(err)
|
|
||||||
for {
|
|
||||||
resp, err := stream.Recv()
|
|
||||||
if err == io.EOF {
|
|
||||||
require.NoError(stream.CloseSend())
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
require.NoError(err)
|
|
||||||
}
|
|
||||||
actualData = append(actualData, resp.GetFileChunk()...)
|
|
||||||
}
|
|
||||||
require.Equal(expectedData, actualData)
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
-206
@@ -1,206 +0,0 @@
|
|||||||
# 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
@@ -1,322 +0,0 @@
|
|||||||
# 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
aws = {
|
|
||||||
source = "hashicorp/aws"
|
|
||||||
version = "~> 6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-49
@@ -1,49 +0,0 @@
|
|||||||
# 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"
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
-180
@@ -1,180 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
data "aws_availability_zones" "azs" {}
|
|
||||||
|
|
||||||
data "aws_ami" "ubuntu" {
|
|
||||||
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" "vpc" {
|
|
||||||
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
|
|
||||||
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(local.instance_tags)
|
|
||||||
ami = data.aws_ami.ubuntu.id
|
|
||||||
instance_type = "t3.nano"
|
|
||||||
subnet_id = aws_subnet.subnet.id
|
|
||||||
|
|
||||||
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" {
|
|
||||||
value = aws_instance.instances.*.id
|
|
||||||
}
|
|
||||||
|
|
||||||
output "instance_addrs" {
|
|
||||||
value = {
|
|
||||||
for _, r in aws_instance.instances : r.id => r.private_ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-3
@@ -1,6 +1,3 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
resource "random_id" "foo" {
|
resource "random_id" "foo" {
|
||||||
byte_length = 2
|
byte_length = 2
|
||||||
prefix = "test-foo"
|
prefix = "test-foo"
|
||||||
|
|||||||
Vendored
-327
@@ -1,327 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
resource "aws_iam_role" "valid" {
|
|
||||||
name = "${random_id.prefix.dec}-valid"
|
|
||||||
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" "valid" {
|
|
||||||
name = "${random_id.prefix.dec}-valid"
|
|
||||||
tags = local.tags
|
|
||||||
|
|
||||||
policy = <<EOT
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"s3:PutObject",
|
|
||||||
"s3:GetObject",
|
|
||||||
"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}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
EOT
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "valid" {
|
|
||||||
role = aws_iam_role.valid.name
|
|
||||||
policy_arn = aws_iam_policy.valid.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role" "missing_put_obj" {
|
|
||||||
name = "${random_id.prefix.dec}-missing-put-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_put_obj" {
|
|
||||||
name = "${random_id.prefix.dec}-missing-put-obj"
|
|
||||||
tags = local.tags
|
|
||||||
|
|
||||||
policy = <<EOT
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"s3:GetObject",
|
|
||||||
"s3:GetObjectAttributes",
|
|
||||||
"s3:ListBucket",
|
|
||||||
"s3:DeleteObject"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "${aws_s3_bucket.test.arn}*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
EOT
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "missing_put_obj" {
|
|
||||||
role = aws_iam_role.missing_put_obj.name
|
|
||||||
policy_arn = aws_iam_policy.missing_put_obj.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role" "missing_get_obj" {
|
|
||||||
name = "${random_id.prefix.dec}-missing-get-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_get_obj" {
|
|
||||||
name = "${random_id.prefix.dec}-missing-get-obj"
|
|
||||||
tags = local.tags
|
|
||||||
|
|
||||||
policy = <<EOT
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"s3:PutObject",
|
|
||||||
"s3:DeleteObject"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "${aws_s3_bucket.test.arn}*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
EOT
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "missing_get_obj" {
|
|
||||||
role = aws_iam_role.missing_get_obj.name
|
|
||||||
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 = "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
|
|
||||||
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" {
|
|
||||||
count = var.iam_user_count
|
|
||||||
user = aws_iam_user.test[count.index].name
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "test_s3" {
|
|
||||||
count = var.iam_user_count
|
|
||||||
user = aws_iam_user.test[count.index].name
|
|
||||||
policy_arn = aws_iam_policy.valid.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_policy" "credentials" {
|
|
||||||
count = var.iam_user_count
|
|
||||||
name = aws_iam_user.test[count.index].name
|
|
||||||
tags = local.tags
|
|
||||||
|
|
||||||
policy = <<EOF
|
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Action": [
|
|
||||||
"iam:DeleteAccessKey",
|
|
||||||
"iam:GetUser",
|
|
||||||
"iam:CreateAccessKey"
|
|
||||||
],
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Resource": "${aws_iam_user.test[count.index].arn}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "test_credentials" {
|
|
||||||
count = var.iam_user_count
|
|
||||||
user = aws_iam_user.test[count.index].name
|
|
||||||
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 = "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
|
|
||||||
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" {
|
|
||||||
user = aws_iam_user.missing_get_obj.name
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "missing_get_obj" {
|
|
||||||
user = aws_iam_user.missing_get_obj.name
|
|
||||||
policy_arn = aws_iam_policy.missing_get_obj.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
# Edge case for missing PutObject permission
|
|
||||||
|
|
||||||
resource "random_id" "missing_put_obj" {
|
|
||||||
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
|
|
||||||
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" {
|
|
||||||
user = aws_iam_user.missing_put_obj.name
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_user_policy_attachment" "missing_put_obj" {
|
|
||||||
user = aws_iam_user.missing_put_obj.name
|
|
||||||
policy_arn = aws_iam_policy.missing_put_obj.arn
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
aws = {
|
|
||||||
source = "hashicorp/aws"
|
|
||||||
version = "~> 6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-75
@@ -1,75 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
output "bucket_name" {
|
|
||||||
value = aws_s3_bucket.test.id
|
|
||||||
}
|
|
||||||
|
|
||||||
output "valid_policy_arn" {
|
|
||||||
value = aws_iam_role.valid.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
output "missing_put_obj_policy_arn" {
|
|
||||||
value = aws_iam_role.missing_put_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" {
|
|
||||||
value = aws_iam_user.test.*.name
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_user_arns" {
|
|
||||||
value = aws_iam_user.test.*.arn
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_access_key_ids" {
|
|
||||||
value = aws_iam_access_key.test.*.id
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_secret_access_keys" {
|
|
||||||
value = aws_iam_access_key.test.*.secret
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_access_key_missing_put_obj" {
|
|
||||||
value = aws_iam_access_key.missing_put_obj.id
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_secret_access_key_missing_put_obj" {
|
|
||||||
value = aws_iam_access_key.missing_put_obj.secret
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
output "iam_access_key_missing_get_obj" {
|
|
||||||
value = aws_iam_access_key.missing_get_obj.id
|
|
||||||
sensitive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
Vendored
-25
@@ -1,25 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
resource "aws_s3_bucket" "test" {
|
|
||||||
bucket = random_id.prefix.dec
|
|
||||||
force_destroy = true
|
|
||||||
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
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
HELLO_WORLD!
|
|
||||||
Vendored
-62
@@ -1,62 +0,0 @@
|
|||||||
# Copyright IBM Corp. 2021, 2026
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
data "aws_caller_identity" "current" {}
|
|
||||||
|
|
||||||
resource "random_id" "prefix" {
|
|
||||||
prefix = "aws-plugin-e2e-test"
|
|
||||||
byte_length = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "iam_user_count" {
|
|
||||||
default = 6
|
|
||||||
}
|
|
||||||
|
|
||||||
locals {
|
|
||||||
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
|
|
||||||
tags = {
|
|
||||||
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/" = ""
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+12
-249
@@ -1,29 +1,15 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/smithy-go"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/values"
|
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
|
||||||
tfjson "github.com/hashicorp/terraform-json"
|
tfjson "github.com/hashicorp/terraform-json"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedIamUserCount = 6
|
|
||||||
|
|
||||||
// TestTerraformer is an object representing the lifecycle of a
|
// TestTerraformer is an object representing the lifecycle of a
|
||||||
// particular Terraform project.
|
// particular Terraform project.
|
||||||
//
|
//
|
||||||
@@ -39,10 +25,6 @@ type TestTerraformer struct {
|
|||||||
// The working directory for Terraform commands.
|
// The working directory for Terraform commands.
|
||||||
workDir string
|
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
|
// The Terraform state. Use the Output method to look up particular
|
||||||
// outputs from the Terraform state.
|
// outputs from the Terraform state.
|
||||||
state *tfjson.State
|
state *tfjson.State
|
||||||
@@ -57,9 +39,8 @@ type TestTerraformer struct {
|
|||||||
|
|
||||||
// NewTestTerraformer initializes a Terraform directory. It just
|
// NewTestTerraformer initializes a Terraform directory. It just
|
||||||
// returns an initialized project, it does not perform any actions on
|
// returns an initialized project, it does not perform any actions on
|
||||||
// it. An optional vars map may be provided; each entry is passed as a
|
// it.
|
||||||
// -var flag to Terraform commands.
|
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
||||||
func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerraformer, error) {
|
|
||||||
tfPath, err := exec.LookPath("terraform")
|
tfPath, err := exec.LookPath("terraform")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("LookPath error: %w", err)
|
return nil, fmt.Errorf("LookPath error: %w", err)
|
||||||
@@ -69,24 +50,10 @@ func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerrafo
|
|||||||
return nil, errors.New("workDir is empty")
|
return nil, errors.New("workDir is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
tf := &TestTerraformer{
|
return &TestTerraformer{
|
||||||
tfPath: tfPath,
|
tfPath: tfPath,
|
||||||
workDir: workDir,
|
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,
|
// Deploy runs the following commands, in order: init, apply, plan,
|
||||||
@@ -138,7 +105,7 @@ func (t *TestTerraformer) Destroy() error {
|
|||||||
//
|
//
|
||||||
// Outputs for the root module are supported only. It's an error to
|
// Outputs for the root module are supported only. It's an error to
|
||||||
// run this when no state exists.
|
// run this when no state exists.
|
||||||
func (t *TestTerraformer) GetOutput(key string) (any, error) {
|
func (t *TestTerraformer) GetOutput(key string) (interface{}, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, errors.New("key must not be empty")
|
return nil, errors.New("key must not be empty")
|
||||||
}
|
}
|
||||||
@@ -185,13 +152,13 @@ func (t *TestTerraformer) GetOutputString(key string) (string, error) {
|
|||||||
|
|
||||||
// GetOutputMap wraps GetOutput and returns the value as a map. It's an
|
// GetOutputMap wraps GetOutput and returns the value as a map. It's an
|
||||||
// error if the return value is actually not a map.
|
// error if the return value is actually not a map.
|
||||||
func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) {
|
func (t *TestTerraformer) GetOutputMap(key string) (map[string]interface{}, error) {
|
||||||
value, err := t.GetOutput(key)
|
value, err := t.GetOutput(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m, ok := value.(map[string]any)
|
m, ok := value.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value)
|
return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value)
|
||||||
}
|
}
|
||||||
@@ -201,13 +168,13 @@ func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) {
|
|||||||
|
|
||||||
// GetOutputSlice wraps GetOutput and returns the value as a slice. It's an
|
// GetOutputSlice wraps GetOutput and returns the value as a slice. It's an
|
||||||
// error if the return value is actually not a slice.
|
// error if the return value is actually not a slice.
|
||||||
func (t *TestTerraformer) GetOutputSlice(key string) ([]any, error) {
|
func (t *TestTerraformer) GetOutputSlice(key string) ([]interface{}, error) {
|
||||||
value, err := t.GetOutput(key)
|
value, err := t.GetOutput(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m, ok := value.([]any)
|
m, ok := value.([]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value)
|
return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value)
|
||||||
}
|
}
|
||||||
@@ -233,8 +200,7 @@ func (t *TestTerraformer) init() error {
|
|||||||
|
|
||||||
// apply runs "terraform apply" in the project's working directory.
|
// apply runs "terraform apply" in the project's working directory.
|
||||||
func (t *TestTerraformer) apply() error {
|
func (t *TestTerraformer) apply() error {
|
||||||
args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...)
|
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
|
||||||
cmd := exec.Command(t.tfPath, args...)
|
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -246,8 +212,7 @@ func (t *TestTerraformer) apply() error {
|
|||||||
|
|
||||||
// destroy runs "terraform destroy" in the project's working directory.
|
// destroy runs "terraform destroy" in the project's working directory.
|
||||||
func (t *TestTerraformer) destroy() error {
|
func (t *TestTerraformer) destroy() error {
|
||||||
args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...)
|
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
|
||||||
cmd := exec.Command(t.tfPath, args...)
|
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -263,8 +228,7 @@ func (t *TestTerraformer) destroy() error {
|
|||||||
// it's run to get the data for for importing the state via
|
// it's run to get the data for for importing the state via
|
||||||
// terraform-json. The apply method ignores the plan generated here.
|
// terraform-json. The apply method ignores the plan generated here.
|
||||||
func (t *TestTerraformer) plan() error {
|
func (t *TestTerraformer) plan() error {
|
||||||
args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...)
|
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
|
||||||
cmd := exec.Command(t.tfPath, args...)
|
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,204 +261,3 @@ func (t *TestTerraformer) showJson() ([]byte, error) {
|
|||||||
|
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireCredentialsInvalid(t *testing.T, accessKeyId, secretAccessKey string) {
|
|
||||||
t.Helper()
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
c, err := awsutil.NewCredentialsConfig(
|
|
||||||
awsutil.WithAccessKey(accessKeyId),
|
|
||||||
awsutil.WithSecretKey(secretAccessKey),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
// We need to wait for invalidation as while awsutil waits for
|
|
||||||
// credential creation, deletion of the old credentials returns
|
|
||||||
// immediately.
|
|
||||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
|
||||||
defer cancel()
|
|
||||||
waitErr:
|
|
||||||
for {
|
|
||||||
_, err = c.GetCallerIdentity(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
// pass
|
|
||||||
|
|
||||||
case <-timeoutCtx.Done():
|
|
||||||
break waitErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NotNil(err)
|
|
||||||
var ae smithy.APIError
|
|
||||||
require.True(errors.As(err, &ae))
|
|
||||||
require.Equal("InvalidClientTokenId", ae.ErrorCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) {
|
|
||||||
t.Helper()
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
c, err := awsutil.NewCredentialsConfig(
|
|
||||||
awsutil.WithAccessKey(accessKeyId),
|
|
||||||
awsutil.WithSecretKey(secretAccessKey),
|
|
||||||
)
|
|
||||||
require.NoError(err)
|
|
||||||
_, err = c.GetCallerIdentity(context.Background())
|
|
||||||
require.NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePersistedSecrets(t *testing.T, persistedSecrets *structpb.Struct, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
require.NotNil(persistedSecrets)
|
|
||||||
persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotZero(persistedAccessKeyId)
|
|
||||||
if rotate {
|
|
||||||
require.NotEqual(accessKeyId, persistedAccessKeyId)
|
|
||||||
} else {
|
|
||||||
require.Equal(accessKeyId, persistedAccessKeyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotZero(persistedSecretAccessKey)
|
|
||||||
if rotate {
|
|
||||||
require.NotEqual(secretAccessKey, persistedSecretAccessKey)
|
|
||||||
} else {
|
|
||||||
require.Equal(secretAccessKey, persistedSecretAccessKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
|
|
||||||
require.NoError(err)
|
|
||||||
if rotate {
|
|
||||||
require.NotZero(persistedCredsLastRotatedTime)
|
|
||||||
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
|
|
||||||
} else {
|
|
||||||
require.Zero(persistedCredsLastRotatedTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
return persistedAccessKeyId, persistedSecretAccessKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateUpdateSecrets(
|
|
||||||
t *testing.T, persistedSecrets *structpb.Struct, currentCredsLastRotatedTime time.Time,
|
|
||||||
currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
|
|
||||||
rotated, rotate bool) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
// Complex checks based on the scenarios.
|
|
||||||
persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotZero(persistedAccessKeyId)
|
|
||||||
persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotZero(persistedSecretAccessKey)
|
|
||||||
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
|
|
||||||
require.NoError(err)
|
|
||||||
|
|
||||||
// Our test scenarios are complex due the multi-dimensional nature
|
|
||||||
// of criteria, so we lay them out in a switch below.
|
|
||||||
switch {
|
|
||||||
case newAccessKeyId != "" && rotated && rotate:
|
|
||||||
// The new access key ID was provided, we had previously rotated
|
|
||||||
// the credentials before, and the new credential set is to be
|
|
||||||
// rotated as well. In this case, the old credentials should have
|
|
||||||
// been deleted, and the new credentials should have been rotated,
|
|
||||||
// hence, should not match the new credentials initially
|
|
||||||
// provided. Rotation time should be non-zero and updated.
|
|
||||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
|
||||||
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
|
||||||
require.NotZero(persistedCredsLastRotatedTime)
|
|
||||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
|
||||||
|
|
||||||
case newAccessKeyId != "" && rotated && !rotate:
|
|
||||||
// The new access key ID was provided, we had previously rotated
|
|
||||||
// the credentials before, and the new credential is *not*
|
|
||||||
// rotated. In this case, the old credentials should have
|
|
||||||
// been deleted, But the new credentials should have not been
|
|
||||||
// rotated, and hence should be the same. Rotation time should be
|
|
||||||
// zero.
|
|
||||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
|
||||||
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
|
||||||
require.Zero(persistedCredsLastRotatedTime)
|
|
||||||
|
|
||||||
case newAccessKeyId != "" && !rotated && rotate:
|
|
||||||
// The new access key ID was provided, we *have not* previously
|
|
||||||
// rotated the credentials, and the new credential set is to be
|
|
||||||
// rotated. In this case, the old credentials should have been
|
|
||||||
// left alone, and the new credentials should have been rotated,
|
|
||||||
// hence, should not match the new credentials initially
|
|
||||||
// provided. Rotation time should be non-zero, but updated.
|
|
||||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.NotEqual(persistedAccessKeyId, newAccessKeyId)
|
|
||||||
require.NotEqual(persistedSecretAccessKey, newSecretAccessKey)
|
|
||||||
require.NotZero(persistedCredsLastRotatedTime)
|
|
||||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
|
||||||
|
|
||||||
case newAccessKeyId != "" && !rotated && !rotate:
|
|
||||||
// The new access key ID was provided, but we have not rotated
|
|
||||||
// the credentials previously and we still don't plan on rotating
|
|
||||||
// them. In this case, the old credentials should still be valid,
|
|
||||||
// and the persisted ones should match the new ones provided.
|
|
||||||
// Rotation time should be zero.
|
|
||||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.Equal(persistedAccessKeyId, newAccessKeyId)
|
|
||||||
require.Equal(persistedSecretAccessKey, newSecretAccessKey)
|
|
||||||
require.Zero(persistedCredsLastRotatedTime)
|
|
||||||
|
|
||||||
case newAccessKeyId == "" && rotated && rotate:
|
|
||||||
// No new credentials have been provided, but we have previously
|
|
||||||
// rotated and are still rotating credentials. This is a no-op.
|
|
||||||
// Existing credentials should still be valid and match the ones
|
|
||||||
// persisted to state. Rotation time should be identical since
|
|
||||||
// no new rotation occurred.
|
|
||||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
|
||||||
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
|
||||||
require.NotZero(persistedCredsLastRotatedTime)
|
|
||||||
require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime))
|
|
||||||
|
|
||||||
case newAccessKeyId == "" && rotated && !rotate:
|
|
||||||
// No new credentials have been provided, and we have previously
|
|
||||||
// rotated the credentials. This is actually an error, but we
|
|
||||||
// don't test it here; it's covered in unit testing (see
|
|
||||||
// plugin_test.go).
|
|
||||||
require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper")
|
|
||||||
|
|
||||||
case newAccessKeyId == "" && !rotated && rotate:
|
|
||||||
// No new credentials have been provided, and while we did not
|
|
||||||
// rotate before, we want to switch to rotation. In this case,
|
|
||||||
// the existing persisted credentials should have been rotated,
|
|
||||||
// with a new non-zero timestamp.
|
|
||||||
requireCredentialsInvalid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.NotEqual(persistedAccessKeyId, currentAccessKeyId)
|
|
||||||
require.NotEqual(persistedSecretAccessKey, currentSecretAccessKey)
|
|
||||||
require.NotZero(persistedCredsLastRotatedTime)
|
|
||||||
|
|
||||||
case newAccessKeyId == "" && !rotated && !rotate:
|
|
||||||
// No new credentials have been provided and we have not, nor do
|
|
||||||
// not, plan on rotating the credentials. This is a no-op.
|
|
||||||
// Existing credentials should still be valid and match the ones
|
|
||||||
// persisted to state. Rotation time should remain at zero.
|
|
||||||
requireCredentialsValid(t, currentAccessKeyId, currentSecretAccessKey)
|
|
||||||
require.Equal(persistedAccessKeyId, currentAccessKeyId)
|
|
||||||
require.Equal(persistedSecretAccessKey, currentSecretAccessKey)
|
|
||||||
require.Zero(persistedCredsLastRotatedTime)
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Scenario was reached that was not covered by this function.
|
|
||||||
require.FailNow("unknown test scenario")
|
|
||||||
}
|
|
||||||
|
|
||||||
return persistedAccessKeyId, persistedSecretAccessKey
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
// Copyright IBM Corp. 2021, 2026
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in new issue
Block a user