Quick take — A reusable hashicorp/aws ~> 5.0 module for AWS IAM Identity Center (AWS SSO): for_each permission sets with managed and inline policies, identity-store groups, and account assignments wiring groups to accounts — the org instance read, never created. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "identity_center" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-identity-center?ref=v1.0.0"
# Permission sets keyed by name; each gets a session duration + policies.
permission_sets = {
AdministratorAccess = {
description = "..." # Human-readable purpose of the permission set.
session_duration = "PT8H" # ISO-8601 session length (max session before re-auth).
managed_policy_arns = [
"arn:aws:iam::aws:policy/AdministratorAccess",
]
}
}
# Groups to create in the Identity Store (or reference existing ones).
groups = {
platform-admins = { description = "..." } # Display name -> { description }.
}
# Wire group -> permission set -> account.
assignments = {
admins-to-prod = {
group = "platform-admins" # Must match a key in `groups`.
permission_set = "AdministratorAccess" # Must match a key in `permission_sets`.
account_id = "..." # 12-digit AWS account ID.
}
}
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS IAM Identity Center (the successor to AWS SSO) is how an AWS Organization grants humans federated, time-bounded access across many accounts without per-account IAM users. The mental model is three nouns:
- A permission set is a reusable bundle of IAM permissions (managed policy ARNs + an optional inline policy + a session duration). When assigned, Identity Center materialises it as an IAM role in the target account.
- A principal is a user or group from the Identity Store — the directory Identity Center keeps (or syncs from an external IdP).
- An account assignment is the join: this principal gets this permission set in this account. That single triple is what shows up in the user’s access portal.
The one thing you cannot do in Terraform is create the Identity Center instance — it is enabled once per organization from the management account console (or via Control Tower). So this module reads it with data.aws_ssoadmin_instances, pulling out the arns[0] (the instance ARN) and identity_store_ids[0] (the directory ID). Everything downstream hangs off those two values.
From there the module is unapologetically for_each-driven. It creates aws_ssoadmin_permission_set resources (each with an ISO-8601 session_duration like PT8H and optional relay_state to deep-link into a console page), attaches managed policies via aws_ssoadmin_managed_policy_attachment, attaches an optional aws_ssoadmin_permission_set_inline_policy, creates aws_identitystore_groups, and finally fans out aws_ssoadmin_account_assignment resources whose principal_id is the group’s group_id, principal_type = "GROUP", target_id the account number, and target_type = "AWS_ACCOUNT". Wrapping this in a module turns “who can do what, where” into a reviewable map you read top to bottom — the access matrix becomes code.
When to use it
- You run an AWS Organization with many accounts and want federated human access governed centrally, with permission sets defined once and assigned everywhere.
- You want the access matrix in version control — a PR that adds
developers → ReadOnlyAccessin three accounts is the audit trail. - You need least-privilege permission sets that combine AWS managed policies with a tailored inline policy (e.g. ReadOnly plus a narrow break-glass action), with short
session_durationenforced. - You manage groups in the Identity Store (or sync them from an IdP) and want assignments to reference groups, not individual users, so onboarding/offboarding is a directory change rather than a Terraform run.
This module assumes the Identity Center instance and Identity Store are already enabled in the organization — it reads them, it does not provision them. If you sync identities from an external IdP (Okta, Entra ID, Google) via SCIM, point assignments at the synced groups by their display names and let this module create only the groups you own natively. Reach for AWS Control Tower / aws_organizations_* modules to manage the account structure itself; this module owns access into those accounts.
Module structure
terraform-module-aws-iam-identity-center/
├── versions.tf # provider + Terraform version pins
├── main.tf # instance lookup, permission sets, policies, groups, assignments
├── variables.tf # var-driven inputs with validations
└── outputs.tf # permission set ARNs, group IDs, assignment principals
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
# The Identity Center instance is enabled once per org from the management
# account — it cannot be created by Terraform, only read. We pull the instance
# ARN and the Identity Store ID that every resource below depends on.
data "aws_ssoadmin_instances" "this" {}
locals {
instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
tags = merge(
{
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-iam-identity-center"
},
var.tags,
)
# Flatten permission_sets -> managed policy ARNs into one map keyed by a
# composite so each (permission set, policy) pair is a distinct resource.
managed_policy_attachments = merge([
for ps_name, ps in var.permission_sets : {
for arn in ps.managed_policy_arns :
"${ps_name}::${arn}" => {
permission_set = ps_name
policy_arn = arn
}
}
]...)
}
# ---- Permission sets ----
resource "aws_ssoadmin_permission_set" "this" {
for_each = var.permission_sets
name = each.key
description = each.value.description
instance_arn = local.instance_arn
session_duration = each.value.session_duration
relay_state = each.value.relay_state
tags = local.tags
}
# ---- AWS managed policy attachments (one resource per ps/policy pair) ----
resource "aws_ssoadmin_managed_policy_attachment" "this" {
for_each = local.managed_policy_attachments
instance_arn = local.instance_arn
managed_policy_arn = each.value.policy_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.permission_set].arn
}
# ---- Optional inline policy per permission set ----
resource "aws_ssoadmin_permission_set_inline_policy" "this" {
for_each = {
for name, ps in var.permission_sets : name => ps
if ps.inline_policy != null
}
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.key].arn
inline_policy = each.value.inline_policy
}
# ---- Identity Store groups ----
resource "aws_identitystore_group" "this" {
for_each = var.groups
identity_store_id = local.identity_store_id
display_name = each.key
description = each.value.description
}
# ---- Account assignments: group -> permission set -> account ----
resource "aws_ssoadmin_account_assignment" "this" {
for_each = var.assignments
instance_arn = local.instance_arn
permission_set_arn = aws_ssoadmin_permission_set.this[each.value.permission_set].arn
principal_id = aws_identitystore_group.this[each.value.group].group_id
principal_type = "GROUP"
target_id = each.value.account_id
target_type = "AWS_ACCOUNT"
}
variables.tf
variable "permission_sets" {
description = <<-EOT
Map of permission sets keyed by name. Each value:
description - human-readable purpose (required)
session_duration - ISO-8601 duration, e.g. "PT8H" (default "PT4H")
relay_state - optional URL to redirect to after sign-in (e.g. a console deep link)
managed_policy_arns - list of AWS managed policy ARNs to attach (default [])
inline_policy - optional JSON inline policy string (default null)
EOT
type = map(object({
description = string
session_duration = optional(string, "PT4H")
relay_state = optional(string)
managed_policy_arns = optional(list(string), [])
inline_policy = optional(string)
}))
default = {}
validation {
condition = alltrue([
for ps in values(var.permission_sets) :
can(regex("^PT([0-9]+H)?([0-9]+M)?$", ps.session_duration))
])
error_message = "session_duration must be an ISO-8601 duration like PT8H, PT1H30M, or PT45M (max 12 hours)."
}
validation {
condition = alltrue([
for ps in values(var.permission_sets) :
alltrue([for arn in ps.managed_policy_arns : can(regex("^arn:aws[a-z-]*:iam::aws:policy/", arn))])
])
error_message = "Every managed_policy_arn must be an AWS managed policy ARN (arn:aws:iam::aws:policy/...)."
}
}
variable "groups" {
description = <<-EOT
Map of Identity Store groups to create, keyed by display name. Each value:
description - free-text group description (required)
EOT
type = map(object({
description = string
}))
default = {}
}
variable "assignments" {
description = <<-EOT
Map of account assignments keyed by a short, stable name. Each value:
group - key into var.groups (the principal) (required)
permission_set - key into var.permission_sets (required)
account_id - 12-digit target AWS account ID (required)
EOT
type = map(object({
group = string
permission_set = string
account_id = string
}))
default = {}
validation {
condition = alltrue([
for a in values(var.assignments) : can(regex("^[0-9]{12}$", a.account_id))
])
error_message = "Every assignment account_id must be a 12-digit AWS account ID."
}
}
variable "tags" {
description = "Tags merged onto permission sets (the only taggable resources here)."
type = map(string)
default = {}
}
outputs.tf
output "instance_arn" {
description = "ARN of the IAM Identity Center instance (read from the org)."
value = local.instance_arn
}
output "identity_store_id" {
description = "ID of the Identity Store backing the instance."
value = local.identity_store_id
}
output "permission_set_arns" {
description = "Map of permission set name => ARN."
value = { for k, ps in aws_ssoadmin_permission_set.this : k => ps.arn }
}
output "permission_set_ids" {
description = "Map of permission set name => composite ID (instance_arn,permission_set_arn)."
value = { for k, ps in aws_ssoadmin_permission_set.this : k => ps.id }
}
output "group_ids" {
description = "Map of group display name => Identity Store group ID (principal_id)."
value = { for k, g in aws_identitystore_group.this : k => g.group_id }
}
output "assignment_ids" {
description = "Map of assignment key => account assignment resource ID."
value = { for k, a in aws_ssoadmin_account_assignment.this : k => a.id }
}
How to use it
module "identity_center" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-identity-center?ref=v1.0.0"
permission_sets = {
AdministratorAccess = {
description = "Full admin for platform engineers (short session)."
session_duration = "PT4H"
managed_policy_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"]
}
PowerUser = {
description = "Developers: everything except IAM/Organizations writes."
session_duration = "PT8H"
relay_state = "https://us-east-1.console.aws.amazon.com/ecs/v2/home"
managed_policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"]
}
BillingReadOnly = {
description = "Finance: read-only billing plus a tailored cost action."
session_duration = "PT8H"
managed_policy_arns = ["arn:aws:iam::aws:policy/job-function/Billing"]
inline_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["ce:GetCostAndUsage", "ce:GetCostForecast"]
Resource = "*"
}]
})
}
}
groups = {
platform-admins = { description = "Platform engineering team" }
developers = { description = "Application developers" }
finance = { description = "Finance and FinOps" }
}
assignments = {
admins-prod = { group = "platform-admins", permission_set = "AdministratorAccess", account_id = "111111111111" }
admins-staging = { group = "platform-admins", permission_set = "AdministratorAccess", account_id = "222222222222" }
devs-staging = { group = "developers", permission_set = "PowerUser", account_id = "222222222222" }
finance-mgmt = { group = "finance", permission_set = "BillingReadOnly", account_id = "333333333333" }
}
tags = {
Owner = "iam-platform"
CostCenter = "SEC-1100"
}
}
# Downstream: surface the group IDs so a SCIM/IdP automation can map external
# directory groups onto the ones this module created.
resource "aws_ssm_parameter" "sso_group_ids" {
name = "/identity-center/group-ids"
type = "String"
value = jsonencode(module.identity_center.group_ids)
}
Run this module from the management account (or a delegated administrator account for Identity Center). The
aws_ssoadmin_*andaws_identitystore_*APIs are only available there, since the instance lives at the org root.
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/org/identity_center/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-identity-center?ref=v1.0.0"
}
inputs = {
permission_sets = {
AdministratorAccess = {
description = "..."
session_duration = "PT4H"
managed_policy_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"]
}
}
groups = {
platform-admins = { description = "..." }
}
assignments = {
admins-prod = { group = "platform-admins", permission_set = "AdministratorAccess", account_id = "..." }
}
}
3. Deploy one environment, or roll out all modules together:
cd live/org/identity_center && terragrunt apply # this module
terragrunt run-all apply # every module under live/org
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
permission_sets |
map(object) |
{} |
No | Permission sets by name (description, session_duration, relay_state, managed_policy_arns, inline_policy). |
groups |
map(object) |
{} |
No | Identity Store groups to create, keyed by display name (description). |
assignments |
map(object) |
{} |
No | Account assignments (group, permission_set, account_id) keyed by short name. |
tags |
map(string) |
{} |
No | Tags merged onto permission sets. |
Outputs
| Name | Description |
|---|---|
instance_arn |
ARN of the Identity Center instance (read from the org). |
identity_store_id |
ID of the backing Identity Store. |
permission_set_arns |
Map of permission set name → ARN. |
permission_set_ids |
Map of permission set name → composite ID. |
group_ids |
Map of group display name → Identity Store group ID (principal). |
assignment_ids |
Map of assignment key → account assignment ID. |
Enterprise scenario
A SaaS company runs 40 AWS accounts under one Organization with Identity Center enabled and identities synced from Entra ID via SCIM. The security team manages this module from the delegated administrator account. Four permission sets — AdministratorAccess (PT4H), PowerUser (PT8H), ReadOnlyAccess (PT12H), and a tailored BreakGlass set with an inline policy — are defined once. The assignments map then becomes the company’s access matrix: platform-admins → AdministratorAccess in prod, developers → PowerUser in the sandbox and staging accounts, auditors → ReadOnlyAccess across all 40. When a developer joins, they are added to the Entra group; SCIM syncs it; no Terraform run is required. When the company onboards a new product team’s accounts, the change is a dozen new lines in the assignments map reviewed in a single PR — and the diff is the audit evidence the SOC 2 assessor asks for.
Best practices
- Never try to create the instance — read it.
data.aws_ssoadmin_instancesreturns the org’s single instance; this module derivesinstance_arnandidentity_store_idfrom it. Enable Identity Center once in the management account (or via Control Tower) before you apply. - Run from the management or delegated-admin account. The
ssoadminandidentitystoreAPIs only work there. Delegating administration to a dedicated security account keeps this powerful module out of the root management account day to day. - Assign permission sets to groups, not users. Pointing assignments at
aws_identitystore_groupprincipals means onboarding/offboarding is a directory change, not a Terraform apply — and the access matrix stays small and readable. - Keep session durations short and scoped. Use
PT4Hfor admin sets and longer only for read-only roles. Short sessions limit the blast radius of a stolen portal session; the validation here caps you at sane ISO-8601 values. - Prefer managed policies, reach for inline only when you must. Compose permission sets from AWS managed policies for clarity, and use
inline_policyfor the narrow extra (or deny) you cannot express otherwise. Keep inline policies tiny and reviewed. - Treat the
assignmentsmap as your access matrix. Name keys descriptively (devs-staging,auditors-all), require PR review for every change, and let the Git history be your access-change audit trail across the whole organization.