diff --git a/builtin/credential/aws/backend_test.go b/builtin/credential/aws/backend_test.go index d56478266d9a..771dc0e31140 100644 --- a/builtin/credential/aws/backend_test.go +++ b/builtin/credential/aws/backend_test.go @@ -1046,6 +1046,7 @@ This is an acceptance test. export TEST_AWS_EC2_IAM_ROLE_ARN=$(aws iam get-role --role-name $(curl -q http://169.254.169.254/latest/meta-data/iam/security-credentials/ -S -s) --query Role.Arn --output text) export TEST_AWS_EC2_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + If the test is not being run on an EC2 instance that has access to credentials using EC2RoleProvider, on top of the above vars, following needs to be set: @@ -1407,6 +1408,11 @@ func TestBackend_pathStsConfig(t *testing.T) { "sts_role": "arn:aws:iam:account1:role/myRole", } + data2 := map[string]interface{}{ + "sts_role": "arn:aws:iam:account2:role/myRole2", + "external_id": "fake_id", + } + stsReq.Data = data // test create operation resp, err := b.HandleRequest(context.Background(), stsReq) @@ -1440,13 +1446,28 @@ func TestBackend_pathStsConfig(t *testing.T) { stsReq.Operation = logical.CreateOperation stsReq.Path = "config/sts/account2" - stsReq.Data = data - // create another entry to test the list operation + stsReq.Data = data2 + // create another entry with alternate data to test ExternalID and LIST resp, err = b.HandleRequest(context.Background(), stsReq) if err != nil || (resp != nil && resp.IsError()) { t.Fatal(err) } + // test second read + stsReq.Operation = logical.ReadOperation + resp, err = b.HandleRequest(context.Background(), stsReq) + if err != nil { + t.Fatal(err) + } + expectedStsRole = "arn:aws:iam:account2:role/myRole2" + expectedExternalID := "fake_id" + if resp.Data["sts_role"].(string) != expectedStsRole { + t.Fatalf("bad: expected:%s\n got:%s\n", expectedStsRole, resp.Data["sts_role"].(string)) + } + if resp.Data["external_id"].(string) != expectedExternalID { + t.Fatalf("bad: expected:%s\n got:%s\n", expectedExternalID, resp.Data["external_id"].(string)) + } + stsReq.Operation = logical.ListOperation stsReq.Path = "config/sts" // test list operation diff --git a/builtin/credential/aws/client.go b/builtin/credential/aws/client.go index bed15bb6a491..a1a6a391c323 100644 --- a/builtin/credential/aws/client.go +++ b/builtin/credential/aws/client.go @@ -84,7 +84,7 @@ func (b *backend) getRawClientConfig(ctx context.Context, s logical.Storage, reg // It uses getRawClientConfig to obtain config for the runtime environment, and if // stsRole is a non-empty string, it will use AssumeRole to obtain a set of assumed // credentials. The credentials will expire after 15 minutes but will auto-refresh. -func (b *backend) getClientConfig(ctx context.Context, s logical.Storage, region, stsRole, accountID, clientType string) (*aws.Config, error) { +func (b *backend) getClientConfig(ctx context.Context, s logical.Storage, region, stsRole, externalID, accountID, clientType string) (*aws.Config, error) { config, err := b.getRawClientConfig(ctx, s, region, clientType) if err != nil { return nil, err @@ -105,7 +105,7 @@ func (b *backend) getClientConfig(ctx context.Context, s logical.Storage, region if err != nil { return nil, err } - assumedCredentials := stscreds.NewCredentials(sess, stsRole) + assumedCredentials := stscreds.NewCredentials(sess, stsRole, func(p *stscreds.AssumeRoleProvider) { p.ExternalID = aws.String(externalID) }) // Test that we actually have permissions to assume the role if _, err = assumedCredentials.Get(); err != nil { return nil, err @@ -180,22 +180,22 @@ func (b *backend) setCachedUserId(userId, arn string) { } } -func (b *backend) stsRoleForAccount(ctx context.Context, s logical.Storage, accountID string) (string, error) { +func (b *backend) stsRoleForAccount(ctx context.Context, s logical.Storage, accountID string) (string, string, error) { // Check if an STS configuration exists for the AWS account sts, err := b.lockedAwsStsEntry(ctx, s, accountID) if err != nil { - return "", fmt.Errorf("error fetching STS config for account ID %q: %w", accountID, err) + return "", "", fmt.Errorf("error fetching STS config for account ID %q: %w", accountID, err) } // An empty STS role signifies the master account if sts != nil { - return sts.StsRole, nil + return sts.StsRole, sts.ExternalID, nil } - return "", nil + return "", "", nil } // clientEC2 creates a client to interact with AWS EC2 API func (b *backend) clientEC2(ctx context.Context, s logical.Storage, region, accountID string) (*ec2.EC2, error) { - stsRole, err := b.stsRoleForAccount(ctx, s, accountID) + stsRole, stsExternalID, err := b.stsRoleForAccount(ctx, s, accountID) if err != nil { return nil, err } @@ -218,7 +218,7 @@ func (b *backend) clientEC2(ctx context.Context, s logical.Storage, region, acco // Create an AWS config object using a chain of providers var awsConfig *aws.Config - awsConfig, err = b.getClientConfig(ctx, s, region, stsRole, accountID, "ec2") + awsConfig, err = b.getClientConfig(ctx, s, region, stsRole, stsExternalID, accountID, "ec2") if err != nil { return nil, err } @@ -247,7 +247,7 @@ func (b *backend) clientEC2(ctx context.Context, s logical.Storage, region, acco // clientIAM creates a client to interact with AWS IAM API func (b *backend) clientIAM(ctx context.Context, s logical.Storage, region, accountID string) (*iam.IAM, error) { - stsRole, err := b.stsRoleForAccount(ctx, s, accountID) + stsRole, stsExternalID, err := b.stsRoleForAccount(ctx, s, accountID) if err != nil { return nil, err } @@ -277,7 +277,7 @@ func (b *backend) clientIAM(ctx context.Context, s logical.Storage, region, acco // Create an AWS config object using a chain of providers var awsConfig *aws.Config - awsConfig, err = b.getClientConfig(ctx, s, region, stsRole, accountID, "iam") + awsConfig, err = b.getClientConfig(ctx, s, region, stsRole, stsExternalID, accountID, "iam") if err != nil { return nil, err } diff --git a/builtin/credential/aws/path_config_sts.go b/builtin/credential/aws/path_config_sts.go index 21034f69feff..d2ff2d15a3f5 100644 --- a/builtin/credential/aws/path_config_sts.go +++ b/builtin/credential/aws/path_config_sts.go @@ -13,7 +13,8 @@ import ( // awsStsEntry is used to store details of an STS role for assumption type awsStsEntry struct { - StsRole string `json:"sts_role"` + StsRole string `json:"sts_role"` + ExternalID string `json:"external_id,omitempty"` // optional, but recommended } func (b *backend) pathListSts() *framework.Path { @@ -57,6 +58,11 @@ instances in this account.`, Description: `AWS ARN for STS role to be assumed when interacting with the account specified. The Vault server must have permissions to assume this role.`, }, + "external_id": { + Type: framework.TypeString, + Description: `AWS external ID to be used when assuming the STS role.`, + Required: false, + }, }, ExistenceCheck: b.pathConfigStsExistenceCheck, @@ -192,10 +198,15 @@ func (b *backend) pathConfigStsRead(ctx context.Context, req *logical.Request, d return nil, nil } + dt := map[string]interface{}{ + "sts_role": stsEntry.StsRole, + } + if stsEntry.ExternalID != "" { + dt["external_id"] = stsEntry.ExternalID + } + return &logical.Response{ - Data: map[string]interface{}{ - "sts_role": stsEntry.StsRole, - }, + Data: dt, }, nil } @@ -230,6 +241,13 @@ func (b *backend) pathConfigStsCreateUpdate(ctx context.Context, req *logical.Re return logical.ErrorResponse("sts role cannot be empty"), nil } + stsExternalID, ok := data.GetOk("external_id") + if ok { + stsEntry.ExternalID = stsExternalID.(string) + } + + b.Logger().Info("setting sts", "account_id", accountID, "sts_role", stsEntry.StsRole, "external_id", stsEntry.ExternalID) + // save the provided STS role if err := b.nonLockedSetAwsStsEntry(ctx, req.Storage, accountID, stsEntry); err != nil { return nil, err diff --git a/changelog/26628.txt b/changelog/26628.txt new file mode 100644 index 000000000000..2ab067f3ef09 --- /dev/null +++ b/changelog/26628.txt @@ -0,0 +1,3 @@ +```release-note:improvement +auth/aws: add support for external_ids in AWS assume-role +``` \ No newline at end of file diff --git a/website/content/api-docs/auth/aws.mdx b/website/content/api-docs/auth/aws.mdx index b4bb11fa2cb8..450afadec3ed 100644 --- a/website/content/api-docs/auth/aws.mdx +++ b/website/content/api-docs/auth/aws.mdx @@ -438,6 +438,8 @@ when validating IAM principals or EC2 instances in the particular AWS account. - `sts_role` `(string: )` - AWS ARN for STS role to be assumed when interacting with the account specified. The Vault server must have permissions to assume this role. +- `external_id` `(string: "")` - The external ID expected by the STS role. The + associated STS role **must** be configured to require the external ID. ### Sample payload