Back in August AWS announced a new feature that allows you to label your AWS accounts with a color that shows up when you're using the AWS Console. This is a tiny, but very welcome quality of life improvement that gives you a visual indicator of what type of account you're using (e.g., 🟩 for dev and 🟥 for prod) - reducing the chance of production accidents and giving each account a bit more pizzazz.
The feature came with API support on launch (yay!), but without CloudFormation and, somewhat surprisingly, any SDK support1. So most people will probably end up manually configuring this through the AWS Console anyway (boo!)..
But where there's a will an endpoint there's a way. In this article I'll use the account coloring feature as a practical example to demonstrate different approaches to programmatic infrastructure provisioning across AWS accounts. Even if you're not interested in account colors, you might pick up a useful thing or two along the way.
Let's dive in!
The curl way
curl has for some time supported signing requests using AWS SigV4, so the most lo-fi, programmatic way to set the color for an account is something like this:
curl -X PUT \
"https://uxc.us-east-1.api.aws/v1/account-color" \
--aws-sigv4 "aws:amz:us-east-1:uxc" \
--user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
-H "x-amz-security-token: $AWS_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"color":"green"}'
If you're using AWS Identity Center for managing access to your accounts, you can run the above command like this to spin up a subshell with credentials exposed through the expected environment variables:
( eval "$(aws --profile "<my-profile>" configure export-credentials --format "env")" \
&& curl -X PUT ...
;)
This works, but is not very IaC-y. Can we do better?
The CloudFormation way
Enter CloudFormation.
Since there's no CloudFormation support yet we'll need to create a Lambda-backed custom resource to fill in the gap. And since there's no SDK support either, we'll have to perform the request ourselves.
Luckily we can leverage a lower-level module in botocore to help sign our request.
For our custom resource we'll also be using the ServiceTimeout property introduced in 2024 to give us a saner developer experience, especially during development (goodbye 1-hour feedback loops - you won't be missed 🙃).
Bringing it all together, we get the following CloudFormation template:
AWSTemplateFormatVersion: "2010-09-09"
Description: Sets the color for an account in the AWS Console
Parameters:
Color:
Type: String
Default: none
AllowedValues:
# From https://docs.aws.amazon.com/awsconsolehelpdocs/latest/gsg/PutAccountColor.html#put-account-color-response-elements
- none
- pink
- purple
- darkBlue
- lightBlue
- teal
- green
- yellow
- orange
- red
Description: The color to apply for the account
Resources:
Role:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: AllowPutAccountColor
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: uxc:PutAccountColor
Resource: "*"
Function:
Type: AWS::Lambda::Function
Properties:
Runtime: python3.13
Handler: index.lambda_handler
Role: !GetAtt Role.Arn
Timeout: 10
Code:
ZipFile: |
import json
import logging
import typing as t
import urllib.request
from dataclasses import asdict, dataclass, field
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@dataclass
class CfnResponse:
PhysicalResourceId: str
StackId: str
RequestId: str
LogicalResourceId: str
Status: t.Literal["SUCCESS", "FAILED"]
Reason: t.Optional[str] = ""
Data: t.Optional[t.Dict] = field(default_factory=dict)
def send_cfn_response(url: str, cfn_response: CfnResponse):
"""Send minimal CloudFormation custom-resource response (pure stdlib)."""
payload = json.dumps(asdict(cfn_response)).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
method="PUT",
headers={
"content-length": str(len(payload)),
},
)
with urllib.request.urlopen(req) as f:
f.read()
def put_account_color(color: str):
"""Update the color of the current account"""
service = "uxc"
# There's currently only an endpoint for us-east-1
region = "us-east-1"
url = f"https://{service}.{region}.api.aws/v1/account-color"
payload = json.dumps({"color": color}).encode("utf-8")
# There's currently no SDK support for this endpoint, so we create and sign the request ourselves
credentials = Session().get_credentials().get_frozen_credentials()
aws_request = AWSRequest(
method="PUT",
url=url,
data=payload,
headers={"Content-Type": "application/json"},
)
SigV4Auth(credentials, service, region).add_auth(aws_request)
req = urllib.request.Request(
url=url, method="PUT", headers=dict(aws_request.headers), data=payload
)
with urllib.request.urlopen(req) as res:
status_code = res.status
res_body = res.read().decode("utf-8")
return status_code, res_body
def lambda_handler(event, context):
logger.info("Triggered with event: %s", json.dumps(event, indent=2))
color = event.get("ResourceProperties", {}).get("Color", "none")
cfn_response_url = event["ResponseURL"]
cfn_response = CfnResponse(
Status="SUCCESS",
PhysicalResourceId="AccountColor",
StackId=event["StackId"],
RequestId=event["RequestId"],
LogicalResourceId=event["LogicalResourceId"],
)
try:
if event["RequestType"] in ["Create", "Update"]:
status_code, res_body = put_account_color(color)
if status_code not in (200, 204):
cfn_response.Status = "FAILED"
cfn_response.Reason = (
f"Request failed with status {status_code}: {res_body}"
)
send_cfn_response(cfn_response_url, cfn_response)
except Exception as e:
logger.exception("An unexpected error occurred")
cfn_response.Status = "FAILED"
cfn_response.Reason = f"See logs for more details '{context.log_group_name}/{context.log_stream_name}':\n{str(e)}"
send_cfn_response(
cfn_response_url,
cfn_response,
)
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/lambda/${Function}
RetentionInDays: 7
AccountColor:
Type: Custom::AccountColor
DependsOn:
- LogGroup
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
ServiceTimeout: 10
ServiceToken: !GetAtt Function.Arn
Color: !Ref Color
Let's store this template in cfn-templates/account-color.yml.
Cool. Now we have a template we can easily deploy using the AWS CLI to set the color for an account:
aws cloudformation deploy \
--stack-name "example-cli-account-color" \
--template-file "cfn-templates/account-color.yml" \
--capabilities "CAPABILITY_IAM" \
--parameter-overrides "Color=green"
This works well for one account, but what about a handful? And what if we want to use a different color for each account?
The shell-scripted CloudFormation way
Let's bring in the lingua franca of the cloud: Bash.
If we have a naming convention for our AWS CLI profiles, we could do something like this to provision our template across accounts:
#!/usr/bin/env bash
set -euo pipefail
main() {
local color profile
# Loop over a set of AWS CLI profiles
for profile in "foo-dev" "bar-staging" "baz-prod"; do
# Determine the color based on the suffix of the profile
case "$profile" in
*-dev) color="green" ;;
*-staging) color="orange" ;;
*-prod) color="red" ;;
*) color="none" ;;
esac
aws cloudformation deploy \
--profile "$profile" \
--stack-name "example-cli-account-color" \
--template-file "cfn-templates/account-color.yml" \
--capabilities "CAPABILITY_IAM" \
--parameter-overrides "Color=$color" \
done
}
main "$@"
This is decent, but still not ideal. How do we manage permissions to all of these accounts? Where do we run this? How do we handle parallelism and errors?
If only there was an AWS-native mechanism for managing infrastructure across accounts at scale..
✨ The CloudFormation StackSets way ✨
I've written about CloudFormation StackSets before, and I think it's a useful and powerful tool, especially for managing baseline infrastructure across accounts and regions. As I mentioned in that post:
If you need to deploy across AWS organizations, want to have granular control over which accounts are deployed to, or you think you’ll need account-specific parameter values in your stacks, you probably want to go with the self-managed model!
Since colors are more likely to be specific per account, not per Organizational Unit (OU), the self-managed permission model is probably the way to go here because it allows us to set different values of the Color parameter per account 2.
The main drawback of the self-managed permission model is - as the name implies - that you need to manage the permissions. This includes setting up an IAM role (the "administration role") in the StackSet administration account, and an IAM role (the "execution role") in each target account that you want StackSets to deploy to.
Getting started
Below is an example of how to provision our CloudFormation template and set the color for an account using CloudFormation StackSets. Here we're using Terraform to manage the stack set itself, but it should be fairly straight-forward to adapt this to your IaC tool of choice.
To make it easy to try out yourself, everything is isolated to one AWS account - we use the current account as both the stack set administrator and the target account. While you normally wouldn't use StackSets like this (instead you'd just create the CloudFormation stack directly), it demonstrates the important bits and allows you to test in a non-critical account.
⚠️ Note: We will be attaching the AdministratorAccess managed policy to the execution role in the following examples. While you could attempt to scope this down to only the specific permissions needed, doing so for a service like CloudFormation StackSets can be impractical - overly restrictive permissions can lead to deployment failures and debugging headaches. But at the very least, consider the implications of this before production use.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = "eu-west-1"
}
data "aws_caller_identity" "this" {}
locals {
name_prefix = "example-tf"
stackset_execution_role_name = "${local.name_prefix}-cfn-stackset-execution"
current_account_id = data.aws_caller_identity.this.account_id
}
# The admin role is used by CloudFormation StackSets to assume execution roles
# in target accounts.
resource "aws_iam_role" "stackset_admin" {
name = "${local.name_prefix}-cfn-stackset-admin"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "cloudformation.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "assume_to_stackset_admin" {
role = aws_iam_role.stackset_admin.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "sts:AssumeRole"
Resource = "arn:aws:iam::*:role/${local.stackset_execution_role_name}"
}
]
})
}
# The execution role is used by CloudFormation StackSets to create stack instances
# (i.e. CloudFormation stacks) in target accounts.
resource "aws_iam_role" "stackset_execution" {
name = local.stackset_execution_role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Principal = {
AWS = ["arn:aws:iam::${local.current_account_id}:root"]
}
Effect = "Allow"
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy_attachment" "stackset_execution" {
role = aws_iam_role.stackset_execution.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
locals {
accounts = [
{
id = local.current_account_id
environment = "development"
},
# NOTE: Add more accounts here
]
environment_colors = {
development = "green"
staging = "orange"
production = "red"
}
}
resource "aws_cloudformation_stack_set" "account_color" {
name = "${local.name_prefix}-account-color"
permission_model = "SELF_MANAGED"
administration_role_arn = aws_iam_role.stackset_admin.arn
execution_role_name = aws_iam_role.stackset_execution.name
template_body = file("${path.root}/cfn-templates/account-color.yml")
capabilities = ["CAPABILITY_IAM"]
depends_on = [
aws_iam_role_policy.assume_to_stackset_admin,
aws_iam_role_policy_attachment.stackset_execution
]
}
resource "aws_cloudformation_stack_set_instance" "account_color" {
for_each = { for account in local.accounts : account.id => account }
stack_set_name = aws_cloudformation_stack_set.account_color.name
account_id = each.key
stack_set_instance_region = "eu-west-1"
parameter_overrides = {
"Color" = lookup(local.environment_colors, each.value.environment, "none")
}
}
Store this in a file main.tf alongside your cfn-templates directory, authenticate to AWS in an experimental account, and run terraform init and terraform apply.
Scaling up
For provisioning across accounts we can adapt the example from my previous blog post that shows us how to create a service-managed stack set that auto-deploys an IAM role to all accounts in an organization. We can leverage this pattern to easily and automatically create an execution role in all target accounts.
Our final code snippet will create two stack sets:
- A service-managed stack set that deploys an execution role to all accounts in a target OU.
- A self-managed stack set that deploys our CloudFormation template for account coloring to a set predefined accounts.
Note that in order to use service-managed stack sets you need to:
- Use AWS Organizations.
- Enable trusted access for StackSets in the organization.
- Create the stack set through the management account or through a delegated administrator.
The resulting Terraform code can look something like this:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = "eu-west-1"
}
data "aws_caller_identity" "this" {}
data "aws_organizations_organization" "this" {}
locals {
name_prefix = "example-tf"
stackset_execution_role_name = "${local.name_prefix}-cfn-stackset-execution"
current_account_id = data.aws_caller_identity.this.account_id
stack_set_admin_account_id = local.current_account_id
management_account_id = data.aws_organizations_organization.this.master_account_id
call_as = local.current_account_id == local.management_account_id ? "SELF" : "DELEGATED_ADMIN"
target_ou_id = "" # TODO: Add a non-critical OU here for initial testing
}
# A service-managed stack set that creates the required execution role
# for using self-managed stack sets
resource "aws_cloudformation_stack_set" "execution_role" {
name = "${local.name_prefix}-execution-role"
permission_model = "SERVICE_MANAGED"
capabilities = ["CAPABILITY_NAMED_IAM"]
call_as = local.call_as
auto_deployment {
enabled = true
}
parameters = {
TrustedAccountId = local.stack_set_admin_account_id
RoleName = local.stackset_execution_role_name
}
template_body = <<-EOF
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
TrustedAccountId:
Type: String
RoleName:
Type: String
Resources:
Role:
Type: AWS::IAM::Role
Properties:
RoleName: !Ref RoleName
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Sub "arn:aws:iam::$${TrustedAccountId}:root"
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AdministratorAccess
EOF
}
resource "aws_cloudformation_stack_set_instance" "execution_role" {
stack_set_name = aws_cloudformation_stack_set.execution_role.name
call_as = local.call_as
stack_set_instance_region = "eu-west-1"
deployment_targets {
organizational_unit_ids = [local.target_ou_id]
}
}
# The admin role is used by CloudFormation StackSets to assume execution roles
# in target accounts when using self-managed stack sets.
resource "aws_iam_role" "stackset_admin" {
name = "${local.name_prefix}-cfn-stackset-admin"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "cloudformation.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "assume_to_stackset_admin" {
role = aws_iam_role.stackset_admin.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "sts:AssumeRole"
Resource = "arn:aws:iam::*:role/${local.stackset_execution_role_name}"
}
]
})
}
locals {
accounts = [
{
id = "" # TODO: Add the ID of an account in the target OU here
environment = "development"
},
]
environment_colors = {
development = "green"
staging = "orange"
production = "red"
}
}
resource "aws_cloudformation_stack_set" "account_color" {
name = "${local.name_prefix}-account-color"
permission_model = "SELF_MANAGED"
administration_role_arn = aws_iam_role.stackset_admin.arn
execution_role_name = local.stackset_execution_role_name
template_body = file("${path.root}/cfn-templates/account-color.yml")
capabilities = ["CAPABILITY_IAM"]
depends_on = [
aws_iam_role_policy.assume_to_stackset_admin,
aws_cloudformation_stack_set_instance.execution_role,
]
}
resource "aws_cloudformation_stack_set_instance" "account_color" {
for_each = { for account in local.accounts : account.id => account }
stack_set_name = aws_cloudformation_stack_set.account_color.name
account_id = each.key
stack_set_instance_region = "eu-west-1"
parameter_overrides = {
"Color" = lookup(local.environment_colors, each.value.environment, "none")
}
}
You can try it out by storing the snippet above in a file main.tf, fixing the TODOs, running it against a non-critical OU, and verifying that the Terraform plan looks good.
Enjoy the colors 🌈
Summary
There you have it, folks. Colored AWS accounts. We're truly living in the future.
While I do expect that we'll get both SDK and CloudFormation support in the near-ish future (and if we're lucky the possibility to configure this centrally from the management account), you can still get started with the CloudFormation approach laid out here. Once something better and more native shows up, you should be able to delete the custom resource and associated resources without issues and replace it with a new mechanism - the custom resource is configured to be a no-op on deletion.
Note that managed policies typically used for read access (e.g., ViewOnlyAccess and ReadOnlyAccess) do not yet include permissions to fetch the color of an account. If you're using these and want your developers to experience the cloud in all its colorful glory, consider adding uxc:GetAccountColor to the relevant IAM roles.
What we've done here might seem a teeny-tiny tad overkill for this specific use case, but the overall approach and the patterns described can be applied when provisioning any baseline infrastructure across accounts.
-
"[It] is something that would need to be implemented by the service team" ↩
-
If you really wanted to use a service-managed stack set instead of a self-managed one you could do so by updating the custom resource to determine the color (e.g., by looking up account name or alias, and introducing some rules for mapping to colors). Generally speaking I favor more explicit behavior than this though. ↩

Top comments (0)