IaC AWS

Terraform Module: AWS IAM Identity Center — Permission Sets and Account Assignments as Code

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:

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

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_* and aws_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 configlive/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 configlive/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

TerraformAWSIAM Identity CenterModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading