Eliminating Hardcoded AWS Account IDs in Terraform

Welcome to the first post in the Shiny Pebble engineering blog series. We love Terraform and want to share lessons learned, solutions, & tips and tricks with the community.

Introduction

As AWS environments grow in complexity, managing multiple accounts becomes increasingly challenging. One common issue is the need to reference account IDs across different Terraform configurations. Hardcoding these IDs can lead to maintenance headaches and potential security risks. In this post, we’ll explore a solution that leverages AWS Systems Manager (SSM) cross-account parameter store feature to eliminate the need for hardcoded account IDs in your Terraform code.

Our solution creates a centralized, dynamically updated map of AWS account names to their corresponding IDs and email addresses. This map is then shared across your AWS organization, allowing all accounts to reference it without needing direct access to the AWS Organizations API.

Prerequisites

Before implementing this solution, ensure you have the following:

  1. AWS Organizations set up with multiple accounts
  2. Organization sharing enabled in your Resource Access Manager (RAM) settings in the organization’s management account
  3. AWS permissions to:
    • Create and manage SSM parameter store values
    • Create and manage RAM resource shares
    • Read access to the AWS Organizations API

Solution Architecture

This diagram illustrates how the SSM parameter is created in the management account, shared via RAM, and then accessed by all accounts within the AWS Organization.

image

Implementation

Let’s break down the implementation into two parts:

  1. Setting up the centralized account map
  2. Consuming it in other accounts.

Part 1: Setting Up the Centralized Account Map

First, we’ll create a Terraform module in the management account to generate the account map and share it across the organization.

data "aws_organizations_organization" "org" {}

data "aws_organizations_organizational_units" "root_ous" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

data "aws_organizations_organizational_unit_descendant_accounts" "root_accounts" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

locals {
  root_ou_map = {
    for ou in data.aws_organizations_organizational_units.root_ous.children :
    ou.name => ou.id
  }

  nested_ous = flatten([
    for ou_name, ou_id in local.root_ou_map : concat(
      [{
        name = ou_name
        id   = ou_id
      }],
      [for child in data.aws_organizations_organizational_units.nested_ous[ou_name].children : {
        name = child.name
        id   = child.id
      }]
    )
  ])

  all_accounts = merge(
    { for account in data.aws_organizations_organizational_unit_descendant_accounts.root_accounts.accounts :
      account.name => { id = account.id, email = account.email }
    },
    merge([
      for ou in local.nested_ous :
      { for account in data.aws_organizations_organizational_unit_descendant_accounts.ou_accounts[ou.name].accounts :
        account.name => { id = account.id, email = account.email }
      }
    ]...)
  )
}

data "aws_organizations_organizational_units" "nested_ous" {
  for_each  = local.root_ou_map
  parent_id = each.value
}

data "aws_organizations_organizational_unit_descendant_accounts" "ou_accounts" {
  for_each  = { for ou in local.nested_ous : ou.name => ou.id }
  parent_id = each.value
}

resource "aws_ssm_parameter" "account_map" {
  name        = "/org/account-map"
  description = "Map of AWS account names to their IDs and emails"
  type        = "String"
  value       = jsonencode(local.all_accounts)
  tier        = "Advanced"
}

resource "aws_ram_resource_share" "account_map" {
  name                      = "account-map-share"
  allow_external_principals = false
}

resource "aws_ram_resource_association" "account_map" {
  resource_arn       = aws_ssm_parameter.account_map.arn
  resource_share_arn = aws_ram_resource_share.account_map.arn
}

resource "aws_ram_principal_association" "org" {
  principal          = data.aws_organizations_organization.org.arn
  resource_share_arn = aws_ram_resource_share.account_map.arn
}

output "account_map" {
  description = "Map of AWS account names to their IDs and emails"
  value       = local.all_accounts
}

output "ssm_parameter_name" {
  description = "Name of the SSM parameter storing the account map"
  value       = aws_ssm_parameter.account_map.name
}

output "ram_resource_share_arn" {
  description = "ARN of the RAM resource share for the account map"
  value       = aws_ram_resource_share.account_map.arn
}

This code does the following:

  1. Retrieves information about the AWS Organization structure.
  2. Creates a map of all accounts in the organization, including nested OUs.
  3. Stores this map as a JSON-encoded string in an SSM parameter.
  4. Creates a RAM resource share for the SSM parameter.
  5. Associates the SSM parameter with the RAM resource share.
  6. Shares the resource with the entire AWS Organization.

Part 2: Consuming the Account Map

In other accounts or Terraform configurations, you can now access this shared account map:

data "aws_ram_resource_share" "account_map" {
  name           = "account-map-share"
  resource_owner = "OTHER-ACCOUNTS"
}

data "aws_ssm_parameter" "account_map" {
  name = data.aws_ram_resource_share.account_map.resource_arns[0]
}

locals {
  account_map = jsondecode(data.aws_ssm_parameter.account_map.value)
}

data "aws_iam_policy_document" "example" {
  statement {
    effect    = "Allow"
    actions   = ["s3:*"]
    resources = ["*"]
    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalAccount"
      values = [
        local.account_map["production"].id,
        local.account_map["development"].id,
      ]
    }
  }
}

resource "aws_iam_policy" "example" {
  name   = "example-policy"
  policy = data.aws_iam_policy_document.example.json
}

This code:

  1. Fetches the shared SSM parameter.
  2. Decodes the JSON-encoded account map.
  3. Uses the account map to reference account IDs by their names in resources like IAM policies.

Conclusion

By implementing this solution, you can eliminate hardcoded AWS account IDs from your Terraform configurations. This approach offers several benefits:

  1. Centralized management of account information
  2. Automatic updates when accounts are added or removed
  3. Improved security by reducing the spread of sensitive account IDs
  4. Easier maintenance and reduced risk of errors

Remember to run the account map module in your management account whenever there are changes to your AWS Organization structure. This will ensure that all accounts have access to the most up-to-date information.

With this setup, you can now reference account IDs by their human-readable names across your entire AWS Organization, making your Terraform configurations more maintainable and less error-prone.