Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f9e80b81 | ||
|
|
9e1fb04057 | ||
|
|
4656760fb8 |
No files matched your search
@@ -1,23 +0,0 @@
|
|||||||
name: test
|
|
||||||
|
|
||||||
on:
|
|
||||||
- workflow_dispatch
|
|
||||||
- push
|
|
||||||
|
|
||||||
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,7 +4,3 @@ plan.tfplan
|
|||||||
*.log
|
*.log
|
||||||
*.out
|
*.out
|
||||||
*.html
|
*.html
|
||||||
|
|
||||||
# IntelliJ IDEA project files
|
|
||||||
.idea
|
|
||||||
*.iprg
|
|
||||||
@@ -1,5 +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 (c) 2021 HashiCorp, Inc.
|
|
||||||
|
|
||||||
Mozilla Public License Version 2.0
|
Mozilla Public License Version 2.0
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +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).
|
|
||||||
|
|
||||||
### 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,66 +1,44 @@
|
|||||||
module github.com/hashicorp/boundary-plugin-aws
|
module github.com/hashicorp/boundary-plugin-host-aws
|
||||||
|
|
||||||
go 1.21
|
go 1.17
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2
|
github.com/aws/aws-sdk-go v1.42.2
|
||||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.186.0
|
github.com/hashicorp/boundary/sdk v0.0.12-0.20211112235128-46a1edcb3b7b
|
||||||
github.com/aws/aws-sdk-go-v2/service/iam v1.37.2
|
github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.1
|
github.com/hashicorp/terraform-json v0.13.0
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2
|
github.com/mitchellh/mapstructure v1.4.2
|
||||||
github.com/aws/smithy-go v1.22.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/google/go-cmp v0.6.0
|
google.golang.org/protobuf v1.27.1
|
||||||
github.com/google/uuid v1.4.0
|
|
||||||
github.com/hashicorp/boundary/sdk v0.0.43-0.20240717182311-a20aae98794a
|
|
||||||
github.com/hashicorp/go-multierror v1.1.1
|
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0
|
|
||||||
github.com/hashicorp/terraform-json v0.14.0
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
|
||||||
github.com/stretchr/testify v1.8.4
|
|
||||||
google.golang.org/grpc v1.61.0
|
|
||||||
google.golang.org/protobuf v1.35.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fatih/color v1.15.0 // indirect
|
github.com/fatih/color v1.13.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // 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.6-0.20231025104552-802587e608f0 // 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.5.0 // indirect
|
github.com/hashicorp/go-hclog v0.16.2 // indirect
|
||||||
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14 // indirect
|
github.com/hashicorp/go-kms-wrapping v0.6.6 // indirect
|
||||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/hashicorp/go-version v1.5.0 // indirect
|
github.com/hashicorp/go-uuid v1.0.2 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.3.0 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.11 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.14 // 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/zclconf/go-cty v1.10.0 // indirect
|
github.com/zclconf/go-cty v1.9.1 // indirect
|
||||||
golang.org/x/crypto v0.28.0 // indirect
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
|
||||||
golang.org/x/net v0.30.0 // indirect
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.0.0-20211004093028-2c5d950f24ef // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
golang.org/x/text v0.3.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac // indirect
|
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe // indirect
|
google.golang.org/grpc v1.40.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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 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,33 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package credential
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ConstAccessKey is the key for the region in the aws credentials.
|
|
||||||
ConstRegion = "region"
|
|
||||||
|
|
||||||
// ConstAccessKey is the key for the access key id in the aws credentials.
|
|
||||||
ConstAccessKeyId = "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"
|
|
||||||
)
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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.
|
|
||||||
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
|
|
||||||
if s.CredentialsConfig == nil {
|
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
// Default `WithSharedCredentials` is `true` - This disables the ability to
|
|
||||||
// read credentials from environment variables because we set the default
|
|
||||||
// profile options, so we need to set it to false (see
|
|
||||||
// awsutil@v2.(*CredentialsConfig).generateAwsConfigOptions) for extra
|
|
||||||
// details.
|
|
||||||
opts := append(s.testOpts, awsutil.WithSharedCredentials(false))
|
|
||||||
return s.CredentialsConfig.GenerateCredentialChain(ctx, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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,219 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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/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"
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse some specific aws error codes that we have special cred states for
|
|
||||||
var apiErr smithy.APIError
|
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
switch apiErr.ErrorCode() {
|
|
||||||
case awsErrorAccessDenied:
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// default to evaluating the http status code
|
|
||||||
if httpErr, ok := err.(*awshttp.ResponseError); ok {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// BadRequestStatus returns a status error with an invalid
|
|
||||||
// argument code
|
|
||||||
func BadRequestStatus(format string, args ...any) error {
|
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf(format, args...)).Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnknownStatus returns a status error with an internal
|
|
||||||
// error code
|
|
||||||
func UnknownStatus(format string, args ...any) error {
|
|
||||||
return status.New(codes.Internal, fmt.Sprintf(format, args...)).Err()
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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: "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-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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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_BadRequestStatus(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
err := BadRequestStatus("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_UnknownStatus(t *testing.T) {
|
|
||||||
require, assert := require.New(t), assert.New(t)
|
|
||||||
err := UnknownStatus("test: %s", "hello world")
|
|
||||||
assert.ErrorContains(err, "test: hello world")
|
|
||||||
assert.Equal(status.Code(err), codes.Internal)
|
|
||||||
_, ok := status.FromError(err)
|
|
||||||
require.True(ok)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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/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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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.StoragePlugin{},
|
)
|
||||||
|
|
||||||
|
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,137 +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 `AssumeRole`
|
|
||||||
provider.
|
|
||||||
- `role_session_name` (string): The session name configured for the `AssumeRole`
|
|
||||||
provider.
|
|
||||||
- `role_tags` (object): The key-value pair tags configured for the `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,111 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// DualStack is used for configuring how the aws client will resolve requests.
|
|
||||||
DualStack 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 catalog attributes", badFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CatalogAttributes{
|
|
||||||
CredentialAttributes: credAttributes,
|
|
||||||
DualStack: dualStack,
|
|
||||||
}, 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
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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: "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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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,16 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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"
|
|
||||||
)
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
package host
|
|
||||||
|
|
||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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/service/ec2"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.BadRequestStatus("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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with AWS as expected.
|
|
||||||
opts := []ec2Option{}
|
|
||||||
if catalogAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedProto, err := catalogState.toProto()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
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.BadRequestStatus("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.BadRequestStatus("error setting up new credential persisted state: %s", err)
|
|
||||||
}
|
|
||||||
newCatalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(newCredState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
opts := []ec2Option{}
|
|
||||||
if newCatalogAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, newCatalogState, opts); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with AWS as expected.
|
|
||||||
opts := []ec2Option{}
|
|
||||||
if newCatalogAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedProto, err := catalogState.toProto()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
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.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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
|
|
||||||
}
|
|
||||||
|
|
||||||
describeInstanceFilters, err := buildFilters(setAttrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error building set filters: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []ec2Option{}
|
|
||||||
if catalogAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
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.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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
|
|
||||||
}
|
|
||||||
|
|
||||||
describeInstanceFilters, err := buildFilters(setAttrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error building set filters: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []ec2Option{}
|
|
||||||
if catalogAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
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.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
|
||||||
append([]awsCatalogPersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testCatalogStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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.
|
|
||||||
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, errors.BadRequestStatus("set id is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if set.GetAttributes() == nil {
|
|
||||||
return nil, errors.BadRequestStatus("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.BadRequestStatus("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))
|
|
||||||
}
|
|
||||||
ec2Client, err := catalogState.EC2Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error getting EC2 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run all queries now and assemble output.
|
|
||||||
var maxLen int
|
|
||||||
for i, query := range queries {
|
|
||||||
output, err := ec2Client.DescribeInstances(ctx, query.Input)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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, errors.BadRequestStatus("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 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, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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 types.Instance) (*pb.ListHostsResponseHost, error) {
|
|
||||||
if aws.ToString(instance.InstanceId) == "" {
|
|
||||||
return nil, fmt.Errorf("response integrity error: missing instance id")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress)
|
|
||||||
result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName, instance.PublicDnsName)
|
|
||||||
|
|
||||||
// Now go through all of the interfaces and log 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
|
|
||||||
// credentials, the host listing functionality as well as the filters, if any
|
|
||||||
// are passed in. This function can therefore be used for both host catalog and
|
|
||||||
// host set validation.
|
|
||||||
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
|
|
||||||
if state == nil {
|
|
||||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
ec2Client, err := state.EC2Client(ctx, ec2Opts...)
|
|
||||||
if err != nil {
|
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
|
||||||
if err != nil {
|
|
||||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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,99 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *testMockEC2State) Reset() {
|
|
||||||
s.DescribeInstancesCalled = false
|
|
||||||
s.DescribeInstancesInputParams = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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 (c) HashiCorp, Inc.
|
|
||||||
// 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 = 65536 // 64 KiB
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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 (c) HashiCorp, Inc.
|
|
||||||
// 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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,862 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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/s3/types"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
||||||
"github.com/hashicorp/go-multierror"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ensure that we are implementing StoragePluginServiceServer
|
|
||||||
var _ pb.StoragePluginServiceServer = (*StoragePlugin)(nil)
|
|
||||||
|
|
||||||
// StoragePlugin implements the StoragePluginServiceServer interface for the
|
|
||||||
// AWS storage service plugin.
|
|
||||||
type StoragePlugin struct {
|
|
||||||
pb.UnimplementedStoragePluginServiceServer
|
|
||||||
|
|
||||||
// testCredStateOpts are passed in to the stored state to control test behavior
|
|
||||||
testCredStateOpts []cred.AwsCredentialPersistedStateOption
|
|
||||||
|
|
||||||
// testStorageStateOpts are passed in to the stored state to control test behavior
|
|
||||||
testStorageStateOpts []awsStoragePersistedStateOption
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnCreateStorageBucket is called when a storage bucket is created.
|
|
||||||
func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCreateStorageBucketRequest) (*pb.OnCreateStorageBucketResponse, error) {
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
if bucket.BucketName == "" {
|
|
||||||
return nil, errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to rotate AWS static credentials
|
|
||||||
if cred.GetCredentialType(credConfig) == cred.StaticAWS && !storageAttributes.DisableCredentialRotation {
|
|
||||||
if err := credState.RotateCreds(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
|
||||||
if st := dryRunValidation(ctx, storageState, storageAttributes, bucket); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedProto, err := storageState.toProto()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnCreateStorageBucketResponse{
|
|
||||||
Persisted: persistedProto,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnUpdateStorageBucket is called when a storage bucket is updated.
|
|
||||||
func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpdateStorageBucketRequest) (*pb.OnUpdateStorageBucketResponse, error) {
|
|
||||||
// For storage buckets we only need to really validate what is
|
|
||||||
// currently in the new copy of the storage bucket data, so skip
|
|
||||||
// fetching the new copy to keep things a little less messy.
|
|
||||||
newBucket := req.GetNewBucket()
|
|
||||||
if newBucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("new bucket is required")
|
|
||||||
}
|
|
||||||
if newBucket.BucketName == "" {
|
|
||||||
return nil, errors.BadRequestStatus("new bucketName is required")
|
|
||||||
}
|
|
||||||
currentBucket := req.GetCurrentBucket()
|
|
||||||
if currentBucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("current bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
oldAttrs := currentBucket.GetAttributes()
|
|
||||||
if oldAttrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("current bucket attributes is required")
|
|
||||||
}
|
|
||||||
oldStorageAttributes, err := getStorageAttributes(oldAttrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
newAttrs := newBucket.GetAttributes()
|
|
||||||
if newAttrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("new bucket attributes is required")
|
|
||||||
}
|
|
||||||
newStorageAttributes, err := getStorageAttributes(newAttrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credState, err := cred.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetData(),
|
|
||||||
oldStorageAttributes.CredentialAttributes,
|
|
||||||
oldStorageAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("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 := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes, newStorageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// We will be updating credentials when one of two changes occur:
|
|
||||||
// 1. the new bucket has secrets for static credentials, in which case we need to delete the old static credentials
|
|
||||||
// 2. the new bucket has a different roleARN value for dynamic credentials
|
|
||||||
if newBucket.GetSecrets() != nil || newStorageAttributes.RoleArn != oldStorageAttributes.RoleArn {
|
|
||||||
// Ensure the incoming credentials are valid for interaction with the S3
|
|
||||||
// Bucket before replacing them.
|
|
||||||
newCredState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(updatedCredentials),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up new credential persisted state: %s", err)
|
|
||||||
}
|
|
||||||
newStorageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(newCredState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
if st := dryRunValidation(ctx, newStorageState, newStorageAttributes, newBucket); st != nil {
|
|
||||||
return nil, st.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 cred.GetCredentialType(credState.CredentialsConfig) == cred.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 newStorageAttributes.DisableCredentialRotation && newBucket.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 !newStorageAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
|
|
||||||
if err := credState.RotateCreds(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
|
||||||
if st := dryRunValidation(ctx, storageState, newStorageAttributes, newBucket); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
persistedProto, err := storageState.toProto()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.OnUpdateStorageBucketResponse{
|
|
||||||
Persisted: persistedProto,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnDeleteStorageBucket is called when a storage bucket is deleted.
|
|
||||||
func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDeleteStorageBucketRequest) (*pb.OnDeleteStorageBucketResponse, error) {
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(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 := cred.AwsCredentialPersistedStateFromProto(
|
|
||||||
req.GetPersisted().GetData(),
|
|
||||||
storageAttributes.CredentialAttributes,
|
|
||||||
storageAttributes.DualStack,
|
|
||||||
p.testCredStateOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to delete static credentials
|
|
||||||
if cred.GetCredentialType(credState.CredentialsConfig) == cred.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.OnDeleteStorageBucketResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeadObject is called to get the metadata of an object.
|
|
||||||
func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectRequest) (*pb.HeadObjectResponse, error) {
|
|
||||||
if req.GetKey() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bucket.BucketName == "" {
|
|
||||||
return nil, errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []s3Option{}
|
|
||||||
if storageAttributes.EndpointUrl != "" {
|
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
|
||||||
}
|
|
||||||
if storageAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
|
||||||
}
|
|
||||||
s3Client, err := storageState.S3Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
|
||||||
resp, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, parseS3Error("head object", err, req).Err()
|
|
||||||
}
|
|
||||||
return &pb.HeadObjectResponse{
|
|
||||||
ContentLength: aws.ToInt64(resp.ContentLength),
|
|
||||||
LastModified: timestamppb.New(*resp.LastModified),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidatePermissions is called to validate the secrets associated with the storage bucket.
|
|
||||||
func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.ValidatePermissionsRequest) (*pb.ValidatePermissionsResponse, error) {
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bucket.BucketName == "" {
|
|
||||||
return nil, errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
|
||||||
if st := dryRunValidation(ctx, storageState, storageAttributes, bucket); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.ValidatePermissionsResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetObject is called when retrieving objects from an s3 bucket.
|
|
||||||
// GetObject is a blocking call until the stream has been recieved in full.
|
|
||||||
func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePluginService_GetObjectServer) error {
|
|
||||||
if req.GetKey() == "" {
|
|
||||||
return errors.BadRequestStatus("key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bucket.GetBucketName() == "" {
|
|
||||||
return errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []s3Option{}
|
|
||||||
if storageAttributes.EndpointUrl != "" {
|
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
|
||||||
}
|
|
||||||
if storageAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
|
||||||
}
|
|
||||||
s3Client, err := storageState.S3Client(stream.Context(), opts...)
|
|
||||||
if err != nil {
|
|
||||||
return errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
|
||||||
resp, err := s3Client.GetObject(stream.Context(), &s3.GetObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return parseS3Error("get object", err, req).Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
reader := bufio.NewReader(resp.Body)
|
|
||||||
chunkSize := req.GetChunkSize()
|
|
||||||
if chunkSize == 0 {
|
|
||||||
chunkSize = defaultStreamChunkSize
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
buffer := make([]byte, chunkSize)
|
|
||||||
n, err := reader.Read(buffer)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return errors.UnknownStatus("error reading chunk from s3: %s", err)
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
if err := stream.Send(&pb.GetObjectResponse{
|
|
||||||
FileChunk: buffer[:n],
|
|
||||||
}); err != nil {
|
|
||||||
return errors.UnknownStatus("error sending chunk to client: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PutObject is called when putting objects into an s3 bucket.
|
|
||||||
func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) (*pb.PutObjectResponse, error) {
|
|
||||||
if req == nil {
|
|
||||||
return nil, errors.BadRequestStatus("request is required")
|
|
||||||
}
|
|
||||||
if req.GetKey() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("key is required")
|
|
||||||
}
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
if bucket.GetBucketName() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
if req.GetPath() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("path is required")
|
|
||||||
}
|
|
||||||
file, err := os.Open(req.GetPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("failed to open file")
|
|
||||||
}
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("failed to read file info")
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil, errors.BadRequestStatus("path is not a file")
|
|
||||||
}
|
|
||||||
if info.Size() == 0 {
|
|
||||||
return nil, errors.BadRequestStatus("file is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []s3Option{}
|
|
||||||
if storageAttributes.EndpointUrl != "" {
|
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
|
||||||
}
|
|
||||||
if storageAttributes.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
|
||||||
}
|
|
||||||
s3Client, err := storageState.S3Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := sha256.New()
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return nil, errors.UnknownStatus("failed to calcualte hash")
|
|
||||||
}
|
|
||||||
checksum := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
|
||||||
|
|
||||||
_, err = file.Seek(0, io.SeekStart)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.UnknownStatus("failed to rewind file pointer")
|
|
||||||
}
|
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
|
||||||
resp, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
Body: file,
|
|
||||||
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
|
||||||
ChecksumSHA256: aws.String(checksum),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, parseS3Error("put object", err, req).Err()
|
|
||||||
}
|
|
||||||
if resp.ChecksumSHA256 == nil {
|
|
||||||
return nil, errors.UnknownStatus("missing checksum response from aws")
|
|
||||||
}
|
|
||||||
if checksum != *resp.ChecksumSHA256 {
|
|
||||||
return nil, checksumMistmatchStatus()
|
|
||||||
}
|
|
||||||
decodedChecksum, err := base64.StdEncoding.DecodeString(*resp.ChecksumSHA256)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.UnknownStatus("failed to decode checksum value from aws")
|
|
||||||
}
|
|
||||||
return &pb.PutObjectResponse{
|
|
||||||
ChecksumSha_256: decodedChecksum,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteObjects is used to delete one or many objects from an s3 bucket.
|
|
||||||
func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjectsRequest) (*pb.DeleteObjectsResponse, error) {
|
|
||||||
if req.GetKeyPrefix() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("key prefix is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket := req.GetBucket()
|
|
||||||
if bucket == nil {
|
|
||||||
return nil, errors.BadRequestStatus("bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bucket.GetBucketName() == "" {
|
|
||||||
return nil, errors.BadRequestStatus("bucketName is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := bucket.GetAttributes()
|
|
||||||
if attrs == nil {
|
|
||||||
return nil, errors.BadRequestStatus("attributes is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
storageAttributes, err := getStorageAttributes(attrs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the creds in our persisted state.
|
|
||||||
credState, err := cred.NewAwsCredentialPersistedState(
|
|
||||||
append([]cred.AwsCredentialPersistedStateOption{
|
|
||||||
cred.WithCredentialsConfig(credConfig),
|
|
||||||
}, p.testCredStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
|
||||||
append([]awsStoragePersistedStateOption{
|
|
||||||
withCredentials(credState),
|
|
||||||
}, p.testStorageStateOpts...)...,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []s3Option{}
|
|
||||||
if storageAttributes.EndpointUrl != "" {
|
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
|
||||||
}
|
|
||||||
client, err := storageState.S3Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := path.Join(bucket.GetBucketPrefix(), req.GetKeyPrefix())
|
|
||||||
if strings.HasSuffix(req.GetKeyPrefix(), "/") {
|
|
||||||
// path.Join ends by "cleaning" the path, including removing a trailing slash, if
|
|
||||||
// it exists. given that a slash is used to denote a folder, it is required here.
|
|
||||||
prefix += "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !req.Recursive {
|
|
||||||
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(prefix),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, parseS3Error("delete object", err, req).Err()
|
|
||||||
}
|
|
||||||
return &pb.DeleteObjectsResponse{
|
|
||||||
ObjectsDeleted: uint32(1),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxkeys = 1000
|
|
||||||
|
|
||||||
objects := []types.ObjectIdentifier{}
|
|
||||||
var conToken *string
|
|
||||||
truncated := true
|
|
||||||
for truncated {
|
|
||||||
res, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Prefix: aws.String(prefix),
|
|
||||||
MaxKeys: aws.Int32(maxkeys),
|
|
||||||
ContinuationToken: conToken,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, parseS3Error("list objects", err, req).Err()
|
|
||||||
}
|
|
||||||
truncated = aws.ToBool(res.IsTruncated)
|
|
||||||
conToken = res.NextContinuationToken
|
|
||||||
for _, o := range res.Contents {
|
|
||||||
objects = append(objects, types.ObjectIdentifier{
|
|
||||||
Key: o.Key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted := 0
|
|
||||||
|
|
||||||
for i := 0; i < len(objects); i += maxkeys {
|
|
||||||
toDelete := objects[i:min(len(objects), i+maxkeys)] // min is required to avoid an out of bounds panic
|
|
||||||
res, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Delete: &types.Delete{
|
|
||||||
Objects: toDelete,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, parseS3Error("delete objects", err, req).Err()
|
|
||||||
}
|
|
||||||
deleted += len(res.Deleted)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pb.DeleteObjectsResponse{
|
|
||||||
ObjectsDeleted: uint32(deleted),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dryRunValidation validates that the IAM role policy attached to the given secrets of the
|
|
||||||
// storage bucket has the minimum required permissions needed for this plugin to function
|
|
||||||
// as expected. This function will create an object in the s3 bucket to validate it has
|
|
||||||
// PutObject permissions. This function will read the same file it had uploaded to validate
|
|
||||||
// it has GetObject permissions. This function will read the metadata of the same file it
|
|
||||||
// had uploaded to validate it has GetObjectAttributes permissions.
|
|
||||||
func dryRunValidation(ctx context.Context, state *awsStoragePersistedState, attrs *StorageAttributes, bucket *storagebuckets.StorageBucket) *status.Status {
|
|
||||||
if state == nil {
|
|
||||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
|
||||||
}
|
|
||||||
if attrs == nil {
|
|
||||||
return status.New(codes.InvalidArgument, "attributes is required")
|
|
||||||
}
|
|
||||||
if bucket == nil {
|
|
||||||
return status.New(codes.InvalidArgument, "bucket is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []s3Option{}
|
|
||||||
if attrs.EndpointUrl != "" {
|
|
||||||
opts = append(opts, WithEndpoint(attrs.EndpointUrl))
|
|
||||||
}
|
|
||||||
if attrs.DualStack {
|
|
||||||
opts = append(opts, WithDualStack(attrs.DualStack))
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := state.S3Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting S3 client: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs *multierror.Error
|
|
||||||
permissions := &pb.Permissions{}
|
|
||||||
// we track the codes returned by ParseAWSError and pick the most severe one by using the max func
|
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), uuid.New().String())
|
|
||||||
if _, err := client.PutObject(ctx, &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
Body: bytes.NewReader([]byte("hashicorp boundary aws plugin access test")),
|
|
||||||
}); err != nil {
|
|
||||||
var st *status.Status
|
|
||||||
st, permissions.Write = errors.ParseAWSError("put object", err)
|
|
||||||
errs = multierror.Append(errs, st.Err())
|
|
||||||
} else {
|
|
||||||
permissions.Write = &pb.Permission{State: pb.StateType_STATE_TYPE_OK, CheckedAt: timestamppb.Now()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.GetObject(ctx, &s3.GetObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
}); err != nil {
|
|
||||||
var st *status.Status
|
|
||||||
st, permissions.Read = errors.ParseAWSError("get object", err)
|
|
||||||
errs = multierror.Append(errs, st.Err())
|
|
||||||
} else {
|
|
||||||
permissions.Read = &pb.Permission{State: pb.StateType_STATE_TYPE_OK, CheckedAt: timestamppb.Now()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.HeadObject(ctx, &s3.HeadObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
}); err != nil {
|
|
||||||
var st *status.Status
|
|
||||||
st, permissions.Read = errors.ParseAWSError("head object", err)
|
|
||||||
errs = multierror.Append(errs, st.Err())
|
|
||||||
}
|
|
||||||
|
|
||||||
if res, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Prefix: aws.String(objectKey),
|
|
||||||
}); err != nil {
|
|
||||||
var st *status.Status
|
|
||||||
st, permissions.Read = errors.ParseAWSError("list object", err)
|
|
||||||
errs = multierror.Append(errs, st.Err())
|
|
||||||
} else if res == nil || len(res.Contents) != 1 || *res.Contents[0].Key != objectKey {
|
|
||||||
permissions.Read = &pb.Permission{State: pb.StateType_STATE_TYPE_UNKNOWN, CheckedAt: timestamppb.Now()}
|
|
||||||
errs = multierror.Append(errs, status.New(codes.Aborted, fmt.Sprintf("list response did not contain the expected key: %+v", res)).Err())
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
|
||||||
Key: aws.String(objectKey),
|
|
||||||
}); err != nil {
|
|
||||||
var st *status.Status
|
|
||||||
st, permissions.Delete = errors.ParseAWSError("delete object", err)
|
|
||||||
errs = multierror.Append(errs, st.Err())
|
|
||||||
} else {
|
|
||||||
permissions.Delete = &pb.Permission{State: pb.StateType_STATE_TYPE_OK, CheckedAt: timestamppb.Now()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs != nil {
|
|
||||||
st := status.New(codes.FailedPrecondition, fmt.Sprintf("failed to validate provided aws credentials: %v", errs.Unwrap()))
|
|
||||||
state := &pb.StorageBucketCredentialState{State: permissions}
|
|
||||||
if st, err = st.WithDetails(state); err != nil {
|
|
||||||
st = status.New(codes.Internal, err.Error())
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,176 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return s3.NewFromConfig(*awsCfg, s3Opts...), 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDefaultS3Options() s3Options {
|
|
||||||
return s3Options{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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,413 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"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
|
|
||||||
|
|
||||||
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.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 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 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) 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
variable "iam_user_prefix" {
|
variable "iam_user_prefix" {
|
||||||
default = "boundary-plugin-host-aws-test-iam-user"
|
default = "boundary-plugin-host-aws-test-iam-user"
|
||||||
}
|
}
|
||||||
@@ -19,6 +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
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_access_key" "user_initial_key" {
|
resource "aws_iam_access_key" "user_initial_key" {
|
||||||
@@ -43,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" {
|
||||||
@@ -71,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" {
|
||||||
@@ -95,4 +98,4 @@ output "iam_access_key_ids" {
|
|||||||
output "iam_secret_access_keys" {
|
output "iam_secret_access_keys" {
|
||||||
value = aws_iam_access_key.user_initial_key.*.secret
|
value = aws_iam_access_key.user_initial_key.*.secret
|
||||||
sensitive = true
|
sensitive = true
|
||||||
}
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
variable "instance_tags" {
|
variable "instance_tags" {
|
||||||
default = [
|
default = [
|
||||||
{
|
{
|
||||||
@@ -43,12 +40,16 @@ data "aws_ami" "ubuntu" {
|
|||||||
|
|
||||||
resource "aws_vpc" "vpc" {
|
resource "aws_vpc" "vpc" {
|
||||||
cidr_block = "10.0.0.0/16"
|
cidr_block = "10.0.0.0/16"
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_subnet" "subnet" {
|
resource "aws_subnet" "subnet" {
|
||||||
vpc_id = aws_vpc.vpc.id
|
vpc_id = aws_vpc.vpc.id
|
||||||
cidr_block = aws_vpc.vpc.cidr_block
|
cidr_block = aws_vpc.vpc.cidr_block
|
||||||
availability_zone = data.aws_availability_zones.azs.names[0]
|
availability_zone = data.aws_availability_zones.azs.names[0]
|
||||||
|
|
||||||
|
tags = var.project_path_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_instance" "instances" {
|
resource "aws_instance" "instances" {
|
||||||
@@ -57,7 +58,7 @@ resource "aws_instance" "instances" {
|
|||||||
instance_type = "t3.nano"
|
instance_type = "t3.nano"
|
||||||
subnet_id = aws_subnet.subnet.id
|
subnet_id = aws_subnet.subnet.id
|
||||||
|
|
||||||
tags = var.instance_tags[count.index]
|
tags = merge(var.instance_tags[count.index], var.project_path_tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
output "instance_ids" {
|
output "instance_ids" {
|
||||||
@@ -74,4 +75,4 @@ output "instance_tags" {
|
|||||||
value = {
|
value = {
|
||||||
for _, r in aws_instance.instances : r.id => r.tags
|
for _, r in aws_instance.instances : r.id => r.tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
const expectedEc2InstanceCount = 5
|
|
||||||
|
|
||||||
var expectedTags = []string{"foo", "bar", "baz"}
|
|
||||||
|
|
||||||
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)
|
|
||||||
tf, err := NewTestTerraformer("testdata/host")
|
|
||||||
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(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 := make(map[string][]string)
|
|
||||||
for instanceId, instanceTags := range ec2InstanceTags {
|
|
||||||
for tagKey := range instanceTags.(map[string]any) {
|
|
||||||
for _, expectedTag := range expectedTags {
|
|
||||||
if tagKey == expectedTag {
|
|
||||||
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 *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)
|
|
||||||
}
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// ********************
|
|
||||||
// * 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()
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
aws = {
|
|
||||||
source = "hashicorp/aws"
|
|
||||||
version = "~> 3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-3
@@ -1,6 +1,3 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# 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
-325
@@ -1,325 +0,0 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# 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"
|
|
||||||
],
|
|
||||||
"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}-boundary-iam-user"
|
|
||||||
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"
|
|
||||||
tags = {
|
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}-boundary-iam-user"
|
|
||||||
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"
|
|
||||||
tags = {
|
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}-boundary-iam-user"
|
|
||||||
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"
|
|
||||||
tags = {
|
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (c) HashiCorp, Inc.
|
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
terraform {
|
|
||||||
required_providers {
|
|
||||||
aws = {
|
|
||||||
source = "hashicorp/aws"
|
|
||||||
version = "~> 3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-70
@@ -1,70 +0,0 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
Vendored
-17
@@ -1,17 +0,0 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
HELLO_WORLD!
|
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-220
@@ -1,29 +1,15 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
@@ -119,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")
|
||||||
}
|
}
|
||||||
@@ -166,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)
|
||||||
}
|
}
|
||||||
@@ -182,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)
|
||||||
}
|
}
|
||||||
@@ -275,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 (c) HashiCorp, Inc.
|
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in new issue
Block a user