From 8470de9bcddd050b78715d7de91e98cb7e696473 Mon Sep 17 00:00:00 2001 From: Harsimran Singh Maan Date: Mon, 23 Jan 2023 17:04:05 -0800 Subject: [PATCH] Add Session tags and external id support for AWS Secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It is now possible to set the AWS session tags and external id when assuming an IAM role via STS AssumeRole. Here's an example setup AWS Role - Trust relationship needs the `sts:TagSession` permission set as described in https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_permissions-required ```bash vault secrets enable aws vault write aws/config/root access_key= secret_key= region=us-west-2 vault write aws/roles/my-role credential_type=assumed_role max_sts_ttl=1h role_arns="arn:aws:iam::000000000000:role/test" session_tags="department=eng" session_tags="project=p1" external_id=123 ``` Read the role definition ``` vault read aws/roles/my-role Key Value --- ----- credential_type assumed_role default_sts_ttl 0s external_id 123 iam_groups iam_tags max_sts_ttl 1h permissions_boundary_arn n/a policy_arns policy_document n/a role_arns [arn:aws:iam::000000000000:role/test] session_tags map[department:eng project:p1] user_path n/a ``` ```bash vault read aws/sts/my-role Key Value --- ----- access_key XXXX arn arn:aws:sts::000000000000:assumed-role/test/vault-token-my-role-1674516375-mBKZQssrwtP6gl3kbmWq secret_key XXX security_token XXX ttl 59m59s ``` The cloudtrail for AssumeRole looks like ``` "userIdentity": { "type": "IAMUser", "principalId": "XXX", "arn": "arn:aws:iam::000000000000:user/vault-user", "accountId": "000000000000", "accessKeyId": "XXX", "userName": "vault-user" }, "eventTime": "2023-01-23T23:31:12Z", "eventSource": "sts.amazonaws.com", "eventName": "AssumeRole", "awsRegion": "us-east-1", "sourceIPAddress": "XXX", "userAgent": "aws-sdk-go/1.44.128 (go1.19.5; darwin; arm64)", "requestParameters": { "roleArn": "arn:aws:iam::000000000000:role/test", "roleSessionName": "vault-token-my-role-1674516375", "durationSeconds": 3600, "tags": [ { "key": "department", "value": "eng" }, { "key": "project", "value": "p1" } ], "externalId": "123" }, ... ``` When using the creds are used for accessing an S3 resource with Attribute-based Access Control (ABAC), you can now see that only requests with principalTag project=p1 are allowed while requests to path p2 are denied. ``` aws s3 cp a.txt s3://session-tags-test/p1/a.txt upload: ./a.txt to s3://session-tags-test/p1/a.txt ➜ ~ aws s3 cp a.txt s3://session-tags-test/p2/a.txt upload failed: ./a.txt to s3://session-tags-test/p2/a.txt An error occurred (AccessDenied) when calling the PutObject operation: Access Denied ``` The IAM policy had the following block in the above case ``` { "Action": [ "s3:Describe*", "s3:Get*", "s3:List*", "s3:PutObject*" ], "Effect": "Allow", "Resource": [ "arn:aws:s3:::session-tags-test/${aws:PrincipalTag/project}/*", "arn:aws:s3:::session-tags-test/${aws:PrincipalTag/project}" ] } ``` Troubleshooting: Error: ``` Error assuming role: AccessDenied: User: arn:aws:iam::000000000000:user/vault-user is not authorized to perform: sts:TagSession on resource: arn:aws:iam::000000000000:role/test status code: 403, request id: ba8ab60e-2fdf-4668-81ad-5fe83e9b898e ``` Remedy: Assign the `sts:TagSession` permission to the `arn:aws:iam::000000000000:role/test`. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_permissions-required Error: ``` * Error assuming role: AccessDenied: User: arn:aws:iam::000000000000:user/vault-user is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::000000000000:role/test status code: 403, request id: c0117588-9c84-490d-9b36-91135545dec1 ``` Remedy: If the external ID is set, follow https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html to ensure that external ID matches the ID set on the role. If you have not added the externalID condition on the role, it would not affect the assume role operation when an external ID is set only in Vault. This change does not add support for transitive keys but it should be simple to add it in the future. Closes #3790 #7960 --- builtin/logical/aws/path_roles.go | 44 +++++++++++++++++++++-- builtin/logical/aws/path_roles_test.go | 9 +++-- builtin/logical/aws/path_user.go | 2 +- builtin/logical/aws/secret_access_keys.go | 12 ++++++- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/builtin/logical/aws/path_roles.go b/builtin/logical/aws/path_roles.go index 67545e641434..b8902e8fffd5 100644 --- a/builtin/logical/aws/path_roles.go +++ b/builtin/logical/aws/path_roles.go @@ -91,7 +91,6 @@ user generated. When credential_type is assumed_role or federation_token, this will be passed in as the Policy parameter to the AssumeRole or GetFederationToken API call, acting as a filter on permissions available.`, }, - "iam_groups": { Type: framework.TypeCommaStringSlice, Description: `Names of IAM groups that generated IAM users will be added to. For a credential @@ -115,7 +114,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 and %s credential types when no TTL is explicitly requested with the credentials", assumedRoleCred, federationTokenCred), @@ -328,6 +343,14 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f roleEntry.IAMTags = iamTags.(map[string]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 != "" { @@ -514,6 +537,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 @@ -531,6 +556,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, @@ -576,7 +603,7 @@ func (r *awsRoleEntry) validate() error { errors = multierror.Append(errors, fmt.Errorf("user_path parameter only valid for %s credential type", iamUserCred)) } if !userPathRegex.MatchString(r.UserPath) { - errors = multierror.Append(errors, fmt.Errorf("The specified value for user_path is invalid. It must match %q regexp", userPathRegex.String())) + errors = multierror.Append(errors, fmt.Errorf("invalid user_path value. It must match %q regexp", userPathRegex.String())) } } @@ -592,6 +619,17 @@ func (r *awsRoleEntry) validate() error { if len(r.RoleArns) > 0 && !strutil.StrListContains(r.CredentialTypes, assumedRoleCred) { 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)) + // https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_know + if len(r.SessionTags) > 50 { + errors = multierror.Append(errors, fmt.Errorf("cannot supply more than %d session_tags", 50)) + } + } + + 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 c5bf167866cc..e1da0c79b210 100644 --- a/builtin/logical/aws/path_roles_test.go +++ b/builtin/logical/aws/path_roles_test.go @@ -392,8 +392,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 1b6a5cd3b0e3..3dbefe1cd7db 100644 --- a/builtin/logical/aws/path_user.go +++ b/builtin/logical/aws/path_user.go @@ -152,7 +152,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) default: diff --git a/builtin/logical/aws/secret_access_keys.go b/builtin/logical/aws/secret_access_keys.go index 2f1ac442bcbf..866d82b4e6c3 100644 --- a/builtin/logical/aws/secret_access_keys.go +++ b/builtin/logical/aws/secret_access_keys.go @@ -184,7 +184,7 @@ func (b *backend) getFederationToken(ctx context.Context, s logical.Storage, 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 @@ -241,6 +241,16 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage, if len(policyARNs) > 0 { assumeRoleInput.SetPolicyArns(convertPolicyARNs(policyARNs)) } + if externalID != "" { + assumeRoleInput.SetExternalId(externalID) + } + if len(sessionTags) > 0 { + 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)