From e1a432a167701235c2b6c6cd28f5480a0e8bcc54 Mon Sep 17 00:00:00 2001 From: Theron Voran Date: Tue, 9 Jun 2020 16:56:12 -0700 Subject: [PATCH] AWS: Add iam_groups parameter to role create/update (#8811) Allows vault roles to be associated with IAM groups in the AWS secrets engine, since IAM groups are a recommended way to manage IAM user policies. IAM users generated against a vault role will be added to the IAM Groups. For a credential type of `assumed_role` or `federation_token`, the policies sent to the corresponding AWS call (sts:AssumeRole or sts:GetFederation) will be the policies from each group in `iam_groups` combined with the `policy_document` and `policy_arns` parameters. Co-authored-by: Jim Kalafut --- builtin/logical/aws/backend_test.go | 374 +++++++++++++++++++- builtin/logical/aws/iam_policies.go | 141 ++++++++ builtin/logical/aws/iam_policies_test.go | 255 +++++++++++++ builtin/logical/aws/path_roles.go | 19 + builtin/logical/aws/path_user.go | 4 +- builtin/logical/aws/secret_access_keys.go | 69 +++- website/pages/api-docs/secret/aws/index.mdx | 36 +- website/pages/docs/secrets/aws/index.mdx | 28 +- 8 files changed, 893 insertions(+), 33 deletions(-) create mode 100644 builtin/logical/aws/iam_policies.go create mode 100644 builtin/logical/aws/iam_policies_test.go diff --git a/builtin/logical/aws/backend_test.go b/builtin/logical/aws/backend_test.go index 19dad9088..9876de1cc 100644 --- a/builtin/logical/aws/backend_test.go +++ b/builtin/logical/aws/backend_test.go @@ -20,6 +20,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/iam/iamiface" + "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/sts" cleanhttp "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/vault/helper/testhelpers" @@ -357,6 +358,49 @@ func createUser(t *testing.T, userName string, accessKey *awsAccessKey) { accessKey.SecretAccessKey = *genAccessKey.SecretAccessKey } +// Create an IAM Group and add an inline policy and managed policies if specified +func createGroup(t *testing.T, groupName string, inlinePolicy string, managedPolicies []string) { + awsConfig := &aws.Config{ + Region: aws.String("us-east-1"), + HTTPClient: cleanhttp.DefaultClient(), + } + sess, err := session.NewSession(awsConfig) + if err != nil { + t.Fatal(err) + } + svc := iam.New(sess) + createGroupInput := &iam.CreateGroupInput{ + GroupName: aws.String(groupName), + } + log.Printf("[INFO] AWS CreateGroup: %s", groupName) + if _, err := svc.CreateGroup(createGroupInput); err != nil { + t.Fatalf("AWS CreateGroup failed: %v", err) + } + + if len(inlinePolicy) > 0 { + putPolicyInput := &iam.PutGroupPolicyInput{ + PolicyDocument: aws.String(inlinePolicy), + PolicyName: aws.String("InlinePolicy"), + GroupName: aws.String(groupName), + } + _, err = svc.PutGroupPolicy(putPolicyInput) + if err != nil { + t.Fatalf("AWS PutGroupPolicy failed: %v", err) + } + } + + for _, mp := range managedPolicies { + attachGroupPolicyInput := &iam.AttachGroupPolicyInput{ + PolicyArn: aws.String(mp), + GroupName: aws.String(groupName), + } + _, err = svc.AttachGroupPolicy(attachGroupPolicyInput) + if err != nil { + t.Fatalf("AWS AttachGroupPolicy failed, %v", err) + } + } +} + func deleteTestRole(roleName string) error { awsConfig := &aws.Config{ Region: aws.String("us-east-1"), @@ -452,6 +496,71 @@ func deleteTestUser(accessKey *awsAccessKey, userName string) error { return nil } +func deleteTestGroup(groupName string) error { + awsConfig := &aws.Config{ + Region: aws.String("us-east-1"), + HTTPClient: cleanhttp.DefaultClient(), + } + sess, err := session.NewSession(awsConfig) + if err != nil { + return err + } + svc := iam.New(sess) + + // Detach any managed group policies + getGroupsInput := &iam.ListAttachedGroupPoliciesInput{ + GroupName: aws.String(groupName), + } + getGroupsOutput, err := svc.ListAttachedGroupPolicies(getGroupsInput) + if err != nil { + log.Printf("[WARN] AWS ListAttachedGroupPolicies failed: %v", err) + return err + } + for _, g := range getGroupsOutput.AttachedPolicies { + detachGroupInput := &iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: g.PolicyArn, + } + if _, err := svc.DetachGroupPolicy(detachGroupInput); err != nil { + log.Printf("[WARN] AWS DetachGroupPolicy failed: %v", err) + return err + } + } + + // Remove any inline policies + listGroupPoliciesInput := &iam.ListGroupPoliciesInput{ + GroupName: aws.String(groupName), + } + listGroupPoliciesOutput, err := svc.ListGroupPolicies(listGroupPoliciesInput) + if err != nil { + log.Printf("[WARN] AWS ListGroupPolicies failed: %v", err) + return err + } + for _, g := range listGroupPoliciesOutput.PolicyNames { + deleteGroupPolicyInput := &iam.DeleteGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyName: g, + } + if _, err := svc.DeleteGroupPolicy(deleteGroupPolicyInput); err != nil { + log.Printf("[WARN] AWS DeleteGroupPolicy failed: %v", err) + return err + } + } + + // Delete the group + deleteTestGroupInput := &iam.DeleteGroupInput{ + GroupName: aws.String(groupName), + } + log.Printf("[INFO] AWS DeleteGroup: %s", groupName) + _, err = svc.DeleteGroup(deleteTestGroupInput) + if err != nil { + log.Printf("[WARN] AWS DeleteGroup failed: %v", err) + return err + } + + return nil +} + func testAccStepConfig(t *testing.T) logicaltest.TestStep { return logicaltest.TestStep{ Operation: logical.UpdateOperation, @@ -674,6 +783,25 @@ func listDynamoTablesTest(accessKey, secretKey, token string) error { }) } +func listS3BucketsTest(accessKey, secretKey, token string) error { + creds := credentials.NewStaticCredentials(accessKey, secretKey, token) + awsConfig := &aws.Config{ + Credentials: creds, + Region: aws.String("us-east-1"), + HTTPClient: cleanhttp.DefaultClient(), + } + sess, err := session.NewSession(awsConfig) + if err != nil { + return err + } + client := s3.New(sess) + log.Printf("[WARN] Verifying that the generated credentials work with s3:ListBuckets...") + return retryUntilSuccess(func() error { + _, err := client.ListBuckets(&s3.ListBucketsInput{}) + return err + }) +} + func retryUntilSuccess(op func() error) error { retryCount := 0 success := false @@ -743,6 +871,7 @@ func testAccStepReadPolicy(t *testing.T, name string, value string) logicaltest. "max_sts_ttl": int64(0), "user_path": "", "permissions_boundary_arn": "", + "iam_groups": []string(nil), } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -769,6 +898,20 @@ const testDynamoPolicy = `{ } ` +const testS3Policy = `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:Get*", + "s3:List*" + ], + "Resource": "*" + } + ] +}` + const adminAccessPolicyArn = "arn:aws:iam::aws:policy/AdministratorAccess" const ec2PolicyArn = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess" const iamPolicyArn = "arn:aws:iam::aws:policy/IAMReadOnlyAccess" @@ -825,15 +968,17 @@ func TestBackend_basicPolicyArnRef(t *testing.T) { }) } -func TestBackend_iamUserManagedInlinePolicies(t *testing.T) { +func TestBackend_iamUserManagedInlinePoliciesGroups(t *testing.T) { t.Parallel() compacted, err := compactJSON(testDynamoPolicy) if err != nil { t.Fatalf("bad: %#v", err) } + groupName := generateUniqueName(t.Name()) roleData := map[string]interface{}{ "policy_document": testDynamoPolicy, "policy_arns": []string{ec2PolicyArn, iamPolicyArn}, + "iam_groups": []string{groupName}, "credential_type": iamUserCred, "user_path": "/path/", } @@ -846,18 +991,72 @@ func TestBackend_iamUserManagedInlinePolicies(t *testing.T) { "max_sts_ttl": int64(0), "user_path": "/path/", "permissions_boundary_arn": "", + "iam_groups": []string{groupName}, } logicaltest.Test(t, logicaltest.TestCase{ AcceptanceTest: true, - PreCheck: func() { testAccPreCheck(t) }, + PreCheck: func() { + testAccPreCheck(t) + createGroup(t, groupName, testS3Policy, []string{}) + }, LogicalBackend: getBackend(t), Steps: []logicaltest.TestStep{ testAccStepConfig(t), testAccStepWriteRole(t, "test", roleData), testAccStepReadRole(t, "test", expectedRoleData), - testAccStepRead(t, "creds", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest, assertCreatedIAMUser}), - testAccStepRead(t, "sts", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest}), + testAccStepRead(t, "creds", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest, assertCreatedIAMUser, listS3BucketsTest}), + testAccStepRead(t, "sts", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest, listS3BucketsTest}), + }, + Teardown: func() error { + return deleteTestGroup(groupName) + }, + }) +} + +// Similar to TestBackend_iamUserManagedInlinePoliciesGroups() but managing +// policies only with groups +func TestBackend_iamUserGroups(t *testing.T) { + t.Parallel() + group1Name := generateUniqueName(t.Name()) + group2Name := generateUniqueName(t.Name()) + roleData := map[string]interface{}{ + "iam_groups": []string{group1Name, group2Name}, + "credential_type": iamUserCred, + "user_path": "/path/", + } + expectedRoleData := map[string]interface{}{ + "policy_document": "", + "policy_arns": []string(nil), + "credential_type": iamUserCred, + "role_arns": []string(nil), + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "/path/", + "permissions_boundary_arn": "", + "iam_groups": []string{group1Name, group2Name}, + } + + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + PreCheck: func() { + testAccPreCheck(t) + createGroup(t, group1Name, testS3Policy, []string{ec2PolicyArn, iamPolicyArn}) + createGroup(t, group2Name, testDynamoPolicy, []string{}) + }, + LogicalBackend: getBackend(t), + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepWriteRole(t, "test", roleData), + testAccStepReadRole(t, "test", expectedRoleData), + testAccStepRead(t, "creds", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest, assertCreatedIAMUser, listS3BucketsTest}), + testAccStepRead(t, "sts", "test", []credentialTestFunc{describeInstancesTest, listIamUsersTest, listDynamoTablesTest, listS3BucketsTest}), + }, + Teardown: func() error { + if err := deleteTestGroup(group1Name); err != nil { + return err + } + return deleteTestGroup(group2Name) }, }) } @@ -948,6 +1147,63 @@ func TestBackend_AssumedRoleWithPolicyARN(t *testing.T) { }) } +func TestBackend_AssumedRoleWithGroups(t *testing.T) { + t.Parallel() + roleName := generateUniqueName(t.Name()) + groupName := generateUniqueName(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) + t.Skip("Could not determine AWS account ID from sts:GetCallerIdentity for acceptance tests, skipping") + } + + roleData := map[string]interface{}{ + "iam_groups": []string{groupName}, + "role_arns": []string{fmt.Sprintf("arn:aws:iam::%s:role/%s", awsAccountID, roleName)}, + "credential_type": assumedRoleCred, + } + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + PreCheck: func() { + testAccPreCheck(t) + createRole(t, roleName, awsAccountID, []string{ec2PolicyArn}) + createGroup(t, groupName, allowAllButDescribeAzs, []string{}) + // Sleep sometime because AWS is eventually consistent + 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 { + if err := deleteTestGroup(groupName); err != nil { + return err + } + return deleteTestRole(roleName) + }, + }) +} + func TestBackend_FederationTokenWithPolicyARN(t *testing.T) { t.Parallel() userName := generateUniqueName(t.Name()) @@ -979,6 +1235,56 @@ func TestBackend_FederationTokenWithPolicyARN(t *testing.T) { }) } +func TestBackend_FederationTokenWithGroups(t *testing.T) { + t.Parallel() + userName := generateUniqueName(t.Name()) + groupName := generateUniqueName(t.Name()) + accessKey := &awsAccessKey{} + + // IAM policy where Statement is a single element, not a list + iamSingleStatementPolicy := `{ + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": [ + "s3:Get*", + "s3:List*" + ], + "Resource": "*" + } + }` + + roleData := map[string]interface{}{ + "iam_groups": []string{groupName}, + "policy_document": iamSingleStatementPolicy, + "credential_type": federationTokenCred, + } + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + PreCheck: func() { + testAccPreCheck(t) + createUser(t, userName, accessKey) + createGroup(t, groupName, "", []string{dynamoPolicyArn}) + // Sleep sometime because AWS is eventually consistent + log.Println("[WARN] Sleeping for 10 seconds waiting for AWS...") + time.Sleep(10 * time.Second) + }, + LogicalBackend: getBackend(t), + Steps: []logicaltest.TestStep{ + testAccStepConfigWithCreds(t, accessKey), + testAccStepWriteRole(t, "test", roleData), + testAccStepRead(t, "sts", "test", []credentialTestFunc{listDynamoTablesTest, describeAzsTestUnauthorized, listS3BucketsTest}), + testAccStepRead(t, "creds", "test", []credentialTestFunc{listDynamoTablesTest, describeAzsTestUnauthorized, listS3BucketsTest}), + }, + Teardown: func() error { + if err := deleteTestGroup(groupName); err != nil { + return err + } + return deleteTestUser(accessKey, userName) + }, + }) +} + func TestBackend_RoleDefaultSTSTTL(t *testing.T) { t.Parallel() roleName := generateUniqueName(t.Name()) @@ -1051,6 +1357,7 @@ func testAccStepReadArnPolicy(t *testing.T, name string, value string) logicalte "max_sts_ttl": int64(0), "user_path": "", "permissions_boundary_arn": "", + "iam_groups": []string(nil), } if !reflect.DeepEqual(resp.Data, expected) { return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) @@ -1071,6 +1378,65 @@ func testAccStepWriteArnRoleRef(t *testing.T, vaultRoleName, awsRoleName, awsAcc } } +func TestBackend_iamGroupsCrud(t *testing.T) { + t.Parallel() + logicaltest.Test(t, logicaltest.TestCase{ + AcceptanceTest: true, + LogicalBackend: getBackend(t), + Steps: []logicaltest.TestStep{ + testAccStepConfig(t), + testAccStepWriteIamGroups(t, "test", []string{"group1", "group2"}), + testAccStepReadIamGroups(t, "test", []string{"group1", "group2"}), + testAccStepDeletePolicy(t, "test"), + testAccStepReadIamGroups(t, "test", []string{}), + }, + }) +} + +func testAccStepWriteIamGroups(t *testing.T, name string, groups []string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "roles/" + name, + Data: map[string]interface{}{ + "credential_type": iamUserCred, + "iam_groups": groups, + }, + } +} + +func testAccStepReadIamGroups(t *testing.T, name string, groups []string) logicaltest.TestStep { + return logicaltest.TestStep{ + Operation: logical.ReadOperation, + Path: "roles/" + name, + Check: func(resp *logical.Response) error { + if resp == nil { + if len(groups) == 0 { + return nil + } + + return fmt.Errorf("bad: %#v", resp) + } + + expected := map[string]interface{}{ + "policy_arns": []string(nil), + "role_arns": []string(nil), + "policy_document": "", + "credential_type": iamUserCred, + "default_sts_ttl": int64(0), + "max_sts_ttl": int64(0), + "user_path": "", + "permissions_boundary_arn": "", + "iam_groups": groups, + } + if !reflect.DeepEqual(resp.Data, expected) { + return fmt.Errorf("bad: got: %#v\nexpected: %#v", resp.Data, expected) + } + + return nil + }, + } +} + func generateUniqueName(prefix string) string { return testhelpers.RandomWithPrefix(prefix) } diff --git a/builtin/logical/aws/iam_policies.go b/builtin/logical/aws/iam_policies.go new file mode 100644 index 000000000..a8c134de6 --- /dev/null +++ b/builtin/logical/aws/iam_policies.go @@ -0,0 +1,141 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/iam/iamiface" + "github.com/hashicorp/vault/sdk/logical" +) + +// PolicyDocument represents an IAM policy document +type PolicyDocument struct { + Version string `json:"Version"` + Statements StatementEntries `json:"Statement"` +} + +// StatementEntries is a slice of statements that make up a PolicyDocument +type StatementEntries []interface{} + +// UnmarshalJSON is defined here for StatementEntries because the Statement +// portion of an IAM Policy can either be a list or a single element, so if it's +// a single element this wraps it in a []interface{} so that it's easy to +// combine with other policy statements: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_statement.html +func (se *StatementEntries) UnmarshalJSON(b []byte) error { + var out StatementEntries + + var data interface{} + if err := json.Unmarshal(b, &data); err != nil { + return err + } + + switch t := data.(type) { + case []interface{}: + out = t + case interface{}: + out = []interface{}{t} + default: + return fmt.Errorf("unsupported data type %T for StatementEntries", t) + } + *se = out + return nil +} + +// getGroupPolicies takes a list of IAM Group names and returns a list of their +// inline policy documents, and a list of the attached managed policy ARNs +func (b *backend) getGroupPolicies(ctx context.Context, s logical.Storage, iamGroups []string) ([]string, []string, error) { + var groupPolicies []string + var groupPolicyARNs []string + var err error + var agp *iam.ListAttachedGroupPoliciesOutput + var inlinePolicies *iam.ListGroupPoliciesOutput + var inlinePolicyDoc *iam.GetGroupPolicyOutput + var iamClient iamiface.IAMAPI + + // Return early if there are no groups, to avoid creating an IAM client + // needlessly + if len(iamGroups) == 0 { + return nil, nil, nil + } + + iamClient, err = b.clientIAM(ctx, s) + if err != nil { + return nil, nil, err + } + + for _, g := range iamGroups { + // Collect managed policy ARNs from the IAM Group + agp, err = iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ + GroupName: aws.String(g), + }) + if err != nil { + return nil, nil, err + } + for _, p := range agp.AttachedPolicies { + groupPolicyARNs = append(groupPolicyARNs, *p.PolicyArn) + } + + // Collect inline policy names from the IAM Group + inlinePolicies, err = iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{ + GroupName: aws.String(g), + }) + if err != nil { + return nil, nil, err + } + for _, iP := range inlinePolicies.PolicyNames { + inlinePolicyDoc, err = iamClient.GetGroupPolicy(&iam.GetGroupPolicyInput{ + GroupName: &g, + PolicyName: iP, + }) + if err != nil { + return nil, nil, err + } + if inlinePolicyDoc != nil && inlinePolicyDoc.PolicyDocument != nil { + var policyStr string + if policyStr, err = url.QueryUnescape(*inlinePolicyDoc.PolicyDocument); err != nil { + return nil, nil, err + } + groupPolicies = append(groupPolicies, policyStr) + } + } + } + return groupPolicies, groupPolicyARNs, nil +} + +// combinePolicyDocuments takes policy strings as input, and combines them into +// a single policy document string +func combinePolicyDocuments(policies ...string) (string, error) { + var policy string + var err error + var policyBytes []byte + var newPolicy = PolicyDocument{ + // 2012-10-17 is the current version of the AWS policy language: + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_version.html + Version: "2012-10-17", + } + newPolicy.Statements = make(StatementEntries, 0, len(policies)) + + for _, p := range policies { + if len(p) == 0 { + continue + } + var tmpDoc PolicyDocument + err = json.Unmarshal([]byte(p), &tmpDoc) + if err != nil { + return "", err + } + newPolicy.Statements = append(newPolicy.Statements, tmpDoc.Statements...) + } + + policyBytes, err = json.Marshal(&newPolicy) + if err != nil { + return "", err + } + policy = string(policyBytes) + return policy, nil +} diff --git a/builtin/logical/aws/iam_policies_test.go b/builtin/logical/aws/iam_policies_test.go new file mode 100644 index 000000000..d2521b1dc --- /dev/null +++ b/builtin/logical/aws/iam_policies_test.go @@ -0,0 +1,255 @@ +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/iam/iamiface" + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/assert" +) + +const ec2DescribePolicy = `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["ec2:DescribeInstances"], "Resource": "*"}]}` + +// ec2AllPolicy also uses a string instead of a list for the Action +const ec2AllPolicy = `{"Version": "2012-10-17","Statement": [{"Effect": "Allow", "Action": "ec2:*", "Resource": "*"}]}` + +// ec2SingleStatement is an example of the Statement portion containing a single statement that's not a list +const ec2SingleStatement = `{"Version": "2012-10-17", "Statement": {"Effect": "Allow", "Action": ["ec2:DescribeInstances"], "Resource": "*"}}` + +type mockGroupIAMClient struct { + iamiface.IAMAPI + ListAttachedGroupPoliciesResp iam.ListAttachedGroupPoliciesOutput + ListGroupPoliciesResp iam.ListGroupPoliciesOutput + GetGroupPolicyResp iam.GetGroupPolicyOutput +} + +func (m mockGroupIAMClient) ListAttachedGroupPolicies(in *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) { + return &m.ListAttachedGroupPoliciesResp, nil +} + +func (m mockGroupIAMClient) ListGroupPolicies(in *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { + return &m.ListGroupPoliciesResp, nil +} + +func (m mockGroupIAMClient) GetGroupPolicy(in *iam.GetGroupPolicyInput) (*iam.GetGroupPolicyOutput, error) { + return &m.GetGroupPolicyResp, nil +} + +func Test_getGroupPolicies(t *testing.T) { + t.Parallel() + testCases := []struct { + description string + listAGPResp iam.ListAttachedGroupPoliciesOutput + listGPResp iam.ListGroupPoliciesOutput + getGPResp iam.GetGroupPolicyOutput + iamGroupArg []string + wantGroupPolicies []string + wantGroupPolicyARNs []string + wantErr bool + }{ + { + description: "All IAM calls respond with data", + listAGPResp: iam.ListAttachedGroupPoliciesOutput{ + AttachedPolicies: []*iam.AttachedPolicy{ + { + PolicyArn: aws.String("abcdefghijklmnopqrst"), + PolicyName: aws.String("test policy"), + }, + }, + }, + listGPResp: iam.ListGroupPoliciesOutput{ + PolicyNames: []*string{ + aws.String("inline policy"), + }, + }, + getGPResp: iam.GetGroupPolicyOutput{ + GroupName: aws.String("inline policy"), + PolicyDocument: aws.String(ec2DescribePolicy), + PolicyName: aws.String("ec2 describe"), + }, + iamGroupArg: []string{"testgroup1"}, + wantGroupPolicies: []string{ec2DescribePolicy}, + wantGroupPolicyARNs: []string{"abcdefghijklmnopqrst"}, + wantErr: false, + }, + { + description: "No managed policies", + listAGPResp: iam.ListAttachedGroupPoliciesOutput{}, + listGPResp: iam.ListGroupPoliciesOutput{ + PolicyNames: []*string{ + aws.String("inline policy"), + }, + }, + getGPResp: iam.GetGroupPolicyOutput{ + GroupName: aws.String("inline policy"), + PolicyDocument: aws.String(ec2DescribePolicy), + PolicyName: aws.String("ec2 describe"), + }, + iamGroupArg: []string{"testgroup1", "testgroup2"}, + wantGroupPolicies: []string{ec2DescribePolicy, ec2DescribePolicy}, + wantGroupPolicyARNs: []string(nil), + wantErr: false, + }, + { + description: "No inline policies", + listAGPResp: iam.ListAttachedGroupPoliciesOutput{ + AttachedPolicies: []*iam.AttachedPolicy{ + { + PolicyArn: aws.String("abcdefghijklmnopqrst"), + PolicyName: aws.String("test policy"), + }, + }, + }, + listGPResp: iam.ListGroupPoliciesOutput{}, + getGPResp: iam.GetGroupPolicyOutput{}, + iamGroupArg: []string{"testgroup1"}, + wantGroupPolicies: []string(nil), + wantGroupPolicyARNs: []string{"abcdefghijklmnopqrst"}, + wantErr: false, + }, + { + description: "No policies", + listAGPResp: iam.ListAttachedGroupPoliciesOutput{}, + listGPResp: iam.ListGroupPoliciesOutput{}, + getGPResp: iam.GetGroupPolicyOutput{}, + iamGroupArg: []string{"testgroup1"}, + wantGroupPolicies: []string(nil), + wantGroupPolicyARNs: []string(nil), + wantErr: false, + }, + { + description: "empty iam_groups arg", + listAGPResp: iam.ListAttachedGroupPoliciesOutput{}, + listGPResp: iam.ListGroupPoliciesOutput{}, + getGPResp: iam.GetGroupPolicyOutput{}, + iamGroupArg: []string{}, + wantGroupPolicies: []string(nil), + wantGroupPolicyARNs: []string(nil), + wantErr: false, + }, + } + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + // configure backend and iam client + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + + b := Backend() + if err := b.Setup(context.Background(), config); err != nil { + t.Fatal(err) + } + b.iamClient = &mockGroupIAMClient{ + ListAttachedGroupPoliciesResp: tc.listAGPResp, + ListGroupPoliciesResp: tc.listGPResp, + GetGroupPolicyResp: tc.getGPResp, + } + + // run the test and compare results + groupPolicies, groupPolicyARNs, err := b.getGroupPolicies(context.TODO(), config.StorageView, tc.iamGroupArg) + assert.Equal(t, tc.wantGroupPolicies, groupPolicies) + assert.Equal(t, tc.wantGroupPolicyARNs, groupPolicyARNs) + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func Test_combinePolicyDocuments(t *testing.T) { + t.Parallel() + testCases := []struct { + description string + input []string + expectedOutput string + expectedErr bool + }{ + { + description: "one policy", + input: []string{ + ec2AllPolicy, + }, + expectedOutput: `{"Version":"2012-10-17","Statement":[{"Action":"ec2:*","Effect":"Allow","Resource":"*"}]}`, + expectedErr: false, + }, + { + description: "two policies", + input: []string{ + ec2AllPolicy, + ec2DescribePolicy, + }, + expectedOutput: `{"Version": "2012-10-17", "Statement":[ + {"Action": "ec2:*", "Effect": "Allow", "Resource": "*"}, + {"Action": ["ec2:DescribeInstances"], "Effect": "Allow", "Resource": "*"}]}`, + expectedErr: false, + }, + { + description: "two policies, one with empty statement", + input: []string{ + ec2AllPolicy, + `{"Version": "2012-10-17", "Statement": []}`, + }, + expectedOutput: `{"Version": "2012-10-17", "Statement": [{"Action": "ec2:*", "Effect": "Allow", "Resource": "*"}]}`, + expectedErr: false, + }, + { + description: "malformed json", + input: []string{ + `"Version": "2012-10-17","Statement": [{"Effect": "Allow", "Action": "ec2:*", "Resource": "*"}]}`, + `{"Version": "2012-10-17", "Statement": []}`, + }, + expectedOutput: ``, + expectedErr: true, + }, + { + description: "not action", + input: []string{ + `{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "NotAction": "ec2:DescribeAvailabilityZones", "Resource": "*"}]}`, + }, + expectedOutput: `{"Version": "2012-10-17","Statement":[{"Effect": "Allow","NotAction": "ec2:DescribeAvailabilityZones", "Resource": "*"}]}`, + expectedErr: false, + }, + { + description: "one blank policy", + input: []string{ + "", + `{"Version": "2012-10-17", "Statement": []}`, + }, + expectedOutput: `{"Version": "2012-10-17", "Statement": []}`, + expectedErr: false, + }, + { + description: "when statement is not a list", + input: []string{ + ec2SingleStatement, + }, + expectedOutput: `{"Version": "2012-10-17", "Statement": [{"Action": ["ec2:DescribeInstances"], "Effect": "Allow", "Resource": "*"}]}`, + expectedErr: false, + }, + { + description: "statement is malformed json", + input: []string{ + `{"Version": "2012-10-17", "Statement": {true}`, + }, + expectedOutput: "", + expectedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + policyOut, err := combinePolicyDocuments(tc.input...) + if (err != nil) != tc.expectedErr { + t.Fatalf("got unexpected error: %s", err) + } + if (err != nil) != tc.expectedErr { + t.Fatalf("got unexpected error: %s", err) + } + // remove whitespace + tc.expectedOutput, err = compactJSON(tc.expectedOutput) + if policyOut != tc.expectedOutput { + t.Fatalf("did not receive expected output: want %s, got %s", tc.expectedOutput, policyOut) + } + }) + } +} diff --git a/builtin/logical/aws/path_roles.go b/builtin/logical/aws/path_roles.go index 6633c48a6..a5f225479 100644 --- a/builtin/logical/aws/path_roles.go +++ b/builtin/logical/aws/path_roles.go @@ -80,6 +80,19 @@ will be passed in as the Policy parameter to the AssumeRole or GetFederationToken API call, acting as a filter on permissions available.`, }, + "iam_groups": &framework.FieldSchema{ + Type: framework.TypeCommaStringSlice, + Description: `Names of IAM groups that generated IAM users will be added to. For a credential +type of assumed_role or federation_token, the policies sent to the +corresponding AWS call (sts:AssumeRole or sts:GetFederation) will be the +policies from each group in iam_groups combined with the policy_document +and policy_arns parameters.`, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "IAM Groups", + Value: "group1,group2", + }, + }, + "default_sts_ttl": &framework.FieldSchema{ 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), @@ -284,6 +297,10 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f roleEntry.PermissionsBoundaryARN = permissionsBoundaryARNRaw.(string) } + if iamGroups, ok := d.GetOk("iam_groups"); ok { + roleEntry.IAMGroups = iamGroups.([]string) + } + if legacyRole != "" { roleEntry = upgradeLegacyPolicyEntry(legacyRole) if roleEntry.InvalidData != "" { @@ -468,6 +485,7 @@ type awsRoleEntry struct { PolicyArns []string `json:"policy_arns"` // ARNs of managed policies to attach to an IAM user RoleArns []string `json:"role_arns"` // ARNs of roles to assume for AssumedRole credentials 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 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 @@ -483,6 +501,7 @@ func (r *awsRoleEntry) toResponseData() map[string]interface{} { "policy_arns": r.PolicyArns, "role_arns": r.RoleArns, "policy_document": r.PolicyDocument, + "iam_groups": r.IAMGroups, "default_sts_ttl": int64(r.DefaultSTSTTL.Seconds()), "max_sts_ttl": int64(r.MaxSTSTTL.Seconds()), "user_path": r.UserPath, diff --git a/builtin/logical/aws/path_user.go b/builtin/logical/aws/path_user.go index 6c1f89ad1..c9b43e97e 100644 --- a/builtin/logical/aws/path_user.go +++ b/builtin/logical/aws/path_user.go @@ -126,9 +126,9 @@ 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, ttl) + return b.assumeRole(ctx, req.Storage, req.DisplayName, roleName, roleArn, role.PolicyDocument, role.PolicyArns, role.IAMGroups, ttl) case federationTokenCred: - return b.getFederationToken(ctx, req.Storage, req.DisplayName, roleName, role.PolicyDocument, role.PolicyArns, ttl) + return b.getFederationToken(ctx, req.Storage, req.DisplayName, roleName, role.PolicyDocument, role.PolicyArns, role.IAMGroups, ttl) default: return logical.ErrorResponse(fmt.Sprintf("unknown credential_type: %q", credentialType)), nil } diff --git a/builtin/logical/aws/secret_access_keys.go b/builtin/logical/aws/secret_access_keys.go index 361981962..bbc4011c4 100644 --- a/builtin/logical/aws/secret_access_keys.go +++ b/builtin/logical/aws/secret_access_keys.go @@ -67,7 +67,23 @@ func genUsername(displayName, policyName, userType string) (ret string, warning func (b *backend) getFederationToken(ctx context.Context, s logical.Storage, displayName, policyName, policy string, policyARNs []string, - lifeTimeInSeconds int64) (*logical.Response, error) { + iamGroups []string, lifeTimeInSeconds int64) (*logical.Response, error) { + + groupPolicies, groupPolicyARNs, err := b.getGroupPolicies(ctx, s, iamGroups) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + if groupPolicies != nil { + groupPolicies = append(groupPolicies, policy) + policy, err = combinePolicyDocuments(groupPolicies...) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + } + if len(groupPolicyARNs) > 0 { + policyARNs = append(policyARNs, groupPolicyARNs...) + } + stsClient, err := b.clientSTS(ctx, s) if err != nil { return logical.ErrorResponse(err.Error()), nil @@ -91,14 +107,13 @@ func (b *backend) getFederationToken(ctx context.Context, s logical.Storage, // that by default; the behavior can be explicitly opted in to by associating the Vault role // with a policy ARN or document that allows the appropriate permissions. if policy == "" && len(policyARNs) == 0 { - return logical.ErrorResponse(fmt.Sprintf("must specify at least one of policy_arns or policy_document with %s credential_type", federationTokenCred)), nil + return logical.ErrorResponse("must specify at least one of policy_arns or policy_document with %s credential_type", federationTokenCred), nil } tokenResp, err := stsClient.GetFederationToken(getTokenInput) if err != nil { - return logical.ErrorResponse(fmt.Sprintf( - "Error generating STS keys: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error generating STS keys: %s", err), awsutil.CheckAWSError(err) } resp := b.Secret(secretAccessKeyType).Response(map[string]interface{}{ @@ -126,7 +141,25 @@ 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, - lifeTimeInSeconds int64) (*logical.Response, error) { + iamGroups []string, lifeTimeInSeconds int64) (*logical.Response, error) { + + // grab any IAM group policies associated with the vault role, both inline + // and managed + groupPolicies, groupPolicyARNs, err := b.getGroupPolicies(ctx, s, iamGroups) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + if len(groupPolicies) > 0 { + groupPolicies = append(groupPolicies, policy) + policy, err = combinePolicyDocuments(groupPolicies...) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + } + if len(groupPolicyARNs) > 0 { + policyARNs = append(policyARNs, groupPolicyARNs...) + } + stsClient, err := b.clientSTS(ctx, s) if err != nil { return logical.ErrorResponse(err.Error()), nil @@ -148,8 +181,7 @@ func (b *backend) assumeRole(ctx context.Context, s logical.Storage, tokenResp, err := stsClient.AssumeRole(assumeRoleInput) if err != nil { - return logical.ErrorResponse(fmt.Sprintf( - "Error assuming role: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error assuming role: %s", err), awsutil.CheckAWSError(err) } resp := b.Secret(secretAccessKeyType).Response(map[string]interface{}{ @@ -217,8 +249,7 @@ func (b *backend) secretAccessKeysCreate( iamErr := errwrap.Wrapf("error creating IAM user: {{err}}", err) return nil, errwrap.Wrap(errwrap.Wrapf("failed to delete WAL entry: {{err}}", walErr), iamErr) } - return logical.ErrorResponse(fmt.Sprintf( - "Error creating IAM user: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error creating IAM user: %s", err), awsutil.CheckAWSError(err) } for _, arn := range role.PolicyArns { @@ -228,8 +259,7 @@ func (b *backend) secretAccessKeysCreate( PolicyArn: aws.String(arn), }) if err != nil { - return logical.ErrorResponse(fmt.Sprintf( - "Error attaching user policy: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error attaching user policy: %s", err), awsutil.CheckAWSError(err) } } @@ -241,8 +271,18 @@ func (b *backend) secretAccessKeysCreate( PolicyDocument: aws.String(role.PolicyDocument), }) if err != nil { - return logical.ErrorResponse(fmt.Sprintf( - "Error putting user policy: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error putting user policy: %s", err), awsutil.CheckAWSError(err) + } + } + + for _, group := range role.IAMGroups { + // Add user to IAM groups + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + UserName: aws.String(username), + GroupName: aws.String(group), + }) + if err != nil { + return logical.ErrorResponse("Error adding user to group: %s", err), awsutil.CheckAWSError(err) } } @@ -251,8 +291,7 @@ func (b *backend) secretAccessKeysCreate( UserName: aws.String(username), }) if err != nil { - return logical.ErrorResponse(fmt.Sprintf( - "Error creating access keys: %s", err)), awsutil.CheckAWSError(err) + return logical.ErrorResponse("Error creating access keys: %s", err), awsutil.CheckAWSError(err) } // Remove the WAL entry, we succeeded! If we fail, we don't return diff --git a/website/pages/api-docs/secret/aws/index.mdx b/website/pages/api-docs/secret/aws/index.mdx index 52787cb36..2c3da63e3 100644 --- a/website/pages/api-docs/secret/aws/index.mdx +++ b/website/pages/api-docs/secret/aws/index.mdx @@ -254,6 +254,13 @@ updated with the new attributes. user has. With `assumed_role` and `federation_token`, the policy document will act as a filter on what the credentials can do, similar to `policy_arns`. +- `iam_groups` `(list: [])` - A list of IAM group names. IAM users generated + against this vault role will be added to these IAM Groups. For a credential + type of `assumed_role` or `federation_token`, the policies sent to the + corresponding AWS call (sts:AssumeRole or sts:GetFederation) will be the + policies from each group in `iam_groups` combined with the `policy_document` + and `policy_arns` parameters. + - `default_sts_ttl` `(string)` - The default TTL for STS credentials. When a TTL is not specified when STS credentials are requested, and a default TTL is specified on the role, then this default TTL will be used. Valid only when @@ -313,6 +320,15 @@ Using an ARN: } ``` +Using groups: + +```json +{ + "credential_type": "assumed_role", + "iam_groups": ["group1", "group2"] +} +``` + ## Read Role This endpoint queries an existing role by the given name. If the role does not @@ -348,7 +364,8 @@ For an inline IAM policy: "policy_document": "{\"Version\": \"...\"}", "policy_arns": [], "credential_types": ["assumed_role"], - "role_arns": [] + "role_arns": [], + "iam_groups": [] } } ``` @@ -361,7 +378,22 @@ For a role ARN: "policy_document": "", "policy_arns": [], "credential_types": ["assumed_role"], - "role_arns": ["arn:aws:iam::123456789012:role/example-role"] + "role_arns": ["arn:aws:iam::123456789012:role/example-role"], + "iam_groups": [] + } +} +``` + +For IAM groups: + +```json +{ + "data": { + "policy_document": "", + "policy_arns": [], + "credential_types": ["assumed_role"], + "role_arns": [], + "iam_groups": ["group1", "group2"] } } ``` diff --git a/website/pages/docs/secrets/aws/index.mdx b/website/pages/docs/secrets/aws/index.mdx index 566a94ad4..fdceedfb6 100644 --- a/website/pages/docs/secrets/aws/index.mdx +++ b/website/pages/docs/secrets/aws/index.mdx @@ -98,11 +98,12 @@ management tool. document to the IAM user. Vault will then create an access key and secret key for the IAM user and return these credentials. You supply a user inline policy and/or provide references to an existing AWS policy's full - ARN: + ARN and/or a list of IAM groups: ```text $ vault write aws/roles/my-other-role \ policy_arns=arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess,arn:aws:iam::aws:policy/IAMReadOnlyAccess \ + iam_groups=group1,group2 \ credential_type=iam_user \ policy_document=-<