diff --git a/go.mod b/go.mod index 7d1200a..93f3bdc 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.2.2 + github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 github.com/hashicorp/terraform-json v0.14.0 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.8.4 @@ -21,8 +21,6 @@ require ( google.golang.org/protobuf v1.28.1 ) -replace github.com/hashicorp/go-secure-stdlib/awsutil v0.2.2 => github.com/hashicorp/go-secure-stdlib/awsutilv2 v0.0.0-20230809054404-9029211590fd - require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect diff --git a/go.sum b/go.sum index 6b1bd20..ddf0c17 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,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/awsutilv2 v0.0.0-20230809054404-9029211590fd h1:46A3lfywoivyHBOXlXioGDSDo3UJj2aW2ncnSCSJ7bs= -github.com/hashicorp/go-secure-stdlib/awsutilv2 v0.0.0-20230809054404-9029211590fd/go.mod h1:XnoCjGsUi+UKk0+0P5TIS2rGKdw7O9ehSJy+kIuffBY= +github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 h1:ca5TSI4AgaOncPpyzLDtCGjVEtKukONpeM95vFxXCOQ= +github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0/go.mod h1:7CUvZtfTp2U0CYQCLzMtS2ngckjAZePSfwrE2aeDP1M= github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= github.com/hashicorp/go-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= diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go index 2bff15f..855df5e 100644 --- a/internal/credential/attributes.go +++ b/internal/credential/attributes.go @@ -9,7 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/hashicorp/boundary-plugin-aws/internal/errors" "github.com/hashicorp/boundary-plugin-aws/internal/values" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) @@ -55,10 +55,6 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, } delete(unknownFields, ConstAccessKeyId) - if accessKey != "" && GetCredentialType(accessKey) != Static { - badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "requires long-term access key beginning with \"AKIA\" prefix" - } - secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, required) if err != nil { badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error() @@ -90,6 +86,9 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, // static credential is missing it's access_key_id case accessKey == "" && secretKey != "": badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = "missing required value" + // dynamic credentials and credential rotation is not supported + case len(attrs.RoleArn) > 0 && !attrs.DisableCredentialRotation: + badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = "disable_credential_rotation attribute is required when providing a role_arn" // add static credentials case accessKey != "" && secretKey != "": opts = append(opts, diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go index 79bf050..4718c1e 100644 --- a/internal/credential/attributes_test.go +++ b/internal/credential/attributes_test.go @@ -6,7 +6,7 @@ package credential import ( "testing" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -126,19 +126,6 @@ func TestGetCredentialsConfig(t *testing.T) { Region: "us-west-2", }, }, - { - name: "with invalid static credentials", - secrets: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - ConstAccessKeyId: structpb.NewStringValue("ASIAfoobar"), - ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }, - }, - attrs: &CredentialAttributes{ - Region: "us-west-2", - }, - expectedErrContains: `secrets.access_key_id: requires long-term access key beginning with "AKIA" prefix`, - }, { name: "with static credentials", secrets: &structpb.Struct{ @@ -228,6 +215,7 @@ func TestGetCredentialsConfig(t *testing.T) { RoleTags: map[string]string{ "foo": "bar", }, + DisableCredentialRotation: true, }, expected: &awsutilv2.CredentialsConfig{ Region: "us-west-2", @@ -258,6 +246,20 @@ func TestGetCredentialsConfig(t *testing.T) { }, expectedErrContains: "secrets.access_key_id: conflicts with role_arn value, secrets.role_arn: conflicts with access_key_id and secret_access_key values, secrets.secret_access_key: conflicts with role_arn value", }, + { + name: "with dynamic credentials and no disable credential rotation", + secrets: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + attrs: &CredentialAttributes{ + Region: "us-west-2", + RoleArn: "arn:aws:iam::123456789012:role/S3Access", + RoleExternalId: "1234567890", + RoleSessionName: "test-session", + RoleTags: map[string]string{ + "foo": "bar", + }, + }, + expectedErrContains: "attributes.disable_credential_rotation: disable_credential_rotation attribute is required when providing a role_arn", + }, } for _, tc := range cases { diff --git a/internal/credential/state.go b/internal/credential/state.go index 98acdd2..491da71 100644 --- a/internal/credential/state.go +++ b/internal/credential/state.go @@ -13,16 +13,30 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/hashicorp/boundary-plugin-aws/internal/values" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) type CredentialType int const ( - Unknown CredentialType = iota - Static - Dynamic + // StaticAWS denotes an Access Key Id that begins with "AKIA". These are + // long-term access keys, provided by AWS, for an IAM user or an AWS account + // root user. + // https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html#sec-access-keys-and-secret-access-keys + StaticAWS CredentialType = iota + + // DynamicAWS denotes the presence of a RoleARN, or an Access Key Id that + // begins with "ASIA". The latter are temporary credentials access keys that + // are created using AWS STS operations. + DynamicAWS + + // StaticOther denotes the presence of an Access Key Id that does not follow + // the AKIA/ASIA convention outlined by AWS. + StaticOther + + // Unknown is a catch-all for everything else. + Unknown ) // rotationWaitTimeout controls the time we wait for credential rotation to @@ -112,7 +126,7 @@ func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.New("missing credentials config") } - if GetCredentialType(s.CredentialsConfig.AccessKey) != Static { + if GetCredentialType(s.CredentialsConfig) != StaticAWS { return errors.New("invalid credential type") } if err := s.CredentialsConfig.RotateKeys(ctx, append([]awsutilv2.Option{ @@ -134,13 +148,11 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credenti if s.CredentialsConfig == nil { return errors.New("missing credentials config") } - if GetCredentialType(s.CredentialsConfig.AccessKey) != Static { - return errors.New("invalid credential type") - } // Delete old/existing credentials. This action is only possible for static credential types. // This is done with the same credentials to ensure that it has the proper permissions to do it. - if !s.CredsLastRotatedTime.IsZero() { + ct := GetCredentialType(s.CredentialsConfig) + if !s.CredsLastRotatedTime.IsZero() && ct == StaticAWS { if err := s.DeleteCreds(ctx); err != nil { return err } @@ -159,7 +171,7 @@ func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.New("missing credentials config") } - if GetCredentialType(s.CredentialsConfig.AccessKey) != Static { + if GetCredentialType(s.CredentialsConfig) != StaticAWS { return errors.New("invalid credential type") } @@ -192,7 +204,8 @@ func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Contex // 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) { + ct := GetCredentialType(s.CredentialsConfig) + if ct == DynamicAWS || ct == Unknown { return map[string]any{} } return map[string]any{ @@ -269,29 +282,22 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede return s, nil } -// GetCredentialType returns the credential type based on the given AWS AccessKey. -// -// 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. -// -// 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 GetCredentialType(accessKey string) CredentialType { - if strings.HasPrefix(accessKey, "ASIA") { - return Dynamic +// GetCredentialType returns the credential type based on the given +// AccessKey/RoleARN. See CredentialType definition for more information. +func GetCredentialType(cc *awsutilv2.CredentialsConfig) CredentialType { + if cc == nil { + return Unknown } - if strings.HasPrefix(accessKey, "AKIA") { - return Static + + if len(cc.RoleARN) > 0 || strings.HasPrefix(cc.AccessKey, "ASIA") { + return DynamicAWS } + if strings.HasPrefix(cc.AccessKey, "AKIA") { + return StaticAWS + } + if len(cc.AccessKey) > 0 { + return StaticOther + } + return Unknown } - -// HasDynamicCredentials returns true if the access key is a dynamic credential type. -func HasDynamicCredentials(accessKey string) bool { - return strings.HasPrefix(accessKey, "ASIA") -} - -// HasStaticCredentials returns true if the access key is a static credential type. -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 4febd42..6bed2e3 100644 --- a/internal/credential/state_test.go +++ b/internal/credential/state_test.go @@ -13,7 +13,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) @@ -533,30 +533,6 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) { credentialConfig: &awsutilv2.CredentialsConfig{}, expectedErr: "missing credentials config", }, - { - name: "cannot delete dynamic credential type", - in: &AwsCredentialPersistedState{ - CredentialsConfig: &awsutilv2.CredentialsConfig{ - AccessKey: "ASIAfoobar", - SecretKey: "bazqux", - }, - CredsLastRotatedTime: time.Now(), - }, - credentialConfig: &awsutilv2.CredentialsConfig{}, - expectedErr: "invalid credential type", - }, - { - name: "cannot delete unknown credential type", - in: &AwsCredentialPersistedState{ - CredentialsConfig: &awsutilv2.CredentialsConfig{ - AccessKey: "DNEfoobar", - SecretKey: "bazqux", - }, - CredsLastRotatedTime: time.Now(), - }, - credentialConfig: &awsutilv2.CredentialsConfig{}, - expectedErr: "invalid credential type", - }, { name: "deletion error", in: &AwsCredentialPersistedState{ @@ -815,74 +791,53 @@ func TestAwsCatalogPersistedState_GenerateCredentialChain(t *testing.T) { } } -func TestHasStaticCredentials(t *testing.T) { - require := require.New(t) - cases := []struct { - name string - accessKey string - expected bool +func TestGetCredentialType(t *testing.T) { + tests := []struct { + name string + credConfig *awsutilv2.CredentialsConfig + expectedCredType CredentialType }{ { - name: "dynamic credential", - accessKey: "ASIAEXAMPLE", - expected: false, + name: "nil credential config", + credConfig: nil, + expectedCredType: Unknown, }, { - name: "static credential", - accessKey: "AKIAEXAMPLE", - expected: true, + name: "empty credential config", + credConfig: &awsutilv2.CredentialsConfig{}, + expectedCredType: Unknown, }, { - name: "other credential", - accessKey: "EXAMPLE", - expected: false, + name: "static aws", + credConfig: &awsutilv2.CredentialsConfig{AccessKey: "AKIAfoobar"}, + expectedCredType: StaticAWS, }, { - name: "empty", - accessKey: "", - expected: false, + name: "static other", + credConfig: &awsutilv2.CredentialsConfig{AccessKey: "cK3kNFa24foobar"}, + expectedCredType: StaticOther, + }, + { + name: "dynamic aws - role arn", + credConfig: &awsutilv2.CredentialsConfig{RoleARN: "arn:aws:iam::123456789012:role/S3Access"}, + expectedCredType: DynamicAWS, + }, + { + name: "dynamic aws - access key", + credConfig: &awsutilv2.CredentialsConfig{AccessKey: "ASIAfoobar"}, + expectedCredType: DynamicAWS, + }, + { + name: "dynamic aws - role arn and access key", + credConfig: &awsutilv2.CredentialsConfig{AccessKey: "ASIAfoobar", RoleARN: "arn:aws:iam::123456789012:role/S3Access"}, + expectedCredType: DynamicAWS, }, } - 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 + for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - require.Equal(tc.expected, HasDynamicCredentials(tc.accessKey)) + ct := GetCredentialType(tc.credConfig) + require.Equal(t, tc.expectedCredType, ct) }) } } diff --git a/internal/credential/testing.go b/internal/credential/testing.go index 0884c63..5224eac 100644 --- a/internal/credential/testing.go +++ b/internal/credential/testing.go @@ -8,7 +8,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/iam" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "google.golang.org/protobuf/types/known/structpb" ) diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 6283917..4ec8fa2 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -83,8 +83,7 @@ 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 static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !catalogAttributes.DisableCredentialRotation { if err := credState.RotateCreds(ctx); err != nil { return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) @@ -160,7 +159,7 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if catalogAttributes.DisableCredentialRotation && !updateSecrets { // This is a validate check to make sure that we aren't disabling // rotation for credentials currently being managed by rotation. @@ -244,7 +243,7 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo } // try to delete static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !credState.CredsLastRotatedTime.IsZero() { // Delete old/existing credentials. This is done with the same // credentials to ensure that it has the proper permissions to do diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 66df874..434afe7 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -17,7 +17,7 @@ import ( "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" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" diff --git a/plugin/service/host/state_test.go b/plugin/service/host/state_test.go index 34e9de1..040bfc7 100644 --- a/plugin/service/host/state_test.go +++ b/plugin/service/host/state_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/hashicorp/boundary-plugin-aws/internal/credential" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" ) diff --git a/plugin/service/storage/plugin.go b/plugin/service/storage/plugin.go index 870bd74..83cfca4 100644 --- a/plugin/service/storage/plugin.go +++ b/plugin/service/storage/plugin.go @@ -76,17 +76,10 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err) } - // Try to rotate static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { - if !storageAttributes.DisableCredentialRotation { - if err := credState.RotateCreds(ctx); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) - } - } else { - // Simply validate if we aren't rotating. - if err := credState.ValidateCreds(ctx); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err) - } + // Try to rotate AWS static credentials + if cred.GetCredentialType(credConfig) == cred.StaticAWS && !storageAttributes.DisableCredentialRotation { + if err := credState.RotateCreds(ctx); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } @@ -156,15 +149,18 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err) } + // Verify the incoming credentials are valid and return any errors to the + // user if they're not. Note this doesn't validate the credentials against + // AWS - it only does logical validation on the fields. + updatedCredentials, err := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes, false) + if err != nil { + return nil, err + } + // We will be updating credentials when one of two changes occur: // 1. the new bucket has secrets for static credentials, in which case we need to delete the old static credentials // 2. the new bucket has a different roleARN value for dynamic credentials if newBucket.GetSecrets() != nil || newStorageAttributes.RoleArn != oldStorageAttributes.RoleArn { - updatedCredentials, err := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes, false) - if err != nil { - return nil, err - } - // Replace the existing credential state. // This checks the timestamp on the last rotation time as well // and deletes the credentials if we are managing them @@ -174,9 +170,7 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd } } - credentialType := cred.GetCredentialType(credState.CredentialsConfig.AccessKey) - switch credentialType { - case cred.Static: + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { // This is a validate check to make sure that we aren't disabling // rotation for credentials currently being managed by rotation. // This is not allowed. @@ -193,16 +187,8 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err) } } - case cred.Dynamic: - // We cannot allow credential rotation for dynamic credentials - if !newStorageAttributes.DisableCredentialRotation { - return nil, status.Errorf(codes.InvalidArgument, "cannot enable credential rotation for dynamic credential type") - } - case cred.Unknown: - fallthrough - default: - return nil, status.Errorf(codes.InvalidArgument, "unknown credential type") } + storageState, err := newAwsStoragePersistedState( append([]awsStoragePersistedStateOption{ withCredentials(credState), @@ -268,7 +254,7 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel } // try to delete static credentials - if cred.HasStaticCredentials(credState.CredentialsConfig.AccessKey) { + if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS { if !credState.CredsLastRotatedTime.IsZero() { // Delete old/existing credentials. This is done with the same // credentials to ensure that it has the proper permissions to do diff --git a/plugin/service/storage/plugin_test.go b/plugin/service/storage/plugin_test.go index 50add5e..09eefbe 100644 --- a/plugin/service/storage/plugin_test.go +++ b/plugin/service/storage/plugin_test.go @@ -20,10 +20,12 @@ import ( "github.com/aws/aws-sdk-go-v2/service/iam" iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -104,6 +106,23 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { expectedErrContains: "not a valid region: foobar", expectedErrCode: codes.InvalidArgument, }, + { + name: "dynamic credentials without disable credential rotation", + req: &pb.OnCreateStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + }, + expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn", + expectedErrCode: codes.InvalidArgument, + }, { name: "persisted state setup error", req: validRequest(), @@ -141,21 +160,6 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { expectedErrContains: fmt.Sprintf("error during credential rotation: error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr), expectedErrCode: codes.InvalidArgument, }, - { - name: "validation error", - req: validRequest(), - credOpts: []credential.AwsCredentialPersistedStateOption{ - credential.WithStateTestOpts([]awsutilv2.Option{ - awsutilv2.WithSTSAPIFunc( - awsutilv2.NewMockSTS( - awsutilv2.WithGetCallerIdentityError(errors.New(testGetCallerIdentityErr)), - ), - ), - }), - }, - expectedErrContains: fmt.Sprintf("error during credential validation: error validating credentials: %s", testGetCallerIdentityErr), - expectedErrCode: codes.InvalidArgument, - }, { name: "dryRunValidation failed putObject", req: validRequest(), @@ -324,6 +328,44 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { credential.ConstSecretAccessKey: "two", }, }, + { + name: "success with dynamic credentials", + req: &pb.OnCreateStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: &structpb.Struct{}, + Attributes: credential.MockAssumeRoleAttributes("us-west-2", true), + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts( + []awsutilv2.Option{ + awsutilv2.WithSTSAPIFunc( + awsutilv2.NewMockSTS( + awsutilv2.WithAssumeRoleOutput(&sts.AssumeRoleOutput{ + Credentials: &types.Credentials{ + AccessKeyId: aws.String("ASIAfoobar"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + SecretAccessKey: aws.String("secretkeyfoobar"), + }, + }), + ), + ), + }, + ), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, // No credentials sent back to Boundary as they are temporary. + }, } for _, tc := range cases { @@ -344,8 +386,10 @@ func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) { require.NotNil(resp) require.NotNil(resp.GetPersisted()) require.NotNil(resp.GetPersisted().GetData()) + actualPersistedData := resp.GetPersisted().GetData().AsMap() - if len(actualPersistedData) != 0 { + if len(tc.expectedPersistedData) != 0 { + require.NotEmpty(actualPersistedData) require.Equal(tc.expectedPersistedData[credential.ConstAccessKeyId], actualPersistedData[credential.ConstAccessKeyId]) require.Equal(tc.expectedPersistedData[credential.ConstSecretAccessKey], actualPersistedData[credential.ConstSecretAccessKey]) require.NotEmpty(actualPersistedData[credential.ConstCredsLastRotatedTime]) @@ -383,12 +427,14 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { } } cases := []struct { - name string - req *pb.OnUpdateStorageBucketRequest - credOpts []credential.AwsCredentialPersistedStateOption - storageOpts []awsStoragePersistedStateOption - expectedErrContains string - expectedErrCode codes.Code + name string + req *pb.OnUpdateStorageBucketRequest + credOpts []credential.AwsCredentialPersistedStateOption + storageOpts []awsStoragePersistedStateOption + expectedPersistedData map[string]any + expectCredsLastRotatedTime bool + expectedErrContains string + expectedErrCode codes.Code }{ { name: "nil new bucket", @@ -552,6 +598,36 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { expectedErrContains: "cannot disable rotation for already-rotated credentials", expectedErrCode: codes.FailedPrecondition, }, + { + name: "attempt to enable credential rotation with dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(false), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: credential.MockAssumeRoleAttributes("us-west-2", true), + }, + }, + expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn", + expectedErrCode: codes.InvalidArgument, + }, { name: "updating secrets, replace error", req: &pb.OnUpdateStorageBucketRequest{ @@ -796,6 +872,11 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), ), }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_one", + credential.ConstSecretAccessKey: "two", + }, + expectCredsLastRotatedTime: true, }, { name: "success", @@ -811,6 +892,488 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { ), ), }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_foobar", + credential.ConstSecretAccessKey: "bazqux", + }, + }, + { + name: "dry run fail on dynamic to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{Data: &structpb.Struct{}}, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectError(fmt.Errorf("head object failed oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + ), + ), + }, + expectedErrContains: "head object failed oops", + expectedErrCode: codes.Internal, + }, + { + name: "dynamic to dynamic credentials success", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{Data: &structpb.Struct{}}, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "dry run fail on static non rotated to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectError(fmt.Errorf("put object failed oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedErrContains: "put object failed oops", + expectedErrCode: codes.Internal, + }, + { + name: "success static non rotated to dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "DeleteAccessKey error on static rotated to dynamic credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar_rotated_accesskeyid"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue(time.Now().Add(-time.Hour).Format(time.RFC3339Nano)), + }, + }, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutilv2.Option{ + awsutilv2.WithIAMAPIFunc( + awsutilv2.NewMockIAM( + awsutilv2.WithDeleteAccessKeyError(fmt.Errorf("delete access key fail oops")), + ), + ), + }), + }, + expectedErrContains: "delete access key fail oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success static rotated to dynamic credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar_rotated_accesskeyid"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + credential.ConstCredsLastRotatedTime: structpb.NewStringValue(time.Now().Add(-time.Hour).Format(time.RFC3339Nano)), + }, + }, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutilv2.Option{ + awsutilv2.WithIAMAPIFunc( + awsutilv2.NewMockIAM(), // DeleteAccessKey Success + ), + }), + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{}, + }, + { + name: "dry run fail on dynamic to static non-rotated credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectError(fmt.Errorf("put object fail oops")), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedErrContains: "put object fail oops", + expectedErrCode: codes.Internal, + }, + { + name: "success dynamic to static non-rotated credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + expectedPersistedData: map[string]any{ + credential.ConstAccessKeyId: "AKIA_foobar", + credential.ConstSecretAccessKey: "secretkey", + }, + }, + { + name: "create access key error on dynamic to static rotated credentials update", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutilv2.Option{ + awsutilv2.WithSTSAPIFunc( + awsutilv2.NewMockSTS(), + ), + awsutilv2.WithIAMAPIFunc( + awsutilv2.NewMockIAM( + awsutilv2.WithGetUserOutput( + &iam.GetUserOutput{ + User: &iamTypes.User{ + Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), + UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), + UserName: aws.String("JohnDoe"), + }, + }, + ), + awsutilv2.WithCreateAccessKeyError(fmt.Errorf("create access key fail oops")), + ), + ), + }), + }, + expectedErrContains: "create access key fail oops", + expectedErrCode: codes.InvalidArgument, + }, + { + name: "success dynamic to static rotated credentials", + req: &pb.OnUpdateStorageBucketRequest{ + CurrentBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + NewBucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + }, + }, + Secrets: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), + credential.ConstSecretAccessKey: structpb.NewStringValue("secretkey"), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutilv2.Option{ + awsutilv2.WithSTSAPIFunc( + awsutilv2.NewMockSTS(), + ), + awsutilv2.WithIAMAPIFunc( + awsutilv2.NewMockIAM( + awsutilv2.WithGetUserOutput( + &iam.GetUserOutput{ + User: &iamTypes.User{ + Arn: aws.String("arn:aws:iam::123456789012:user/JohnDoe"), + UserId: aws.String("AIDAJQABLZS4A3QDU576Q"), + UserName: aws.String("JohnDoe"), + }, + }, + ), + awsutilv2.WithCreateAccessKeyOutput( + &iam.CreateAccessKeyOutput{ + AccessKey: &iamTypes.AccessKey{ + AccessKeyId: aws.String("AKIA_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: "AKIA_one", + credential.ConstSecretAccessKey: "two", + }, + expectCredsLastRotatedTime: true, }, } @@ -824,6 +1387,7 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { } resp, err := p.OnUpdateStorageBucket(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 @@ -833,18 +1397,18 @@ func TestStoragePlugin_OnUpdateStorageBucket(t *testing.T) { require.NotNil(resp.GetPersisted()) require.NotNil(resp.GetPersisted().GetData()) - var disableCredentialRotation bool - expectedAccessKey := tc.req.Persisted.Data.AsMap()[credential.ConstAccessKeyId] - expectedSecretKey := tc.req.Persisted.Data.AsMap()[credential.ConstSecretAccessKey] - if value, ok := tc.req.GetNewBucket().GetAttributes().AsMap()[credential.ConstDisableCredentialRotation]; ok { - disableCredentialRotation = value.(bool) + actualPersistedData := resp.GetPersisted().GetData().AsMap() + if len(actualPersistedData) > 0 && len(tc.expectedPersistedData) == 0 { + t.Fatalf("test case expected no persisted data, but got %#v", actualPersistedData) } - if !disableCredentialRotation { - expectedAccessKey = "AKIA_one" - expectedSecretKey = "two" + if len(tc.expectedPersistedData) != 0 { + require.NotEmpty(actualPersistedData) + require.Equal(tc.expectedPersistedData[credential.ConstAccessKeyId], actualPersistedData[credential.ConstAccessKeyId]) + require.Equal(tc.expectedPersistedData[credential.ConstSecretAccessKey], actualPersistedData[credential.ConstSecretAccessKey]) + if tc.expectCredsLastRotatedTime { + require.NotEmpty(actualPersistedData[credential.ConstCredsLastRotatedTime]) + } } - require.Equal(expectedAccessKey, resp.GetPersisted().GetData().AsMap()[credential.ConstAccessKeyId]) - require.Equal(expectedSecretKey, resp.GetPersisted().GetData().AsMap()[credential.ConstSecretAccessKey]) }) } } @@ -988,6 +1552,23 @@ func TestStoragePlugin_OnDeleteStorageBucket(t *testing.T) { }), }, }, + { + name: "success with dynamic credentials", + req: &pb.OnDeleteStorageBucketRequest{ + Bucket: &storagebuckets.StorageBucket{ + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::098765432109:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Persisted: &storagebuckets.StorageBucketPersisted{ + Data: &structpb.Struct{Fields: map[string]*structpb.Value{}}, + }, + }, + }, } for _, tc := range cases { @@ -1171,6 +1752,37 @@ func TestStoragePlugin_HeadObject(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.HeadObjectRequest{ + Key: "/foo/bar/key", + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Secrets: new(structpb.Struct), + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + contentLength: 1024, + lastModified: createTime(t, "2006-01-02T15:04:05Z"), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{ + ContentLength: 1024, + LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")), + }), + ), + ), + }, + }, } for _, tc := range cases { @@ -1401,6 +2013,32 @@ func TestStoragePlugin_ValidatePermissions(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.ValidatePermissionsRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{}), + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{}), + testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{}), + ), + ), + }, + }, } for _, tc := range cases { @@ -1602,6 +2240,34 @@ func TestStoragePlugin_GetObject(t *testing.T) { ), }, }, + { + name: "success with dynamic credentials", + req: &pb.GetObjectRequest{ + Key: "/foo/bar/key", + Bucket: &storagebuckets.StorageBucket{ + BucketName: "foo", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + }, + credOpts: validSTSMock(), + objectData: []byte("test"), + storageOpts: []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + nil, + testMockS3WithGetObjectOutput(&s3.GetObjectOutput{ + Body: ioutil.NopCloser(bytes.NewReader([]byte("test"))), + }), + ), + ), + }, + }, } for _, tc := range cases { @@ -1970,6 +2636,43 @@ func TestStoragePlugin_PutObject(t *testing.T) { } }(), }, + { + name: "valid file using dynamic credentials", + request: &pb.PutObjectRequest{ + Bucket: &storagebuckets.StorageBucket{ + BucketName: "external-obj-store", + Attributes: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + credential.ConstRegion: structpb.NewStringValue("us-west-2"), + credential.ConstRoleArn: structpb.NewStringValue("arn:aws:iam::123456789012:role/S3Access"), + credential.ConstDisableCredentialRotation: structpb.NewBoolValue(true), + }, + }, + }, + Key: "mock-object", + Path: validFilePath, + }, + credOpts: validSTSMock(), + expectedObject: []byte("CONTENT CHECK"), + storageOpts: func() []awsStoragePersistedStateOption { + hash := sha256.New() + _, err := hash.Write([]byte("CONTENT CHECK")) + require.NoError(t, err) + checksum := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + return []awsStoragePersistedStateOption{ + withTestS3APIFunc( + newTestMockS3( + &testMockS3State{ + PutObjectBody: []byte("CONTENT CHECK"), + }, + testMockS3WithPutObjectOutput(&s3.PutObjectOutput{ + ChecksumSHA256: aws.String(checksum), + }), + ), + ), + } + }(), + }, } for _, tc := range cases { diff --git a/plugin/service/storage/state_test.go b/plugin/service/storage/state_test.go index d212e15..66b30d5 100644 --- a/plugin/service/storage/state_test.go +++ b/plugin/service/storage/state_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/hashicorp/boundary-plugin-aws/internal/credential" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" ) diff --git a/plugin/service/storage/testing.go b/plugin/service/storage/testing.go index fb797de..6613797 100644 --- a/plugin/service/storage/testing.go +++ b/plugin/service/storage/testing.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/structpb" diff --git a/testing/testing.go b/testing/testing.go index 6552d1c..4b56fde 100644 --- a/testing/testing.go +++ b/testing/testing.go @@ -16,7 +16,7 @@ import ( "github.com/aws/smithy-go" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary-plugin-aws/internal/values" - awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil" + awsutilv2 "github.com/hashicorp/go-secure-stdlib/awsutil/v2" tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb"