Programmatic AWS multi-account access using SSO sessions and Python Boto3

For scripted operations on multiple AWS accounts

·

9 min read

Introduction

AWS IAM Identity Center (formerly AWS SSO) is a great service for managing access to multiple AWS accounts. With this service, you only need a single set of user credentials to access all of your AWS accounts. It supports access to your accounts through the AWS Management Console and programmatically using the AWS CLI or an AWS SDK such as Python (Boto3).

Over time, you may find yourself having a growing number of AWS accounts. As an AWS systems administrator, from time to time you may need to do sweeping operations across all of your AWS accounts. For example, you may need to generate a list of all S3 buckets in all your accounts. Or you may need to deactivate IAM user access keys that are older than a certain date across all your accounts. Ideally, you would want this done quickly with little effort, thus a programmatic scripted method is preferable over doing this manually.

In this post, I will show you a scripted method to use your IAM Identity Center SSO session to gain access to all of your AWS accounts. You can then perform operations against all of your AWS accounts. This scripted method uses Python and Boto3. This method has the following conveniences:

  • Only need to log in once using your IAM Identity Center (AWS SSO) user

  • No need to set up AWS profiles config for every AWS account

Pre-requisites

You will require AWS Organizations with IAM Identity Center enabled and configured with the following:

  • A Permission set that:

    • has policies granting the permissions for your desired operation

    • has been provisioned for each account you wish to access

  • An IAM identity Center user with access to your above Permission set in each account you wish to access

On your local workstation, you will require the following:

Depending on your use case for multi-account access and the sweeping operation you wish to do, you will require familiarity using Python Boto3.

Use the AWS CLI to log in with your AWS IAM Identity Center user

To use the AWS CLI to log in with your AWS IAM Identity Center user, you will require a sso-session section configured in your ~/.aws/config file. This can be configured with the following command:

aws configure sso-session

For more information about configuring the sso-session section, see AWS documentation here.

The sso-session section in your ~/.aws/config file should look similar to the below:

[sso-session freddy-sso]
sso_start_url = https://freddy-sso-portal.awsapps.com/start#/
sso_region = ap-southeast-2
sso_registration_scopes = sso:account:access

You can then log in with your named SSO session like so:

aws sso login --sso-session freddy-sso

The login process will open up a web browser to the SSO authorization page. After successfully logging in, your SSO session authentication token is written to a cache file on your local disk, in the directory ~/.aws/sso/cache.

$ ls ~/.aws/sso/cache
19cb4ff71cb852913b9731d3ae4d7dc6aaba4cfa.json
22aadd6b478161afbf3edcae38b019c98a32a297.json

We will make use of the cached authentication token to programmatically access and operate on multiple AWS accounts. This will be covered in the next section.

Step by step: Use Python Boto3 for AWS multi-account access

After logging in to your AWS IAM Identity Center user using the AWS CLI, we are now ready to craft a script using Python and Boto3 to access and operate on multiple AWS accounts. The next few sections will break down how this is done into steps with code examples.

1 - Retrieve the SSO Access Token from the cache file

The first step for accessing multiple AWS accounts is to retrieve the SSO session authentication token that has been cached in the directory ~/.aws/sso/cache.

The cache directory can have multiple files depending on the number of SSO sessions you have had. According to the AWS documentation, the cache filename is based on the session name. However, the filename is not human-readable. Thus, a crude way to locate the authentication token we want to use is to read each cache file and find a startUrl that matches our SSO session.

In this blog post, we are using the example SSO session startURL of https://freddy-sso-portal.awsapps.com/start#/

The below code will print out the contents of the cache file (in JSON format) that has the matching startUrl.

from os import listdir
from os.path import isfile, join, expanduser
import json

START_URL = "https://freddy-sso-portal.awsapps.com/start#/"
SSO_TOKEN_DIR = join(expanduser("~"), ".aws/sso/cache")

# Retrieve SSO Token from the cache file
sso_cache_files = [f for f in listdir(SSO_TOKEN_DIR) if isfile(join(SSO_TOKEN_DIR, f))]
for sso_cache_file in sso_cache_files:
    with open(join(SSO_TOKEN_DIR, sso_cache_file), "r") as fp:
        sso_cache_obj = json.load(fp)
    if sso_cache_obj.get("startUrl") == START_URL:
        print(json.dumps(sso_cache_obj))
        break

An example of the printed cache file contents is below (with sensitive values obscured for obvious reasons).

{
  "startUrl": "https://freddy-sso-portal.awsapps.com/start#/",
  "region": "ap-southeast-2",
  "accessToken": "xxxxxxaccessTokenxxxxxx",
  "expiresAt": "2023-08-07T06:52:55Z",
  "clientId": "xxxxxxclientIdxxxxxx",
  "clientSecret": "xxxxxxclientSecretxxxxxx",
  "registrationExpiresAt": "2023-11-04T23:49:15Z"
}

2 - List all accounts that are accessible with your SSO session

Within the located SSO session cached file is the accessToken which can be used to make authorized calls to AWS IAM Identity Center API. We can use this API to obtain a list of the AWS accounts that we are authorized to access.

The below code uses boto3 to call ListAccounts to show all the accounts we can access with our SSO session. To run the code, we can take the accessToken retrieved from the last step and provide it as a value to the variable ACCESS_TOKEN.

import json

ACCESS_TOKEN = "xxxxxxaccessTokenxxxxxx"

sso_client = boto3.client("sso")

# List all accounts that are accessible from SSO
accounts_list = []
paginator = sso_client.get_paginator("list_accounts")
page_iterator = paginator.paginate(accessToken=ACCESS_TOKEN)
for page in page_iterator:
    for account in page["accountList"]:
        accounts_list.append(account)
print(json.dumps(accounts_list))

An example output showing the accessible accounts is below.

[
  {
    "accountId": "111111111111",
    "accountName": "freddy-dev",
    "emailAddress": "freddy.dev@example.com"
  },
  {
    "accountId": "222222222222",
    "accountName": "freddy-sandpit",
    "emailAddress": "freddy.sandpit@example.com"
  }
]

3 - List all roles for each account that are accessible with your SSO session

The AWS IAM Identity Center API can also be used to list the roles that are assigned to our user, for each account. Note that these roles are named after the AWS IAM Identity Center Permission set.

The below code uses boto3 to call ListAccountRoles and display all the accessible roles for each account. Once again, we have used the accessToken retrieved in the first step. We have also taken the output of the previous step to form accounts_list.

import boto3
import json

ACCESS_TOKEN = "xxxxxxaccessTokenxxxxxx"
accounts_list = [
    {"accountId": "111111111111", "accountName": "freddy-dev", "emailAddress": "freddy.dev@example.com"},
    {"accountId": "222222222222", "accountName": "freddy-sandpit", "emailAddress": "freddy.sandpit@example.com"}
]

sso_client = boto3.client("sso")

# For each account list the roles accessible from SSO
paginator = sso_client.get_paginator("list_account_roles")
for account in accounts_list:
    page_iterator = paginator.paginate(
        accessToken=ACCESS_TOKEN,
        accountId=account["accountId"],
    )
    roles_map = {}
    for page in page_iterator:
        for role in page["roleList"]:
            roles_map[role["roleName"]] = {}
    account["roles"] = roles_map
print(json.dumps(accounts_list))

An example output showing the accessible accounts and roles is below.

[
    {
        "accountId": "111111111111",
        "accountName": "freddy-dev",
        "emailAddress": "freddy.dev@example.com",
        "roles": {
            "AWSReadOnlyAccess": {}
        }
    },
    {
        "accountId": "222222222222",
        "accountName": "freddy-sandpit",
        "emailAddress": "freddy.sandpit@example.com",
        "roles": {
            "AWSReadOnlyAccess": {}
        }
    }
]

4 - Get Role credentials for each account

Now that we have a list of accessible roles for each account, we can obtain STS (Security Token Service) short-term credentials for a specified role name in each account. The AWS IAM Identity Center API is again used to do this.

The below code uses boto3 to call GetRoleCredentials to obtain the STS credentials for each account role. Once again the variables ACCESS_TOKEN and accounts_list values have been taken from previous steps. The variable ROLE_NAME is used to specify the role (permission set) name we wish to use for access. STS credentials are obtained for the role specified by ROLE_NAME in each account, only if the role exists.

import boto3
import json

ACCESS_TOKEN = "xxxxxxaccessTokenxxxxxx"
ROLE_NAME = "AWSReadOnlyAccess"
accounts_list = [
    {"accountId": "111111111111", "accountName": "freddy-dev", "emailAddress": "freddy.dev@example.com",
    "roles": {"AWSReadOnlyAccess": {}}},
    {"accountId": "222222222222", "accountName": "freddy-sandpit", "emailAddress": "freddy.sandpit@example.com",
    "roles": {"AWSReadOnlyAccess": {}}}
]

sso_client = boto3.client("sso")

# Get the role credentials of a given `ROLE_NAME` for each account
for account in accounts_list:
    if (
        ROLE_NAME in account["roles"]
    ):  # Only get role credential if account has the `ROLE_NAME`
        response = sso_client.get_role_credentials(
            roleName=ROLE_NAME,
            accountId=account["accountId"],
            accessToken=ACCESS_TOKEN,
        )
        account["roles"][ROLE_NAME] = response["roleCredentials"]
print(json.dumps(accounts_list))

An example output showing the role STS credentials for each account is below.

[
    {
        "accountId": "111111111111",
        "accountName": "freddy-dev",
        "emailAddress": "freddy.dev@example.com",
        "roles": {
            "AWSReadOnlyAccess": {
                "accessKeyId": "accessKeyId111111",
                "secretAccessKey": "secretAccessKey111111",
                "sessionToken": "sessionToken111111",
                "expiration": 1691389448000
            }
        }
    },
    {
        "accountId": "222222222222",
        "accountName": "freddy-sandpit",
        "emailAddress": "freddy.sandpit@example.com",
        "roles": {
            "AWSReadOnlyAccess": {
                "accessKeyId": "accessKeyId222222",
                "secretAccessKey": "secretAccessKey222222",
                "sessionToken": "sessionToken222222",
                "expiration": 1691389448000
            }
        }
    }
]

5 - Combining all together: Generate SSO role credentials file

This step combines the code of all the previous steps. With one execution, we can now use our SSO session to obtain STS credentials for our desired role in each account. The output is now written to a file specified by OUTPUT_FILE (i.e. sso_role_credentials.json).

from os import listdir
from os.path import isfile, join, expanduser
import json
import boto3

START_URL = "https://freddy-sso-portal.awsapps.com/start#/"
SSO_TOKEN_DIR = join(expanduser("~"), ".aws/sso/cache")
ROLE_NAME = "AWSReadOnlyAccess"
OUTPUT_FILE = "sso_role_credentials.json"

sso_client = boto3.client("sso")

# Retrieve SSO Token from the cache file
sso_cache_files = [f for f in listdir(SSO_TOKEN_DIR) if isfile(join(SSO_TOKEN_DIR, f))]
for sso_cache_file in sso_cache_files:
    with open(join(SSO_TOKEN_DIR, sso_cache_file), "r", encoding="utf-8") as fp:
        sso_cache_obj = json.load(fp)
    if sso_cache_obj.get("startUrl") == START_URL:
        sso_access_token = sso_cache_obj["accessToken"]
        break

# List all accounts that are accessible from SSO
accounts_list = []
paginator = sso_client.get_paginator("list_accounts")
page_iterator = paginator.paginate(accessToken=sso_access_token)
for page in page_iterator:
    for account in page["accountList"]:
        accounts_list.append(account)

# For each account list the roles accessible from SSO
paginator = sso_client.get_paginator("list_account_roles")
for account in accounts_list:
    page_iterator = paginator.paginate(
        accessToken=sso_access_token, accountId=account["accountId"],
    )
    roles_map = {}
    for page in page_iterator:
        for role in page["roleList"]:
            roles_map[role["roleName"]] = {}
    account["roles"] = roles_map

# Get the role credentials of a given `ROLE_NAME` for each account
for account in accounts_list:
    if (
        ROLE_NAME in account["roles"]
    ):  # Only get role credential if account has the `ROLE_NAME`
        response = sso_client.get_role_credentials(
            roleName=ROLE_NAME, accountId=account["accountId"], accessToken=sso_access_token,
        )
        account["roles"][ROLE_NAME] = response["roleCredentials"]

with open(OUTPUT_FILE, "w") as fp:
    json.dump(accounts_list, fp)

Perform operations across all your AWS accounts

We now can achieve multiple account access by using the role STS credentials that were written to the OUTPUT_FILE (i.e. sso_role_credentials.json). The JSON structure of the file contents is an array, where each element is an AWS account object containing the role credentials. The structure is the same as the code output of step 4 - Get Role credentials for each account.

The role credentials consist of the fieldsaccessKeyId, secretAccessKey and sessionToken which can be passed as arguments to the boto3.client() object initialiser. For example:

client = boto3.client(
    "s3",
    aws_access_key_id=accessKeyId,
    aws_secret_access_key=secretAccessKey,
    aws_session_token=sessionToken,
)

The below code demonstrates how the credentials can be loaded from the file, providing account access to do a repeated operation across all the accounts. In this example, we do a list S3 bucket operation (ListBuckets) in each account.

import json
import boto3

ROLE_CREDENTIALS_FILE = "sso_role_credentials.json"
ROLE_NAME = "AWSReadOnlyAccess"

with open(ROLE_CREDENTIALS_FILE, "r") as fp:
    accounts_list = json.load(fp)

for account in accounts_list:
    if ROLE_NAME in account["roles"]:
        client = boto3.client(
            "s3",
            aws_access_key_id=account["roles"][ROLE_NAME]["accessKeyId"],
            aws_secret_access_key=account["roles"][ROLE_NAME]["secretAccessKey"],
            aws_session_token=account["roles"][ROLE_NAME]["sessionToken"],
        )
        response = client.list_buckets()
        for bucket in response["Buckets"]:
            account_id = account["accountId"]
            bucket_name = bucket["Name"]
            print(account_id + "|" + bucket_name)

Example output for this code is below:

111111111111|freddy-dev-bucket-1
111111111111|freddy-dev-bucket-2
111111111111|freddy-dev-bucket-3
222222222222|freddy-sandpit-bucket-a
222222222222|freddy-sandpit-bucket-b
222222222222|freddy-sandpit-bucket-c

WARNING: Large Blast Radius Potential

A word of caution regarding the potential risks associated with performing sweeping operations across all your accounts: this could potentially have a large blast radius. Exercise utmost care when dealing with operations that may cause undesirable changes to multiple accounts. Write and delete operations could potentially cause a loss of data or a service outage.

It can be easy to overlook something unintended when making sweeping changes across many accounts. Here are a few suggestions to help prevent mishaps: Approach the task with caution, thoroughly reviewing your planned actions. Before full implementation, test your scripted operations on a smaller scale or on non-production accounts. Additionally, consider having someone else review your code before applying any modifications.