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:
- Login to your root admin account.
- Select the primary AWS region you will operate in.
- Use the Search Bar to navigate to the IAM Identity Center console.
- Click on the orange
Enable
button to activate Identity Center for your organization. - Configure your Identity Source. You can use the default AWS Identity Center directory like I do, or connect an external identity provider.
- Configure the AWS access portal URL as well as your Instance name.
- Configure Multi Factor Authentication.
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.