DEV Community

Erlend Ekern for AWS Community Builders

Posted on • Originally published at blog.ekern.me

Coloring your AWS accounts (at scale!) 🌈

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.

A screenshot showing the color of an account in the AWS Console

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"}'
Enter fullscreen mode Exit fullscreen mode

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 ...
;)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 "$@"
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A service-managed stack set that deploys an execution role to all accounts in a target OU.
  2. 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:

  1. Use AWS Organizations.
  2. Enable trusted access for StackSets in the organization.
  3. 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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


  1. "[It] is something that would need to be implemented by the service team" 

  2. 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)