Quick take — Build reusable customer-managed AWS IAM policies in Terraform with aws_iam_policy. Generate least-privilege JSON safely, validate inputs, manage versions, and attach to roles, users, and groups. 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_policy" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-policy?ref=v1.0.0"
name = "..." # Name (or prefix when `use_name_prefix = true`) of the p…
}
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 customer-managed policy is a standalone, versioned permission document that you author once and attach to as many roles, users, or groups as you need. Unlike an inline policy (which is embedded in a single principal and dies with it), a managed policy is a first-class resource with its own ARN, its own version history (up to five versions), and a clean attach/detach lifecycle. In Terraform that resource is aws_iam_policy.
Wrapping it in a reusable module matters because IAM policy JSON is where most security incidents are actually born: a stray "Action": "*" or "Resource": "*" that nobody reviewed, copy-pasted across ten repos. This module forces every policy through one place that builds the document with aws_iam_policy_document (so you get HCL validation, automatic JSON encoding, and no malformed-JSON apply failures), tags it for ownership and cost allocation, validates the policy name and path, and optionally wires the attachments. You change least-privilege rules in one module and every consumer inherits the fix.
When to use it
- You need the same permission set on more than one principal — e.g. a “read-only S3 data lake” policy attached to three different service roles. Inline policies would force you to duplicate the JSON; a managed policy is attached by ARN.
- You want auditable, version-controlled permissions. Customer-managed policies keep a version history and are visible in IAM, Access Analyzer, and CloudTrail as a discrete object you can review.
- You are building permission boundaries or reusable job-function policies (data-engineer, ci-deployer, sre-readonly) that many teams consume.
- You are replacing sprawling inline blocks (
aws_iam_role_policy) with named, greppable policies that show up cleanly interraform plan.
Reach for inline policies instead only when the permissions are genuinely unique to exactly one role and you want them deleted automatically when that role is destroyed.
Module structure
terraform-module-aws-iam-policy/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_iam_policy_document + aws_iam_policy + attachments
├── variables.tf # var-driven inputs with validation
└── outputs.tf # arn, id, name, policy_id, attachment counts
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# If the caller supplies raw JSON, use it verbatim; otherwise build the
# document from structured statements. Exactly one path is ever active.
use_raw_json = var.policy_json != null
policy_document = local.use_raw_json ? var.policy_json : data.aws_iam_policy_document.this[0].json
}
# Build the policy document from structured input so the JSON is always valid.
data "aws_iam_policy_document" "this" {
count = local.use_raw_json ? 0 : 1
dynamic "statement" {
for_each = var.statements
content {
sid = lookup(statement.value, "sid", null)
effect = lookup(statement.value, "effect", "Allow")
actions = lookup(statement.value, "actions", null)
resources = lookup(statement.value, "resources", null)
dynamic "condition" {
for_each = lookup(statement.value, "conditions", [])
content {
test = condition.value.test
variable = condition.value.variable
values = condition.value.values
}
}
}
}
}
resource "aws_iam_policy" "this" {
name = var.use_name_prefix ? null : var.name
name_prefix = var.use_name_prefix ? var.name : null
path = var.path
description = var.description
policy = local.policy_document
tags = merge(
var.tags,
{
Name = var.name
ManagedBy = "terraform"
},
)
}
# Optional attachments. Each list holds ARNs/names of principals to attach to.
resource "aws_iam_role_policy_attachment" "role" {
for_each = toset(var.attach_to_roles)
role = each.value
policy_arn = aws_iam_policy.this.arn
}
resource "aws_iam_user_policy_attachment" "user" {
for_each = toset(var.attach_to_users)
user = each.value
policy_arn = aws_iam_policy.this.arn
}
resource "aws_iam_group_policy_attachment" "group" {
for_each = toset(var.attach_to_groups)
group = each.value
policy_arn = aws_iam_policy.this.arn
}
variables.tf
variable "name" {
description = "Name (or name prefix when use_name_prefix = true) of the IAM policy."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9+=,.@_-]+$", var.name))
error_message = "Policy name may only contain alphanumerics and + = , . @ _ - characters."
}
validation {
condition = length(var.name) <= 128
error_message = "Policy name must be 128 characters or fewer."
}
}
variable "use_name_prefix" {
description = "If true, treat 'name' as a prefix and let AWS append a unique suffix (avoids name collisions across environments)."
type = bool
default = false
}
variable "path" {
description = "IAM path for the policy, used for organizing and scoping (e.g. /service-roles/ or /ci/)."
type = string
default = "/"
validation {
condition = can(regex("^/.*/$|^/$", var.path))
error_message = "Path must begin and end with a forward slash (e.g. \"/\" or \"/team/\")."
}
}
variable "description" {
description = "Human-readable description of what this policy grants. Strongly recommended for auditability."
type = string
default = "Managed by Terraform"
validation {
condition = length(var.description) <= 1000
error_message = "Policy description must be 1000 characters or fewer."
}
}
variable "statements" {
description = <<-EOT
Structured list of policy statements. Each object supports:
sid (optional string)
effect (optional, "Allow" or "Deny"; defaults to "Allow")
actions (list of action strings)
resources (list of resource ARNs)
conditions (optional list of { test, variable, values })
Ignored when policy_json is set.
EOT
type = list(object({
sid = optional(string)
effect = optional(string, "Allow")
actions = list(string)
resources = list(string)
conditions = optional(list(object({
test = string
variable = string
values = list(string)
})), [])
}))
default = []
validation {
condition = alltrue([
for s in var.statements : contains(["Allow", "Deny"], s.effect)
])
error_message = "Each statement effect must be either \"Allow\" or \"Deny\"."
}
validation {
condition = alltrue([
for s in var.statements : !contains(s.actions, "*") || s.effect == "Deny"
])
error_message = "Wildcard \"*\" actions are only permitted in Deny statements. Enumerate Allow actions explicitly."
}
}
variable "policy_json" {
description = "Escape hatch: a complete, pre-rendered IAM policy JSON string. When set, 'statements' is ignored. Use data.aws_iam_policy_document upstream rather than hand-written JSON."
type = string
default = null
}
variable "attach_to_roles" {
description = "List of IAM role names to attach this policy to."
type = list(string)
default = []
}
variable "attach_to_users" {
description = "List of IAM user names to attach this policy to. Prefer roles over users."
type = list(string)
default = []
}
variable "attach_to_groups" {
description = "List of IAM group names to attach this policy to."
type = list(string)
default = []
}
variable "tags" {
description = "Tags to apply to the IAM policy for ownership and cost allocation."
type = map(string)
default = {}
}
outputs.tf
output "arn" {
description = "ARN of the IAM policy. Use this to attach the policy elsewhere."
value = aws_iam_policy.this.arn
}
output "id" {
description = "ID (ARN) of the IAM policy."
value = aws_iam_policy.this.id
}
output "name" {
description = "Final name of the IAM policy (includes the AWS-generated suffix when use_name_prefix = true)."
value = aws_iam_policy.this.name
}
output "policy_id" {
description = "Stable, unique policy ID assigned by AWS (PolicyId), useful for Access Analyzer and CloudTrail correlation."
value = aws_iam_policy.this.policy_id
}
output "policy_json" {
description = "The rendered policy document JSON actually applied."
value = aws_iam_policy.this.policy
}
output "attachment_count" {
description = "Number of principals this module attached the policy to (roles + users + groups)."
value = length(var.attach_to_roles) + length(var.attach_to_users) + length(var.attach_to_groups)
}
How to use it
module "iam_policy" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-policy?ref=v1.0.0"
name = "datalake-readonly"
path = "/data/"
description = "Read-only access to the curated data-lake bucket and its KMS key."
statements = [
{
sid = "ListCuratedBucket"
actions = ["s3:ListBucket", "s3:GetBucketLocation"]
resources = ["arn:aws:s3:::acme-curated-datalake"]
},
{
sid = "ReadCuratedObjects"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::acme-curated-datalake/*"]
conditions = [
{
test = "StringEquals"
variable = "s3:ExistingObjectTag/classification"
values = ["internal"]
},
]
},
{
sid = "DecryptWithDataKey"
actions = ["kms:Decrypt", "kms:DescribeKey"]
resources = ["arn:aws:kms:ap-south-1:111122223333:key/abcd1234-ab12-cd34-ef56-1234567890ab"]
},
]
# Attach straight to the analytics service role.
attach_to_roles = ["analytics-runtime-role"]
tags = {
Team = "data-platform"
Environment = "prod"
CostCenter = "CC-4412"
}
}
# Downstream: reference the policy ARN on a role created elsewhere in the stack.
resource "aws_iam_role_policy_attachment" "notebook_role_datalake" {
role = aws_iam_role.sagemaker_notebook.name
policy_arn = module.iam_policy.arn
}
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_policy/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-policy?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/iam_policy && 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 (or prefix when use_name_prefix = true) of the policy. Validated against the IAM character set and 128-char limit. |
use_name_prefix |
bool |
false |
No | Treat name as a prefix and let AWS append a unique suffix to avoid cross-environment collisions. |
path |
string |
"/" |
No | IAM path for organizing/scoping the policy. Must start and end with /. |
description |
string |
"Managed by Terraform" |
No | Human-readable description (max 1000 chars). |
statements |
list(object) |
[] |
No | Structured statements (sid, effect, actions, resources, conditions). Wildcard actions are rejected unless effect = "Deny". Ignored if policy_json is set. |
policy_json |
string |
null |
No | Escape hatch: a complete pre-rendered policy JSON string. When set, statements is ignored. |
attach_to_roles |
list(string) |
[] |
No | IAM role names to attach this policy to. |
attach_to_users |
list(string) |
[] |
No | IAM user names to attach this policy to (prefer roles). |
attach_to_groups |
list(string) |
[] |
No | IAM group names to attach this policy to. |
tags |
map(string) |
{} |
No | Tags for ownership and cost allocation; merged with Name and ManagedBy. |
Outputs
| Name | Description |
|---|---|
arn |
ARN of the IAM policy — use this to attach it to other principals. |
id |
ID (ARN) of the IAM policy. |
name |
Final policy name, including the AWS-generated suffix when use_name_prefix = true. |
policy_id |
Stable AWS-assigned PolicyId, useful for Access Analyzer and CloudTrail correlation. |
policy_json |
The rendered policy document JSON actually applied. |
attachment_count |
Count of principals (roles + users + groups) this module attached the policy to. |
Enterprise scenario
A fintech platform team maintains a single ci-deployer customer-managed policy that every product team’s GitHub Actions OIDC role assumes to deploy into their own account. By owning the policy in this module, the platform team adds a Deny statement blocking iam:CreateUser and iam:CreatePolicyVersion with a wildcard, bumps the module to v1.1.0, and all forty consuming roles inherit the tightened boundary on their next pipeline run — no per-team edits, and the change is reviewed once in a single PR. Access Analyzer findings are correlated back to the stable policy_id output, so the security team can prove which exact policy version triggered any unused-permission alert.
Best practices
- Never wildcard
Actionin anAllow. This module enforces that with a variable validation, but the principle stands: enumerate the actions a workload needs, then use IAM Access Analyzer policy generation and last-accessed data to trim further. Wildcards belong only inDenyguardrails. - Build documents with
aws_iam_policy_document, not hand-written JSON. Heredoc JSON skips HCL validation and silently breaksapplyon a trailing comma. Thepolicy_jsonescape hatch exists for generated documents only — prefer the structuredstatementspath. - Scope
Resourceto specific ARNs and addConditionkeys (aws:SourceArn,aws:PrincipalOrgID, tag matches) so a leaked credential can’t reach beyond its intended blast radius. - Use paths and consistent naming (
/ci/,/service-roles/,<system>-<function>-<access>likedatalake-readonly) so policies are greppable in IAM, scopeable by permission boundaries, and easy to govern with SCPs. - Mind the version and size limits. Managed policies keep only five versions (Terraform reuses the default version slot) and cap at 6,144 characters — split large permission sets into focused, attachable policies rather than one monolith.
- There’s no cost in attaching, but there is risk in over-attaching. Attach by role wherever possible, avoid attaching directly to users, and let downstream stacks consume the
arnoutput so a single policy stays the source of truth.