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 <jkalafut@hashicorp.com>
This commit is contained in:
Theron Voran 2020-06-09 16:56:12 -07:00 committed by GitHub
parent 315d1ba9c5
commit e1a432a167
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 893 additions and 33 deletions

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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,

View File

@ -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
}

View File

@ -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

View File

@ -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"]
}
}
```

View File

@ -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=-<<EOF
{
@ -177,6 +178,7 @@ permissions Vault needs:
"iam:ListGroupsForUser",
"iam:ListUserPolicies",
"iam:PutUserPolicy",
"iam:AddUserToGroup",
"iam:RemoveUserFromGroup"
],
"Resource": ["arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:user/vault-*"]
@ -203,6 +205,7 @@ user, you can use a policy like:
"iam:ListAttachedUserPolicies",
"iam:ListGroupsForUser",
"iam:ListUserPolicies",
"iam:AddUserToGroup",
"iam:RemoveUserFromGroup"
],
"Resource": ["arn:aws:iam::ACCOUNT-ID-WITHOUT-HYPHENS:user/vault-*"]
@ -254,8 +257,9 @@ An STS federation token inherits a set of permissions that are the combination
3. The managed policy ARNs configured in the Vault role
4. An implicit deny policy on IAM or STS operations.
Roles with a `credential_type` of `federation_token` can specify both a
`policy_document` and `policy_arns` parameter in the Vault role.
Roles with a `credential_type` of `federation_token` can specify one or more of
the `policy_document`, `policy_arns`, and `iam_groups` parameters in the Vault
role.
The `aws/config/root` credentials require IAM permissions for
`sts:GetFederationToken` and the permissions to delegate to the STS
@ -373,20 +377,22 @@ specify more than one IAM role ARN. If you do so, Vault clients can select which
role ARN they would like to assume when retrieving credentials from that role.
Further, you can specify both a `policy_document` and `policy_arns` parameters;
if specified, each acts as a
filter on the IAM permissions granted to the assumed role. For an action to be
if specified, each acts as a filter on the IAM permissions granted to the
assumed role. If `iam_groups` is specified, the inline and attached policies for
each IAM group will be added to the `policy_document` and `policy_arns`
parameters, respectively, when calling [sts:AssumeRole]. For an action to be
allowed, it must be permitted by both the IAM policy on the AWS role that is
assumed, the `policy_document` specified on the Vault role (if specified), and
the managed policies specified by the `policy_arns` parameter. (The
`policy_document` parameter is passed in as the `Policy` parameter to the
[sts:AssumeRole](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html)
API call, while the `policy_arns` parameter is passed in as the `PolicyArns`
parameter to the same call.)
[sts:AssumeRole] API call, while the `policy_arns` parameter is passed in as the
`PolicyArns` parameter to the same call.)
Note: When multiple `role_arns` are specified, clients requesting credentials
can specify any of the role ARNs that are defined on the Vault role in order to
retrieve credentials. However, when a `policy_document` is specified, that will
apply to ALL role credentials retrieved from AWS.
retrieve credentials. However, when `policy_document`, `policy_arns`, or
`iam_groups` are specified, that will apply to ALL role credentials retrieved
from AWS.
Let's create a "deploy" policy using the arn of our role to assume:
@ -410,6 +416,8 @@ secret_key HSs0DYYYYYY9W81DXtI0K7X84H+OVZXK5BXXXX
security_token AQoDYXdzEEwasAKwQyZUtZaCjVNDiXXXXXXXXgUgBBVUUbSyujLjsw6jYzboOQ89vUVIehUw/9MreAifXFmfdbjTr3g6zc0me9M+dB95DyhetFItX5QThw0lEsVQWSiIeIotGmg7mjT1//e7CJc4LpxbW707loFX1TYD1ilNnblEsIBKGlRNXZ+QJdguY4VkzXxv2urxIH0Sl14xtqsRPboV7eYruSEZlAuP3FLmqFbmA0AFPCT37cLf/vUHinSbvw49C4c9WQLH7CeFPhDub7/rub/QU/lCjjJ43IqIRo9jYgcEvvdRkQSt70zO8moGCc7pFvmL7XGhISegQpEzudErTE/PdhjlGpAKGR3d5qKrHpPYK/k480wk1Ai/t1dTa/8/3jUYTUeIkaJpNBnupQt7qoaXXXXXXXXXX
```
[sts:AssumeRole]: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
## Troubleshooting
### Dynamic IAM user errors