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
- You are provisioning compute identities — EC2 instance profiles, Lambda execution roles, ECS task roles, EKS IRSA roles — and want the role + its trust + its permissions defined as one unit.
- You need cross-account access (a CI/CD account assuming a deploy role in prod) and want the
sts:ExternalIdcondition and account-scoped trust enforced consistently. - Your security team mandates a permissions boundary on every human- and machine-created role, and you want Terraform to refuse to create a role without one.
- You want to separate managed policies (shared, reusable, governed) from inline policies (role-specific, tightly coupled) and attach both cleanly.
- You are standardising naming, paths, and tags across hundreds of roles so they can be targeted by SCPs, ABAC conditions, and cost/ownership reporting.
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_boundaryguardrail is enforced with apreconditionon the role so a missing boundary fails at plan time. Add this lifecycle block insideresource "aws_iam_role" "this"inmain.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 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_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
- Scope the trust policy tighter than the permissions. Name exact principal ARNs and add conditions (
aws:SourceAccount,sts:ExternalId,aws:PrincipalOrgID) — a wildcardPrincipalwith"AWS": "*"plus a weak condition is how confused-deputy attacks start. - Make the permissions boundary mandatory. Keep
require_permissions_boundary = truefor all pipeline-created roles so a forgotten boundary fails at plan time rather than shipping an unbounded role. - Prefer customer-managed policies over inline for anything shared. Use
inline_policiesonly for permissions that should live and die with this one role; reuse and version everything else asmanaged_policy_arns. - Use
pathand consistent naming for governance. A predictable path like/service/<team>/lets you target roles in SCPs, ABAC, and cost/ownership reports without enumerating ARNs. - Keep
max_session_durationshort. Default 1 hour for machine roles; only raise it (up to 12h) for long-running jobs that genuinely need it, since longer sessions widen the credential-theft window. - Never embed long-lived keys when a role will do. Use instance profiles, IRSA, or service-linked trust instead of IAM user access keys, and let the role’s temporary STS credentials rotate automatically.