IaC AWS

Terraform Module: AWS IAM Policy — Versioned, Least-Privilege Customer-Managed Policies

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

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 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_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

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