From 0f8e8183111b2043c566462ef2943ce8fd2d92a5 Mon Sep 17 00:00:00 2001 From: Joel Thompson Date: Mon, 22 May 2017 21:57:20 -0400 Subject: [PATCH] Resolve bound_iam_principal_arn to internal AWS ID This adds a (now-default) option on roles to resolve the bound_iam_principal_arn (when using AWS IAM auth) to AWS's internal unique ID. The primary reason for this is to prevent a particular role or user from being deleted and recreated with the same ARN and thus taking over Vault permissions that were intended to be bound to the previous ARN, which more closely mimics AWS's behavior. By preferentially resolving via the internal unqiue ID rather than the ARN, this also fixes the issue in #2729 --- builtin/credential/aws/backend.go | 81 +++++++++ builtin/credential/aws/backend_test.go | 33 +++- builtin/credential/aws/client.go | 66 ++++++-- builtin/credential/aws/path_config_client.go | 4 + builtin/credential/aws/path_login.go | 169 +++++++++++-------- builtin/credential/aws/path_login_test.go | 57 +++---- builtin/credential/aws/path_role.go | 64 ++++++- builtin/credential/aws/path_role_test.go | 117 ++++++++++++- website/source/docs/auth/aws.html.md | 27 +++ 9 files changed, 492 insertions(+), 126 deletions(-) diff --git a/builtin/credential/aws/backend.go b/builtin/credential/aws/backend.go index 50e1e61e2f7f..dd09f6b07c70 100644 --- a/builtin/credential/aws/backend.go +++ b/builtin/credential/aws/backend.go @@ -1,9 +1,11 @@ package awsauth import ( + "fmt" "sync" "time" + "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/iam" "github.com/hashicorp/vault/logical" @@ -54,6 +56,15 @@ type backend struct { // When the credentials are modified or deleted, all the cached client objects // will be flushed. The empty STS role signifies the master account IAMClientsMap map[string]map[string]*iam.IAM + + // AWS Account ID of the "default" AWS credentials + // This cache avoids the need to call GetCallerIdentity repeatedly to learn it + // We can't store this because, in certain pathological cases, it could change + // out from under us, such as a standby and active Vault server in different AWS + // accounts using their IAM instance profile to get their credentials. + defaultAWSAccountID string + + resolveArnToUniqueId func(logical.Storage, string) (string, error) } func Backend(conf *logical.BackendConfig) (*backend, error) { @@ -65,6 +76,8 @@ func Backend(conf *logical.BackendConfig) (*backend, error) { IAMClientsMap: make(map[string]map[string]*iam.IAM), } + b.resolveArnToUniqueId = b.resolveArnToRealUniqueId + b.Backend = &framework.Backend{ PeriodicFunc: b.periodicFunc, AuthRenew: b.pathLoginRenew, @@ -171,7 +184,75 @@ func (b *backend) invalidate(key string) { defer b.configMutex.Unlock() b.flushCachedEC2Clients() b.flushCachedIAMClients() + b.defaultAWSAccountID = "" + } +} + +// Putting this here so we can inject a fake resolver into the backend for unit testing +// purposes +func (b *backend) resolveArnToRealUniqueId(s logical.Storage, arn string) (string, error) { + entity, err := parseIamArn(arn) + if err != nil { + return "", err } + // This odd-looking code is here because IAM is an inherently global service. IAM and STS ARNs + // don't have regions in them, and there is only a single global endpoint for IAM; see + // http://docs.aws.amazon.com/general/latest/gr/rande.html#iam_region + // However, the ARNs do have a partition in them, because the GovCloud and China partitions DO + // have their own separate endpoints, and the partition is encoded in the ARN. If Amazon's Go SDK + // would allow us to pass a partition back to the IAM client, it would be much simpler. But it + // doesn't appear that's possible, so in order to properly support GovCloud and China, we do a + // circular dance of extracting the partition from the ARN, finding any arbitrary region in the + // partition, and passing that region back back to the SDK, so that the SDK can figure out the + // proper partition from the arbitrary region we passed in to look up the endpoint. + // Sigh + region := getAnyRegionForAwsPartition(entity.Partition) + if region == nil { + return "", fmt.Errorf("Unable to resolve partition %q to a region", entity.Partition) + } + iamClient, err := b.clientIAM(s, region.ID(), entity.AccountNumber) + if err != nil { + return "", err + } + + switch entity.Type { + case "user": + userInfo, err := iamClient.GetUser(&iam.GetUserInput{UserName: &entity.FriendlyName}) + if err != nil { + return "", err + } + return *userInfo.User.UserId, nil + case "role": + roleInfo, err := iamClient.GetRole(&iam.GetRoleInput{RoleName: &entity.FriendlyName}) + if err != nil { + return "", err + } + return *roleInfo.Role.RoleId, nil + case "instance-profile": + profileInfo, err := iamClient.GetInstanceProfile(&iam.GetInstanceProfileInput{InstanceProfileName: &entity.FriendlyName}) + if err != nil { + return "", err + } + return *profileInfo.InstanceProfile.InstanceProfileId, nil + default: + return "", fmt.Errorf("unrecognized error type %#v", entity.Type) + } +} + +// Adapted from https://docs.aws.amazon.com/sdk-for-go/api/aws/endpoints/ +// the "Enumerating Regions and Endpoint Metadata" section +func getAnyRegionForAwsPartition(partitionId string) *endpoints.Region { + resolver := endpoints.DefaultResolver() + partitions := resolver.(endpoints.EnumPartitions).Partitions() + + for _, p := range partitions { + if p.ID() == partitionId { + for _, r := range p.Regions() { + return &r + } + } + } + return nil } const backendHelp = ` diff --git a/builtin/credential/aws/backend_test.go b/builtin/credential/aws/backend_test.go index a539fbac18be..d1172a8f45d9 100644 --- a/builtin/credential/aws/backend_test.go +++ b/builtin/credential/aws/backend_test.go @@ -1346,7 +1346,7 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { if err != nil { t.Fatalf("Received error retrieving identity: %s", err) } - testIdentityArn, _, _, err := parseIamArn(*testIdentity.Arn) + entity, err := parseIamArn(*testIdentity.Arn) if err != nil { t.Fatal(err) } @@ -1385,7 +1385,7 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { // configuring the valid role we'll be able to login to roleData := map[string]interface{}{ - "bound_iam_principal_arn": testIdentityArn, + "bound_iam_principal_arn": entity.canonicalArn(), "policies": "root", "auth_type": iamAuthType, } @@ -1417,8 +1417,17 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { t.Fatalf("bad: failed to create role; resp:%#v\nerr:%v", resp, err) } + fakeArn := "arn:aws:iam::123456789012:role/FakeRole" + fakeArnResolver := func(s logical.Storage, arn string) (string, error) { + if arn == fakeArn { + return fmt.Sprintf("FakeUniqueIdFor%s", fakeArn), nil + } + return b.resolveArnToRealUniqueId(s, arn) + } + b.resolveArnToUniqueId = fakeArnResolver + // now we're creating the invalid role we won't be able to login to - roleData["bound_iam_principal_arn"] = "arn:aws:iam::123456789012:role/FakeRole" + roleData["bound_iam_principal_arn"] = fakeArn roleRequest.Path = "role/" + testInvalidRoleName resp, err = b.HandleRequest(roleRequest) if err != nil || (resp != nil && resp.IsError()) { @@ -1491,7 +1500,7 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { t.Errorf("bad: expected failed login due to bad auth type: resp:%#v\nerr:%v", resp, err) } - // finally, the happy path tests :) + // finally, the happy path test :) loginData["role"] = testValidRoleName resp, err = b.HandleRequest(loginRequest) @@ -1501,4 +1510,20 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { if resp == nil || resp.Auth == nil || resp.IsError() { t.Errorf("bad: expected valid login: resp:%#v", resp) } + + // Now, fake out the unique ID resolver to ensure we fail login if the unique ID + // changes from under us + b.resolveArnToUniqueId = resolveArnToFakeUniqueId + // First, we need to update the role to force Vault to use our fake resolver to + // pick up the fake user ID + roleData["bound_iam_principal_arn"] = entity.canonicalArn() + roleRequest.Path = "role/" + testValidRoleName + resp, err = b.HandleRequest(roleRequest) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("bad: failed to recreate role: resp:%#v\nerr:%v", resp, err) + } + resp, err = b.HandleRequest(loginRequest) + if err != nil || resp == nil || !resp.IsError() { + t.Errorf("bad: expected failed login due to changed AWS role ID: resp: %#v\nerr:%v", resp, err) + } } diff --git a/builtin/credential/aws/client.go b/builtin/credential/aws/client.go index 1647f4527b7f..c153f68cc626 100644 --- a/builtin/credential/aws/client.go +++ b/builtin/credential/aws/client.go @@ -8,6 +8,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/sts" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/vault/helper/awsutil" "github.com/hashicorp/vault/logical" @@ -70,7 +71,7 @@ func (b *backend) getRawClientConfig(s logical.Storage, region, clientType strin // It uses getRawClientConfig to obtain config for the runtime environemnt, and if // stsRole is a non-empty string, it will use AssumeRole to obtain a set of assumed // credentials. The credentials will expire after 15 minutes but will auto-refresh. -func (b *backend) getClientConfig(s logical.Storage, region, stsRole, clientType string) (*aws.Config, error) { +func (b *backend) getClientConfig(s logical.Storage, region, stsRole, accountID, clientType string) (*aws.Config, error) { config, err := b.getRawClientConfig(s, region, clientType) if err != nil { @@ -80,20 +81,36 @@ func (b *backend) getClientConfig(s logical.Storage, region, stsRole, clientType return nil, fmt.Errorf("could not compile valid credentials through the default provider chain") } + stsConfig, err := b.getRawClientConfig(s, region, "sts") + if stsConfig == nil { + return nil, fmt.Errorf("could not configure STS client") + } + if err != nil { + return nil, err + } if stsRole != "" { - assumeRoleConfig, err := b.getRawClientConfig(s, region, "sts") - if err != nil { - return nil, err - } - if assumeRoleConfig == nil { - return nil, fmt.Errorf("could not configure STS client") - } - assumedCredentials := stscreds.NewCredentials(session.New(assumeRoleConfig), stsRole) + assumedCredentials := stscreds.NewCredentials(session.New(stsConfig), stsRole) // Test that we actually have permissions to assume the role if _, err = assumedCredentials.Get(); err != nil { return nil, err } config.Credentials = assumedCredentials + } else { + if b.defaultAWSAccountID == "" { + client := sts.New(session.New(stsConfig)) + if client == nil { + return nil, fmt.Errorf("could not obtain sts client: %v", err) + } + inputParams := &sts.GetCallerIdentityInput{} + identity, err := client.GetCallerIdentity(inputParams) + if err != nil { + return nil, fmt.Errorf("unable to fetch current caller: %v", err) + } + b.defaultAWSAccountID = *identity.Account + } + if b.defaultAWSAccountID != accountID { + return nil, fmt.Errorf("unable to fetch client for account ID %s -- default client is for account %s", accountID, b.defaultAWSAccountID) + } } return config, nil @@ -121,8 +138,25 @@ func (b *backend) flushCachedIAMClients() { } } +func (b *backend) stsRoleForAccount(s logical.Storage, accountID string) (string, error) { + // Check if an STS configuration exists for the AWS account + sts, err := b.lockedAwsStsEntry(s, accountID) + if err != nil { + return "", fmt.Errorf("error fetching STS config for account ID %q: %q\n", accountID, err) + } + // An empty STS role signifies the master account + if sts != nil { + return sts.StsRole, nil + } + return "", nil +} + // clientEC2 creates a client to interact with AWS EC2 API -func (b *backend) clientEC2(s logical.Storage, region string, stsRole string) (*ec2.EC2, error) { +func (b *backend) clientEC2(s logical.Storage, region, accountID string) (*ec2.EC2, error) { + stsRole, err := b.stsRoleForAccount(s, accountID) + if err != nil { + return nil, err + } b.configMutex.RLock() if b.EC2ClientsMap[region] != nil && b.EC2ClientsMap[region][stsRole] != nil { defer b.configMutex.RUnlock() @@ -142,8 +176,7 @@ func (b *backend) clientEC2(s logical.Storage, region string, stsRole string) (* // Create an AWS config object using a chain of providers var awsConfig *aws.Config - var err error - awsConfig, err = b.getClientConfig(s, region, stsRole, "ec2") + awsConfig, err = b.getClientConfig(s, region, stsRole, accountID, "ec2") if err != nil { return nil, err @@ -168,7 +201,11 @@ func (b *backend) clientEC2(s logical.Storage, region string, stsRole string) (* } // clientIAM creates a client to interact with AWS IAM API -func (b *backend) clientIAM(s logical.Storage, region string, stsRole string) (*iam.IAM, error) { +func (b *backend) clientIAM(s logical.Storage, region, accountID string) (*iam.IAM, error) { + stsRole, err := b.stsRoleForAccount(s, accountID) + if err != nil { + return nil, err + } b.configMutex.RLock() if b.IAMClientsMap[region] != nil && b.IAMClientsMap[region][stsRole] != nil { defer b.configMutex.RUnlock() @@ -188,8 +225,7 @@ func (b *backend) clientIAM(s logical.Storage, region string, stsRole string) (* // Create an AWS config object using a chain of providers var awsConfig *aws.Config - var err error - awsConfig, err = b.getClientConfig(s, region, stsRole, "iam") + awsConfig, err = b.getClientConfig(s, region, stsRole, accountID, "iam") if err != nil { return nil, err diff --git a/builtin/credential/aws/path_config_client.go b/builtin/credential/aws/path_config_client.go index 3787aed3b1a6..df1a6234d611 100644 --- a/builtin/credential/aws/path_config_client.go +++ b/builtin/credential/aws/path_config_client.go @@ -129,6 +129,9 @@ func (b *backend) pathConfigClientDelete( // Remove all the cached EC2 client objects in the backend. b.flushCachedIAMClients() + // unset the cached default AWS account ID + b.defaultAWSAccountID = "" + return nil, nil } @@ -234,6 +237,7 @@ func (b *backend) pathConfigClientCreateUpdate( if changedCreds { b.flushCachedEC2Clients() b.flushCachedIAMClients() + b.defaultAWSAccountID = "" } return nil, nil diff --git a/builtin/credential/aws/path_login.go b/builtin/credential/aws/path_login.go index bf50898405c3..f4cb63593952 100644 --- a/builtin/credential/aws/path_login.go +++ b/builtin/credential/aws/path_login.go @@ -151,20 +151,8 @@ func (b *backend) instanceIamRoleARN(iamClient *iam.IAM, instanceProfileName str // validateInstance queries the status of the EC2 instance using AWS EC2 API // and checks if the instance is running and is healthy func (b *backend) validateInstance(s logical.Storage, instanceID, region, accountID string) (*ec2.Instance, error) { - - // Check if an STS configuration exists for the AWS account - sts, err := b.lockedAwsStsEntry(s, accountID) - if err != nil { - return nil, fmt.Errorf("error fetching STS config for account ID %q: %q\n", accountID, err) - } - // An empty STS role signifies the master account - stsRole := "" - if sts != nil { - stsRole = sts.StsRole - } - // Create an EC2 client to pull the instance information - ec2Client, err := b.clientEC2(s, region, stsRole) + ec2Client, err := b.clientEC2(s, region, accountID) if err != nil { return nil, err } @@ -484,19 +472,8 @@ func (b *backend) verifyInstanceMeetsRoleRequirements( return nil, fmt.Errorf("failed to extract out IAM instance profile name from IAM instance profile ARN") } - // Check if an STS configuration exists for the AWS account - sts, err := b.lockedAwsStsEntry(s, identityDoc.AccountID) - if err != nil { - return fmt.Errorf("error fetching STS config for account ID %q: %q\n", identityDoc.AccountID, err), nil - } - // An empty STS role signifies the master account - stsRole := "" - if sts != nil { - stsRole = sts.StsRole - } - // Use instance profile ARN to fetch the associated role ARN - iamClient, err := b.clientIAM(s, identityDoc.Region, stsRole) + iamClient, err := b.clientIAM(s, identityDoc.Region, identityDoc.AccountID) if err != nil { return nil, fmt.Errorf("could not fetch IAM client: %v", err) } else if iamClient == nil { @@ -949,6 +926,19 @@ func (b *backend) pathLoginRenewIam( if roleEntry.BoundIamPrincipalARN != canonicalArn { return nil, fmt.Errorf("role no longer bound to arn %q", canonicalArn) } + // Need to hanndle the case where an auth token was generated before we put client_user_id in the metadata + // Basic goal here is: + // 1. If no client_user_id metadata exists, then skip the check (it might be nice to fill it in later, but + // could be complicated) + // 2. If role is not bound to an ID, that means that checking the unique ID has been disabled, so skip the + // check + // 3. Otherwise, ensure that the stored client_user_id matches the bound IAM principal ID. If an IAM user + // or role is deleted and recreated, then existing clients will NOT be able to renew and they'll need + // to reauthenticate to Vault with updated IAM credentials + originalUserId, ok := req.Auth.Metadata["client_user_id"] + if ok && roleEntry.BoundIamPrincipalID != "" && roleEntry.BoundIamPrincipalID != req.Auth.Metadata["client_user_id"] { + return nil, fmt.Errorf("role no longer bound to ID %q", originalUserId) + } return framework.LeaseExtend(roleEntry.TTL, roleEntry.MaxTTL, b.System())(req, data) @@ -1124,18 +1114,18 @@ func (b *backend) pathLoginUpdateIam( } } - clientArn, accountID, err := submitCallerIdentityRequest(method, endpoint, parsedUrl, body, headers) + callerID, err := submitCallerIdentityRequest(method, endpoint, parsedUrl, body, headers) if err != nil { return logical.ErrorResponse(fmt.Sprintf("error making upstream request: %v", err)), nil } - canonicalArn, principalName, sessionName, err := parseIamArn(clientArn) + entity, err := parseIamArn(callerID.Arn) if err != nil { return logical.ErrorResponse(fmt.Sprintf("Error parsing arn: %v", err)), nil } roleName := data.Get("role").(string) if roleName == "" { - roleName = principalName + roleName = entity.FriendlyName } roleEntry, err := b.lockedAWSRole(req.Storage, roleName) @@ -1152,8 +1142,21 @@ func (b *backend) pathLoginUpdateIam( // The role creation should ensure that either we're inferring this is an EC2 instance // or that we're binding an ARN - if roleEntry.BoundIamPrincipalARN != "" && roleEntry.BoundIamPrincipalARN != canonicalArn { - return logical.ErrorResponse(fmt.Sprintf("IAM Principal %q does not belong to the role %q", clientArn, roleName)), nil + // The only way BoundIamPrincipalID could get set is if BoundIamPrincipalARN was also set and + // resolving to internal IDs was turned on, which can't be turned off. So, there should be no + // way for this to be set and not match BoundIamPrincipalARN + if roleEntry.BoundIamPrincipalID != "" { + // This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID" + // (in the case of an IAM user). + uniqueId := strings.Split(callerID.UserId, ":")[0] + if err != nil { + return nil, err + } + if uniqueId != roleEntry.BoundIamPrincipalID { + return logical.ErrorResponse(fmt.Sprintf("expected IAM %s %s to resolve to unique AWS ID %q but got %q instead", entity.Type, entity.FriendlyName, roleEntry.BoundIamPrincipalID, uniqueId)), nil + } + } else if roleEntry.BoundIamPrincipalARN != "" && roleEntry.BoundIamPrincipalARN != entity.canonicalArn() { + return logical.ErrorResponse(fmt.Sprintf("IAM Principal %q does not belong to the role %q", callerID.Arn, roleName)), nil } policies := roleEntry.Policies @@ -1161,9 +1164,9 @@ func (b *backend) pathLoginUpdateIam( inferredEntityType := "" inferredEntityId := "" if roleEntry.InferredEntityType == ec2EntityType { - instance, err := b.validateInstance(req.Storage, sessionName, roleEntry.InferredAWSRegion, accountID) + instance, err := b.validateInstance(req.Storage, entity.SessionInfo, roleEntry.InferredAWSRegion, callerID.Account) if err != nil { - return logical.ErrorResponse(fmt.Sprintf("failed to verify %s as a valid EC2 instance in region %s", sessionName, roleEntry.InferredAWSRegion)), nil + return logical.ErrorResponse(fmt.Sprintf("failed to verify %s as a valid EC2 instance in region %s", entity.SessionInfo, roleEntry.InferredAWSRegion)), nil } // build a fake identity doc to pass on metadata about the instance to verifyInstanceMeetsRoleRequirements @@ -1171,7 +1174,7 @@ func (b *backend) pathLoginUpdateIam( Tags: nil, // Don't really need the tags, so not doing the work of converting them from Instance.Tags to identityDocument.Tags InstanceID: *instance.InstanceId, AmiID: *instance.ImageId, - AccountID: accountID, + AccountID: callerID.Account, Region: roleEntry.InferredAWSRegion, PendingTime: instance.LaunchTime.Format(time.RFC3339), } @@ -1181,29 +1184,30 @@ func (b *backend) pathLoginUpdateIam( return nil, err } if validationError != nil { - return logical.ErrorResponse(fmt.Sprintf("Error validating instance: %s", validationError)), nil + return logical.ErrorResponse(fmt.Sprintf("error validating instance: %s", validationError)), nil } inferredEntityType = ec2EntityType - inferredEntityId = sessionName + inferredEntityId = entity.SessionInfo } resp := &logical.Response{ Auth: &logical.Auth{ Policies: policies, Metadata: map[string]string{ - "client_arn": clientArn, - "canonical_arn": canonicalArn, + "client_arn": callerID.Arn, + "canonical_arn": entity.canonicalArn(), + "client_user_id": callerID.UserId, "auth_type": iamAuthType, "inferred_entity_type": inferredEntityType, "inferred_entity_id": inferredEntityId, "inferred_aws_region": roleEntry.InferredAWSRegion, - "account_id": accountID, + "account_id": entity.AccountNumber, }, InternalData: map[string]interface{}{ "role_name": roleName, }, - DisplayName: principalName, + DisplayName: entity.FriendlyName, LeaseOptions: logical.LeaseOptions{ Renewable: true, TTL: roleEntry.TTL, @@ -1256,29 +1260,43 @@ func hasValuesForIamAuth(data *framework.FieldData) (bool, bool) { (hasRequestMethod || hasRequestUrl || hasRequestBody || hasRequestHeaders) } -func parseIamArn(iamArn string) (string, string, string, error) { +func parseIamArn(iamArn string) (*iamEntity, error) { // iamArn should look like one of the following: - // 1. arn:aws:iam:::user/ + // 1. arn:aws:iam:::/ // 2. arn:aws:sts:::assumed-role// // if we get something like 2, then we want to transform that back to what // most people would expect, which is arn:aws:iam:::role/ + var entity iamEntity fullParts := strings.Split(iamArn, ":") - principalFullName := fullParts[5] - // principalFullName would now be something like user/ or assumed-role// - parts := strings.Split(principalFullName, "/") - principalName := parts[1] - // now, principalName should either be or - transformedArn := iamArn - sessionName := "" - if parts[0] == "assumed-role" { - transformedArn = fmt.Sprintf("arn:aws:iam::%s:role/%s", fullParts[4], principalName) - // fullParts[4] is the - sessionName = parts[2] - // sessionName is - } else if parts[0] != "user" { - return "", "", "", fmt.Errorf("unrecognized principal type: %q", parts[0]) - } - return transformedArn, principalName, sessionName, nil + if fullParts[0] != "arn" { + return nil, fmt.Errorf("unrecognized arn: does not begin with arn:") + } + // normally aws, but could be aws-cn or aws-us-gov + entity.Partition = fullParts[1] + if fullParts[2] != "iam" && fullParts[2] != "sts" { + return nil, fmt.Errorf("unrecognized service: %v, not one of iam or sts", fullParts[2]) + } + // fullParts[3] is the region, which doesn't matter for AWS IAM entities + entity.AccountNumber = fullParts[4] + // fullParts[5] would now be something like user/ or assumed-role// + parts := strings.Split(fullParts[5], "/") + entity.Type = parts[0] + entity.Path = strings.Join(parts[1:len(parts)-1], "/") + entity.FriendlyName = parts[len(parts)-1] + // now, entity.FriendlyName should either be or + switch entity.Type { + case "assumed-role": + // Assumed roles don't have paths and have a slightly different format + // parts[2] is + entity.Path = "" + entity.FriendlyName = parts[1] + entity.SessionInfo = parts[2] + case "user": + case "role": + default: + return &iamEntity{}, fmt.Errorf("unrecognized principal type: %q", entity.Type) + } + return &entity, nil } func validateVaultHeaderValue(headers http.Header, requestUrl *url.URL, requiredHeaderValue string) error { @@ -1381,7 +1399,7 @@ func parseGetCallerIdentityResponse(response string) (GetCallerIdentityResponse, return result, err } -func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (string, string, error) { +func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (*GetCallerIdentityResult, error) { // NOTE: We need to ensure we're calling STS, instead of acting as an unintended network proxy // The protection against this is that this method will only call the endpoint specified in the // client config (defaulting to sts.amazonaws.com), so it would require a Vault admin to override @@ -1390,7 +1408,7 @@ func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, bo client := cleanhttp.DefaultClient() response, err := client.Do(request) if err != nil { - return "", "", fmt.Errorf("error making request: %v", err) + return nil, fmt.Errorf("error making request: %v", err) } if response != nil { defer response.Body.Close() @@ -1398,17 +1416,13 @@ func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, bo // we check for status code afterwards to also print out response body responseBody, err := ioutil.ReadAll(response.Body) if response.StatusCode != 200 { - return "", "", fmt.Errorf("received error code %s from STS: %s", response.StatusCode, string(responseBody)) + return nil, fmt.Errorf("received error code %s from STS: %s", response.StatusCode, string(responseBody)) } callerIdentityResponse, err := parseGetCallerIdentityResponse(string(responseBody)) if err != nil { - return "", "", fmt.Errorf("error parsing STS response") + return nil, fmt.Errorf("error parsing STS response") } - clientArn := callerIdentityResponse.GetCallerIdentityResult[0].Arn - if clientArn == "" { - return "", "", fmt.Errorf("no ARN validated") - } - return clientArn, callerIdentityResponse.GetCallerIdentityResult[0].Account, nil + return &callerIdentityResponse.GetCallerIdentityResult[0], nil } type GetCallerIdentityResponse struct { @@ -1446,6 +1460,29 @@ type roleTagLoginResponse struct { DisallowReauthentication bool `json:"disallow_reauthentication" structs:"disallow_reauthentication" mapstructure:"disallow_reauthentication"` } +type iamEntity struct { + Partition string + AccountNumber string + Type string + Path string + FriendlyName string + SessionInfo string +} + +func (e *iamEntity) canonicalArn() string { + entityType := e.Type + // canonicalize "assumed-role" into "role" + if entityType == "assumed-role" { + entityType = "role" + } + // Annoyingly, the assumed-role entity type doesn't have the Path of the role which was assumed + // So, we "canonicalize" it by just completely dropping the path. The other option would be to + // make an AWS API call to look up the role by FriendlyName, which introduces more complexity to + // code and test, and it also breaks backwards compatibility in an area where we would really want + // it + return fmt.Sprintf("arn:%s:iam::%s:%s/%s", e.Partition, e.AccountNumber, entityType, e.FriendlyName) +} + const iamServerIdHeader = "X-Vault-AWS-IAM-Server-ID" const pathLoginSyn = ` diff --git a/builtin/credential/aws/path_login_test.go b/builtin/credential/aws/path_login_test.go index e96bed835034..c47d392cf71e 100644 --- a/builtin/credential/aws/path_login_test.go +++ b/builtin/credential/aws/path_login_test.go @@ -48,37 +48,32 @@ func TestBackend_pathLogin_getCallerIdentityResponse(t *testing.T) { } func TestBackend_pathLogin_parseIamArn(t *testing.T) { - userArn := "arn:aws:iam::123456789012:user/MyUserName" - assumedRoleArn := "arn:aws:sts::123456789012:assumed-role/RoleName/RoleSessionName" - baseRoleArn := "arn:aws:iam::123456789012:role/RoleName" - - xformedUser, principalFriendlyName, sessionName, err := parseIamArn(userArn) - if err != nil { - t.Fatal(err) - } - if xformedUser != userArn { - t.Fatalf("expected to transform ARN %#v into %#v but got %#v instead", userArn, userArn, xformedUser) - } - if principalFriendlyName != "MyUserName" { - t.Fatalf("expected to extract MyUserName from ARN %#v but got %#v instead", userArn, principalFriendlyName) - } - if sessionName != "" { - t.Fatalf("expected to extract no session name from ARN %#v but got %#v instead", userArn, sessionName) - } - - xformedRole, principalFriendlyName, sessionName, err := parseIamArn(assumedRoleArn) - if err != nil { - t.Fatal(err) - } - if xformedRole != baseRoleArn { - t.Fatalf("expected to transform ARN %#v into %#v but got %#v instead", assumedRoleArn, baseRoleArn, xformedRole) - } - if principalFriendlyName != "RoleName" { - t.Fatalf("expected to extract principal name of RoleName from ARN %#v but got %#v instead", assumedRoleArn, sessionName) - } - if sessionName != "RoleSessionName" { - t.Fatalf("expected to extract role session name of RoleSessionName from ARN %#v but got %#v instead", assumedRoleArn, sessionName) - } + testParser := func(inputArn, expectedCanonicalArn string, expectedEntity iamEntity) { + entity, err := parseIamArn(inputArn) + if err != nil { + t.Fatal(err) + } + if expectedCanonicalArn != "" && entity.canonicalArn() != expectedCanonicalArn { + t.Fatalf("expected to canonicalize ARN %q into %q but got %q instead", inputArn, expectedCanonicalArn, entity.canonicalArn()) + } + if *entity != expectedEntity { + t.Fatalf("expected to get iamEntity %#v from input ARN %q but instead got %#v", expectedEntity, inputArn, *entity) + } + } + + testParser("arn:aws:iam::123456789012:user/UserPath/MyUserName", + "arn:aws:iam::123456789012:user/MyUserName", + iamEntity{Partition: "aws", AccountNumber: "123456789012", Type: "user", Path: "UserPath", FriendlyName: "MyUserName"}, + ) + canonicalRoleArn := "arn:aws:iam::123456789012:role/RoleName" + testParser("arn:aws:sts::123456789012:assumed-role/RoleName/RoleSessionName", + canonicalRoleArn, + iamEntity{Partition: "aws", AccountNumber: "123456789012", Type: "assumed-role", FriendlyName: "RoleName", SessionInfo: "RoleSessionName"}, + ) + testParser("arn:aws:iam::123456789012:role/RolePath/RoleName", + canonicalRoleArn, + iamEntity{Partition: "aws", AccountNumber: "123456789012", Type: "role", Path: "RolePath", FriendlyName: "RoleName"}, + ) } func TestBackend_validateVaultHeaderValue(t *testing.T) { diff --git a/builtin/credential/aws/path_role.go b/builtin/credential/aws/path_role.go index f6c19f23790f..481ac334c7b9 100644 --- a/builtin/credential/aws/path_role.go +++ b/builtin/credential/aws/path_role.go @@ -63,6 +63,14 @@ with an IAM instance profile ARN which has a prefix that matches the value specified by this parameter. The value is prefix-matched (as though it were a glob ending in '*'). This is only checked when auth_type is ec2.`, + }, + "resolve_aws_unique_ids": { + Type: framework.TypeBool, + Default: true, + Description: `If set, resolve all AWS IAM ARNs into AWS's internal unique IDs. +When an IAM entity (e.g., user, role, or instance profile) is deleted, then all references +to it within the role will be invalidated, which prevents a new IAM entity from being created +with the same name and matching the role's IAM binds. Once set, this cannot be unset.`, }, "inferred_entity_type": { Type: framework.TypeString, @@ -210,7 +218,7 @@ func (b *backend) lockedAWSRole(s logical.Storage, roleName string) (*awsRoleEnt if roleEntry == nil { return nil, nil } - needUpgrade, err := upgradeRoleEntry(roleEntry) + needUpgrade, err := b.upgradeRoleEntry(s, roleEntry) if err != nil { return nil, fmt.Errorf("error upgrading roleEntry: %v", err) } @@ -228,7 +236,7 @@ func (b *backend) lockedAWSRole(s logical.Storage, roleName string) (*awsRoleEnt return nil, nil } // now re-check to see if we need to upgrade - if needUpgrade, err = upgradeRoleEntry(roleEntry); err != nil { + if needUpgrade, err = b.upgradeRoleEntry(s, roleEntry); err != nil { return nil, fmt.Errorf("error upgrading roleEntry: %v", err) } if needUpgrade { @@ -284,7 +292,7 @@ func (b *backend) nonLockedSetAWSRole(s logical.Storage, roleName string, // If needed, updates the role entry and returns a bool indicating if it was updated // (and thus needs to be persisted) -func upgradeRoleEntry(roleEntry *awsRoleEntry) (bool, error) { +func (b *backend) upgradeRoleEntry(s logical.Storage, roleEntry *awsRoleEntry) (bool, error) { if roleEntry == nil { return false, fmt.Errorf("received nil roleEntry") } @@ -307,6 +315,18 @@ func upgradeRoleEntry(roleEntry *awsRoleEntry) (bool, error) { upgraded = true } + if roleEntry.AuthType == iamAuthType && + roleEntry.ResolveAWSUniqueIDs && + roleEntry.BoundIamPrincipalARN != "" && + roleEntry.BoundIamPrincipalID == "" { + principalId, err := b.resolveArnToUniqueId(s, roleEntry.BoundIamPrincipalARN) + if err != nil { + return false, err + } + roleEntry.BoundIamPrincipalID = principalId + upgraded = true + } + return upgraded, nil } @@ -411,7 +431,7 @@ func (b *backend) pathRoleCreateUpdate( if roleEntry == nil { roleEntry = &awsRoleEntry{} } else { - needUpdate, err := upgradeRoleEntry(roleEntry) + needUpdate, err := b.upgradeRoleEntry(req.Storage, roleEntry) if err != nil { return logical.ErrorResponse(fmt.Sprintf("failed to update roleEntry: %v", err)), nil } @@ -445,6 +465,19 @@ func (b *backend) pathRoleCreateUpdate( roleEntry.BoundSubnetID = boundSubnetIDRaw.(string) } + if resolveAWSUniqueIDsRaw, ok := data.GetOk("resolve_aws_unique_ids"); ok { + switch { + case req.Operation == logical.CreateOperation: + roleEntry.ResolveAWSUniqueIDs = resolveAWSUniqueIDsRaw.(bool) + case roleEntry.ResolveAWSUniqueIDs && !resolveAWSUniqueIDsRaw.(bool): + return logical.ErrorResponse("changing resolve_aws_unique_ids from true to false is not allowed"), nil + default: + roleEntry.ResolveAWSUniqueIDs = resolveAWSUniqueIDsRaw.(bool) + } + } else if req.Operation == logical.CreateOperation { + roleEntry.ResolveAWSUniqueIDs = data.Get("resolve_aws_unique_ids").(bool) + } + if boundIamRoleARNRaw, ok := data.GetOk("bound_iam_role_arn"); ok { roleEntry.BoundIamRoleARN = boundIamRoleARNRaw.(string) } @@ -454,7 +487,26 @@ func (b *backend) pathRoleCreateUpdate( } if boundIamPrincipalARNRaw, ok := data.GetOk("bound_iam_principal_arn"); ok { - roleEntry.BoundIamPrincipalARN = boundIamPrincipalARNRaw.(string) + principalARN := boundIamPrincipalARNRaw.(string) + roleEntry.BoundIamPrincipalARN = principalARN + // Explicitly not checking to see if the user has changed the ARN under us + // This allows the user to sumbit an update with the same ARN to force Vault + // to re-resolve the ARN to the unique ID, in case an entity was deleted and + // recreated + if roleEntry.ResolveAWSUniqueIDs { + principalID, err := b.resolveArnToUniqueId(req.Storage, principalARN) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("error resolving ARN %#v to internal ID: %#v", principalARN, err)), nil + } + roleEntry.BoundIamPrincipalID = principalID + } + } else if roleEntry.ResolveAWSUniqueIDs && roleEntry.BoundIamPrincipalARN != "" { + // we're turning on resolution on this role, so ensure we update it + principalID, err := b.resolveArnToUniqueId(req.Storage, roleEntry.BoundIamPrincipalARN) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("error resolving ARN %#v to internal ID: %#v", roleEntry.BoundIamPrincipalARN, err)), nil + } + roleEntry.BoundIamPrincipalID = principalID } if inferRoleTypeRaw, ok := data.GetOk("inferred_entity_type"); ok { @@ -682,6 +734,7 @@ type awsRoleEntry struct { BoundAmiID string `json:"bound_ami_id" structs:"bound_ami_id" mapstructure:"bound_ami_id"` BoundAccountID string `json:"bound_account_id" structs:"bound_account_id" mapstructure:"bound_account_id"` BoundIamPrincipalARN string `json:"bound_iam_principal_arn" structs:"bound_iam_principal_arn" mapstructure:"bound_iam_principal_arn"` + BoundIamPrincipalID string `json:"bound_iam_principal_id" structs:"bound_iam_principal_id" mapstructure:"bound_iam_principal_id"` BoundIamRoleARN string `json:"bound_iam_role_arn" structs:"bound_iam_role_arn" mapstructure:"bound_iam_role_arn"` BoundIamInstanceProfileARN string `json:"bound_iam_instance_profile_arn" structs:"bound_iam_instance_profile_arn" mapstructure:"bound_iam_instance_profile_arn"` BoundRegion string `json:"bound_region" structs:"bound_region" mapstructure:"bound_region"` @@ -689,6 +742,7 @@ type awsRoleEntry struct { BoundVpcID string `json:"bound_vpc_id" structs:"bound_vpc_id" mapstructure:"bound_vpc_id"` InferredEntityType string `json:"inferred_entity_type" structs:"inferred_entity_type" mapstructure:"inferred_entity_type"` InferredAWSRegion string `json:"inferred_aws_region" structs:"inferred_aws_region" mapstructure:"inferred_aws_region"` + ResolveAWSUniqueIDs bool `json:"resolve_aws_unique_ids" structs:"resolve_aws_unique_ids" mapstructure:"resolve_aws_unique_ids"` RoleTag string `json:"role_tag" structs:"role_tag" mapstructure:"role_tag"` AllowInstanceMigration bool `json:"allow_instance_migration" structs:"allow_instance_migration" mapstructure:"allow_instance_migration"` TTL time.Duration `json:"ttl" structs:"ttl" mapstructure:"ttl"` diff --git a/builtin/credential/aws/path_role_test.go b/builtin/credential/aws/path_role_test.go index 52ff435744af..672dcdd76ae1 100644 --- a/builtin/credential/aws/path_role_test.go +++ b/builtin/credential/aws/path_role_test.go @@ -135,7 +135,81 @@ func TestBackend_pathRoleEc2(t *testing.T) { if resp != nil { t.Fatalf("bad: response: expected:nil actual:%#v\n", resp) } +} + +func Test_enableIamIDResolution(t *testing.T) { + config := logical.TestBackendConfig() + storage := &logical.InmemStorage{} + config.StorageView = storage + + b, err := Backend(config) + if err != nil { + t.Fatal(err) + } + _, err = b.Setup(config) + if err != nil { + t.Fatal(err) + } + roleName := "upgradable_role" + + b.resolveArnToUniqueId = resolveArnToFakeUniqueId + + data := map[string]interface{}{ + "auth_type": iamAuthType, + "policies": "p,q", + "bound_iam_principal_arn": "arn:aws:iam::123456789012:role/MyRole", + "resolve_aws_unique_ids": false, + } + + submitRequest := func(roleName string, op logical.Operation) (*logical.Response, error) { + return b.HandleRequest(&logical.Request{ + Operation: op, + Path: "role/" + roleName, + Data: data, + Storage: storage, + }) + } + + resp, err := submitRequest(roleName, logical.CreateOperation) + if err != nil { + t.Fatal(err) + } + if resp != nil && resp.IsError() { + t.Fatalf("failed to create role: %#v", resp) + } + resp, err = submitRequest(roleName, logical.ReadOperation) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.IsError() { + t.Fatalf("failed to read role: resp:%#v,\nerr:%#v", resp, err) + } + if resp.Data["bound_iam_principal_id"] != "" { + t.Fatalf("expected to get no unique ID in role, but got %q", resp.Data["bound_iam_principal_id"]) + } + + data = map[string]interface{}{ + "resolve_aws_unique_ids": true, + } + resp, err = submitRequest(roleName, logical.UpdateOperation) + if err != nil { + t.Fatal(err) + } + if resp != nil && resp.IsError() { + t.Fatalf("unable to upgrade role to resolve internal IDs: resp:%#v", resp) + } + + resp, err = submitRequest(roleName, logical.ReadOperation) + if err != nil { + t.Fatal(err) + } + if resp == nil || resp.IsError() { + t.Fatalf("failed to read role: resp:%#v,\nerr:%#v", resp, err) + } + if resp.Data["bound_iam_principal_id"] != "FakeUniqueId1" { + t.Fatalf("bad: expected upgrade of role resolve principal ID to %q, but got %q instead", "FakeUniqueId1", resp.Data["bound_iam_principal_id"]) + } } func TestBackend_pathIam(t *testing.T) { @@ -174,6 +248,7 @@ func TestBackend_pathIam(t *testing.T) { "policies": "p,q,r,s", "max_ttl": "2h", "bound_iam_principal_arn": "n:aws:iam::123456789012:user/MyUserName", + "resolve_aws_unique_ids": false, } resp, err = b.HandleRequest(&logical.Request{ Operation: logical.CreateOperation, @@ -369,6 +444,7 @@ func TestBackend_pathRoleMixedTypes(t *testing.T) { data["inferred_entity_type"] = ec2EntityType data["inferred_aws_region"] = "us-east-1" + data["resolve_aws_unique_ids"] = false resp, err = submitRequest("multipleTypesInferred", logical.CreateOperation) if err != nil { t.Fatal(err) @@ -376,6 +452,29 @@ func TestBackend_pathRoleMixedTypes(t *testing.T) { if resp.IsError() { t.Fatalf("didn't allow creation of roles with only inferred bindings") } + + b.resolveArnToUniqueId = resolveArnToFakeUniqueId + data["resolve_aws_unique_ids"] = true + resp, err = submitRequest("withInternalIdResolution", logical.CreateOperation) + if err != nil { + t.Fatal(err) + } + if resp.IsError() { + t.Fatalf("didn't allow creation of role resolving unique IDs") + } + resp, err = submitRequest("withInternalIdResolution", logical.ReadOperation) + if resp.Data["bound_iam_principal_id"] != "FakeUniqueId1" { + t.Fatalf("expected fake unique ID of FakeUniqueId1, got %q", resp.Data["bound_iam_principal_id"]) + } + data["resolve_aws_unique_ids"] = false + resp, err = submitRequest("withInternalIdResolution", logical.UpdateOperation) + if err != nil { + t.Fatal(err) + } + if !resp.IsError() { + t.Fatalf("allowed changing resolve_aws_unique_ids from true to false") + } + } func TestAwsEc2_RoleCrud(t *testing.T) { @@ -417,11 +516,12 @@ func TestAwsEc2_RoleCrud(t *testing.T) { "bound_ami_id": "testamiid", "bound_account_id": "testaccountid", "bound_region": "testregion", - "bound_iam_role_arn": "testiamrolearn", - "bound_iam_instance_profile_arn": "testiaminstanceprofilearn", + "bound_iam_role_arn": "arn:aws:iam::123456789012:role/MyRole", + "bound_iam_instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/MyInstanceProfile", "bound_subnet_id": "testsubnetid", "bound_vpc_id": "testvpcid", "role_tag": "testtag", + "resolve_aws_unique_ids": false, "allow_instance_migration": true, "ttl": "10m", "max_ttl": "20m", @@ -451,12 +551,14 @@ func TestAwsEc2_RoleCrud(t *testing.T) { "bound_account_id": "testaccountid", "bound_region": "testregion", "bound_iam_principal_arn": "", - "bound_iam_role_arn": "testiamrolearn", - "bound_iam_instance_profile_arn": "testiaminstanceprofilearn", + "bound_iam_principal_id": "", + "bound_iam_role_arn": "arn:aws:iam::123456789012:role/MyRole", + "bound_iam_instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/MyInstanceProfile", "bound_subnet_id": "testsubnetid", "bound_vpc_id": "testvpcid", "inferred_entity_type": "", "inferred_aws_region": "", + "resolve_aws_unique_ids": false, "role_tag": "testtag", "allow_instance_migration": true, "ttl": time.Duration(600), @@ -519,7 +621,8 @@ func TestAwsEc2_RoleDurationSeconds(t *testing.T) { roleData := map[string]interface{}{ "auth_type": "ec2", - "bound_iam_instance_profile_arn": "testarn", + "bound_iam_instance_profile_arn": "arn:aws:iam::123456789012:instance-profile/test-profile-name", + "resolve_aws_unique_ids": false, "ttl": "10s", "max_ttl": "20s", "period": "30s", @@ -554,3 +657,7 @@ func TestAwsEc2_RoleDurationSeconds(t *testing.T) { t.Fatalf("bad: period; expected: 30, actual: %d", resp.Data["period"]) } } + +func resolveArnToFakeUniqueId(s logical.Storage, arn string) (string, error) { + return "FakeUniqueId1", nil +} diff --git a/website/source/docs/auth/aws.html.md b/website/source/docs/auth/aws.html.md index 147dcc8b8d38..e8804fdc552d 100644 --- a/website/source/docs/auth/aws.html.md +++ b/website/source/docs/auth/aws.html.md @@ -1425,6 +1425,33 @@ The response will be in JSON. For example: activated. This only applies to the iam auth method. +
    +
  • + resolve_aws_unique_ids + optional + When set, resolves the `bound_iam_principal_arn` to the [AWS Unique + ID](http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-unique-ids). + This requires Vault to be able to call `iam:GetUser` or `iam:GetRole` on + the `bound_iam_principal_arn` that is being bound. Resolving to internal + AWS IDs more closely mimics the behavior of AWS services in that if an + IAM user or role is deleted and a new one is recreated with the same + name, those new users or roles won't get access to roles in Vault that + were permissioned to the prior principals of the same name. The default + value for new roles is true, while the default value for roles that + existed prior to this option existing is false. Any authentication + tokens created prior to this being supported won't verify the unique ID + upon token renewal. When this is changed from false to true on an + existing role, Vault will attempt to resolve the role's bound IAM ARN to + the unique ID and, if unable to do so, will fail to enable this option. + If this option is set to false, then you MUST leave out the path + component in bound_iam_principal_arn for **roles** only, but not IAM + users. That is, if your IAM role ARN is of the form + `arn:aws:iam::123456789012:role/some/path/to/MyRoleName`, you **must** + specify a bound_iam_principal_arn of + `arn:aws:iam::123456789012:role/MyRoleName` for authentication to work. +
  • +
+
  • ttl