IaC AWS

Terraform Module: AWS IAM Group — Policy-Driven Group Membership Without the Drift

Quick take — Build a reusable Terraform module for AWS IAM Group: managed + inline policies, membership control, and path-based organization on hashicorp/aws ~> 5.0 — with validations and clean outputs. 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 "iam_group" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-group?ref=v1.0.0"

  name = "..."  # Name of the IAM group; 1-128 chars, validated against t…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS IAM Group is a named collection of IAM users that lets you attach permissions once and have every member inherit them. Instead of attaching the same AdministratorAccess or a custom read-only policy to ten individual users (and forgetting one of them next quarter), you attach it to a group and manage membership as a list. Groups carry no credentials of their own — they cannot be a principal in a trust policy and they cannot assume roles — they exist purely to aggregate permissions for humans who sign in with long-lived IAM user credentials.

The raw aws_iam_group resource is deceptively small: it only takes a name and a path. The real work lives in the resources around it — aws_iam_group_policy_attachment for managed policies, aws_iam_group_policy for inline JSON, and aws_iam_group_membership (or aws_iam_user_group_membership) to wire users in. Stitching those together correctly, every time, by hand is where drift and copy-paste mistakes creep in. This module wraps the group plus its three most common companions behind a clean, validated variable interface so that “create a developers group with these two managed policies, this inline guardrail, and these five members” becomes a dozen lines of HCL that look identical across every account you run them in.

When to use it

Module structure

terraform-module-aws-iam-group/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Normalize the path so callers can pass "developers" or "/developers/"
  # and always get a valid IAM path with leading + trailing slashes.
  group_path = "/${trim(var.path, "/")}/" == "//" ? "/" : "/${trim(var.path, "/")}/"
}

resource "aws_iam_group" "this" {
  name = var.name
  path = local.group_path
}

# Attach managed policies (AWS-managed or customer-managed) by ARN.
resource "aws_iam_group_policy_attachment" "managed" {
  for_each = toset(var.managed_policy_arns)

  group      = aws_iam_group.this.name
  policy_arn = each.value
}

# Optional inline policy — typically a guardrail Deny that should never
# be detachable independently of the group.
resource "aws_iam_group_policy" "inline" {
  count = var.inline_policy_json == null ? 0 : 1

  name   = "${var.name}-inline"
  group  = aws_iam_group.this.name
  policy = var.inline_policy_json
}

# Authoritative membership. When enabled, Terraform owns the full member
# list and reverts any user added or removed out-of-band.
resource "aws_iam_group_membership" "this" {
  count = var.manage_membership ? 1 : 0

  name  = "${var.name}-membership"
  group = aws_iam_group.this.name
  users = var.members
}

variables.tf

variable "name" {
  description = "Name of the IAM group (e.g. \"developers\"). Must be unique within the account."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9+=,.@_-]{1,128}$", var.name))
    error_message = "name must be 1-128 chars using only A-Z a-z 0-9 and + = , . @ _ - characters."
  }
}

variable "path" {
  description = "IAM path used to organize the group (e.g. \"engineering\" or \"/engineering/\"). Leading/trailing slashes are normalized."
  type        = string
  default     = "/"
}

variable "managed_policy_arns" {
  description = "List of managed policy ARNs (AWS-managed or customer-managed) to attach to the group."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for arn in var.managed_policy_arns : can(regex("^arn:aws[a-z-]*:iam::(aws|[0-9]{12}):policy/", arn))])
    error_message = "Each entry in managed_policy_arns must be a valid IAM policy ARN (arn:aws:iam::<aws|account-id>:policy/...)."
  }
}

variable "inline_policy_json" {
  description = "Optional inline IAM policy document (JSON string). Use for guardrails that must travel with the group. Set to null to omit."
  type        = string
  default     = null

  validation {
    condition     = var.inline_policy_json == null || can(jsondecode(var.inline_policy_json))
    error_message = "inline_policy_json must be null or a valid JSON document (use jsonencode() or aws_iam_policy_document)."
  }
}

variable "manage_membership" {
  description = "If true, Terraform authoritatively manages the group's full member list and reverts out-of-band changes."
  type        = bool
  default     = true
}

variable "members" {
  description = "List of IAM user names to place in the group. Only used when manage_membership = true."
  type        = list(string)
  default     = []
}

outputs.tf

output "id" {
  description = "The IAM group's name (Terraform resource id for aws_iam_group)."
  value       = aws_iam_group.this.id
}

output "name" {
  description = "The name of the IAM group."
  value       = aws_iam_group.this.name
}

output "arn" {
  description = "The ARN assigned by AWS to the IAM group."
  value       = aws_iam_group.this.arn
}

output "unique_id" {
  description = "The stable, unique string identifying the group (GROUP... prefix)."
  value       = aws_iam_group.this.unique_id
}

output "path" {
  description = "The normalized IAM path of the group."
  value       = aws_iam_group.this.path
}

output "attached_policy_arns" {
  description = "Managed policy ARNs attached to the group."
  value       = var.managed_policy_arns
}

output "members" {
  description = "IAM user names that are members of the group (empty when membership is unmanaged)."
  value       = var.manage_membership ? var.members : []
}

How to use it

# A region/billing guardrail that should never be detachable on its own.
data "aws_iam_policy_document" "dev_guardrail" {
  statement {
    sid       = "DenyOutsideApprovedRegions"
    effect    = "Deny"
    actions   = ["*"]
    resources = ["*"]

    condition {
      test     = "StringNotEquals"
      variable = "aws:RequestedRegion"
      values   = ["ap-south-1", "ap-south-2"]
    }
  }

  statement {
    sid       = "DenyBillingAccess"
    effect    = "Deny"
    actions   = ["aws-portal:*", "budgets:*", "ce:*"]
    resources = ["*"]
  }
}

module "iam_group" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-group?ref=v1.0.0"

  name = "developers"
  path = "engineering"

  managed_policy_arns = [
    "arn:aws:iam::aws:policy/PowerUserAccess",
    "arn:aws:iam::aws:policy/IAMUserChangePassword",
  ]

  inline_policy_json = data.aws_iam_policy_document.dev_guardrail.json

  manage_membership = true
  members           = ["asha", "ravi", "meera"]
}

# Downstream reference: grant a CI role permission to add/remove users
# to exactly this group by consuming the module's ARN output.
data "aws_iam_policy_document" "group_admin" {
  statement {
    sid     = "ManageDevelopersGroupMembership"
    effect  = "Allow"
    actions = ["iam:AddUserToGroup", "iam:RemoveUserFromGroup"]
    resources = [module.iam_group.arn]
  }
}

resource "aws_iam_policy" "group_admin" {
  name   = "developers-group-membership-admin"
  policy = data.aws_iam_policy_document.group_admin.json
}

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/prod/iam_group/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-group?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/iam_group && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

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
name string Yes Name of the IAM group; 1-128 chars, validated against the allowed IAM character set.
path string "/" No IAM path to organize the group; leading/trailing slashes are normalized.
managed_policy_arns list(string) [] No Managed policy ARNs (AWS- or customer-managed) to attach; each is validated as an IAM policy ARN.
inline_policy_json string null No Optional inline policy JSON (validated parseable); use for guardrails that must travel with the group.
manage_membership bool true No When true, Terraform authoritatively owns the full member list and reverts out-of-band changes.
members list(string) [] No IAM user names to place in the group; only applied when manage_membership = true.

Outputs

Name Description
id The IAM group’s name (Terraform resource id).
name The name of the IAM group.
arn The ARN assigned by AWS to the group.
unique_id The stable, unique GROUP... identifier for the group.
path The normalized IAM path of the group.
attached_policy_arns The managed policy ARNs attached to the group.
members The IAM user names that are members (empty when membership is unmanaged).

Enterprise scenario

A fintech running in ap-south-1 keeps its engineers on AWS IAM Identity Center for daily work, but a small set of long-lived break-glass IAM users must exist for the rare event that SSO is unavailable. The platform team uses this module to stamp out a break-glass-operators group in every workload account with PowerUserAccess plus an inline guardrail that denies anything outside the two approved Indian regions and blocks billing APIs. Membership is Terraform-authoritative, so an auditor who is removed from the group in the console is silently added back — or, more importantly, an unauthorized addition is reverted — on the next pipeline run, and the change shows up clearly in the plan diff.

Best practices

TerraformAWSIAM GroupModuleIaC
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