From 5882c9cefa6c8e4c558a8b26e40c27e5b8b83092 Mon Sep 17 00:00:00 2001 From: hc-github-team-secure-vault-core Date: Fri, 5 Jul 2024 11:51:36 -0600 Subject: [PATCH] backport of commit a05deb5f374c0b2199ec97d6aea4a4c260f22ab7 (#27662) Co-authored-by: Ben Ash <32777270+benashz@users.noreply.github.com> --- builtin/logical/aws/backend_test.go | 377 +++++++++++++++++----- builtin/logical/aws/path_roles.go | 38 ++- builtin/logical/aws/path_roles_test.go | 79 ++++- builtin/logical/aws/path_user.go | 2 +- builtin/logical/aws/secret_access_keys.go | 15 +- changelog/27620.txt | 5 + website/content/api-docs/secret/aws.mdx | 21 +- 7 files changed, 445 insertions(+), 92 deletions(-) create mode 100644 changelog/27620.txt diff --git a/builtin/logical/aws/backend_test.go b/builtin/logical/aws/backend_test.go index b5376f64687e..56cd095a3ba7 100644 --- a/builtin/logical/aws/backend_test.go +++ b/builtin/logical/aws/backend_test.go @@ -36,6 +36,23 @@ import ( var initSetup sync.Once +// This looks a bit curious. The policy document and the role document act +// as a logical intersection of policies. The role allows ec2:Describe* +// (among other permissions). This policy allows everything BUT +// ec2:DescribeAvailabilityZones. Thus, the logical intersection of the two +// is all ec2:Describe* EXCEPT ec2:DescribeAvailabilityZones, and so the +// describeAZs call should fail +const allowAllButDescribeAzs = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "NotAction": "ec2:DescribeAvailabilityZones", + "Resource": "*" + } + ] +}` + type mockIAMClient struct { iamiface.IAMAPI } @@ -97,7 +114,7 @@ func TestAcceptanceBackend_basicSTS(t *testing.T) { PreCheck: func() { testAccPreCheck(t) createUser(t, userName, accessKey) - createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}, nil) // Sleep sometime because AWS is eventually consistent // Both the createUser and createRole depend on this log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") @@ -123,7 +140,8 @@ func TestAcceptanceBackend_basicSTS(t *testing.T) { }) } -func TestBackend_policyCrud(t *testing.T) { +// TestBackend_policyCRUD tests the CRUD operations for a policy. +func TestBackend_policyCRUD(t *testing.T) { t.Parallel() compacted, err := compactJSON(testDynamoPolicy) if err != nil { @@ -252,23 +270,32 @@ func getAccountID() (string, error) { return *res.Account, nil } -func createRole(t *testing.T, roleName, awsAccountID string, policyARNs []string) { - const testRoleAssumePolicy = `{ +func createRole(t *testing.T, roleName, awsAccountID string, policyARNs, extraTrustPolicies []string) { + t.Helper() + + trustPolicyStmts := append([]string{ + fmt.Sprintf(` + { + "Effect":"Allow", + "Principal": { + "AWS": "arn:aws:iam::%s:root" + }, + "Action": [ + "sts:AssumeRole", + "sts:SetSourceIdentity" + ] + }`, awsAccountID), + }, + extraTrustPolicies...) + + testRoleAssumePolicy := fmt.Sprintf(`{ "Version": "2012-10-17", "Statement": [ - { - "Effect":"Allow", - "Principal": { - "AWS": "arn:aws:iam::%s:root" - }, - "Action": [ - "sts:AssumeRole", - "sts:SetSourceIdentity" - ] - } +%s ] } -` +`, strings.Join(trustPolicyStmts, ",")) + awsConfig := &aws.Config{ Region: aws.String("us-east-1"), HTTPClient: cleanhttp.DefaultClient(), @@ -278,23 +305,23 @@ func createRole(t *testing.T, roleName, awsAccountID string, policyARNs []string t.Fatal(err) } svc := iam.New(sess) - trustPolicy := fmt.Sprintf(testRoleAssumePolicy, awsAccountID) params := &iam.CreateRoleInput{ - AssumeRolePolicyDocument: aws.String(trustPolicy), + AssumeRolePolicyDocument: aws.String(testRoleAssumePolicy), RoleName: aws.String(roleName), Path: aws.String("/"), } log.Printf("[INFO] AWS CreateRole: %s", roleName) - if _, err := svc.CreateRole(params); err != nil { + output, err := svc.CreateRole(params) + if err != nil { t.Fatalf("AWS CreateRole failed: %v", err) } for _, policyARN := range policyARNs { attachment := &iam.AttachRolePolicyInput{ PolicyArn: aws.String(policyARN), - RoleName: aws.String(roleName), // Required + RoleName: output.Role.RoleName, } _, err = svc.AttachRolePolicy(attachment) if err != nil { @@ -315,21 +342,21 @@ func createUser(t *testing.T, userName string, accessKey *awsAccessKey) { // do anything // 4. Generate API creds to get an actual access key and secret key timebombPolicyTemplate := `{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Deny", - "Action": "*", - "Resource": "*", - "Condition": { - "DateGreaterThan": { - "aws:CurrentTime": "%s" - } + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": { + "DateGreaterThan": { + "aws:CurrentTime": "%s" } } - ] - } - ` + } + ] +} +` validity := time.Duration(2 * time.Hour) expiry := time.Now().Add(validity) timebombPolicy := fmt.Sprintf(timebombPolicyTemplate, expiry.Format(time.RFC3339)) @@ -657,7 +684,7 @@ func testAccStepRotateRoot(oldAccessKey *awsAccessKey) logicaltest.TestStep { } } -func testAccStepRead(t *testing.T, path, name string, credentialTests []credentialTestFunc) logicaltest.TestStep { +func testAccStepRead(_ *testing.T, path, name string, credentialTests []credentialTestFunc) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.ReadOperation, Path: path + "/" + name, @@ -909,6 +936,8 @@ func testAccStepReadPolicy(t *testing.T, name string, value string) logicaltest. "iam_groups": []string(nil), "iam_tags": map[string]string(nil), "mfa_serial_number": "", + "session_tags": map[string]string(nil), + "external_id": "", } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -1107,22 +1136,7 @@ func TestAcceptanceBackend_iamUserGroups(t *testing.T) { func TestAcceptanceBackend_AssumedRoleWithPolicyDoc(t *testing.T) { t.Parallel() roleName := generateUniqueRoleName(t.Name()) - // This looks a bit curious. The policy document and the role document act - // as a logical intersection of policies. The role allows ec2:Describe* - // (among other permissions). This policy allows everything BUT - // ec2:DescribeAvailabilityZones. Thus, the logical intersection of the two - // is all ec2:Describe* EXCEPT ec2:DescribeAvailabilityZones, and so the - // describeAZs call should fail - allowAllButDescribeAzs := ` -{ - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "NotAction": "ec2:DescribeAvailabilityZones", - "Resource": "*" - }] -} -` + awsAccountID, err := getAccountID() if err != nil { t.Logf("Unable to retrive user via sts:GetCallerIdentity: %#v", err) @@ -1137,7 +1151,7 @@ func TestAcceptanceBackend_AssumedRoleWithPolicyDoc(t *testing.T) { AcceptanceTest: true, PreCheck: func() { testAccPreCheck(t) - createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}, nil) // Sleep sometime because AWS is eventually consistent log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") time.Sleep(10 * time.Second) @@ -1173,7 +1187,7 @@ func TestAcceptanceBackend_AssumedRoleWithPolicyARN(t *testing.T) { AcceptanceTest: true, PreCheck: func() { testAccPreCheck(t) - createRole(t, roleName, awsAccountID, []string{ec2PolicyArn, iamPolicyArn}) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn, iamPolicyArn}, nil) log.Printf("[WARN] Sleeping for 10 seconds waiting for AWS...") time.Sleep(10 * time.Second) }, @@ -1194,22 +1208,7 @@ func TestAcceptanceBackend_AssumedRoleWithGroups(t *testing.T) { t.Parallel() roleName := generateUniqueRoleName(t.Name()) groupName := generateUniqueGroupName(t.Name()) - // This looks a bit curious. The policy document and the role document act - // as a logical intersection of policies. The role allows ec2:Describe* - // (among other permissions). This policy allows everything BUT - // ec2:DescribeAvailabilityZones. Thus, the logical intersection of the two - // is all ec2:Describe* EXCEPT ec2:DescribeAvailabilityZones, and so the - // describeAZs call should fail - allowAllButDescribeAzs := `{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "NotAction": "ec2:DescribeAvailabilityZones", - "Resource": "*" - } - ] -}` + awsAccountID, err := getAccountID() if err != nil { t.Logf("Unable to retrive user via sts:GetCallerIdentity: %#v", err) @@ -1225,7 +1224,7 @@ func TestAcceptanceBackend_AssumedRoleWithGroups(t *testing.T) { AcceptanceTest: true, PreCheck: func() { testAccPreCheck(t) - createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}, nil) createGroup(t, groupName, allowAllButDescribeAzs, []string{}) // Sleep sometime because AWS is eventually consistent log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") @@ -1247,6 +1246,62 @@ func TestAcceptanceBackend_AssumedRoleWithGroups(t *testing.T) { }) } +// TestAcceptanceBackend_AssumedRoleWithSessionTags tests that session tags are +// passed to the assumed role. +func TestAcceptanceBackend_AssumedRoleWithSessionTags(t *testing.T) { + t.Parallel() + roleName := generateUniqueRoleName(t.Name()) + awsAccountID, err := getAccountID() + if err != nil { + t.Logf("Unable to retrive user via sts:GetCallerIdentity: %#v", err) + t.Skip("Could not determine AWS account ID from sts:GetCallerIdentity for acceptance tests, skipping") + } + + roleARN := fmt.Sprintf("arn:aws:iam::%s:role/%s", awsAccountID, roleName) + roleData := map[string]interface{}{ + "policy_document": allowAllButDescribeAzs, + "role_arns": []string{roleARN}, + "credential_type": assumedRoleCred, + "session_tags": map[string]string{ + "foo": "bar", + "baz": "qux", + }, + } + + // allowSessionTagsPolicy allows the role to tag the session, it needs to be + // included in the trust policy. + allowSessionTagsPolicy := fmt.Sprintf(` + { + "Sid": "AllowPassSessionTagsAndTransitive", + "Effect": "Allow", + "Action": "sts:TagSession", + "Principal": { + "AWS": "arn:aws:iam::%s:root" + } + } +`, awsAccountID) + + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + PreCheck: func() { + testAccPreCheck(t) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}, []string{allowSessionTagsPolicy}) + log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") + time.Sleep(10 * time.Second) + }, + LogicalBackend: getBackend(t), + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepWriteRole(t, "test", roleData), + testAccStepRead(t, "sts", "test", []credentialTestFunc{describeInstancesTest, describeAzsTestUnauthorized}), + testAccStepRead(t, "creds", "test", []credentialTestFunc{describeInstancesTest, describeAzsTestUnauthorized}), + }, + Teardown: func() error { + return deleteTestRole(roleName) + }, + }) +} + func TestAcceptanceBackend_FederationTokenWithPolicyARN(t *testing.T) { t.Parallel() userName := generateUniqueUserName(t.Name()) @@ -1328,6 +1383,7 @@ func TestAcceptanceBackend_FederationTokenWithGroups(t *testing.T) { }) } +// TestAcceptanceBackend_SessionToken func TestAcceptanceBackend_SessionToken(t *testing.T) { t.Parallel() userName := generateUniqueUserName(t.Name()) @@ -1427,7 +1483,7 @@ func TestAcceptanceBackend_RoleDefaultSTSTTL(t *testing.T) { AcceptanceTest: true, PreCheck: func() { testAccPreCheck(t) - createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}, nil) log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") time.Sleep(10 * time.Second) }, @@ -1443,7 +1499,8 @@ func TestAcceptanceBackend_RoleDefaultSTSTTL(t *testing.T) { }) } -func TestBackend_policyArnCrud(t *testing.T) { +// TestBackend_policyArnCRUD test the CRUD operations for policy ARNs. +func TestBackend_policyArnCRUD(t *testing.T) { t.Parallel() logicaltest.Test(t, logicaltest.TestCase{ AcceptanceTest: false, @@ -1483,6 +1540,8 @@ func testAccStepReadArnPolicy(t *testing.T, name string, value string) logicalte "iam_groups": []string(nil), "iam_tags": map[string]string(nil), "mfa_serial_number": "", + "session_tags": map[string]string(nil), + "external_id": "", } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -1503,7 +1562,8 @@ func testAccStepWriteArnRoleRef(t *testing.T, vaultRoleName, awsRoleName, awsAcc } } -func TestBackend_iamGroupsCrud(t *testing.T) { +// TestBackend_iamGroupsCRUD tests CRUD operations for IAM groups. +func TestBackend_iamGroupsCRUD(t *testing.T) { t.Parallel() logicaltest.Test(t, logicaltest.TestCase{ AcceptanceTest: false, @@ -1554,6 +1614,8 @@ func testAccStepReadIamGroups(t *testing.T, name string, groups []string) logica "iam_groups": groups, "iam_tags": map[string]string(nil), "mfa_serial_number": "", + "session_tags": map[string]string(nil), + "external_id": "", } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -1564,7 +1626,8 @@ func testAccStepReadIamGroups(t *testing.T, name string, groups []string) logica } } -func TestBackend_iamTagsCrud(t *testing.T) { +// TestBackend_iamTagsCRUD tests the CRUD operations for IAM tags. +func TestBackend_iamTagsCRUD(t *testing.T) { logicaltest.Test(t, logicaltest.TestCase{ AcceptanceTest: false, LogicalBackend: getBackend(t), @@ -1614,6 +1677,176 @@ func testAccStepReadIamTags(t *testing.T, name string, tags map[string]string) l "iam_groups": []string(nil), "iam_tags": tags, "mfa_serial_number": "", + "session_tags": map[string]string(nil), + "external_id": "", + } + if !reflect.DeepEqual(resp.Data, expected) { + return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) + } + + return nil + }, + } +} + +// TestBackend_stsSessionTagsCRUD tests the CRUD operations for STS session tags. +func TestBackend_stsSessionTagsCRUD(t *testing.T) { + t.Parallel() + + tagParams0 := map[string]string{"tag1": "value1", "tag2": "value2"} + tagParams1 := map[string]string{"tag1": "value1", "tag2": "value4", "tag3": "value3"} + + // list of tags in the form of "key=value" + tagParamsList0 := []string{"key1=value1", "key2=value2"} + tagParamsList0Expect := map[string]string{"key1": "value1", "key2": "value2"} + tagParamsList1 := []string{"key1=value2", "key3=value4"} + tagParamsList1Expect := map[string]string{"key1": "value2", "key3": "value4"} + + type testCase struct { + name string + expectTags []map[string]string + tagsParams []any + externalIDs []string + } + + for _, tt := range []testCase{ + { + name: "mapped-only", + tagsParams: []any{ + tagParams0, + map[string]string{}, + tagParams1, + }, + expectTags: []map[string]string{ + tagParams0, + {}, + tagParams1, + }, + externalIDs: []string{"foo", "", "bar"}, + }, + { + name: "string-list-only", + tagsParams: []any{ + tagParamsList0, + tagParamsList1, + }, + expectTags: []map[string]string{ + tagParamsList0Expect, + tagParamsList1Expect, + }, + externalIDs: []string{"foo"}, + }, + { + name: "mixed-param-types", + tagsParams: []any{ + tagParams0, + tagParamsList0, + tagParams1, + tagParamsList1, + }, + expectTags: []map[string]string{ + tagParams0, + tagParamsList0Expect, + tagParams1, + tagParamsList1Expect, + }, + externalIDs: []string{"foo", "bar"}, + }, + { + name: "unset-tags", + tagsParams: []any{ + tagParams0, + map[string]string{}, + }, + expectTags: []map[string]string{ + tagParams0, + {}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + steps := []logicaltest.TestStep{ + testAccStepConfig(t), + } + + if len(tt.tagsParams) != len(tt.expectTags) { + t.Fatalf("invalid test case: test case params and expect must have the same length") + } + + // lastNonEmptyExternalID is used to store the last non-empty external ID for the + // test case. The value will is expected to be set on the role. Setting the value + // to an empty string has no effect on update operations. + var lastNonEmptyExternalID string + for idx, params := range tt.tagsParams { + var externalID string + if len(tt.externalIDs) > idx { + externalID = tt.externalIDs[idx] + } + if externalID != "" { + lastNonEmptyExternalID = externalID + } + steps = append(steps, testAccStepWriteSTSSessionTags(t, tt.name, params, externalID)) + steps = append(steps, testAccStepReadSTSSessionTags(t, tt.name, tt.expectTags[idx], lastNonEmptyExternalID, false)) + } + steps = append( + steps, + testAccStepDeletePolicy(t, tt.name), + testAccStepReadSTSSessionTags(t, tt.name, nil, "", true), + ) + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: false, + LogicalBackend: getBackend(t), + Steps: steps, + }) + }) + } +} + +func testAccStepWriteSTSSessionTags(t *testing.T, name string, tags any, externalID string) logicaltest.TestStep { + t.Helper() + + data := map[string]interface{}{ + "credential_type": assumedRoleCred, + "session_tags": tags, + } + if externalID != "" { + data["external_id"] = externalID + } + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "roles/" + name, + Data: data, + } +} + +func testAccStepReadSTSSessionTags(t *testing.T, name string, tags any, externalID string, expectNilResp bool) logicaltest.TestStep { + t.Helper() + + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "roles/" + name, + Check: func(resp *logical.Response) error { + if resp == nil { + if expectNilResp { + return nil + } + return fmt.Errorf("vault response not received") + } + + expected := map[string]interface{}{ + "policy_arns": []string(nil), + "role_arns": []string(nil), + "policy_document": "", + "credential_type": assumedRoleCred, + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "", + "permissions_boundary_arn": "", + "iam_groups": []string(nil), + "iam_tags": map[string]string(nil), + "mfa_serial_number": "", + "session_tags": tags, + "external_id": externalID, } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) diff --git a/builtin/logical/aws/path_roles.go b/builtin/logical/aws/path_roles.go index abf24a072efa..1c1ef3546aed 100644 --- a/builtin/logical/aws/path_roles.go +++ b/builtin/logical/aws/path_roles.go @@ -115,7 +115,23 @@ delimited key pairs.`, Value: "[key1=value1, key2=value2]", }, }, - + "session_tags": { + Type: framework.TypeKVPairs, + Description: fmt.Sprintf(`Session tags to be set for %q creds created by this role. These must be presented +as Key-Value pairs. This can be represented as a map or a list of equal sign +delimited key pairs.`, assumedRoleCred), + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Session Tags", + Value: "[key1=value1, key2=value2]", + }, + }, + "external_id": { + Type: framework.TypeString, + Description: "External ID to set when assuming the role; only valid when credential_type is " + assumedRoleCred, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "External ID", + }, + }, "default_sts_ttl": { Type: framework.TypeDurationSecond, Description: fmt.Sprintf("Default TTL for %s, %s, and %s credential types when no TTL is explicitly requested with the credentials", assumedRoleCred, federationTokenCred, sessionTokenCred), @@ -341,6 +357,14 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f roleEntry.SerialNumber = serialNumber.(string) } + if sessionTags, ok := d.GetOk("session_tags"); ok { + roleEntry.SessionTags = sessionTags.(map[string]string) + } + + if externalID, ok := d.GetOk("external_id"); ok { + roleEntry.ExternalID = externalID.(string) + } + if legacyRole != "" { roleEntry = upgradeLegacyPolicyEntry(legacyRole) if roleEntry.InvalidData != "" { @@ -527,6 +551,8 @@ type awsRoleEntry struct { PolicyDocument string `json:"policy_document"` // JSON-serialized inline policy to attach to IAM users and/or to specify as the Policy parameter in AssumeRole calls IAMGroups []string `json:"iam_groups"` // Names of IAM groups that generated IAM users will be added to IAMTags map[string]string `json:"iam_tags"` // IAM tags that will be added to the generated IAM users + SessionTags map[string]string `json:"session_tags"` // Session tags that will be added as Tags parameter in AssumedRole calls + ExternalID string `json:"external_id"` // External ID to added as ExternalID in AssumeRole calls InvalidData string `json:"invalid_data,omitempty"` // Invalid role data. Exists to support converting the legacy role data into the new format ProhibitFlexibleCredPath bool `json:"prohibit_flexible_cred_path,omitempty"` // Disallow accessing STS credentials via the creds path and vice verse Version int `json:"version"` // Version number of the role format @@ -545,6 +571,8 @@ func (r *awsRoleEntry) toResponseData() map[string]interface{} { "policy_document": r.PolicyDocument, "iam_groups": r.IAMGroups, "iam_tags": r.IAMTags, + "session_tags": r.SessionTags, + "external_id": r.ExternalID, "default_sts_ttl": int64(r.DefaultSTSTTL.Seconds()), "max_sts_ttl": int64(r.MaxSTSTTL.Seconds()), "user_path": r.UserPath, @@ -612,6 +640,14 @@ func (r *awsRoleEntry) validate() error { errors = multierror.Append(errors, fmt.Errorf("cannot supply role_arns when credential_type isn't %s", assumedRoleCred)) } + if len(r.SessionTags) > 0 && !strutil.StrListContains(r.CredentialTypes, assumedRoleCred) { + errors = multierror.Append(errors, fmt.Errorf("cannot supply session_tags when credential_type isn't %s", assumedRoleCred)) + } + + if r.ExternalID != "" && !strutil.StrListContains(r.CredentialTypes, assumedRoleCred) { + errors = multierror.Append(errors, fmt.Errorf("cannot supply external_id when credential_type isn't %s", assumedRoleCred)) + } + return errors.ErrorOrNil() } diff --git a/builtin/logical/aws/path_roles_test.go b/builtin/logical/aws/path_roles_test.go index 32d65da7bb81..80328cc5f01a 100644 --- a/builtin/logical/aws/path_roles_test.go +++ b/builtin/logical/aws/path_roles_test.go @@ -5,11 +5,13 @@ package aws import ( "context" + "errors" "reflect" "strconv" "strings" "testing" + "github.com/hashicorp/go-multierror" "github.com/hashicorp/vault/sdk/logical" ) @@ -366,22 +368,74 @@ func TestRoleEntryValidationIamUserCred(t *testing.T) { CredentialTypes: []string{iamUserCred}, RoleArns: []string{"arn:aws:iam::123456789012:role/SomeRole"}, } - if roleEntry.validate() == nil { - t.Errorf("bad: invalid roleEntry with invalid RoleArns parameter %#v passed validation", roleEntry) - } + assertMultiError(t, roleEntry.validate(), + []error{ + errors.New( + "cannot supply role_arns when credential_type isn't assumed_role", + ), + }) roleEntry = awsRoleEntry{ CredentialTypes: []string{iamUserCred}, PolicyArns: []string{adminAccessPolicyARN}, DefaultSTSTTL: 1, } - if roleEntry.validate() == nil { - t.Errorf("bad: invalid roleEntry with unrecognized DefaultSTSTTL %#v passed validation", roleEntry) - } + assertMultiError(t, roleEntry.validate(), + []error{ + errors.New( + "default_sts_ttl parameter only valid for assumed_role, federation_token, and session_token credential types", + ), + }) roleEntry.DefaultSTSTTL = 0 + roleEntry.MaxSTSTTL = 1 - if roleEntry.validate() == nil { - t.Errorf("bad: invalid roleEntry with unrecognized MaxSTSTTL %#v passed validation", roleEntry) + assertMultiError(t, roleEntry.validate(), + []error{ + errors.New( + "max_sts_ttl parameter only valid for assumed_role, federation_token, and session_token credential types", + ), + }) + roleEntry.MaxSTSTTL = 0 + + roleEntry.SessionTags = map[string]string{ + "Key1": "Value1", + "Key2": "Value2", + } + assertMultiError(t, roleEntry.validate(), + []error{ + errors.New( + "cannot supply session_tags when credential_type isn't assumed_role", + ), + }) + roleEntry.SessionTags = nil + + roleEntry.ExternalID = "my-ext-id" + assertMultiError(t, roleEntry.validate(), + []error{ + errors.New( + "cannot supply external_id when credential_type isn't assumed_role"), + }) +} + +func assertMultiError(t *testing.T, err error, expected []error) { + t.Helper() + + if err == nil { + t.Errorf("expected error, got nil") + return + } + + var multiErr *multierror.Error + if errors.As(err, &multiErr) { + if multiErr.Len() != len(expected) { + t.Errorf("expected %d error, got %d", len(expected), multiErr.Len()) + } else { + if !reflect.DeepEqual(expected, multiErr.Errors) { + t.Errorf("expected error %q, actual %q", expected, multiErr.Errors) + } + } + } else { + t.Errorf("expected multierror, got %T", err) } } @@ -392,8 +446,13 @@ func TestRoleEntryValidationAssumedRoleCred(t *testing.T) { RoleArns: []string{"arn:aws:iam::123456789012:role/SomeRole"}, PolicyArns: []string{adminAccessPolicyARN}, PolicyDocument: allowAllPolicyDocument, - DefaultSTSTTL: 2, - MaxSTSTTL: 3, + ExternalID: "my-ext-id", + SessionTags: map[string]string{ + "Key1": "Value1", + "Key2": "Value2", + }, + DefaultSTSTTL: 2, + MaxSTSTTL: 3, } if err := roleEntry.validate(); err != nil { t.Errorf("bad: valid roleEntry %#v failed validation: %v", roleEntry, err) diff --git a/builtin/logical/aws/path_user.go b/builtin/logical/aws/path_user.go index 46b9c3e928a9..430f7754eec9 100644 --- a/builtin/logical/aws/path_user.go +++ b/builtin/logical/aws/path_user.go @@ -157,7 +157,7 @@ func (b *backend) pathCredsRead(ctx context.Context, req *logical.Request, d *fr case !strutil.StrListContains(role.RoleArns, roleArn): return logical.ErrorResponse(fmt.Sprintf("role_arn %q not in allowed role arns for Vault role %q", roleArn, roleName)), nil } - return b.assumeRole(ctx, req.Storage, req.DisplayName, roleName, roleArn, role.PolicyDocument, role.PolicyArns, role.IAMGroups, ttl, roleSessionName) + return b.assumeRole(ctx, req.Storage, req.DisplayName, roleName, roleArn, role.PolicyDocument, role.PolicyArns, role.IAMGroups, ttl, roleSessionName, role.SessionTags, role.ExternalID) case federationTokenCred: return b.getFederationToken(ctx, req.Storage, req.DisplayName, roleName, role.PolicyDocument, role.PolicyArns, role.IAMGroups, ttl) case sessionTokenCred: diff --git a/builtin/logical/aws/secret_access_keys.go b/builtin/logical/aws/secret_access_keys.go index 151a9a5cd754..a9a9290cc5b7 100644 --- a/builtin/logical/aws/secret_access_keys.go +++ b/builtin/logical/aws/secret_access_keys.go @@ -238,7 +238,7 @@ func (b *backend) getSessionToken(ctx context.Context, s logical.Storage, serial func (b *backend) assumeRole(ctx context.Context, s logical.Storage, displayName, roleName, roleArn, policy string, policyARNs []string, - iamGroups []string, lifeTimeInSeconds int64, roleSessionName string) (*logical.Response, error, + iamGroups []string, lifeTimeInSeconds int64, roleSessionName string, sessionTags map[string]string, externalID string) (*logical.Response, error, ) { // grab any IAM group policies associated with the vault role, both inline // and managed @@ -295,6 +295,19 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage, if len(policyARNs) > 0 { assumeRoleInput.SetPolicyArns(convertPolicyARNs(policyARNs)) } + if externalID != "" { + assumeRoleInput.SetExternalId(externalID) + } + var tags []*sts.Tag + for k, v := range sessionTags { + tags = append(tags, + &sts.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }, + ) + } + assumeRoleInput.SetTags(tags) tokenResp, err := stsClient.AssumeRoleWithContext(ctx, assumeRoleInput) if err != nil { return logical.ErrorResponse("Error assuming role: %s", err), awsutil.CheckAWSError(err) diff --git a/changelog/27620.txt b/changelog/27620.txt new file mode 100644 index 000000000000..e808a0b4e0e7 --- /dev/null +++ b/changelog/27620.txt @@ -0,0 +1,5 @@ +```release-note:feature +**AWS secrets engine STS session tags support**: Adds support for setting STS +session tags when generating temporary credentials using the AWS secrets +engine. +``` diff --git a/website/content/api-docs/secret/aws.mdx b/website/content/api-docs/secret/aws.mdx index 681dc1af9280..be9d2f86e4f5 100644 --- a/website/content/api-docs/secret/aws.mdx +++ b/website/content/api-docs/secret/aws.mdx @@ -31,7 +31,7 @@ files, or IAM/ECS instances. - Static credentials provided to the API as a payload -- [Plugin workload identity federation](/vault/docs/secrets/aws#plugin-workload-identity-federation-wif) +- [Plugin workload identity federation](/vault/docs/secrets/aws#plugin-workload-identity-federation-wif) credentials - Credentials in the `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_REGION` @@ -60,15 +60,15 @@ valid AWS credentials with proper permissions. - `secret_key` `(string: "")` – Specifies the AWS secret access key. Must be provided with `access_key`. -- `role_arn` `(string: "")` – Role ARN to assume +- `role_arn` `(string: "")` – Role ARN to assume for plugin workload identity federation. Required with `identity_token_audience`. -- `identity_token_audience` `(string: "")` - The - audience claim value for plugin identity tokens. Must match an allowed audience configured +- `identity_token_audience` `(string: "")` - The + audience claim value for plugin identity tokens. Must match an allowed audience configured for the target [IAM OIDC identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html#manage-oidc-provider-console). Mutually exclusive with `access_key`. -- `identity_token_ttl` `(string/int: 3600)` - The +- `identity_token_ttl` `(string/int: 3600)` - The TTL of generated tokens. Defaults to 1 hour. Uses [duration format strings](/vault/docs/concepts/duration-format). - `region` `(string: )` – Specifies the AWS region. If not set it @@ -316,6 +316,13 @@ updated with the new attributes. TTL are capped to `max_sts_ttl`). Valid only when `credential_type` is one of `assumed_role` or `federation_token`. +- `session_tags` `(list: [])` - The set of key-value pairs to be included as tags for the STS session. + Allowed formats are a map of strings or a list of strings in the format `key=value`. + Valid only when `credential_type` is set to `assumed_role`. + +- `external_id` `(string)` - The external ID to use when assuming the role. + Valid only when `credential_type` is set to `assumed_role`. + - `user_path` `(string)` - The path for the user name. Valid only when `credential_type` is `iam_user`. Default is `/` @@ -645,7 +652,7 @@ $ curl \ "data": { "access_key": "AKIA...", "secret_key": "xlCs...", - "session_token": "FwoG...", + "session_token": "FwoG..." } } ``` @@ -660,7 +667,7 @@ to the configured `rotation_period`. Vault will create a new credential upon configuration, and if the maximum number of access keys already exist, Vault will rotate the oldest one. Vault must do this to know the credential. At each rotation period, Vault will continue to prioritize rotating the oldest-existing credential. - + For example, if an IAM User has no access keys when onboarded into Vault, then Vault will generate its first access key for the user. On the first rotation, Vault will generate a second access key for the user. It is only upon the next rotation cycle that the first access key will now be rotated.