IaC AWS

Terraform Module: AWS IAM Role — least-privilege roles with safe trust policies

Quick take — A reusable Terraform module for aws_iam_role that wires up trust policies, inline and managed policy attachments, permissions boundaries, and a path/name prefix convention for least-privilege AWS access. 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_role" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-iam-role?ref=v1.0.0"

  # (no required inputs — all have sensible defaults)
}

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 Role is an identity with a set of permissions that can be assumed by a principal — an EC2 instance, a Lambda function, an EKS service account via IRSA, another AWS account, or a federated user — without baking long-lived access keys into your workloads. Every role has two distinct policy surfaces: the trust policy (assume_role_policy), which answers who is allowed to become this role, and the permission policies (inline + attached managed), which answer what the role can do once assumed. Getting these two wrong in opposite directions is the most common source of AWS security incidents — an over-broad trust policy lets the wrong principal in, and an over-broad permission policy gives that principal too much once they’re in.

Wrapping aws_iam_role in a reusable module enforces the patterns you want every role to follow: a consistent naming/path convention so roles are discoverable and scopable in policies (arn:aws:iam::*:role/platform/*), a mandatory permissions boundary option so even a compromised pipeline can’t escalate beyond a guardrail, and a single switch between inline policies (managed by this role, deleted with it) and attached customer-managed policies (shared, versioned independently). It also centralises the boilerplate of aws_iam_role_policy, aws_iam_role_policy_attachment, and the trust document, so consumers pass a few typed variables instead of hand-rolling JSON in every stack.

When to use it

Reach for the raw aws_iam_role resource only for a one-off bootstrap role; for everything that ships through a pipeline, the module keeps the trust/permission split disciplined.

Module structure

terraform-module-aws-iam-role/
├── 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 {
  # Either name or name_prefix is used, never both.
  use_name_prefix = var.name == null

  # Normalise the boundary ARN so an empty string behaves like null.
  permissions_boundary = (
    var.permissions_boundary_arn != null && var.permissions_boundary_arn != ""
    ? var.permissions_boundary_arn
    : null
  )
}

resource "aws_iam_role" "this" {
  name        = local.use_name_prefix ? null : var.name
  name_prefix = local.use_name_prefix ? var.name_prefix : null
  path        = var.path
  description = var.description

  # Who is allowed to assume this role.
  assume_role_policy = var.assume_role_policy

  # Guardrail that caps the effective permissions of this role.
  permissions_boundary = local.permissions_boundary

  # Max session length for sts:AssumeRole (seconds). 1h–12h.
  max_session_duration = var.max_session_duration

  # Allow a session policy / tags to flow through on AssumeRole when set.
  force_detach_policies = var.force_detach_policies

  tags = var.tags
}

# Inline policies: created, versioned, and destroyed with the role.
resource "aws_iam_role_policy" "inline" {
  for_each = var.inline_policies

  name   = each.key
  role   = aws_iam_role.this.id
  policy = each.value
}

# Customer-managed / AWS-managed policy attachments by ARN.
resource "aws_iam_role_policy_attachment" "managed" {
  for_each = toset(var.managed_policy_arns)

  role       = aws_iam_role.this.name
  policy_arn = each.value
}

# Optional instance profile so the role can be attached to EC2 directly.
resource "aws_iam_instance_profile" "this" {
  count = var.create_instance_profile ? 1 : 0

  name        = local.use_name_prefix ? null : var.name
  name_prefix = local.use_name_prefix ? var.name_prefix : null
  path        = var.path
  role        = aws_iam_role.this.name

  tags = var.tags
}

variables.tf

variable "name" {
  description = "Exact name of the IAM role. Mutually exclusive with name_prefix; set one."
  type        = string
  default     = null

  validation {
    condition     = var.name == null || can(regex("^[a-zA-Z0-9+=,.@_-]{1,64}$", var.name))
    error_message = "name must be 1-64 chars of [a-zA-Z0-9+=,.@_-]."
  }
}

variable "name_prefix" {
  description = "Prefix AWS appends a unique suffix to. Mutually exclusive with name."
  type        = string
  default     = null

  validation {
    condition     = var.name_prefix == null || can(regex("^[a-zA-Z0-9+=,.@_-]{1,38}$", var.name_prefix))
    error_message = "name_prefix must be 1-38 chars of [a-zA-Z0-9+=,.@_-]."
  }
}

variable "path" {
  description = "IAM path for the role, e.g. /platform/ — used for ARN scoping in policies."
  type        = string
  default     = "/"

  validation {
    condition     = can(regex("^/($|[a-zA-Z0-9+=,.@_/-]+/$)", var.path))
    error_message = "path must begin and end with / (e.g. / or /platform/)."
  }
}

variable "description" {
  description = "Human-readable description of what assumes the role and why."
  type        = string
  default     = null
}

variable "assume_role_policy" {
  description = "Trust policy JSON controlling which principals may assume this role."
  type        = string

  validation {
    condition     = can(jsondecode(var.assume_role_policy))
    error_message = "assume_role_policy must be valid JSON."
  }
}

variable "permissions_boundary_arn" {
  description = "ARN of the managed policy used as a permissions boundary. Empty string disables it."
  type        = string
  default     = null
}

variable "require_permissions_boundary" {
  description = "Fail the plan if no permissions_boundary_arn is supplied (security guardrail)."
  type        = bool
  default     = true
}

variable "max_session_duration" {
  description = "Maximum AssumeRole session duration in seconds (3600-43200)."
  type        = number
  default     = 3600

  validation {
    condition     = var.max_session_duration >= 3600 && var.max_session_duration <= 43200
    error_message = "max_session_duration must be between 3600 (1h) and 43200 (12h)."
  }
}

variable "force_detach_policies" {
  description = "Force-detach any attached policies before destroying the role."
  type        = bool
  default     = false
}

variable "inline_policies" {
  description = "Map of inline policy name => policy JSON, lifecycle-bound to the role."
  type        = map(string)
  default     = {}

  validation {
    condition     = alltrue([for p in values(var.inline_policies) : can(jsondecode(p))])
    error_message = "Every inline_policies value must be valid JSON."
  }
}

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

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

variable "create_instance_profile" {
  description = "Create an aws_iam_instance_profile wrapping the role (for EC2 use)."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Tags applied to the role and instance profile."
  type        = map(string)
  default     = {}
}

The require_permissions_boundary guardrail is enforced with a precondition on the role so a missing boundary fails at plan time. Add this lifecycle block inside resource "aws_iam_role" "this" in main.tf:

  lifecycle {
    precondition {
      condition     = !var.require_permissions_boundary || local.permissions_boundary != null
      error_message = "A permissions_boundary_arn is required. Set require_permissions_boundary = false to override."
    }
  }

outputs.tf

output "id" {
  description = "Role name (same as the resource id for aws_iam_role)."
  value       = aws_iam_role.this.id
}

output "name" {
  description = "Final name of the role (resolved even when name_prefix is used)."
  value       = aws_iam_role.this.name
}

output "arn" {
  description = "ARN of the role — use this in trust policies and resource policies."
  value       = aws_iam_role.this.arn
}

output "unique_id" {
  description = "Stable unique ID (RoleId) for the role, used in ABAC conditions."
  value       = aws_iam_role.this.unique_id
}

output "instance_profile_arn" {
  description = "ARN of the instance profile, or null if not created."
  value       = try(aws_iam_instance_profile.this[0].arn, null)
}

output "instance_profile_name" {
  description = "Name of the instance profile, or null if not created."
  value       = try(aws_iam_instance_profile.this[0].name, null)
}

How to use it

data "aws_iam_policy_document" "lambda_trust" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "lambda_permissions" {
  statement {
    sid       = "ReadOrders"
    actions   = ["dynamodb:GetItem", "dynamodb:Query"]
    resources = [aws_dynamodb_table.orders.arn]
  }
  statement {
    sid       = "WriteLogs"
    actions   = ["logs:CreateLogStream", "logs:PutLogEvents"]
    resources = ["arn:aws:logs:*:*:log-group:/aws/lambda/orders-*:*"]
  }
}

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

  name        = "orders-api-lambda"
  path        = "/service/orders/"
  description = "Execution role for the orders-api Lambda function"

  assume_role_policy       = data.aws_iam_policy_document.lambda_trust.json
  permissions_boundary_arn = "arn:aws:iam::123456789012:policy/boundary/service-boundary"
  max_session_duration     = 3600

  inline_policies = {
    orders-access = data.aws_iam_policy_document.lambda_permissions.json
  }

  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole",
  ]

  tags = {
    Service     = "orders-api"
    Environment = "prod"
    ManagedBy   = "terraform"
  }
}

# Downstream: wire the role ARN straight into the Lambda function.
resource "aws_lambda_function" "orders_api" {
  function_name = "orders-api"
  role          = module.iam_role.arn
  runtime       = "python3.12"
  handler       = "app.handler"
  filename      = "build/orders-api.zip"
}

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_role/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-role?ref=v1.0.0"
}

inputs = {
  # (no required inputs)
}

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

cd live/prod/iam_role && 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 null No Exact role name. Mutually exclusive with name_prefix.
name_prefix string null No Prefix AWS extends with a unique suffix.
path string "/" No IAM path, e.g. /platform/, for ARN scoping.
description string null No What assumes the role and why.
assume_role_policy string n/a Yes Trust policy JSON (who can assume the role).
permissions_boundary_arn string null No Managed policy ARN used as a permissions boundary.
require_permissions_boundary bool true No Fail the plan if no boundary is supplied.
max_session_duration number 3600 No AssumeRole session length in seconds (3600–43200).
force_detach_policies bool false No Force-detach policies before destroy.
inline_policies map(string) {} No Map of inline policy name => policy JSON.
managed_policy_arns list(string) [] No Managed policy ARNs to attach.
create_instance_profile bool false No Create an instance profile for EC2 use.
tags map(string) {} No Tags for the role and instance profile.

Outputs

Name Description
id Role name (resource id of aws_iam_role).
name Final resolved role name (works with name_prefix).
arn Role ARN — use in trust and resource policies.
unique_id Stable RoleId for ABAC conditions.
instance_profile_arn Instance profile ARN, or null.
instance_profile_name Instance profile name, or null.

Enterprise scenario

A platform team runs a centralised CI/CD account that deploys into ~40 workload accounts. In each workload account they instantiate this module to create a platform/cicd-deploy role whose assume_role_policy trusts only the CI/CD account’s pipeline role and requires an sts:ExternalId matching the repository name, while permissions_boundary_arn pins every deploy role to an org-wide boundary that denies IAM user creation and access-key minting. Because the role path is /platform/, the org’s SCPs can allow pipeline assumption with a single arn:aws:iam::*:role/platform/* condition, and the boundary guarantees that even a compromised pipeline can never escalate beyond what the security team approved.

Best practices

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