So your organization has many AWS accounts, but you have services (like monitoring, deploying, security) that require access to resources across many/all of those accounts.

There are a few options for these services:

Deploy the service in each account: to add an account you need to recreate the entire service with all its resources, and then maintain all of that infrastructure in perpetuity. Deploy the service in one account with access keys to other accounts: each account requires a user with a policy and access key that is given to the service. This can quickly get out of hand as the number of keys explode, both in maintaining the services access to them and security concerns around rolling them. Deploy the service in one account that can assume roles into other accounts: the service requires a user, instance profile, or role that is trusted by roles in the other accounts. The service only needs to know the name of the role and the account ID to work.

The latter option has the least surface area to secure, requires the least amount of maintenance, and is the easiest to scale with the number of accounts. This post briefly goes over how to manage the assumed roles in a service written in Go.

Trusting Role

A role allows a service to assume it with a policy like:

{

"Version": "2012-10-17",

"Statement": [{

"Effect": "Allow",

"Principal": {

"AWS": "arn:aws:iam::<account_id>:role/<remote_role_name>"

},

"Action": "sts:AssumeRole"

}]

}

If the role name is standardized, e.g. as the name of the service, then the only information needed to assume that role is the account_id . This can be very powerful, if the account ID is passed to the service as a parameter it breaks the dependency of the service on the accounts it works in. This makes adding a new account effortless and easily scalable.

Assuming Roles

Assuming roles into multiple accounts without continuously re-authenticating means the service has to maintain sessions for each role/account.

For this it needs to store two pieces of information: 1. session.Session for the service role 2. aws.Config for each assumed role

To store these we need an implementation:

type Clients struct {

session *session.Session

configs map[string]*aws.Config

}

To create or retrieve a session:

func (c Clients) Session() *session.Session {

if c.session != nil {

return c.session

}

sess := session.Must(session.NewSession())

c.session = sess

return sess

}

This is a pretty vanilla method. It is much more interesting to create, store, and retrieve configs:

func (c Clients) Config(

region *string,

account_id *string,

role *string) *aws.Config {



// return no config for nil inputs

if account_id == nil || region == nil || role == nil {

return nil

}



arn := fmt.Sprintf(

"arn:aws:iam::%v:role/%v",

*account_id,

*role,

)



// include region in cache key otherwise concurrency errors

key := fmt.Sprintf("%v::%v", *region, arn)



// check for cached config

if c.configs != nil && c.configs[key] != nil {

return c.configs[key]

}



// new creds

creds := stscreds.NewCredentials(c.Session(), arn)



// new config

config := aws.NewConfig().

WithCredentials(creds).

WithRegion(*region).

WithMaxRetries(10)



if c.configs == nil {

c.configs = map[string]*aws.Config{}

}



c.configs[key] = config

return config

}

This will cache a unique config for each role and region, and retrieve that configuration if already created.

The magic of this method is in stscreds.NewCredentials . It returns credentials that expire in 15 minutes, but will auto-refresh them when needed. This means that we can cache the config without having to worry about either session or config expiring the credentials.

Clients

A method to create a S3 client looks like:

func (c *Clients) S3(

region *string,

account_id *string,

role *string) s3iface.S3API {

return s3.New(c.Session(), c.Config(region, account_id, role))

}

To get a client for the service role we call the method with:

c.S3(nil, nil, nil)

Or to return a client for an assumed role: