From 961f4468385aa08848baee1b27e64b93904c8bd7 Mon Sep 17 00:00:00 2001 From: Joel Thompson Date: Thu, 19 Sep 2019 16:35:12 -0700 Subject: [PATCH] secret/aws: Support permissions boundaries on iam_user creds (#6786) * secrets/aws: Support permissions boundaries on iam_user creds This allows configuring Vault to attach a permissions boundary policy to IAM users that it creates, configured on a per-Vault-role basis. * Fix indentation of policy in docs Use spaces instead of tabs --- builtin/logical/aws/backend_test.go | 65 ++++++--- builtin/logical/aws/path_roles.go | 54 +++++++- builtin/logical/aws/path_roles_test.go | 131 +++++++++++++++++- builtin/logical/aws/secret_access_keys.go | 11 +- website/source/api/secret/aws/index.html.md | 6 + website/source/docs/secrets/aws/index.html.md | 63 ++++++++- 6 files changed, 289 insertions(+), 41 deletions(-) diff --git a/builtin/logical/aws/backend_test.go b/builtin/logical/aws/backend_test.go index ec14fc198e1ea..5c372e513f230 100644 --- a/builtin/logical/aws/backend_test.go +++ b/builtin/logical/aws/backend_test.go @@ -57,6 +57,25 @@ func TestBackend_basic(t *testing.T) { }) } +func TestBackend_IamUserWithPermissionsBoundary(t *testing.T) { + t.Parallel() + roleData := map[string]interface{}{ + "credential_type": iamUserCred, + "policy_arns": adminAccessPolicyArn, + "permissions_boundary_arn": iamPolicyArn, + } + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + PreCheck: func() { testAccPreCheck(t) }, + LogicalBackend: getBackend(t), + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepWriteRole(t, "test", roleData), + testAccStepRead(t, "creds", "test", []credentialTestFunc{listIamUsersTest, describeAzsTestUnauthorized}), + }, + }) +} + func TestBackend_basicSTS(t *testing.T) { t.Parallel() awsAccountID, err := getAccountID() @@ -681,13 +700,14 @@ func testAccStepReadPolicy(t *testing.T, name string, value string) logicaltest. } expected := map[string]interface{}{ - "policy_arns": []string(nil), - "role_arns": []string(nil), - "policy_document": value, - "credential_type": strings.Join([]string{iamUserCred, federationTokenCred}, ","), - "default_sts_ttl": int64(0), - "max_sts_ttl": int64(0), - "user_path": "", + "policy_arns": []string(nil), + "role_arns": []string(nil), + "policy_document": value, + "credential_type": strings.Join([]string{iamUserCred, federationTokenCred}, ","), + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "", + "permissions_boundary_arn": "", } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -714,6 +734,7 @@ const testDynamoPolicy = `{ } ` +const adminAccessPolicyArn = "arn:aws:iam::aws:policy/AdministratorAccess" const ec2PolicyArn = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess" const iamPolicyArn = "arn:aws:iam::aws:policy/IAMReadOnlyAccess" const dynamoPolicyArn = "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess" @@ -782,13 +803,14 @@ func TestBackend_iamUserManagedInlinePolicies(t *testing.T) { "user_path": "/path/", } expectedRoleData := map[string]interface{}{ - "policy_document": compacted, - "policy_arns": []string{ec2PolicyArn, iamPolicyArn}, - "credential_type": iamUserCred, - "role_arns": []string(nil), - "default_sts_ttl": int64(0), - "max_sts_ttl": int64(0), - "user_path": "/path/", + "policy_document": compacted, + "policy_arns": []string{ec2PolicyArn, iamPolicyArn}, + "credential_type": iamUserCred, + "role_arns": []string(nil), + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "/path/", + "permissions_boundary_arn": "", } logicaltest.Test(t, logicaltest.TestCase{ @@ -986,13 +1008,14 @@ func testAccStepReadArnPolicy(t *testing.T, name string, value string) logicalte } expected := map[string]interface{}{ - "policy_arns": []string{value}, - "role_arns": []string(nil), - "policy_document": "", - "credential_type": iamUserCred, - "default_sts_ttl": int64(0), - "max_sts_ttl": int64(0), - "user_path": "", + "policy_arns": []string{value}, + "role_arns": []string(nil), + "policy_document": "", + "credential_type": iamUserCred, + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "", + "permissions_boundary_arn": "", } 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 915914222403a..6df1459cc1c99 100644 --- a/builtin/logical/aws/path_roles.go +++ b/builtin/logical/aws/path_roles.go @@ -96,6 +96,14 @@ GetFederationToken API call, acting as a filter on permissions available.`, }, }, + "permissions_boundary_arn": &framework.FieldSchema{ + Type: framework.TypeString, + Description: "ARN of an IAM policy to attach as a permissions boundary on IAM user credentials; only valid when credential_type is" + iamUserCred, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Permissions Boundary ARN", + }, + }, + "arn": &framework.FieldSchema{ Type: framework.TypeString, Description: `Use role_arns or policy_arns instead.`, @@ -269,6 +277,13 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f roleEntry.UserPath = userPathRaw.(string) } + if permissionsBoundaryARNRaw, ok := d.GetOk("permissions_boundary_arn"); ok { + if legacyRole != "" { + return logical.ErrorResponse("cannot supply deprecated role or policy parameters with permissions_boundary_arn"), nil + } + roleEntry.PermissionsBoundaryARN = permissionsBoundaryARNRaw.(string) + } + if legacyRole != "" { roleEntry = upgradeLegacyPolicyEntry(legacyRole) if roleEntry.InvalidData != "" { @@ -414,6 +429,20 @@ func upgradeLegacyPolicyEntry(entry string) *awsRoleEntry { return newRoleEntry } +func validateAWSManagedPolicy(policyARN string) error { + parsedARN, err := arn.Parse(policyARN) + if err != nil { + return err + } + if parsedARN.Service != "iam" { + return fmt.Errorf("expected a service of iam but got %s", parsedARN.Service) + } + if !strings.HasPrefix(parsedARN.Resource, "policy/") { + return fmt.Errorf("expected a resource type of policy but got %s", parsedARN.Resource) + } + return nil +} + func setAwsRole(ctx context.Context, s logical.Storage, roleName string, roleEntry *awsRoleEntry) error { if roleName == "" { return fmt.Errorf("empty role name") @@ -445,17 +474,19 @@ type awsRoleEntry struct { DefaultSTSTTL time.Duration `json:"default_sts_ttl"` // Default TTL for STS credentials MaxSTSTTL time.Duration `json:"max_sts_ttl"` // Max allowed TTL for STS credentials UserPath string `json:"user_path"` // The path for the IAM user when using "iam_user" credential type + PermissionsBoundaryARN string `json:"permissions_boundary_arn"` // ARN of an IAM policy to attach as a permissions boundary } func (r *awsRoleEntry) toResponseData() map[string]interface{} { respData := map[string]interface{}{ - "credential_type": strings.Join(r.CredentialTypes, ","), - "policy_arns": r.PolicyArns, - "role_arns": r.RoleArns, - "policy_document": r.PolicyDocument, - "default_sts_ttl": int64(r.DefaultSTSTTL.Seconds()), - "max_sts_ttl": int64(r.MaxSTSTTL.Seconds()), - "user_path": r.UserPath, + "credential_type": strings.Join(r.CredentialTypes, ","), + "policy_arns": r.PolicyArns, + "role_arns": r.RoleArns, + "policy_document": r.PolicyDocument, + "default_sts_ttl": int64(r.DefaultSTSTTL.Seconds()), + "max_sts_ttl": int64(r.MaxSTSTTL.Seconds()), + "user_path": r.UserPath, + "permissions_boundary_arn": r.PermissionsBoundaryARN, } if r.InvalidData != "" { @@ -501,6 +532,15 @@ func (r *awsRoleEntry) validate() error { } } + if r.PermissionsBoundaryARN != "" { + if !strutil.StrListContains(r.CredentialTypes, iamUserCred) { + errors = multierror.Append(errors, fmt.Errorf("cannot supply permissions_boundary_arn when credential_type isn't %s", iamUserCred)) + } + if err := validateAWSManagedPolicy(r.PermissionsBoundaryARN); err != nil { + errors = multierror.Append(fmt.Errorf("invalid permissions_boundary_arn parameter: %v", err)) + } + } + 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)) } diff --git a/builtin/logical/aws/path_roles_test.go b/builtin/logical/aws/path_roles_test.go index 246dfa2d36982..d280dfcd0d1e4 100644 --- a/builtin/logical/aws/path_roles_test.go +++ b/builtin/logical/aws/path_roles_test.go @@ -10,6 +10,8 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const adminAccessPolicyARN = "arn:aws:iam::aws:policy/AdministratorAccess" + func TestBackend_PathListRoles(t *testing.T) { var resp *logical.Response var err error @@ -214,10 +216,114 @@ func TestUserPathValidity(t *testing.T) { } } +func TestRoleCRUDWithPermissionsBoundary(t *testing.T) { + roleName := "test_perm_boundary" + + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + + b := Backend() + if err := b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + permissionsBoundaryARN := "arn:aws:iam::aws:policy/EC2FullAccess" + + roleData := map[string]interface{}{ + "credential_type": iamUserCred, + "policy_arns": []string{adminAccessPolicyARN}, + "permissions_boundary_arn": permissionsBoundaryARN, + } + request := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "roles/" + roleName, + Storage: config.StorageView, + Data: roleData, + } + resp, err := b.HandleRequest(context.Background(), request) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: role creation failed. resp:%#v\nerr:%v", resp, err) + } + + request = &logical.Request{ + Operation: logical.ReadOperation, + Path: "roles/" + roleName, + Storage: config.StorageView, + } + resp, err = b.HandleRequest(context.Background(), request) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: reading role failed. resp:%#v\nerr:%v", resp, err) + } + if resp.Data["credential_type"] != iamUserCred { + t.Errorf("bad: expected credential_type of %s, got %s instead", iamUserCred, resp.Data["credential_type"]) + } + if resp.Data["permissions_boundary_arn"] != permissionsBoundaryARN { + t.Errorf("bad: expected permissions_boundary_arn of %s, got %s instead", permissionsBoundaryARN, resp.Data["permissions_boundary_arn"]) + } +} + +func TestRoleWithPermissionsBoundaryValidation(t *testing.T) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + + b := Backend() + if err := b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + + roleData := map[string]interface{}{ + "credential_type": assumedRoleCred, // only iamUserCred supported with permissions_boundary_arn + "role_arns": []string{"arn:aws:iam::123456789012:role/VaultRole"}, + "permissions_boundary_arn": "arn:aws:iam::aws:policy/FooBar", + } + request := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "roles/test_perm_boundary", + Storage: config.StorageView, + Data: roleData, + } + resp, err := b.HandleRequest(context.Background(), request) + if err == nil && (resp == nil || !resp.IsError()) { + t.Fatalf("bad: expected role creation to fail due to bad credential_type, but it didn't. resp:%#v\nerr:%v", resp, err) + } + + roleData = map[string]interface{}{ + "credential_type": iamUserCred, + "policy_arns": []string{adminAccessPolicyARN}, + "permissions_boundary_arn": "arn:aws:notiam::aws:policy/FooBar", + } + request.Data = roleData + resp, err = b.HandleRequest(context.Background(), request) + if err == nil && (resp == nil || !resp.IsError()) { + t.Fatalf("bad: expected role creation to fail due to malformed permissions_boundary_arn, but it didn't. resp:%#v\nerr:%v", resp, err) + } +} + +func TestValidateAWSManagedPolicy(t *testing.T) { + expectErr := func(arn string) { + err := validateAWSManagedPolicy(arn) + if err == nil { + t.Errorf("bad: expected arn of %s to return an error but it didn't", arn) + } + } + + expectErr("not_an_arn") + expectErr("notarn:aws:iam::aws:policy/FooBar") + expectErr("arn:aws:notiam::aws:policy/FooBar") + expectErr("arn:aws:iam::aws:notpolicy/FooBar") + expectErr("arn:aws:iam::aws:policynot/FooBar") + + arn := "arn:aws:iam::aws:policy/FooBar" + err := validateAWSManagedPolicy(arn) + if err != nil { + t.Errorf("bad: expected arn of %s to not return an error but it did: %#v", arn, err) + } +} + func TestRoleEntryValidationCredTypes(t *testing.T) { roleEntry := awsRoleEntry{ CredentialTypes: []string{}, - PolicyArns: []string{"arn:aws:iam::aws:policy/AdministratorAccess"}, + PolicyArns: []string{adminAccessPolicyARN}, } if roleEntry.validate() == nil { t.Errorf("bad: invalid roleEntry with no CredentialTypes %#v passed validation", roleEntry) @@ -234,10 +340,10 @@ func TestRoleEntryValidationCredTypes(t *testing.T) { func TestRoleEntryValidationIamUserCred(t *testing.T) { var allowAllPolicyDocument = `{"Version": "2012-10-17", "Statement": [{"Sid": "AllowAll", "Effect": "Allow", "Action": "*", "Resource": "*"}]}` - roleEntry := awsRoleEntry{ - CredentialTypes: []string{iamUserCred}, - PolicyArns: []string{"arn:aws:iam::aws:policy/AdministratorAccess"}, + CredentialTypes: []string{iamUserCred}, + PolicyArns: []string{adminAccessPolicyARN}, + PermissionsBoundaryARN: adminAccessPolicyARN, } err := roleEntry.validate() if err != nil { @@ -264,7 +370,7 @@ func TestRoleEntryValidationIamUserCred(t *testing.T) { roleEntry = awsRoleEntry{ CredentialTypes: []string{iamUserCred}, - PolicyArns: []string{"arn:aws:iam::aws:policy/AdministratorAccess"}, + PolicyArns: []string{adminAccessPolicyARN}, DefaultSTSTTL: 1, } if roleEntry.validate() == nil { @@ -282,7 +388,7 @@ func TestRoleEntryValidationAssumedRoleCred(t *testing.T) { roleEntry := awsRoleEntry{ CredentialTypes: []string{assumedRoleCred}, RoleArns: []string{"arn:aws:iam::123456789012:role/SomeRole"}, - PolicyArns: []string{"arn:aws:iam::aws:policy/AdministratorAccess"}, + PolicyArns: []string{adminAccessPolicyARN}, PolicyDocument: allowAllPolicyDocument, DefaultSTSTTL: 2, MaxSTSTTL: 3, @@ -300,6 +406,11 @@ func TestRoleEntryValidationAssumedRoleCred(t *testing.T) { if roleEntry.validate() == nil { t.Errorf("bad: invalid roleEntry with unrecognized UserPath %#v passed validation", roleEntry) } + roleEntry.UserPath = "" + roleEntry.PermissionsBoundaryARN = adminAccessPolicyARN + if roleEntry.validate() == nil { + t.Errorf("bad: invalid roleEntry with unrecognized PermissionsBoundary %#v passed validation", roleEntry) + } } func TestRoleEntryValidationFederationTokenCred(t *testing.T) { @@ -307,7 +418,7 @@ func TestRoleEntryValidationFederationTokenCred(t *testing.T) { roleEntry := awsRoleEntry{ CredentialTypes: []string{federationTokenCred}, PolicyDocument: allowAllPolicyDocument, - PolicyArns: []string{"arn:aws:iam::aws:policy/AdministratorAccess"}, + PolicyArns: []string{adminAccessPolicyARN}, DefaultSTSTTL: 2, MaxSTSTTL: 3, } @@ -330,4 +441,10 @@ func TestRoleEntryValidationFederationTokenCred(t *testing.T) { if roleEntry.validate() == nil { t.Errorf("bad: invalid roleEntry with MaxSTSTTL < DefaultSTSTTL %#v passed validation", roleEntry) } + roleEntry.MaxSTSTTL = 0 + roleEntry.PermissionsBoundaryARN = adminAccessPolicyARN + if roleEntry.validate() == nil { + t.Errorf("bad: invalid roleEntry with unrecognized PermissionsBoundary %#v passed validation", roleEntry) + } + } diff --git a/builtin/logical/aws/secret_access_keys.go b/builtin/logical/aws/secret_access_keys.go index 3d8f1261781b4..c512aaa4f7230 100644 --- a/builtin/logical/aws/secret_access_keys.go +++ b/builtin/logical/aws/secret_access_keys.go @@ -202,11 +202,16 @@ func (b *backend) secretAccessKeysCreate( userPath = "/" } - // Create the user - _, err = iamClient.CreateUser(&iam.CreateUserInput{ + createUserRequest := &iam.CreateUserInput{ UserName: aws.String(username), Path: aws.String(userPath), - }) + } + if role.PermissionsBoundaryARN != "" { + createUserRequest.PermissionsBoundary = aws.String(role.PermissionsBoundaryARN) + } + + // Create the user + _, err = iamClient.CreateUser(createUserRequest) if err != nil { if walErr := framework.DeleteWAL(ctx, s, walID); walErr != nil { iamErr := errwrap.Wrapf("error creating IAM user: {{err}}", err) diff --git a/website/source/api/secret/aws/index.html.md b/website/source/api/secret/aws/index.html.md index e2bbbf30b4f1f..dc7f731a8e9a4 100644 --- a/website/source/api/secret/aws/index.html.md +++ b/website/source/api/secret/aws/index.html.md @@ -266,6 +266,12 @@ updated with the new attributes. - `user_path` `(string)` - The path for the user name. Valid only when `credential_type` is `iam_user`. Default is `/` +- `permissions_boundary_arn` `(string)` - The ARN of the [AWS Permissions + Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) + to attach to IAM users created in the role. Valid only when `credential_type` + is `iam_user`. If not specified, then no permissions boundary policy will be + attached. + Legacy parameters: These parameters are supported for backwards compatibility only. They cannot be diff --git a/website/source/docs/secrets/aws/index.html.md b/website/source/docs/secrets/aws/index.html.md index 68cfcc8cf9daa..389e5c6e85dec 100644 --- a/website/source/docs/secrets/aws/index.html.md +++ b/website/source/docs/secrets/aws/index.html.md @@ -19,9 +19,13 @@ and are automatically revoked when the Vault lease expires. Vault supports three different types of credentials to retrieve from AWS: 1. `iam_user`: Vault will create an IAM user for each lease, attach the managed - and inline IAM policies as specified in the role to the user, and then return - the access key and secret key to the caller. IAM users have no session tokens - and so no session token will be returned. + and inline IAM policies as specified in the role to the user, and if a + [permissions + boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) + is specified on the role, the permissions boundary will also be attached. + Vault will then generate an access key and secret key for the IAM user and + return them to the caller. IAM users have no session tokens and so no + session token will be returned. 2. `assumed_role`: Vault will call [sts:AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) and return the access key, secret key, and session token to the caller. @@ -185,6 +189,59 @@ permissions Vault needs: } ``` +Vault also supports AWS Permissions Boundaries when creating IAM users. If you +wish to enforce that Vault always attaches a permissions boundary to an IAM +user, you can use a policy like: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserPolicies", + "iam:RemoveUserFromGroup" + ], + "Resource": [ + "arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:user/vault-*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "iam:AttachUserPolicy", + "iam:CreateUser", + "iam:DeleteUserPolicy", + "iam:DetachUserPolicy", + "iam:PutUserPolicy" + ], + "Resource": [ + "arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:user/vault-*" + ], + "Condition": { + "StringEquals": { + "iam:PermissionsBoundary": [ + "arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:policy/PolicyName" + ] + } + } + } + ] +} +``` + +where the "iam:PermissionsBoundary" condition contains the list of permissions +boundary policies that you wish to ensure that Vault uses. This policy will +ensure that Vault uses one of the permissions boundaries specified (not all of +them). + ## STS credentials The above demonstrated usage with `iam_user` credential types. As mentioned,