From 120ba8e8e440247886383dcd902e397a0a957de3 Mon Sep 17 00:00:00 2001 From: Delaine Rogers Date: Tue, 8 Sep 2026 14:23:32 -0700 Subject: [PATCH] Fix role arn fallthrough bug --- internal/credential/state.go | 25 +++++++++++++++++++ plugin/service/host/plugin.go | 18 ++++++++++++++ plugin/service/host/plugin_test.go | 40 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/internal/credential/state.go b/internal/credential/state.go index 774c131..8dcdce2 100644 --- a/internal/credential/state.go +++ b/internal/credential/state.go @@ -112,10 +112,35 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) ( // and runs sts.GetCallerIdentity for the current credentials, which is done // to check that the credentials are valid. This method returns a status error // with PluginError details. +// +// When RoleARN is set and no static key is present (ambient role path), +// credentials are eagerly retrieved before calling GetCallerIdentity. This +// forces sts:AssumeRole to be called immediately so that any role errors +// (e.g. no such role, access denied) surface here rather than being deferred +// to the first real AWS API call. func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error { if s.CredentialsConfig == nil { return errors.BadRequestStatus("missing credentials config") } + + // For the ambient role path (RoleARN set, no static key), eagerly resolve + // the credential chain so that sts:AssumeRole is called now. Without this, + // the AssumeRoleProvider inside the chain is lazy and GetCallerIdentity + // would succeed even with an invalid role ARN (it uses the mock/ambient + // identity directly rather than the assumed one). + cc := s.CredentialsConfig + if cc.RoleARN != "" && cc.AccessKey == "" { + cfg, err := s.GenerateCredentialChain(ctx) + if err != nil { + st, _ := errors.ParseAWSError("validating credentials", err) + return st.Err() + } + if _, err := cfg.Credentials.Retrieve(ctx); err != nil { + st, _ := errors.ParseAWSError("validating credentials", err) + return st.Err() + } + } + if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil { st, _ := errors.ParseAWSError("validating credentials", err) return st.Err() diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index 64d2afa..3628771 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -90,6 +90,15 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo } } + // Validate assumed-role credentials at catalog-creation time so that an + // invalid role_arn is rejected immediately rather than silently falling + // back to ambient credentials. + if credential.GetCredentialType(credState.CredentialsConfig) == credential.DynamicAWS { + if err := credState.ValidateCreds(ctx); err != nil { + return nil, err + } + } + catalogState, err := newAwsCatalogPersistedState( append([]awsCatalogPersistedStateOption{ withCredentials(credState), @@ -185,6 +194,15 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo if err != nil { return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err) } + + // Validate assumed-role credentials when the role_arn changes so that + // an invalid ARN is caught before replacing the existing credential state. + if credential.GetCredentialType(newCredState.CredentialsConfig) == credential.DynamicAWS { + if err := newCredState.ValidateCreds(ctx); err != nil { + return nil, err + } + } + newCatalogState, err := newAwsCatalogPersistedState( append([]awsCatalogPersistedStateOption{ withCredentials(newCredState), diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 060d5c9..8457964 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -16,6 +16,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/iam" iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/google/go-cmp/cmp" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" @@ -158,6 +160,20 @@ func TestPluginOnCreateCatalogSuccess(t *testing.T) { testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithSTSAPIFunc(awsutil.NewMockSTS( + awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{ + Credentials: &stsTypes.Credentials{ + AccessKeyId: aws.String("ASIAfoobar"), + SecretAccessKey: aws.String("secretkeyfoobar"), + SessionToken: aws.String("sessiontokenfoobar"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + }, + }), + )), + }), + }, expRsp: &pb.OnCreateCatalogResponse{Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}}}, }, } @@ -315,6 +331,20 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) { testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, + credOpts: []credential.AwsCredentialPersistedStateOption{ + credential.WithStateTestOpts([]awsutil.Option{ + awsutil.WithSTSAPIFunc(awsutil.NewMockSTS( + awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{ + Credentials: &stsTypes.Credentials{ + AccessKeyId: aws.String("ASIAfoobar"), + SecretAccessKey: aws.String("secretkeyfoobar"), + SessionToken: aws.String("sessiontokenfoobar"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + }, + }), + )), + }), + }, expRsp: &pb.OnUpdateCatalogResponse{ Persisted: &pb.HostCatalogPersisted{Secrets: &structpb.Struct{}}, }, @@ -421,6 +451,16 @@ func TestPluginOnUpdateCatalogSuccess(t *testing.T) { credOpts: []credential.AwsCredentialPersistedStateOption{ credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithIAMAPIFunc(awsutil.NewMockIAM()), + awsutil.WithSTSAPIFunc(awsutil.NewMockSTS( + awsutil.WithAssumeRoleOutput(&sts.AssumeRoleOutput{ + Credentials: &stsTypes.Credentials{ + AccessKeyId: aws.String("ASIAfoobar"), + SecretAccessKey: aws.String("secretkeyfoobar"), + SessionToken: aws.String("sessiontokenfoobar"), + Expiration: aws.Time(time.Now().Add(time.Hour)), + }, + }), + )), }), }, expRsp: &pb.OnUpdateCatalogResponse{