AWS Identity Center with OpenTofu/Terraform

2025-07-01 - Centralized access control with free sub-accounts
Tags: AWS OpenTofu terraform

Introduction

Many people make mistakes and create immediate tech debt when they get started with AWS. They rightfully fear that everything is going to be expensive and try to keep it under control by cramming everything in a single VPC in a single AWS Account.

This is a big mistake though, because creating multiple AWS accounts (which are administrative objects isolating everything) is free, and using IAM Identity Center (successor to AWS Single Sign-On) is a great way to centralize access control to a multitude of AWS accounts. Separating each environment or project into its own sub-account is a great way to control security boundaries as well as keep an eye on costs per AWS sub-account.

I recently took the time to advise a former colleague and friend about getting started on AWS: here are the resulting notes.

Bootstrapping AWS Identity Center

Sadly not everything can be automated with OpenTofu/Terraform. The initial bootstrap must be performed by clicking through the AWS web console:

With these settings out of the way, everything else can be automated with OpenTofu/Terraform.

Creating sub-accounts

I use something close to the following input variable in order to manage my additional AWS accounts:

variable "aws_accounts" {
  description = "AWS accounts to manage."
  nullable    = false
  type = map(object({
    email = string
    ou    = optional(string, null)
  }))
}

Here is an example terraform.tfvars file provisioning this data structure:

aws_accounts = {
  core = {
    email = "julien.dessaux+aws-core@adyxax.eu"
    ou    = "core-engineering"
  }
  root = {
    email = "julien.dessaux+aws-root@adyxax.eu"
  }
  tests = {
    email = "julien.dessaux+aws-tests@adyxax.eu"
    ou    = "core-engineering"
  }
}

You might ask yourselves why I use an email attribute that looks pretty easy to derive consistently from the account name. I do this because creating and deleting AWS accounts is easy! Though the account names can be reused, the email address cannot.

After a few years of projects creations and deletions, you will happen to reuse an account name and will need a different email address. This data structure helps me remember that and I keep a list of former accounts email addresses in a comment above this structure.

I manage the Organization Units (OUs) with:

locals {
  ous = toset([for name, info in var.aws_accounts :
    info.ou if info.ou != null
  ])
}

data "aws_organizations_organization" "org" {}

resource "aws_organizations_organizational_unit" "ou" {
  for_each = local.ous

  name      = each.key
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

And I manage the AWS accounts using the following configuration:

data "aws_ssoadmin_instances" "root" {}

locals {
  identity_center_arn      = data.aws_ssoadmin_instances.root.arns[0]
  identity_center_store_id = data.aws_ssoadmin_instances.root.identity_store_ids[0]
}

resource "aws_organizations_account" "main" {
  for_each = var.aws_accounts

  close_on_deletion = true
  email             = each.value.email
  name              = each.key
  parent_id         = each.value.ou != null ? aws_organizations_organizational_unit.ou[each.value.ou].id : null

  lifecycle {
    ignore_changes = [role_name]
  }
}

The ignore_changes lifecycle entry allows importing existing accounts into this automation, which I do for the root account itself.

Managing user accounts

I use something close to the following input variable in order to manage Identity Center user accounts:

variable "users" {
  description = "Users to manage accounts for."
  nullable    = false
  type = map(object({
    admin = object({
      aws = bool
    })
    display_name = optional(string, null)
    email        = string
    family_name  = optional(string, null)
    given_name   = optional(string, null)
  }))
}

Here is an example terraform.tfvars file provisioning this data structure:

users = {
  julien-dessaux = {
    admin = { aws = true }
    email = "julien.dessaux@adyxax.org"
  }
}

The following local variable augments the user accounts by setting defaults for the optional fields. I use the convention that all usernames follow the <firstname>-<lastname> format and this handles cases where a user’s display name, family name or given name do not exactly fit this scheme:

locals {
  users = { for username, info in var.users :
    username => merge(
      info,
      info.display_name == null ? { display_name = title(replace(username, "-", " ")) } : {},
      info.family_name == null ? { family_name = split(" ", title(replace(username, "-", " ")))[1] } : {},
      info.given_name == null ? { given_name = split(" ", title(replace(username, "-", " ")))[0] } : {},
    )
  }
}

Creating the actual IAM Identity Center user is done with:

resource "aws_identitystore_user" "main" {
  for_each = local.users

  display_name = each.value.display_name
  emails {
    primary = true
    value   = each.value.email
  }
  identity_store_id = local.identity_center_store_id
  name {
    family_name = each.value.family_name
    given_name  = each.value.given_name
  }
  user_name = each.key
}

Now that we have users provisioned, let’s grant them permissions on our infrastructure.

Granting admin access

An IAM Identity Center group can be created with:

resource "aws_identitystore_group" "admin" {
  display_name      = "admin"
  identity_store_id = local.identity_center_store_id
}

Assigning members to this group is a matter of:

resource "aws_identitystore_group_membership" "admin" {
  for_each = { for username, info in var.users :
    username => info if info.admin.aws
  }

  group_id          = aws_identitystore_group.admin.group_id
  identity_store_id = local.identity_center_store_id
  member_id         = aws_identitystore_user.main[each.key].user_id
}

Permissions are granted through permission sets of attached policies:

resource "aws_ssoadmin_permission_set" "admin" {
  instance_arn     = local.identity_center_arn
  name             = "admin"
  session_duration = "PT12H"
}

resource "aws_ssoadmin_managed_policy_attachment" "admin" {
  instance_arn       = local.identity_center_arn
  managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
  permission_set_arn = aws_ssoadmin_permission_set.admin.arn
}

resource "aws_ssoadmin_account_assignment" "admin" {
  for_each = aws_organizations_account.main

  instance_arn       = local.identity_center_arn
  permission_set_arn = aws_ssoadmin_permission_set.admin.arn
  principal_id       = aws_identitystore_group.admin.group_id
  principal_type     = "GROUP"
  target_id          = each.value.id
  target_type        = "AWS_ACCOUNT"
}

Generating your AWS CLI configuration file

I rely on the following OpenTofu/Terraform template to generate my ~/.aws/config file:

[default]
region = eu-west-3
sso_session = adyxax

[sso-session adyxax]
sso_start_url = https://adyxax.awsapps.com/start
sso_region = eu-west-3
sso_registration_scopes = sso:account:access

%{~for name, id in accounts}
[profile ${name}]
sso_account_id = ${id}
sso_role_name = admin
sso_session = adyxax
%{endfor~}

Using this template, I output my configuration with:

output "aws_config" {
  value = templatefile("./aws_config", {
    accounts = { for name, info in aws_organizations_account.main :
      name => info.id
    }
  })
}

Each morning, I log in with:

aws sso login

To access a specific account, I use the --profile CLI flag:

aws --profile core s3 ls

Conclusion

Starting your AWS journey with multiple accounts and centralized access management as shown in this article will help you avoid quite a few pitfalls. Though there are a few clicks to perform for the initial setup, everything important can be automated quite well.

I recommend everyone to make the effort to commit to this approach from the beginning in order to have a scalable, secure and cost-effective AWS environment at your disposal.