From 8148b7d0712fd330127b8779cab26df80e0493bb Mon Sep 17 00:00:00 2001 From: Ben Ash Date: Wed, 26 Jun 2024 22:13:26 +0000 Subject: [PATCH] AWS secrets: add support for STS session tags Adds support for configuring session tags for assume role operations. --- builtin/logical/aws/backend_test.go | 126 ++++++++++++++++++---- 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 | 28 ++--- 7 files changed, 245 insertions(+), 48 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..cd69a77270ae 100644 --- a/builtin/logical/aws/backend_test.go +++ b/builtin/logical/aws/backend_test.go @@ -97,7 +97,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...") @@ -252,23 +252,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 +287,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 { @@ -657,7 +666,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, @@ -1137,7 +1146,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 +1182,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) }, @@ -1225,7 +1234,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 +1256,77 @@ func TestAcceptanceBackend_AssumedRoleWithGroups(t *testing.T) { }) } +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") + } + + // 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": "*" + } + ] +}` + + 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()) @@ -1427,7 +1507,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) }, 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..768f72c06e5d 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..." } } ``` @@ -658,12 +665,9 @@ 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. + Vault will rotate the oldest one. Vault must do this to know the credential. + + At each rotation, Vault will rotate the oldest existing credential.