Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth/aws: Allow wildcard in bound_iam_principal_id #3213

Merged
merged 4 commits into from
Aug 30, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion builtin/credential/aws/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ type backend struct {
// will be flushed. The empty STS role signifies the master account
IAMClientsMap map[string]map[string]*iam.IAM

// Map of AWS unique IDs to the full ARN corresponding to that unique ID
// This avoids the overhead of an AWS API hit for every login request
// using the IAM auth method when bound_iam_principal_arn contains a wildcard
iamUserIdToArn map[string]*awsUniqueIdMapEntry

// 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
Expand All @@ -77,6 +82,7 @@ func Backend(conf *logical.BackendConfig) (*backend, error) {
tidyCooldownPeriod: time.Hour,
EC2ClientsMap: make(map[string]map[string]*ec2.EC2),
IAMClientsMap: make(map[string]map[string]*iam.IAM),
iamUserIdToArn: make(map[string]*awsUniqueIdMapEntry),
}

b.resolveArnToUniqueIDFunc = b.resolveArnToRealUniqueId
Expand Down Expand Up @@ -124,7 +130,8 @@ func Backend(conf *logical.BackendConfig) (*backend, error) {
// Currently this will be triggered once in a minute by the RollbackManager.
//
// The tasks being done currently by this function are to cleanup the expired
// entries of both blacklist role tags and whitelist identities. Tidying is done
// entries of both blacklist role tags and whitelist identities as well as stale
// entries in the iamUserIdToArn cache. Tidying is done
// not once in a minute, but once in an hour, controlled by 'tidyCooldownPeriod'.
// Tidying of blacklist and whitelist are by default enabled. This can be
// changed using `config/tidy/roletags` and `config/tidy/identities` endpoints.
Expand Down Expand Up @@ -174,6 +181,9 @@ func (b *backend) periodicFunc(req *logical.Request) error {
b.tidyWhitelistIdentity(req.Storage, safety_buffer)
}

// get rid of old unique ID entries
b.cleanOldCachedUniqueIdMapping()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see a reason to do it like this instead of using go-cache, since this is purely in-memory? I don't have a strong preference, go-cache will just do it internally and individually rather than on a schedule in a block.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also get rid of the need to hold a mutex to do this operation and the need to store a last-accessed time. The downside, mainly, is that updating the expiration time still requires a replace operation (but the locking is handled internally).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I wasn't aware go-cache existed :) I was mainly pattern matching off of the cached AWS clients. And hopefully patrickmn/go-cache#66 will get merged to make it easier to update the expiration time.

I'll work on moving to go-cache and push a new commit when ready.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!


// Update the time at which to run the tidy functions again.
b.nextTidyTime = time.Now().Add(b.tidyCooldownPeriod)
}
Expand Down
75 changes: 63 additions & 12 deletions builtin/credential/aws/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,10 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) {
t.Fatal(err)
}

// Calling this once here to ensure it won't raise any unexpected errors on an empty
// cache
b.cleanOldCachedUniqueIdMapping()

// Override the default AWS env vars (if set) with our test creds
// so that the credential provider chain will pick them up
// NOTE that I'm not bothing to override the shared config file location,
Expand Down Expand Up @@ -1522,22 +1526,14 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) {
t.Fatalf("bad: expected valid login: resp:%#v", resp)
}

renewReq := &logical.Request{
Storage: storage,
Auth: &logical.Auth{},
}
renewReq := generateRenewRequest(storage, resp.Auth)
// dump a fake ARN into the metadata to ensure that we ONLY look
// at the unique ID that has been generated
renewReq.Auth.Metadata["canonical_arn"] = "fake_arn"
empty_login_fd := &framework.FieldData{
Raw: map[string]interface{}{},
Schema: pathLogin(b).Fields,
}
renewReq.Auth.InternalData = resp.Auth.InternalData
renewReq.Auth.Metadata = resp.Auth.Metadata
renewReq.Auth.LeaseOptions = resp.Auth.LeaseOptions
renewReq.Auth.Policies = resp.Auth.Policies
renewReq.Auth.IssueTime = time.Now()
// dump a fake ARN into the metadata to ensure that we ONLY look
// at the unique ID that has been generated
renewReq.Auth.Metadata["canonical_arn"] = "fake_arn"
// ensure we can renew
resp, err = b.pathLoginRenew(renewReq, empty_login_fd)
if err != nil {
Expand Down Expand Up @@ -1571,5 +1567,60 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) {
if err == nil || (resp != nil && !resp.IsError()) {
t.Errorf("bad: expected failed renew due to changed AWS role ID: resp: %#v", resp, err)
}
// Undo the fake resolver...
b.resolveArnToUniqueIDFunc = b.resolveArnToRealUniqueId

// Now test that wildcard matching works
wildcardRoleName := "valid_wildcard"
wildcardEntity := *entity
wildcardEntity.FriendlyName = "*"
roleData["bound_iam_principal_arn"] = wildcardEntity.canonicalArn()
roleRequest.Path = "role/" + wildcardRoleName
resp, err = b.HandleRequest(roleRequest)
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("bad: failed to create wildcard role: resp:%#v\nerr:%v", resp, err)
}

loginData["role"] = wildcardRoleName
resp, err = b.HandleRequest(loginRequest)
if err != nil {
t.Fatal(err)
}
if resp == nil || resp.Auth == nil || resp.IsError() {
t.Fatalf("bad: expected valid login: resp:%#v", resp)
}
// and ensure we can renew
renewReq = generateRenewRequest(storage, resp.Auth)
resp, err = b.pathLoginRenew(renewReq, empty_login_fd)
if err != nil {
t.Fatal(err)
}
if resp == nil {
t.Fatal("got nil response from renew")
}
if resp.IsError() {
t.Fatalf("got error when renewing: %#v", *resp)
}
// ensure the cache is populated
cachedArn := b.getCachedUserId(resp.Auth.Metadata["client_user_id"])
if cachedArn == "" {
t.Errorf("got empty ARN back from user ID cache; expected full arn")
}
// Calling this again to ensure it won't panic or raise unexpected errors when there
// are cached values
b.cleanOldCachedUniqueIdMapping()
}

func generateRenewRequest(s logical.Storage, auth *logical.Auth) *logical.Request {
renewReq := &logical.Request{
Storage: s,
Auth: &logical.Auth{},
}
renewReq.Auth.InternalData = auth.InternalData
renewReq.Auth.Metadata = auth.Metadata
renewReq.Auth.LeaseOptions = auth.LeaseOptions
renewReq.Auth.Policies = auth.Policies
renewReq.Auth.IssueTime = time.Now()

return renewReq
}
48 changes: 48 additions & 0 deletions builtin/credential/aws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package awsauth

import (
"fmt"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
Expand Down Expand Up @@ -141,6 +142,48 @@ func (b *backend) flushCachedIAMClients() {
}
}

// Deletes old cached mappings of unique IDs to ARNs
// Write locks configMutex
func (b *backend) cleanOldCachedUniqueIdMapping() {
b.configMutex.Lock()
defer b.configMutex.Unlock()
for id, mapEntry := range b.iamUserIdToArn {
// clean all entries last accessed more than MaxLeaseTTL ago
if time.Since(mapEntry.LastAccessed) > b.System().MaxLeaseTTL() {
delete(b.iamUserIdToArn, id)
}
}
}

// Gets an entry out of the user ID cache
// Write locks configMutex
func (b *backend) getCachedUserId(userId string) string {
if userId == "" {
return ""
}
b.configMutex.Lock()
defer b.configMutex.Unlock()
if entry, ok := b.iamUserIdToArn[userId]; ok {
entry.LastAccessed = time.Now()
return entry.Arn
}
return ""
}

// Sets an entry in the user ID cache
// Write locks configMutex
func (b *backend) setCachedUserId(userId, arn string) {
if userId != "" {
b.configMutex.Lock()
defer b.configMutex.Unlock()
entry := awsUniqueIdMapEntry{
LastAccessed: time.Now(),
Arn: arn,
}
b.iamUserIdToArn[userId] = &entry
}
}

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)
Expand Down Expand Up @@ -250,3 +293,8 @@ func (b *backend) clientIAM(s logical.Storage, region, accountID string) (*iam.I
}
return b.IAMClientsMap[region][stsRole], nil
}

type awsUniqueIdMapEntry struct {
LastAccessed time.Time // This is primarily so we can clean up the cache so it doesn't monotonically grow
Arn string
}
97 changes: 92 additions & 5 deletions builtin/credential/aws/path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,12 @@ func (b *backend) pathLoginRenewIam(
}
}

// Note that the error messages below can leak a little bit of information about the role information
// For example, if on renew, the client gets the "error parsing ARN..." error message, the client
// will konw that it's a wildcard bind (but not the actual bind), even if the client can't actually
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/konw/know

// read the role directly to know what the bind is. It's a relatively small amount of leakage, in
// some fairly corner cases, and in the most likely error case (role has been changed to a new ARN),
// the error message is identical.
if roleEntry.BoundIamPrincipalARN != "" {
// We might not get here if all bindings were on the inferred entity, which we've already validated
// above
Expand All @@ -936,10 +942,31 @@ func (b *backend) pathLoginRenewIam(
// Resolving unique IDs is enabled and the auth metadata contains the unique ID, so checking the
// unique ID is authoritative at this stage
if roleEntry.BoundIamPrincipalID != clientUserId {
return nil, fmt.Errorf("role no longer bound to ID %q", clientUserId)
return nil, fmt.Errorf("role no longer bound to ARN %q", canonicalArn)
}
} else if strings.HasSuffix(roleEntry.BoundIamPrincipalARN, "*") {
fullArn := b.getCachedUserId(clientUserId)
if fullArn == "" {
entity, err := parseIamArn(canonicalArn)
if err != nil {
return nil, fmt.Errorf("error parsing ARN %q: %v", canonicalArn, err)
}
fullArn, err = b.fullArn(entity, req.Storage)
if err != nil {
return nil, fmt.Errorf("error looking up full ARN of entity %v: %v", entity, err)
}
if fullArn == "" {
return nil, fmt.Errorf("got empty string back when looking up full ARN of entity %v", entity)
}
if clientUserId != "" {
b.setCachedUserId(clientUserId, fullArn)
}
}
if !strutil.GlobbedStringsMatch(roleEntry.BoundIamPrincipalARN, fullArn) {
return nil, fmt.Errorf("role no longer bound to ARN %q", canonicalArn)
}
} else if roleEntry.BoundIamPrincipalARN != canonicalArn {
return nil, fmt.Errorf("role no longer bound to arn %q", canonicalArn)
return nil, fmt.Errorf("role no longer bound to ARN %q", canonicalArn)
}
}

Expand Down Expand Up @@ -1123,7 +1150,7 @@ func (b *backend) pathLoginUpdateIam(
callerUniqueId := strings.Split(callerID.UserId, ":")[0]
entity, err := parseIamArn(callerID.Arn)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("Error parsing arn: %v", err)), nil
return logical.ErrorResponse(fmt.Sprintf("error parsing arn %q: %v", callerID.Arn, err)), nil
}

roleName := data.Get("role").(string)
Expand Down Expand Up @@ -1152,8 +1179,27 @@ func (b *backend) pathLoginUpdateIam(
if callerUniqueId != 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, callerUniqueId)), 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
} else if roleEntry.BoundIamPrincipalARN != "" {
if strings.HasSuffix(roleEntry.BoundIamPrincipalARN, "*") {
fullArn := b.getCachedUserId(callerUniqueId)
if fullArn == "" {
fullArn, err = b.fullArn(entity, req.Storage)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("error looking up full ARN of entity %v: %v", entity, err)), nil
}
if fullArn == "" {
return logical.ErrorResponse(fmt.Sprintf("got empty string back when looking up full ARN of entity %v", entity)), nil
}
b.setCachedUserId(callerUniqueId, fullArn)
}
if !strutil.GlobbedStringsMatch(roleEntry.BoundIamPrincipalARN, fullArn) {
// Note: Intentionally giving the exact same error message as a few lines below. Otherwise, we might leak information
// about whether the bound IAM principal ARN is a wildcard or not, and what that wildcard is.
return logical.ErrorResponse(fmt.Sprintf("IAM Principal %q does not belong to the role %q", callerID.Arn, roleName)), nil
}
} else if 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
Expand Down Expand Up @@ -1502,6 +1548,7 @@ type iamEntity struct {
SessionInfo string
}

// Returns a Vault-internal canonical ARN for referring to an IAM entity
func (e *iamEntity) canonicalArn() string {
entityType := e.Type
// canonicalize "assumed-role" into "role"
Expand All @@ -1516,6 +1563,46 @@ func (e *iamEntity) canonicalArn() string {
return fmt.Sprintf("arn:%s:iam::%s:%s/%s", e.Partition, e.AccountNumber, entityType, e.FriendlyName)
}

// This returns the "full" ARN of an iamEntity, how it would be referred to in AWS proper
func (b *backend) fullArn(e *iamEntity, s logical.Storage) (string, error) {
// Not assuming path is reliable for any entity types
client, err := b.clientIAM(s, getAnyRegionForAwsPartition(e.Partition).ID(), e.AccountNumber)
if err != nil {
return "", fmt.Errorf("error creating IAM client: %v", err)
}

switch e.Type {
case "user":
input := iam.GetUserInput{
UserName: aws.String(e.FriendlyName),
}
resp, err := client.GetUser(&input)
if err != nil {
return "", fmt.Errorf("error fetching user %q: %v", e.FriendlyName, err)
}
if resp == nil {
return "", fmt.Errorf("nil response from GetUser")
}
return *(resp.User.Arn), nil
case "assumed-role":
fallthrough
case "role":
input := iam.GetRoleInput{
RoleName: aws.String(e.FriendlyName),
}
resp, err := client.GetRole(&input)
if err != nil {
return "", fmt.Errorf("error fetching role %q: %v", e.FriendlyName, err)
}
if resp == nil {
return "", fmt.Errorf("nil response form GetRole")
}
return *(resp.Role.Arn), nil
default:
return "", fmt.Errorf("unrecognized entity type: %s", e.Type)
}
}

const iamServerIdHeader = "X-Vault-AWS-IAM-Server-ID"

const pathLoginSyn = `
Expand Down
10 changes: 7 additions & 3 deletions builtin/credential/aws/path_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,8 @@ func (b *backend) upgradeRoleEntry(s logical.Storage, roleEntry *awsRoleEntry) (
if roleEntry.AuthType == iamAuthType &&
roleEntry.ResolveAWSUniqueIDs &&
roleEntry.BoundIamPrincipalARN != "" &&
roleEntry.BoundIamPrincipalID == "" {
roleEntry.BoundIamPrincipalID == "" &&
!strings.HasSuffix(roleEntry.BoundIamPrincipalARN, "*") {
principalId, err := b.resolveArnToUniqueIDFunc(s, roleEntry.BoundIamPrincipalARN)
if err != nil {
return false, err
Expand Down Expand Up @@ -493,14 +494,17 @@ func (b *backend) pathRoleCreateUpdate(
// 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 {
if roleEntry.ResolveAWSUniqueIDs && !strings.HasSuffix(roleEntry.BoundIamPrincipalARN, "*") {
principalID, err := b.resolveArnToUniqueIDFunc(req.Storage, principalARN)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("failed updating the unique ID of ARN %#v: %#v", principalARN, err)), nil
}
roleEntry.BoundIamPrincipalID = principalID
} else {
// Need to handle the case where we're switching from a non-wildcard principal to a wildcard principal
roleEntry.BoundIamPrincipalID = ""
}
} else if roleEntry.ResolveAWSUniqueIDs && roleEntry.BoundIamPrincipalARN != "" {
} else if roleEntry.ResolveAWSUniqueIDs && roleEntry.BoundIamPrincipalARN != "" && !strings.HasSuffix(roleEntry.BoundIamPrincipalARN, "*") {
// we're turning on resolution on this role, so ensure we update it
principalID, err := b.resolveArnToUniqueIDFunc(req.Storage, roleEntry.BoundIamPrincipalARN)
if err != nil {
Expand Down
Loading