diff --git a/README.md b/README.md index 972a679..222ee74 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,32 @@ Boundary](https://www.boundaryproject.io/). ## Credential Rotation -Although credentials are stored encrypted within Boundary, by default this +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. +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. diff --git a/go.mod b/go.mod index 7e72051..1091aeb 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.3.0 github.com/hashicorp/boundary/sdk v0.0.33 - github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6 + github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2 github.com/hashicorp/terraform-json v0.14.0 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.8.2 diff --git a/go.sum b/go.sum index b1604f3..08e5d0e 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,7 @@ github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= -github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.44.80 h1:jEXGecSgPdvM5KnyDsSgFhZSm7WwaTp4h544Im4SfhI= github.com/aws/aws-sdk-go v1.44.80/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= @@ -54,7 +54,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= @@ -86,7 +85,6 @@ github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20230428153751-cca4458 github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20230428153751-cca445805f24/go.mod h1:EQPLoX6CONA9BSYUovTQBHfPGE91g7wOxv03sO29FzY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.4 h1:8z+Zi0uLYXONkxwWb/D7O5t1IUucB6Ft3uvSOa950bM= @@ -95,8 +93,8 @@ github.com/hashicorp/go-kms-wrapping/v2 v2.0.9/go.mod h1:NtMaPhqSlfQ72XWDD2g80o8 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.4.9 h1:ESiK220/qE0aGxWdzKIvRH69iLiuN/PjoLTm69RoWtU= -github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6 h1:W9WN8p6moV1fjKLkeqEgkAMu5rauy9QeYDAmIaPuuiA= -github.com/hashicorp/go-secure-stdlib/awsutil v0.1.6/go.mod h1:MpCPSPGLDILGb4JMm94/mMi3YysIqsXzGCzkEZjcjXg= +github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2 h1:kWg2vyKl7BRXrNxYziqDJ55n+vtOQ1QsGORjzoeB+uM= +github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2/go.mod h1:oKHSQs4ivIfZ3fbXGQOop1XuDfdSb8RIsWTGaAanSfg= github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.9 h1:YZ01/aiARPneDUryzjiPGvr3pmjCSC+1WBZKI1P7I7k= @@ -134,14 +132,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -175,7 +169,6 @@ github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -221,12 +214,9 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go index 95f79a6..689bf90 100644 --- a/internal/credential/attributes.go +++ b/internal/credential/attributes.go @@ -20,33 +20,47 @@ type CredentialAttributes struct { // 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 input and returns a +// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a // CredentialsConfig used for configuring an AWS session. An error is returned if -// any of the following fields are missing from the protobuf struct input or have -// invalid value types: access_key_id, secret_access_key. An error is returned if // any unrecognized fields are found in the protobuf struct input. -func GetCredentialsConfig(in *structpb.Struct, region string) (*awsutil.CredentialsConfig, error) { - unknownFields := values.StructFields(in) +func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, required 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(in, ConstAccessKeyId, true) + accessKey, err := values.GetStringValue(secrets, ConstAccessKeyId, required) if err != nil { badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = err.Error() } delete(unknownFields, ConstAccessKeyId) - secretKey, err := values.GetStringValue(in, ConstSecretAccessKey, true) + secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, required) if err != nil { badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error() } delete(unknownFields, ConstSecretAccessKey) - if region == "" { - badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error() - } - // 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) @@ -58,11 +72,32 @@ func GetCredentialsConfig(in *structpb.Struct, region string) (*awsutil.Credenti return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields) } - return awsutil.NewCredentialsConfig( - awsutil.WithAccessKey(accessKey), - awsutil.WithSecretKey(secretKey), - awsutil.WithRegion(region), - ) + opts := []awsutil.Option{ + awsutil.WithEnvironmentCredentials(true), + } + if accessKey != "" && secretKey != "" { + opts = append(opts, + awsutil.WithAccessKey(accessKey), + awsutil.WithSecretKey(secretKey), + ) + } + if attrs.Region != "" { + opts = append(opts, awsutil.WithRegion(attrs.Region)) + } + if attrs.RoleArn != "" { + opts = append(opts, awsutil.WithRoleArn(attrs.RoleArn)) + } + 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)) + } + + return awsutil.NewCredentialsConfig(opts...) } // GetCredentialAttributes parses values out of a protobuf struct input and returns a @@ -88,6 +123,26 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) 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) } @@ -95,5 +150,9 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) return &CredentialAttributes{ Region: region, DisableCredentialRotation: disableCredentialRotation, + RoleArn: roleArn, + RoleExternalId: roleExternalId, + RoleSessionName: roleSessionName, + RoleTags: roleTags, }, nil } diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go index 14c94d1..7bac81c 100644 --- a/internal/credential/attributes_test.go +++ b/internal/credential/attributes_test.go @@ -61,6 +61,28 @@ func TestGetCredentialAttributes(t *testing.T) { 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 { @@ -89,44 +111,91 @@ func TestGetCredentialAttributes(t *testing.T) { func TestGetCredentialsConfig(t *testing.T) { cases := []struct { name string - in map[string]any - region string + secrets *structpb.Struct + required bool + attrs *CredentialAttributes expected *awsutil.CredentialsConfig expectedErrContains string }{ { - name: "missing access_key_id", - in: map[string]any{}, - region: "us-west-2", - expectedErrContains: "missing required value \"access_key_id\"", + name: "no secrets", + attrs: &CredentialAttributes{ + Region: "us-west-2", + }, + expected: &awsutil.CredentialsConfig{ + Region: "us-west-2", + }, }, { - name: "missing secret_access_key", - in: map[string]any{ - ConstAccessKeyId: "foobar", + name: "with static credentials", + secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }, }, - region: "us-west-2", - expectedErrContains: "missing required value \"secret_access_key\"", + attrs: &CredentialAttributes{ + Region: "us-west-2", + }, + expected: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, + }, + { + name: "missing access key", + secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + }, + }, + attrs: &CredentialAttributes{ + Region: "us-west-2", + }, + required: true, + expectedErrContains: "secrets.access_key_id: missing required value", + }, + { + name: "missing secret key", + secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstAccessKeyId: structpb.NewStringValue("foobar"), + }, + }, + required: true, + attrs: &CredentialAttributes{ + Region: "us-west-2", + }, + expectedErrContains: "secrets.secret_access_key: missing required value", }, { name: "unknown fields", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - "foo": true, - "bar": true, + secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + ConstAccessKeyId: structpb.NewStringValue("foobar"), + ConstSecretAccessKey: structpb.NewStringValue("bazqux"), + "foo": structpb.NewBoolValue(true), + "bar": structpb.NewBoolValue(true), + }, + }, + attrs: &CredentialAttributes{ + Region: "us-west-2", }, - region: "us-west-2", expectedErrContains: "secrets.bar: unrecognized field, secrets.foo: unrecognized field", }, { name: "valid ignore creds_last_rotated_time", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", + 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", }, - region: "us-west-2", expected: &awsutil.CredentialsConfig{ AccessKey: "foobar", SecretKey: "bazqux", @@ -134,16 +203,56 @@ func TestGetCredentialsConfig(t *testing.T) { }, }, { - name: "good", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", + 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", + }, }, - region: "us-west-2", expected: &awsutil.CredentialsConfig{ - AccessKey: "foobar", - SecretKey: "bazqux", - Region: "us-west-2", + 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("foobar"), + 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", + }, + }, + expected: &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", + }, }, }, } @@ -153,10 +262,7 @@ func TestGetCredentialsConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - input, err := structpb.NewStruct(tc.in) - require.NoError(err) - - actual, err := GetCredentialsConfig(input, tc.region) + actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.required) if tc.expectedErrContains != "" { require.Error(err) require.Contains(err.Error(), tc.expectedErrContains) @@ -168,6 +274,10 @@ func TestGetCredentialsConfig(t *testing.T) { 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) }) } } diff --git a/internal/credential/const.go b/internal/credential/const.go index cde18e7..23de425 100644 --- a/internal/credential/const.go +++ b/internal/credential/const.go @@ -18,4 +18,16 @@ const ( // 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" ) diff --git a/internal/credential/state.go b/internal/credential/state.go index 9a5fba5..d25ffd0 100644 --- a/internal/credential/state.go +++ b/internal/credential/state.go @@ -6,6 +6,7 @@ package credential import ( "errors" "fmt" + "strings" "time" "github.com/aws/aws-sdk-go/aws/awserr" @@ -25,19 +26,13 @@ type AwsCredentialPersistedStateOption func(s *AwsCredentialPersistedState) erro // AwsCredentialPersistedState is the persisted state for the AWS credential. type AwsCredentialPersistedState struct { - // AccessKeyId is the access key id for the AWS credential. - AccessKeyId string - // SecretAccessKey is the secret access key for the AWS credential. - SecretAccessKey string + // 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 - - // Region is not a part of persisted state but if set is passed to clients - // created with this state - region string } // WithStateTestOpts enables unit testing different edge cases @@ -50,31 +45,19 @@ func WithStateTestOpts(opts []awsutil.Option) AwsCredentialPersistedStateOption } } -// WithAccessKeyId sets the value for AccesskeyId in the storage persisted state. -func WithAccessKeyId(x string) AwsCredentialPersistedStateOption { +// WithCredentialsConfig sets the value for CredentialsConfig in the credential persisted state. +func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedStateOption { return func(s *AwsCredentialPersistedState) error { - if s.AccessKeyId != "" { - return errors.New("access key id already set") + if s.CredentialsConfig != nil { + return errors.New("credentials config already set") } - s.AccessKeyId = x + s.CredentialsConfig = x return nil } } -// WithSecretAccessKey sets the value for SecretAccessKey in the storage persisted state. -func WithSecretAccessKey(x string) AwsCredentialPersistedStateOption { - return func(s *AwsCredentialPersistedState) error { - if s.SecretAccessKey != "" { - return errors.New("secret access key already set") - } - - s.SecretAccessKey = x - return nil - } -} - -// WithCredsLastRotatedTime sets the value for CredsLastRotatedTime in the storage persisted state. +// 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() { @@ -86,18 +69,6 @@ func WithCredsLastRotatedTime(t time.Time) AwsCredentialPersistedStateOption { } } -// WithRegion sets the value for region in the storage persisted state. -func WithRegion(with string) AwsCredentialPersistedStateOption { - return func(s *AwsCredentialPersistedState) error { - if s.region != "" { - return errors.New("region already set") - } - - s.region = with - return nil - } -} - // NewAwsCredentialPersistedState returns a AwsCredentialPersistedState. // Supported options include: WithAccessKeyId, WithSecretAccessKey // WithCredsLastRotatedTime, & WithRegion. @@ -108,27 +79,19 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) ( return nil, err } } - return s, nil } -// ValidateCreds takes the access key and secret key from the persisted state +// 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. func (s *AwsCredentialPersistedState) ValidateCreds() error { - c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - awsutil.WithRegion(s.region), - }, s.testOpts...)...) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) + if s.CredentialsConfig == nil { + return errors.New("missing credentials config") } - - if _, err := c.GetCallerIdentity(s.testOpts...); err != nil { + if _, err := s.CredentialsConfig.GetCallerIdentity(s.testOpts...); err != nil { return fmt.Errorf("error validating credentials: %w", err) } - return nil } @@ -138,42 +101,27 @@ func (s *AwsCredentialPersistedState) ValidateCreds() error { // 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. func (s *AwsCredentialPersistedState) RotateCreds() error { - c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - awsutil.WithRegion(s.region), - }, s.testOpts...)...) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) + if s.CredentialsConfig == nil { + return errors.New("missing credentials config") } - - if err := c.RotateKeys(append([]awsutil.Option{ + if err := s.CredentialsConfig.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 *AwsCredentialPersistedState) ReplaceCreds(accessKeyId, secretAccessKey string) error { - if accessKeyId == "" { - return errors.New("access key id cannot be empty") +func (s *AwsCredentialPersistedState) ReplaceCreds(credentialsConfig *awsutil.CredentialsConfig) error { + if credentialsConfig == nil { + return errors.New("missing new credentials config") } - - 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.CredentialsConfig == nil { + return errors.New("missing credentials config") } if !s.CredsLastRotatedTime.IsZero() { @@ -186,8 +134,7 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(accessKeyId, secretAccessKey } // Set the new attributes and clear the rotated time. - s.AccessKeyId = accessKeyId - s.SecretAccessKey = secretAccessKey + s.CredentialsConfig = credentialsConfig s.CredsLastRotatedTime = time.Time{} return nil } @@ -196,23 +143,17 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(accessKeyId, secretAccessKey // 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 *AwsCredentialPersistedState) DeleteCreds() error { - c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - awsutil.WithRegion(s.region), - }, s.testOpts...)...) - if err != nil { - return fmt.Errorf("error loading credentials: %w", err) + if s.CredentialsConfig == nil { + return errors.New("missing credentials config") } - if err := c.DeleteAccessKey(s.AccessKeyId, s.testOpts...); err != nil { + if err := s.CredentialsConfig.DeleteAccessKey(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 awserr.Error if errors.As(err, &awsErr) { if awsErr.Code() == iam.ErrCodeNoSuchEntityException { - s.AccessKeyId = "" - s.SecretAccessKey = "" + s.CredentialsConfig = nil s.CredsLastRotatedTime = time.Time{} return nil } @@ -222,8 +163,7 @@ func (s *AwsCredentialPersistedState) DeleteCreds() error { return err } - s.AccessKeyId = "" - s.SecretAccessKey = "" + s.CredentialsConfig = nil s.CredsLastRotatedTime = time.Time{} return nil } @@ -231,50 +171,50 @@ func (s *AwsCredentialPersistedState) DeleteCreds() error { // GetSession returns a configured AWS session for the credentials in // the state. func (s *AwsCredentialPersistedState) GetSession() (*session.Session, error) { - c, err := awsutil.NewCredentialsConfig(append([]awsutil.Option{ - awsutil.WithAccessKey(s.AccessKeyId), - awsutil.WithSecretKey(s.SecretAccessKey), - awsutil.WithRegion(s.region), - }, s.testOpts...)...) - if err != nil { - return nil, err - } - - return c.GetSession() + return s.CredentialsConfig.GetSession(s.testOpts...) } -// ToMap returns a map of the credentials stored in the persisted state, -// which includes the following keys: access_key_id, secret_access_key, -// & creds_last_rotated_time +// 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 { + if HasDynamicCredentials(s.CredentialsConfig.AccessKey) { + return map[string]any{} + } return map[string]any{ - ConstAccessKeyId: s.AccessKeyId, - ConstSecretAccessKey: s.SecretAccessKey, + 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. -// An error is returned if any of the following fields are missing from the protobuf -// struct input or have invalid value types: access_key_id, secret_access_key, & -// creds_last_rotated_time. -func AwsCredentialPersistedStateFromProto(in *structpb.Struct, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) { - if in == nil { - return nil, errors.New("missing persisted secrets") +func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, 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{}, + } } - accessKeyId, err := values.GetStringValue(in, ConstAccessKeyId, true) + 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 error: %w", err) } - secretAccessKey, err := values.GetStringValue(in, ConstSecretAccessKey, true) + secretAccessKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, false) if err != nil { return nil, fmt.Errorf("persisted state integrity error: %w", err) } - credsLastRotatedTime, err := values.GetTimeValue(in, ConstCredsLastRotatedTime) + credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime) if err != nil { return nil, fmt.Errorf("persisted state integrity error: %w", err) } @@ -284,9 +224,50 @@ func AwsCredentialPersistedStateFromProto(in *structpb.Struct, opts ...AwsCreden return nil, err } - s.AccessKeyId = accessKeyId - s.SecretAccessKey = secretAccessKey + 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)) + } + credentialsConfig, err := awsutil.NewCredentialsConfig(awsOpts...) + if err != nil { + return nil, err + } + s.CredentialsConfig = credentialsConfig s.CredsLastRotatedTime = credsLastRotatedTime return s, nil } + +// HasDynamicCredentials returns true if the access key is a dynamic credential type. +// +// https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys +// Access key IDs beginning with ASIA are temporary credentials access keys that you create using AWS STS operations. +func HasDynamicCredentials(accessKey string) bool { + return strings.HasPrefix(accessKey, "ASIA") +} + +// HasStaticCredentials returns true if the access key is a static credential type. +// +// https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys +// Access key IDs beginning with AKIA are long-term access keys for an IAM user or an AWS account root user. +func HasStaticCredentials(accessKey string) bool { + return strings.HasPrefix(accessKey, "AKIA") +} diff --git a/internal/credential/state_test.go b/internal/credential/state_test.go index e4b9546..02467a9 100644 --- a/internal/credential/state_test.go +++ b/internal/credential/state_test.go @@ -36,21 +36,22 @@ func TestNewAwsCredentialPersistedState(t *testing.T) { expectedErr: testOptionErr, }, { - name: "access key id", + name: "with credentials config", opts: []AwsCredentialPersistedStateOption{ - WithAccessKeyId("foobar"), + WithCredentialsConfig( + &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, + ), }, expected: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - }, - }, - { - name: "secret access key", - opts: []AwsCredentialPersistedStateOption{ - WithSecretAccessKey("bazqux"), - }, - expected: &AwsCredentialPersistedState{ - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, }, }, { @@ -63,29 +64,12 @@ func TestNewAwsCredentialPersistedState(t *testing.T) { }, }, { - name: "region key", + name: "double set credentials config", opts: []AwsCredentialPersistedStateOption{ - WithRegion("foobar"), + WithCredentialsConfig(&awsutil.CredentialsConfig{}), + WithCredentialsConfig(&awsutil.CredentialsConfig{}), }, - expected: &AwsCredentialPersistedState{ - region: "foobar", - }, - }, - { - name: "double set access key id", - opts: []AwsCredentialPersistedStateOption{ - WithAccessKeyId("foobar"), - WithAccessKeyId("onetwo"), - }, - expectedErr: "access key id already set", - }, - { - name: "double set secret access key", - opts: []AwsCredentialPersistedStateOption{ - WithSecretAccessKey("bazqux"), - WithSecretAccessKey("threefour"), - }, - expectedErr: "secret access key already set", + expectedErr: "credentials config already set", }, { name: "double set rotation time", @@ -95,14 +79,6 @@ func TestNewAwsCredentialPersistedState(t *testing.T) { }, expectedErr: "last rotation time already set", }, - { - name: "double set region", - opts: []AwsCredentialPersistedStateOption{ - WithRegion("foobar"), - WithRegion("barfoo"), - }, - expectedErr: "region already set", - }, } for _, tc := range cases { @@ -116,7 +92,13 @@ func TestNewAwsCredentialPersistedState(t *testing.T) { } require.NoError(err) - require.Equal(tc.expected, 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) + } }) } } @@ -124,43 +106,95 @@ func TestNewAwsCredentialPersistedState(t *testing.T) { func TestAwsCredentialPersistedStateFromProto(t *testing.T) { cases := []struct { name string - in map[string]any + secrets *structpb.Struct + attrs *CredentialAttributes opts []AwsCredentialPersistedStateOption expected *AwsCredentialPersistedState expectedErr string }{ { - name: "no secrets", - in: nil, - expectedErr: "missing persisted secrets", + name: "missing credential attributes", + expectedErr: "missing credential attributes", }, { - name: "missing access key ID", - in: map[string]any{}, - expectedErr: "persisted state integrity error: missing required value \"access_key_id\"", - }, - { - name: "missing secret access key", - in: map[string]any{ - ConstAccessKeyId: "foobar", + 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", + }, }, - expectedErr: "persisted state integrity error: missing required value \"secret_access_key\"", }, { name: "bad last rotated time", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: "notatime", + 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 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: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", + 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 { @@ -171,14 +205,22 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) { }, { name: "good with non-zero timestamp", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00", + 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{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + 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 { @@ -190,33 +232,79 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) { }, { name: "good with zero timestamp", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + 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{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, CredsLastRotatedTime: time.Time{}, }, }, { - name: "good (ignoring non-test options)", - in: map[string]any{ - ConstAccessKeyId: "foobar", - ConstSecretAccessKey: "bazqux", - ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), - }, - opts: []AwsCredentialPersistedStateOption{ - WithAccessKeyId("ignored"), - WithRegion("us-west-2"), + 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{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + 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{}, - region: "us-west-2", }, }, } @@ -226,55 +314,67 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - var ( - input *structpb.Struct - err error - ) - if tc.in != nil { - input, err = structpb.NewStruct(tc.in) - require.NoError(err) - } - - actual, err := AwsCredentialPersistedStateFromProto(input, tc.opts...) + actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.opts...) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return } require.NoError(err) - require.Equal(tc.expected.AccessKeyId, actual.AccessKeyId) - require.Equal(tc.expected.SecretAccessKey, actual.SecretAccessKey) - require.Equal(tc.expected.region, actual.region) + 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) + } }) } } -func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) { +func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) { cases := []struct { name string in *AwsCredentialPersistedState expectedErr string }{ { - name: "could not load credentials", - in: &AwsCredentialPersistedState{ - testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, - }, - expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), + name: "missing credentials config", + in: &AwsCredentialPersistedState{}, + expectedErr: "missing credentials config", }, { name: "validation error", in: &AwsCredentialPersistedState{ - testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS(awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr))))}, + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, + testOpts: []awsutil.Option{ + awsutil.WithSTSAPIFunc( + awsutil.NewMockSTS( + awsutil.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr)), + ), + ), + }, }, expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr), }, { name: "good", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", - testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())}, + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, + testOpts: []awsutil.Option{awsutil.WithSTSAPIFunc(awsutil.NewMockSTS())}, }, }, } @@ -294,27 +394,27 @@ func TestAwsCatalogPersistedStateValidateCreds(t *testing.T) { } } -func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { +func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) { cases := []struct { name string in *AwsCredentialPersistedState - expectedAccessKeyId string - expectedSecretAccessKey string + exepected *awsutil.CredentialsConfig expectedNonZeroRotatedTime bool expectedErr string }{ { - name: "could not load credentials", - in: &AwsCredentialPersistedState{ - testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, - }, - expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), + name: "missing credentials config", + in: &AwsCredentialPersistedState{}, + expectedErr: "missing credentials config", }, { name: "rotation error", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( @@ -328,8 +428,11 @@ func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { { name: "good", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + Region: "us-west-2", + }, testOpts: []awsutil.Option{ awsutil.WithSTSAPIFunc( awsutil.NewMockSTS(), @@ -358,8 +461,11 @@ func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { ), }, }, - expectedAccessKeyId: "one", - expectedSecretAccessKey: "two", + exepected: &awsutil.CredentialsConfig{ + AccessKey: "one", + SecretKey: "two", + Region: "us-west-2", + }, expectedNonZeroRotatedTime: true, }, } @@ -375,52 +481,42 @@ func TestAwsCatalogPersistedStateRotateCreds(t *testing.T) { } require.NoError(err) - require.Equal(tc.expectedAccessKeyId, tc.in.AccessKeyId) - require.Equal(tc.expectedSecretAccessKey, tc.in.SecretAccessKey) + 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 TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { +func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { // NOTE: Not safe to run this test in parallel state := new(testMockIAMState) cases := []struct { - name string - in *AwsCredentialPersistedState - accessKeyId string - secretAccessKey string - expectedAccessKeyId string - expectedSecretAccessKey string - expectedDeleteCalled bool - expectedErr string + name string + in *AwsCredentialPersistedState + credentialConfig *awsutil.CredentialsConfig + expected *awsutil.CredentialsConfig + expectedDeleteCalled bool + expectedErr string }{ { - name: "missing access key id", + name: "missing new credentials config", in: &AwsCredentialPersistedState{}, - expectedErr: "access key id cannot be empty", + expectedErr: "missing new credentials config", }, { - name: "missing access key id", - in: &AwsCredentialPersistedState{}, - accessKeyId: "one", - expectedErr: "secret access key cannot be empty", - }, - { - name: "identical access key id", - in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", - }, - accessKeyId: "foobar", - secretAccessKey: "bazqux", - expectedErr: "attempting to replace access key with the same one", + name: "missing credentials config", + in: &AwsCredentialPersistedState{}, + credentialConfig: &awsutil.CredentialsConfig{}, + expectedErr: "missing credentials config", }, { name: "deletion error", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, CredsLastRotatedTime: time.Now(), testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( @@ -430,15 +526,16 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { ), }, }, - accessKeyId: "one", - secretAccessKey: "two", - expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr), + credentialConfig: &awsutil.CredentialsConfig{}, + expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr), }, { name: "good with delete of old rotated", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, CredsLastRotatedTime: time.Now(), testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( @@ -446,28 +543,38 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { ), }, }, - accessKeyId: "one", - secretAccessKey: "two", - expectedAccessKeyId: "one", - expectedSecretAccessKey: "two", - expectedDeleteCalled: true, + 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{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( newTestMockIAM(state), ), }, }, - accessKeyId: "one", - secretAccessKey: "two", - expectedAccessKeyId: "one", - expectedSecretAccessKey: "two", - expectedDeleteCalled: false, + credentialConfig: &awsutil.CredentialsConfig{ + AccessKey: "one", + SecretKey: "two", + }, + expected: &awsutil.CredentialsConfig{ + AccessKey: "one", + SecretKey: "two", + }, + expectedDeleteCalled: false, }, } @@ -476,39 +583,39 @@ func TestAwsCatalogPersistedStateReplaceCreds(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) state.Reset() - err := tc.in.ReplaceCreds(tc.accessKeyId, tc.secretAccessKey) + err := tc.in.ReplaceCreds(tc.credentialConfig) 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.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 TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { +func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) { cases := []struct { name string in *AwsCredentialPersistedState expectedErr string }{ { - name: "could not load credentials", - in: &AwsCredentialPersistedState{ - testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, - }, - expectedErr: fmt.Sprintf("error loading credentials: error reading options in NewCredentialsConfig: %s", testOptionErr), + name: "missing credentials config", + in: &AwsCredentialPersistedState{}, + expectedErr: "missing credentials config", }, { name: "deletion error", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( @@ -522,8 +629,10 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { { name: "deletion error, but OK because key was just gone", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM( @@ -538,8 +647,10 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { { name: "good", in: &AwsCredentialPersistedState{ - AccessKeyId: "foobar", - SecretAccessKey: "bazqux", + CredentialsConfig: &awsutil.CredentialsConfig{ + AccessKey: "foobar", + SecretKey: "bazqux", + }, testOpts: []awsutil.Option{ awsutil.WithIAMAPIFunc( awsutil.NewMockIAM(), @@ -560,19 +671,149 @@ func TestAwsCatalogPersistedStateDeleteCreds(t *testing.T) { } require.NoError(err) - require.Empty(tc.in.AccessKeyId) - require.Empty(tc.in.SecretAccessKey) + require.Nil(tc.in.CredentialsConfig) require.Zero(tc.in.CredsLastRotatedTime) }) } } -func TestAwsCatalogPersistedStateGetSessionErr(t *testing.T) { +func TestAwsCatalogPersistedState_GetSession(t *testing.T) { require := require.New(t) - state := &AwsCredentialPersistedState{ - testOpts: []awsutil.Option{awsutil.MockOptionErr(errors.New(testOptionErr))}, + 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 GetSession: %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.GetSession() + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + require.NoError(err) + }) + } +} + +func TestHasStaticCredentials(t *testing.T) { + require := require.New(t) + cases := []struct { + name string + accessKey string + expected bool + }{ + { + name: "dynamic credential", + accessKey: "ASIAEXAMPLE", + expected: false, + }, + { + name: "static credential", + accessKey: "AKIAEXAMPLE", + expected: true, + }, + { + name: "other credential", + accessKey: "EXAMPLE", + expected: false, + }, + { + name: "empty", + accessKey: "", + expected: false, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require.Equal(tc.expected, HasStaticCredentials(tc.accessKey)) + }) + } +} + +func TestHasDynamicCredentials(t *testing.T) { + require := require.New(t) + cases := []struct { + name string + accessKey string + expected bool + }{ + { + name: "dynamic credential", + accessKey: "ASIAEXAMPLE", + expected: true, + }, + { + name: "static credential", + accessKey: "AKIAEXAMPLE", + expected: false, + }, + { + name: "other credential", + accessKey: "EXAMPLE", + expected: false, + }, + { + name: "empty", + accessKey: "", + expected: false, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require.Equal(tc.expected, HasDynamicCredentials(tc.accessKey)) + }) } - - _, err := state.GetSession() - require.EqualError(err, fmt.Sprintf("error reading options in NewCredentialsConfig: %s", testOptionErr)) } diff --git a/internal/credential/testing.go b/internal/credential/testing.go index 35aaf06..d7b753c 100644 --- a/internal/credential/testing.go +++ b/internal/credential/testing.go @@ -8,6 +8,7 @@ import ( "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 ( @@ -48,3 +49,39 @@ func newTestMockIAM(state *testMockIAMState, opts ...awsutil.MockIAMOption) awsu 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"), + }, + }), + }, + } +} diff --git a/internal/values/proto.go b/internal/values/proto.go index fa050fa..552e02c 100644 --- a/internal/values/proto.go +++ b/internal/values/proto.go @@ -84,6 +84,33 @@ func GetTimeValue(in *structpb.Struct, k string) (time.Time, error) { 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{} { diff --git a/internal/values/proto_test.go b/internal/values/proto_test.go index b38fdd9..250af07 100644 --- a/internal/values/proto_test.go +++ b/internal/values/proto_test.go @@ -208,3 +208,95 @@ func TestGetTimeValue(t *testing.T) { }) } } + +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) + }) + } +} diff --git a/plugin/service/host/README.md b/plugin/service/host/README.md index 1d59680..16337f9 100644 --- a/plugin/service/host/README.md +++ b/plugin/service/host/README.md @@ -105,18 +105,19 @@ a configured IAM user for this provider: 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) section - above. + 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. -The following `secrets` are valid on an AWS host catalog resource: +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: diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 91c1be7..3b16d15 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -38,6 +38,14 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { 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" } diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index 2ed5d5d..e84c042 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -20,36 +21,34 @@ import ( func TestGetCatalogAttributes(t *testing.T) { cases := []struct { name string - in map[string]any + in *structpb.Struct expected *CatalogAttributes expectedErrContains string }{ { - name: "missing region", - in: map[string]any{}, + name: "missing region", + in: &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + }, expectedErrContains: "missing required value \"region\"", }, - { - name: "bad value for disable_credential_rotation", - in: map[string]any{ - credential.ConstRegion: "us-west-2", - credential.ConstDisableCredentialRotation: "sure", - }, - expectedErrContains: "unexpected type for value \"disable_credential_rotation\": want bool, got string", - }, { name: "unknown fields", - in: map[string]any{ - credential.ConstRegion: "us-west-2", - "foo": true, - "bar": true, + 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: "default", - in: map[string]any{ - credential.ConstRegion: "us-west-2", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + }, }, expected: &CatalogAttributes{ CredentialAttributes: &credential.CredentialAttributes{ @@ -59,15 +58,31 @@ func TestGetCatalogAttributes(t *testing.T) { }, }, { - name: "with disable_credential_rotation", - in: map[string]any{ - credential.ConstRegion: "us-west-2", - credential.ConstDisableCredentialRotation: true, + 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: &credential.CredentialAttributes{ + 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", + }, }, }, }, @@ -78,10 +93,7 @@ func TestGetCatalogAttributes(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - input, err := structpb.NewStruct(tc.in) - require.NoError(err) - - actual, err := getCatalogAttributes(input) + actual, err := getCatalogAttributes(tc.in) if tc.expectedErrContains != "" { require.Error(err) require.Contains(err.Error(), tc.expectedErrContains) diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 4c725c1..e838f22 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -59,7 +59,7 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo if err != nil { return nil, err } - credConfig, err := cred.GetCredentialsConfig(secrets, catalogAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true) if err != nil { return nil, err } @@ -67,9 +67,7 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo // Set up the creds in our persisted state. credState, err := credential.NewAwsCredentialPersistedState( append([]credential.AwsCredentialPersistedStateOption{ - credential.WithAccessKeyId(credConfig.AccessKey), - credential.WithSecretAccessKey(credConfig.SecretKey), - credential.WithRegion(credConfig.Region), + credential.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) if err != nil { @@ -85,15 +83,17 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo 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 := credState.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 := credState.ValidateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) + // Try to rotate static credentials + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if !catalogAttributes.DisableCredentialRotation { + if err := credState.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 := credState.ValidateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) + } } } @@ -145,7 +145,8 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo // is fine and important, but this may change in the future. credState, err := credential.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetSecrets(), - append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + catalogAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } @@ -159,38 +160,39 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo 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 !credState.CredsLastRotatedTime.IsZero() { - return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if catalogAttributes.DisableCredentialRotation && !updateSecrets { + // This is a validate check to make sure that we aren't disabling + // rotation for credentials currently being managed by rotation. + // This is not allowed. + if !credState.CredsLastRotatedTime.IsZero() { + return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") + } + } + + if updateSecrets { + catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true) + if err != nil { + return nil, err + } + + // Replace the credentials. This checks the timestamp on the last + // rotation time as well and deletes the credentials if we are + // managing them (ie: if we've rotated them before). + if err := credState.ReplaceCreds(catalogSecrets); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) + } + } + + if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { + // If we're enabling rotation now but didn't before, or have + // freshly replaced credentials, we can rotate here. + if err := credState.RotateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) + } } } - if updateSecrets { - catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.Region) - if err != nil { - return nil, err - } - - // Replace the credentials. This checks the timestamp on the last - // rotation time as well and deletes the credentials if we are - // managing them (ie: if we've rotated them before). - if err := credState.ReplaceCreds(catalogSecrets.AccessKey, catalogSecrets.SecretKey); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) - } - } - - if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { - // If we're enabling rotation now but didn't before, or have - // freshly replaced credentials, we can rotate here. - if err := credState.RotateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) - } - } - - // That's it! persistedProto, err := catalogState.toProto() if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) @@ -226,7 +228,8 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo // may be corrupt/and or legitimately missing. credState, err := credential.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetSecrets(), - append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + catalogAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } @@ -240,12 +243,15 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - 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(); err != nil { - return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err) + // try to delete static credentials + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + 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(); err != nil { + return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err) + } } } @@ -298,7 +304,8 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest credState, err := credential.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetSecrets(), - append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + catalogAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } @@ -371,7 +378,8 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest credState, err := credential.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetSecrets(), - append(p.testCredStateOpts, credential.WithRegion(catalogAttributes.Region))...) + catalogAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } @@ -451,7 +459,10 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* return nil, err } - credState, err := credential.AwsCredentialPersistedStateFromProto(req.GetPersisted().GetSecrets(), p.testCredStateOpts...) + credState, err := credential.AwsCredentialPersistedStateFromProto( + req.GetPersisted().GetSecrets(), + catalogAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 3f240cb..6d40581 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -39,14 +39,6 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { expectedErrContains: "catalog is nil", expectedErrCode: codes.InvalidArgument, }, - { - name: "nil secrets", - req: &pb.OnCreateCatalogRequest{ - Catalog: &hostcatalogs.HostCatalog{}, - }, - expectedErrContains: "secrets are required", - expectedErrCode: codes.InvalidArgument, - }, { name: "nil attributes", req: &pb.OnCreateCatalogRequest{ @@ -93,7 +85,7 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ @@ -114,7 +106,7 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ @@ -140,7 +132,7 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ @@ -170,7 +162,7 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Catalog: &hostcatalogs.HostCatalog{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }}, Attrs: &hostcatalogs.HostCatalog_Attributes{ @@ -281,7 +273,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -312,7 +304,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -338,7 +330,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -354,7 +346,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { NewCatalog: &hostcatalogs.HostCatalog{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("onetwo"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_onetwo"), credential.ConstSecretAccessKey: structpb.NewStringValue("threefour"), }, }, @@ -370,7 +362,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -404,7 +396,7 @@ func TestPluginOnUpdateCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }, }, @@ -463,7 +455,7 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -493,7 +485,7 @@ func TestPluginOnDeleteCatalogErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -598,7 +590,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -628,7 +620,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -653,7 +645,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -679,7 +671,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -714,7 +706,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -741,7 +733,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, - expectedErrContains: fmt.Sprintf("error getting EC2 client: error getting AWS session when fetching EC2 client: error reading options in NewCredentialsConfig: %s", testOptionErr), + expectedErrContains: fmt.Sprintf("error loading persisted state: error reading options in NewCredentialsConfig: %s", testOptionErr), expectedErrCode: codes.InvalidArgument, }, { @@ -759,7 +751,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -805,7 +797,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -850,7 +842,7 @@ func TestPluginOnCreateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -967,7 +959,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -997,7 +989,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1022,7 +1014,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1048,7 +1040,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1083,7 +1075,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1110,7 +1102,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, - expectedErrContains: fmt.Sprintf("error getting EC2 client: error getting AWS session when fetching EC2 client: error reading options in NewCredentialsConfig: %s", testOptionErr), + expectedErrContains: fmt.Sprintf("error loading persisted state: error reading options in NewCredentialsConfig: %s", testOptionErr), expectedErrCode: codes.InvalidArgument, }, { @@ -1128,7 +1120,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1174,7 +1166,7 @@ func TestPluginOnUpdateSetErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1291,7 +1283,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1321,7 +1313,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1346,7 +1338,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1372,7 +1364,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1402,7 +1394,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1440,7 +1432,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1470,7 +1462,7 @@ func TestPluginListHostsErr(t *testing.T) { awsutil.MockOptionErr(errors.New(testOptionErr)), }), }, - expectedErrContains: fmt.Sprintf("error getting EC2 client: error getting AWS session when fetching EC2 client: error reading options in NewCredentialsConfig: %s", testOptionErr), + expectedErrContains: fmt.Sprintf("error loading persisted state: error reading options in NewCredentialsConfig: %s", testOptionErr), expectedErrCode: codes.InvalidArgument, }, { @@ -1488,7 +1480,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -1537,7 +1529,7 @@ func TestPluginListHostsErr(t *testing.T) { Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, diff --git a/plugin/service/host/state_test.go b/plugin/service/host/state_test.go index a9c537e..79ca6c1 100644 --- a/plugin/service/host/state_test.go +++ b/plugin/service/host/state_test.go @@ -8,15 +8,18 @@ import ( "testing" "github.com/hashicorp/boundary-plugin-aws/internal/credential" + "github.com/hashicorp/go-secure-stdlib/awsutil" "github.com/stretchr/testify/require" ) func TestAwsCatalogPersistedStateEC2Client(t *testing.T) { require := require.New(t) cred, err := credential.NewAwsCredentialPersistedState( - credential.WithRegion("us-west-2"), - credential.WithAccessKeyId("foobar"), - credential.WithSecretAccessKey("barfoo"), + credential.WithCredentialsConfig(&awsutil.CredentialsConfig{ + Region: "us-west-2", + AccessKey: "foobar", + SecretKey: "barfoo", + }), ) require.NoError(err) diff --git a/plugin/service/storage/README.md b/plugin/service/storage/README.md index 5de8240..2d3bc8a 100644 --- a/plugin/service/storage/README.md +++ b/plugin/service/storage/README.md @@ -42,7 +42,7 @@ Example policy: ``` * `iam:GetUser`, `iam:CreateAccessKey`, and `iam:DeleteAccessKey`, configured to - the IAM user to allow credential rotation. Example policy: + the IAM user to allow static credential rotation. Example policy: ``` { @@ -66,18 +66,24 @@ Example policy: The following `attributes` are valid on an AWS storage bucket resource: - `disable_credential_rotation` (bool): If `true`, credential rotation will not - be performed. See the [Credential Rotation](../../../README.md#credential-rotation) section - above. + 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 valid on an AWS storage bucket resource: +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. \ No newline at end of file + with this storage bucket. + +See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information. diff --git a/plugin/service/storage/attributes.go b/plugin/service/storage/attributes.go index b357839..0ce76ac 100644 --- a/plugin/service/storage/attributes.go +++ b/plugin/service/storage/attributes.go @@ -44,6 +44,14 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) { 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" } diff --git a/plugin/service/storage/attributes_test.go b/plugin/service/storage/attributes_test.go index 4dfaac6..344b9e1 100644 --- a/plugin/service/storage/attributes_test.go +++ b/plugin/service/storage/attributes_test.go @@ -16,29 +16,35 @@ import ( func TestGetStorageAttributes(t *testing.T) { cases := []struct { name string - in map[string]any + in *structpb.Struct expected *StorageAttributes expectedErrContains string }{ { - name: "missing region", - in: map[string]any{}, + name: "missing region", + in: &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + }, expectedErrContains: "missing required value \"region\"", }, { name: "unknown fields", - in: map[string]any{ - "region": "us-west-2", - "foo": true, - "bar": true, + 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/ endpoint", - in: map[string]any{ - cred.ConstRegion: "us-west-2", - ConstAwsEndpointUrl: "0.0.0.0", + 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{ @@ -49,8 +55,10 @@ func TestGetStorageAttributes(t *testing.T) { }, { name: "good w/o endpoint", - in: map[string]any{ - cred.ConstRegion: "us-west-2", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + }, }, expected: &StorageAttributes{ CredentialAttributes: &cred.CredentialAttributes{ @@ -58,6 +66,35 @@ func TestGetStorageAttributes(t *testing.T) { }, }, }, + { + 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 { @@ -65,10 +102,7 @@ func TestGetStorageAttributes(t *testing.T) { t.Run(tc.name, func(t *testing.T) { require := require.New(t) - input, err := structpb.NewStruct(tc.in) - require.NoError(err) - - actual, err := getStorageAttributes(input) + actual, err := getStorageAttributes(tc.in) if tc.expectedErrContains != "" { require.Error(err) require.Contains(err.Error(), tc.expectedErrContains) diff --git a/plugin/service/storage/plugin.go b/plugin/service/storage/plugin.go index 497870c..f0269aa 100644 --- a/plugin/service/storage/plugin.go +++ b/plugin/service/storage/plugin.go @@ -51,11 +51,6 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre return nil, status.Error(codes.InvalidArgument, "bucketName is required") } - secrets := bucket.GetSecrets() - if secrets == nil { - return nil, status.Error(codes.InvalidArgument, "secrets is required") - } - attrs := bucket.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "attributes is required") @@ -66,7 +61,7 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre return nil, err } - credConfig, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false) if err != nil { return nil, err } @@ -74,24 +69,24 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre // Set up the creds in our persisted state. credState, err := cred.NewAwsCredentialPersistedState( append([]cred.AwsCredentialPersistedStateOption{ - cred.WithAccessKeyId(credConfig.AccessKey), - cred.WithSecretAccessKey(credConfig.SecretKey), - cred.WithRegion(credConfig.Region), + cred.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) 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 !storageAttributes.DisableCredentialRotation { - if err := credState.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 := credState.ValidateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) + // Try to rotate static credentials + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if !storageAttributes.DisableCredentialRotation { + if err := credState.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 := credState.ValidateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) + } } } @@ -160,39 +155,42 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd // is fine and important, but this may change in the future. credState, err := cred.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetData(), - append(p.testCredStateOpts, cred.WithRegion(storageAttributes.Region))...) + storageAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - if storageAttributes.DisableCredentialRotation && !updateSecrets { - // This is a validate check to make sure that we aren't disabling - // rotation for credentials currently being managed by rotation. - // This is not allowed. - if !credState.CredsLastRotatedTime.IsZero() { - return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") - } - } - - if updateSecrets { - storageSecrets, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) - if err != nil { - return nil, err + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if storageAttributes.DisableCredentialRotation && !updateSecrets { + // This is a validate check to make sure that we aren't disabling + // rotation for credentials currently being managed by rotation. + // This is not allowed. + if !credState.CredsLastRotatedTime.IsZero() { + return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials") + } } - // Replace the credentials. This checks the timestamp on the last - // rotation time as well and deletes the credentials if we are - // managing them (ie: if we've rotated them before). - if err := credState.ReplaceCreds(storageSecrets.AccessKey, storageSecrets.SecretKey); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) - } - } + if updateSecrets { + storageSecrets, err := cred.GetCredentialsConfig(secrets, storageAttributes.CredentialAttributes, false) + if err != nil { + return nil, err + } - if !storageAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { - // If we're enabling rotation now but didn't before, or have - // freshly replaced credentials, we can rotate here. - if err := credState.RotateCreds(); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) + // Replace the credentials. This checks the timestamp on the last + // rotation time as well and deletes the credentials if we are + // managing them (ie: if we've rotated them before). + if err := credState.ReplaceCreds(storageSecrets); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err) + } + } + + if !storageAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() { + // If we're enabling rotation now but didn't before, or have + // freshly replaced credentials, we can rotate here. + if err := credState.RotateCreds(); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) + } } } @@ -245,7 +243,8 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel // may be corrupt/and or legitimately missing. credState, err := cred.AwsCredentialPersistedStateFromProto( req.GetPersisted().GetData(), - append(p.testCredStateOpts, cred.WithRegion(storageAttributes.Region))...) + storageAttributes.CredentialAttributes, + p.testCredStateOpts...) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } @@ -259,12 +258,15 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - 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(); err != nil { - return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during storage bucket deletion: %s", err) + // try to delete static credentials + if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + 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(); err != nil { + return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during storage bucket deletion: %s", err) + } } } @@ -286,11 +288,6 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques return nil, status.Error(codes.InvalidArgument, "bucketName is required") } - secrets := bucket.GetSecrets() - if secrets == nil { - return nil, status.Error(codes.InvalidArgument, "secrets is required") - } - attrs := bucket.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "attributes is required") @@ -301,7 +298,7 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques return nil, err } - credConfig, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false) if err != nil { return nil, err } @@ -309,9 +306,7 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques // Set up the creds in our persisted state. credState, err := cred.NewAwsCredentialPersistedState( append([]cred.AwsCredentialPersistedStateOption{ - cred.WithAccessKeyId(credConfig.AccessKey), - cred.WithSecretAccessKey(credConfig.SecretKey), - cred.WithRegion(credConfig.Region), + cred.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) if err != nil { @@ -364,11 +359,6 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat return nil, status.Error(codes.InvalidArgument, "bucketName is required") } - secrets := bucket.GetSecrets() - if secrets == nil { - return nil, status.Error(codes.InvalidArgument, "secrets is required") - } - attrs := bucket.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "attributes is required") @@ -379,7 +369,7 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat return nil, err } - credConfig, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false) if err != nil { return nil, err } @@ -387,9 +377,7 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat // Set up the creds in our persisted state. credState, err := cred.NewAwsCredentialPersistedState( append([]cred.AwsCredentialPersistedStateOption{ - cred.WithAccessKeyId(credConfig.AccessKey), - cred.WithSecretAccessKey(credConfig.SecretKey), - cred.WithRegion(credConfig.Region), + cred.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) if err != nil { @@ -429,11 +417,6 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu return status.Error(codes.InvalidArgument, "bucketName is required") } - secrets := bucket.GetSecrets() - if secrets == nil { - return status.Error(codes.InvalidArgument, "secrets is required") - } - attrs := bucket.GetAttributes() if attrs == nil { return status.Error(codes.InvalidArgument, "attributes is required") @@ -444,7 +427,7 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu return err } - credConfig, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false) if err != nil { return err } @@ -452,9 +435,7 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu // Set up the creds in our persisted state. credState, err := cred.NewAwsCredentialPersistedState( append([]cred.AwsCredentialPersistedStateOption{ - cred.WithAccessKeyId(credConfig.AccessKey), - cred.WithSecretAccessKey(credConfig.SecretKey), - cred.WithRegion(credConfig.Region), + cred.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) if err != nil { @@ -533,10 +514,6 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) if bucket.GetBucketName() == "" { return nil, status.Error(codes.InvalidArgument, "bucketName is required") } - secrets := bucket.GetSecrets() - if secrets == nil { - return nil, status.Error(codes.InvalidArgument, "secrets is required") - } attrs := bucket.GetAttributes() if attrs == nil { return nil, status.Error(codes.InvalidArgument, "attributes is required") @@ -564,7 +541,7 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) return nil, err } - credConfig, err := cred.GetCredentialsConfig(secrets, storageAttributes.Region) + credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false) if err != nil { return nil, err } @@ -572,9 +549,7 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) // Set up the creds in our persisted state. credState, err := cred.NewAwsCredentialPersistedState( append([]cred.AwsCredentialPersistedStateOption{ - cred.WithAccessKeyId(credConfig.AccessKey), - cred.WithSecretAccessKey(credConfig.SecretKey), - cred.WithRegion(credConfig.Region), + cred.WithCredentialsConfig(credConfig), }, p.testCredStateOpts...)..., ) if err != nil { diff --git a/plugin/service/storage/plugin_test.go b/plugin/service/storage/plugin_test.go index 1f01cc2..b1157df 100644 --- a/plugin/service/storage/plugin_test.go +++ b/plugin/service/storage/plugin_test.go @@ -34,11 +34,7 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { return &pb.OnCreateStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -49,12 +45,13 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { } } cases := []struct { - name string - req *pb.OnCreateStorageBucketRequest - credOpts []credential.AwsCredentialPersistedStateOption - storageOpts []awsStoragePersistedStateOption - expectedErrContains string - expectedErrCode codes.Code + name string + req *pb.OnCreateStorageBucketRequest + credOpts []credential.AwsCredentialPersistedStateOption + storageOpts []awsStoragePersistedStateOption + expectedErrContains string + expectedErrCode codes.Code + expectedPersistedData map[string]any }{ { name: "nil storage bucket", @@ -70,22 +67,11 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { expectedErrContains: "bucketName is required", expectedErrCode: codes.InvalidArgument, }, - { - name: "nil secrets", - req: &pb.OnCreateStorageBucketRequest{ - Bucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - }, - }, - expectedErrContains: "secrets is required", - expectedErrCode: codes.InvalidArgument, - }, { name: "nil attributes", req: &pb.OnCreateStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: new(structpb.Struct), }, }, expectedErrContains: "attributes is required", @@ -96,39 +82,17 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { req: &pb.OnCreateStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: new(structpb.Struct), Attributes: new(structpb.Struct), }, }, expectedErrContains: "missing required value \"region\"", expectedErrCode: codes.InvalidArgument, }, - { - name: "error reading secrets", - req: &pb.OnCreateStorageBucketRequest{ - Bucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - Secrets: new(structpb.Struct), - Attributes: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstRegion: structpb.NewStringValue("us-west-2"), - }, - }, - }, - }, - expectedErrContains: "missing required value \"access_key_id\"", - expectedErrCode: codes.InvalidArgument, - }, { name: "invalid region", req: &pb.OnCreateStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("foobar"), @@ -156,11 +120,7 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { req: &pb.OnCreateStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -301,6 +261,67 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { ), ), }, + expectedPersistedData: credential.MockStaticCredentialSecrets().AsMap(), + }, + { + name: "success rotate static creds", + req: &pb.OnCreateStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: credential.MockStaticCredentialSecrets(), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(false), + }, + }, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts( + []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"), + }, + }, + ), + ), + ), + }, + ), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "one", + credential.ConstSecretAccessKey: "two", + }, }, } @@ -322,12 +343,12 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { require.NotNil(resp) require.NotNil(resp.GetPersisted()) require.NotNil(resp.GetPersisted().GetData()) - require.Equal( - resp.GetPersisted().GetData().AsMap()[credential.ConstAccessKeyId], - tc.req.Bucket.Secrets.AsMap()[credential.ConstAccessKeyId]) - require.Equal( - resp.GetPersisted().GetData().AsMap()[credential.ConstSecretAccessKey], - tc.req.Bucket.Secrets.AsMap()[credential.ConstSecretAccessKey]) + actualPersistedData := resp.GetPersisted().GetData().AsMap() + if len(actualPersistedData) != 0 { + require.Equal(tc.expectedPersistedData[credential.ConstAccessKeyId], actualPersistedData[credential.ConstAccessKeyId]) + require.Equal(tc.expectedPersistedData[credential.ConstSecretAccessKey], actualPersistedData[credential.ConstSecretAccessKey]) + require.NotEmpty(actualPersistedData[credential.ConstCredsLastRotatedTime]) + } }) } } @@ -345,12 +366,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { }, }, Persisted: &storagebuckets.StorageBucketPersisted{ - Data: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }, - }, + Data: credential.MockStaticCredentialSecrets(), }, } } @@ -428,7 +444,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -458,7 +474,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -468,31 +484,6 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { expectedErrContains: "cannot disable rotation for already-rotated credentials", expectedErrCode: codes.FailedPrecondition, }, - { - name: "error reading secrets", - req: &pb.OnUpdateStorageBucketRequest{ - NewBucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - Secrets: new(structpb.Struct), - Attributes: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstRegion: structpb.NewStringValue("us-west-2"), - }, - }, - }, - Persisted: &storagebuckets.StorageBucketPersisted{ - Data: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), - }, - }, - }, - }, - expectedErrContains: "missing required value \"access_key_id\"", - expectedErrCode: codes.InvalidArgument, - }, { name: "updating secrets, replace error", req: &pb.OnUpdateStorageBucketRequest{ @@ -500,7 +491,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { BucketName: "foo", Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("onetwo"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_onetwo"), credential.ConstSecretAccessKey: structpb.NewStringValue("threefour"), }, }, @@ -514,7 +505,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -547,7 +538,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }, }, @@ -671,7 +662,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), }, }, @@ -696,7 +687,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { awsutil.WithCreateAccessKeyOutput( &iam.CreateAccessKeyOutput{ AccessKey: &iam.AccessKey{ - AccessKeyId: aws.String("one"), + AccessKeyId: aws.String("AKIA_one"), SecretAccessKey: aws.String("two"), UserName: aws.String("JohnDoe"), }, @@ -760,7 +751,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { disableCredentialRotation = value.(bool) } if !disableCredentialRotation { - expectedAccessKey = "one" + expectedAccessKey = "AKIA_one" expectedSecretKey = "two" } require.Equal(expectedAccessKey, resp.GetPersisted().GetData().AsMap()[credential.ConstAccessKeyId]) @@ -807,11 +798,7 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { req: &pb.OnDeleteStorageBucketRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("foobar"), @@ -835,7 +822,7 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -863,7 +850,7 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -895,7 +882,7 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { Persisted: &storagebuckets.StorageBucketPersisted{ Data: &structpb.Struct{ Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"), }, @@ -924,6 +911,7 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { } resp, err := p.OnDeleteStorageBucket(context.Background(), tc.req) if tc.expectedErrContains != "" { + require.NotNil(err) require.Contains(err.Error(), tc.expectedErrContains) require.Equal(status.Code(err).String(), tc.expectedErrCode.String()) return @@ -940,11 +928,7 @@ func TestStoragePlugin_HeadObject(t *testing.T) { Key: "/foo/bar/key", Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -986,17 +970,6 @@ func TestStoragePlugin_HeadObject(t *testing.T) { expectedErrContains: "bucketName is required", expectedErrCode: codes.InvalidArgument, }, - { - name: "nil secrets", - req: &pb.HeadObjectRequest{ - Key: "/foo/bar/key", - Bucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - }, - }, - expectedErrContains: "secrets is required", - expectedErrCode: codes.InvalidArgument, - }, { name: "nil attributes", req: &pb.HeadObjectRequest{ @@ -1140,11 +1113,7 @@ func TestStoragePlugin_ValidatePermissions(t *testing.T) { return &pb.ValidatePermissionsRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -1175,16 +1144,6 @@ func TestStoragePlugin_ValidatePermissions(t *testing.T) { expectedErrContains: "bucketName is required", expectedErrCode: codes.InvalidArgument, }, - { - name: "nil secrets", - req: &pb.ValidatePermissionsRequest{ - Bucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - }, - }, - expectedErrContains: "secrets is required", - expectedErrCode: codes.InvalidArgument, - }, { name: "nil attributes", req: &pb.ValidatePermissionsRequest{ @@ -1383,11 +1342,7 @@ func TestStoragePlugin_GetObject(t *testing.T) { Key: "/foo/bar/key", Bucket: &storagebuckets.StorageBucket{ BucketName: "foo", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -1428,17 +1383,6 @@ func TestStoragePlugin_GetObject(t *testing.T) { expectedErrContains: "bucketName is required", expectedErrCode: codes.InvalidArgument, }, - { - name: "nil secrets", - req: &pb.GetObjectRequest{ - Key: "/foo/bar/key", - Bucket: &storagebuckets.StorageBucket{ - BucketName: "foo", - }, - }, - expectedErrContains: "secrets is required", - expectedErrCode: codes.InvalidArgument, - }, { name: "nil attributes", req: &pb.GetObjectRequest{ @@ -1616,12 +1560,7 @@ func TestStoragePlugin_PutObject(t *testing.T) { return &pb.PutObjectRequest{ Bucket: &storagebuckets.StorageBucket{ BucketName: "external-obj-store", - Secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - credential.ConstAccessKeyId: structpb.NewStringValue("foobar"), - credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }, - }, + Secrets: credential.MockStaticCredentialSecrets(), Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue("us-west-2"), @@ -1671,17 +1610,6 @@ func TestStoragePlugin_PutObject(t *testing.T) { expectedErrContains: "bucketName is required", expectedErrCode: codes.InvalidArgument, }, - { - name: "missing bucket secrets", - request: &pb.PutObjectRequest{ - Bucket: &storagebuckets.StorageBucket{ - BucketName: "external-obj-store", - }, - Key: "mock-object", - }, - expectedErrContains: "secrets is required", - expectedErrCode: codes.InvalidArgument, - }, { name: "missing attributes", request: &pb.PutObjectRequest{ diff --git a/plugin/service/storage/state_test.go b/plugin/service/storage/state_test.go index 1ec5af6..8efa3fc 100644 --- a/plugin/service/storage/state_test.go +++ b/plugin/service/storage/state_test.go @@ -9,35 +9,78 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/hashicorp/boundary-plugin-aws/internal/credential" + "github.com/hashicorp/go-secure-stdlib/awsutil" "github.com/stretchr/testify/require" ) func TestAwsStoragePersistedStateS3Client(t *testing.T) { - require := require.New(t) - cred, err := credential.NewAwsCredentialPersistedState( - credential.WithRegion("us-west-2"), - credential.WithAccessKeyId("foobar"), - credential.WithSecretAccessKey("barfoo"), - ) - require.NoError(err) + 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{ + WithRegion("us-west-2"), + 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) - state, err := newAwsStoragePersistedState( - withCredentials(cred), - withTestS3APIFunc(newTestMockS3(nil)), - ) - require.NoError(err) + cred, err := credential.NewAwsCredentialPersistedState( + credential.WithCredentialsConfig(tc.creds), + credential.WithStateTestOpts(tc.awsOpts), + ) + require.NoError(err) - clientRaw, err := state.S3Client(context.Background(), WithRegion("us-west-2"), WithEndpoint("0.0.0.0")) - require.NoError(err) - require.NotNil(clientRaw) - client, ok := clientRaw.(*testMockS3) - require.True(ok) - require.NotNil(client) - require.Equal("us-west-2", client.Region) + state, err := newAwsStoragePersistedState( + append(tc.opts, withCredentials(cred))..., + ) + require.NoError(err) - endpoint, err := client.Endpoint.ResolveEndpoint(s3.ServiceID, client.Region) - require.NoError(err) - require.Equal("0.0.0.0", endpoint.URL) - require.Equal("us-west-2", endpoint.SigningRegion) - require.Equal("aws", endpoint.PartitionID) + 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) + + if tc.expectedEndpoint != "" { + endpoint, err := client.Endpoint.ResolveEndpoint(s3.ServiceID, client.Region) + require.NoError(err) + require.Equal(tc.expectedEndpoint, endpoint.URL) + require.Equal(tc.expectedRegion, endpoint.SigningRegion) + require.Equal("aws", endpoint.PartitionID) + } + }) + } } diff --git a/testing/e2e_host_test.go b/testing/e2e_host_test.go index 7c8906b..e47888c 100644 --- a/testing/e2e_host_test.go +++ b/testing/e2e_host_test.go @@ -11,26 +11,20 @@ import ( "testing" "time" - "github.com/aws/aws-sdk-go/aws/awserr" "github.com/hashicorp/boundary-plugin-aws/internal/credential" - "github.com/hashicorp/boundary-plugin-aws/internal/values" "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/hashicorp/go-secure-stdlib/awsutil" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) -const ( - expectedIamUserCount = 6 - expectedEc2InstanceCount = 5 -) +const expectedEc2InstanceCount = 5 var expectedTags = []string{"foo", "bar", "baz"} -func TestPlugin(t *testing.T) { +func TestHostPlugin(t *testing.T) { region := os.Getenv("AWS_REGION") if region == "" { t.Skip("set AWS_REGION to use this test") @@ -195,36 +189,7 @@ func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPl require.NotNil(response) persisted := response.GetPersisted() require.NotNil(persisted) - persistedSecrets := persisted.GetSecrets() - 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 + return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate) } func testPluginOnUpdateCatalog( @@ -293,116 +258,7 @@ func testPluginOnUpdateCatalog( require.NotNil(response) persisted := response.GetPersisted() require.NotNil(persisted) - persistedSecrets := persisted.GetSecrets() - require.NotNil(persistedSecrets) - - // 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 + 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) { @@ -588,53 +444,3 @@ func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, // 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*30) - 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) -} diff --git a/testing/e2e_storage_test.go b/testing/e2e_storage_test.go new file mode 100644 index 0000000..dfa8238 --- /dev/null +++ b/testing/e2e_storage_test.go @@ -0,0 +1,411 @@ +// 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/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" +) + +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) + + // 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(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 + // ******************** + // + // Reassign the keyid and secret first. + keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string) + testPluginObjectMethods(ctx, t, p, bucketName, region, keyid, secret) + + // ******************** + // * Validate Permissions + // ******************** + // + // Validate error is returned for missing get object permission + testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingGetObject, iamSecretAccessKeyMissingGetObject) + + // Validate error is returned for missing put object permission + testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingPutObject, iamSecretAccessKeyMissingPutObject) +} + +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) { + t.Helper() + t.Logf("testing ValidatePermissions") + require := require.New(t) + + 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) +} + +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", "localhost:2030") + require.NoError(err) + grpcServer := grpc.NewServer() + pb.RegisterStoragePluginServiceServer(grpcServer, &storage.StoragePlugin{}) + go func() { + grpcServer.Serve(lis) + }() + + conn, err := grpc.Dial("localhost: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() +} diff --git a/testing/testdata/storage/iam.tf b/testing/testdata/storage/iam.tf new file mode 100644 index 0000000..6b23c28 --- /dev/null +++ b/testing/testdata/storage/iam.tf @@ -0,0 +1,240 @@ +# 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 = <