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
- You manage human IAM users (break-glass operators, contractors without SSO, service consoles) and want their permissions grouped by job function rather than smeared across individual users.
- You are standardizing permission baselines —
developers,read-only-auditors,billing-viewers— and need the same group definition reproduced across dev/stage/prod accounts. - You want to enforce an inline guardrail (for example, an explicit
Denyon leaving a region or touching billing) that travels with the group and cannot be detached by accident the way a managed policy attachment can. - You need authoritative, Terraform-owned membership so that a user added manually in the console gets reverted on the next
apply. - If you have AWS IAM Identity Center (SSO) for your workforce, prefer permission sets and groups there — reach for this module for the residual IAM-user population that SSO does not cover.
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 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/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
- Group by job function, not by team name.
read-only-auditorsanddatabase-operatorssurvive reorgs;team-falcondoes not. Permissions should describe what the members can do, not who they report to. - Put hard guardrails inline, convenience permissions in managed policies. An inline
Denycannot be detached by aaws_iam_group_policy_attachmentchange or a console click independent of the group, making it the right home for region locks and billing blocks. - Keep
manage_membership = truefor security-sensitive groups so out-of-band additions are reverted and every membership change is visible in a reviewedterraform plan. Flip it off only when another system legitimately co-owns membership. - Prefer customer-managed policies over inline for anything reused. Reserve inline policy for the one guardrail unique to this group; share everything else as
aws_iam_policyso it can be versioned and audited in one place. - Use IAM
pathto scope permission boundaries and least-privilege admin. Grouping under/engineering/lets you write aresources = ["arn:aws:iam::*:group/engineering/*"]policy for delegated group admins without granting account-wide IAM control. - Mind the limits and avoid groups for machines. A user can belong to at most 10 groups, and groups cannot be assumed or nested — for workloads, use IAM roles, never an IAM user in a group.