315 lines
8.6 KiB
Markdown
315 lines
8.6 KiB
Markdown
---
|
|
title: 'AWS Identity Center with OpenTofu/Terraform'
|
|
description: 'Centralized access control with free sub-accounts'
|
|
date: '2025-07-01'
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` hcl
|
|
[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:
|
|
|
|
``` hcl
|
|
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:
|
|
|
|
``` shell
|
|
aws sso login
|
|
```
|
|
|
|
To access a specific account, I use the `--profile` CLI flag:
|
|
|
|
``` shell
|
|
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.
|