TerraformXIX · Security: Credentials, Secrets, and AuditProduction Terraform
IAM and Resource Policies in Terraform
What you'll learn
- Distinguish per-environment, per-state, and CI-federated IAM roles in a Terraform estate
- Write an OIDC trust policy that scopes role assumption to a specific repo and branch
- Recognise the cost of a wildcard principal or wildcard action in a Terraform-managed IAM policy
- Manage IAM entirely from Terraform to prevent console-side drift
- Diagnose a misconfigured trust policy from the CloudTrail event
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
IAM in a Terraform estate is not one role. It is a small graph of roles, each with a different scope and a different trust policy. The three patterns are:
1. Per-environment role
"terraform-dev", "terraform-staging", "terraform-prod"
Assumed by engineers via SSO; chained from the base
SSO credentials.
2. Per-state role
"terraform-state-prod"
Assumed by the Terraform engine during the apply;
scoped to the state bucket and lock table.
3. CI-federated role
"terraform-ci-plan", "terraform-ci-apply"
Assumed by the CI runner via OIDC; scoped to the
trust-policy condition (repo + branch).
Each role has its own trust policy. Each trust policy is the production control that decides who can assume the role. A misconfigured trust policy is a credential boundary failure; the leak is the role, not just the credential.
This lesson covers the three patterns, the trust policy shape that respects each, the cost of a wildcard, and the discipline of managing IAM entirely from Terraform.
Pattern 1 — per-environment role
The engineer’s shell obtains SSO credentials for the dev or
prod account. The Terraform provider chains an AssumeRole
to a narrower role specific to the environment.
resource "aws_iam_role" "terraform_prod" {
name = "terraform-prod"
path = "/terraform/"
permissions_boundary = aws_iam_policy.terraform_boundary.arn
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
# Only the org's SSO role can chain into this role
AWS = "arn:aws:iam::123456789012:role/AWSReservedSSO_AdministratorAccess_abc123"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"aws:RequestTag/Environment" = "prod"
}
}
}
]
})
}
The trust policy permits assumption only from the SSO permission set the operations team uses for production. The condition enforces that the request tag matches the environment. The blast radius is the SSO-permission-set policy intersected with the role’s identity-based policy intersected with the permissions boundary.
The engineer’s workflow:
# READ-ONLY
aws sso login --profile prod
export AWS_PROFILE=prod
aws sts get-caller-identity
{
"Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abc123/alice"
}
The provider’s assume_role block then chains:
provider "aws" {
region = "eu-west-2"
assume_role {
role_arn = aws_iam_role.terraform_prod.arn
session_name = "terraform-${var.engineer}"
duration = "1h"
}
}
The final CloudTrail entry for the apply shows:
userIdentity.arn = arn:aws:sts::123456789012:assumed-role/terraform-prod/terraform-alice
The trail identifies the engineer (via the SSO identity) and the scoped role. Both are required for forensic clarity.
Pattern 2 — per-state role
A second role is narrower still: it manages only the state bucket and lock table. The configuration that manages IAM uses the engineer’s role. The configuration that manages infrastructure uses the state role.
resource "aws_iam_role" "terraform_state_prod" {
name = "terraform-state-prod"
permissions_boundary = aws_iam_policy.terraform_boundary.arn
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = [
aws_iam_role.terraform_prod.arn,
aws_iam_role.terraform_ci_apply.arn
]
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy" "state_access" {
name = "state-access"
role = aws_iam_role.terraform_state_prod.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
]
Resource = [
aws_s3_bucket.tfstate.arn,
"${aws_s3_bucket.tfstate.arn}/*"
]
},
{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
]
Resource = aws_dynamodb_table.tflock.arn
}
]
})
}
The role can only read/write the state bucket and lock table. It cannot list other buckets, cannot terminate instances, cannot modify IAM. The blast radius is the state.
This is the pattern Terraform Cloud uses internally for dynamic provider credentials: a per-workspace, per-state role that the workspace assumes at apply time. The pattern works the same in self-hosted estates.
Pattern 3 — OIDC-federated CI role
The CI runner does not have an SSO session. It has a JWT signed by the CI platform. The trust policy accepts the JWT only when the claims match.
resource "aws_iam_role" "terraform_ci_apply" {
name = "terraform-ci-apply"
permissions_boundary = aws_iam_policy.terraform_boundary.arn
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = [
"repo:runbook/terraform-modules:ref:refs/heads/main",
"repo:runbook/terraform-modules:ref:refs/tags/v*"
]
}
}
}
]
})
}
Three production details matter:
Principal.Federatedreferences the OIDC provider by ARN, not by URL. The provider must exist in IAM before the role can assume.Condition.StringEqualsonaudmatches the JWT’s audience claim tosts.amazonaws.com. Without this, a JWT intended for another service can be replayed.Condition.StringLikeonsubmatches the JWT’s subject claim to the specific repo and ref. The role is assumable only by workflows running onmainor on tags starting withv. A pull request from a fork triggers a JWT withsub=repo:runbook/terraform-modules:pull_request:1, which does not match theref:refs/heads/mainorref:refs/tags/v*patterns and is rejected.
The cost of a wildcard
Two wildcards in IAM policies have outsized production cost:
{
"Effect": "Allow",
"Principal": "*",
"Action": "sts:AssumeRole"
}
A trust policy with Principal: "*" is open to the
internet. The role can be assumed by any AWS principal in any
account. The blast radius is the policy attached to the role.
The exposure is the role, not the credential; the role does
not expire.
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
A wildcard action with a wildcard resource is indistinguishable from no policy at all. The role can do anything in the account. The audit trail still records the role name, but the blast radius is the account.
The production cost of either wildcard is incident scope.
A role with Principal: "*" and Action: s3:* is a
publicly-assumable state-bucket-writer. A leaked role ARN is
the leak; rotation of the credential does not help. The
recovery is the same as a leaked access key, except there is
no credential to rotate.
Managing IAM from Terraform, exclusively
IAM resources managed by Terraform are the production default. The discipline is:
- All IAM resources (
aws_iam_role,aws_iam_policy,aws_iam_role_policy_attachment) live in Terraform code under source control. - Console edits to IAM are drift. The next
terraform applyreverts them. The drift window is the incident window. - Alerting on console-side IAM edits:
CloudTrail’s
iam:*events fromuserIdentity.type = "IAMUser"(a human, not a role) trigger a page to the on-call. - Import for legacy IAM:
terraform import aws_iam_role.prodbrings an existing role under management without recreating it.
The aws_iam_role resource has a managed_policy_arns
argument for attaching managed policies and an
assume_role_policy argument for the trust policy. Both
arguments are required for a complete role definition. A
missing assume_role_policy means the role cannot be
assumed; a missing identity policy means it cannot do
anything once assumed. Both must be present.
How to validate the configuration
# READ-ONLY — confirm the trust policy on the role
aws iam get-role --role-name terraform-ci-apply \
--query 'Role.AssumeRolePolicyDocument'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": { ... }
}
]
}
Confirm there is no Principal: "*" and no Action: "*"
anywhere in the policy.
# READ-ONLY — confirm the role is Terraform-managed
terraform state show aws_iam_role.terraform_ci_apply
Confirm the role is in the state file. A role that exists in IAM but not in state is being managed by hand. It is drift.
Production failure modes
Six failures account for most production incidents:
-
Principal: "*"in a trust policy. The role can be assumed by any AWS principal. The fix is to specify the principal explicitly and add aConditionblock. -
OIDC trust policy without a
Conditionblock. The role is assumable by any workflow in any repo in the organisation. The fix is thesubandaudconditions scoped to the specific repo and ref. -
Console-side IAM edit. A human uses the console to widen a policy during an incident, then forgets to revert. The next
terraform applyreverts the policy. The drift window is the incident window. The fix is alerting on console-side IAM edits and a discipline of no-console changes. -
Cross-account trust too broad. The role trusts an entire AWS account (
Principal.AWS = "arn:aws:iam::OTHERACCOUNT:root") when it should trust a specific role in that account. The fix is to name the role ARN, not the account ARN. -
OIDC provider thumbprint drift. GitHub rotates its OIDC signing certificate. The provider’s thumbprint in IAM no longer matches. Every CI assumption fails. The fix is to monitor GitHub’s published thumbprints and to update the provider’s thumbprint in Terraform when GitHub announces a rotation.
-
Role managed by Terraform, but policy attached by hand. The role is in state; the policy is not. The next apply that includes the policy module re-attaches the policy and produces an unwanted diff. The fix is to manage the policy attachment in the same module as the role.
Security and performance implications
The performance cost of three roles instead of one is zero; AWS evaluates policies at the same speed regardless of how many roles exist.
The security cost is operational: three roles means three trust policies to maintain, three boundary attachments, three sets of Access Analyzer findings. The maintenance burden is the cost of doing it right.
The cost of one role with a wildcard principal is incident scope. A leaked role ARN with a permissive trust policy is the same blast radius as a leaked access key, except there is no credential to rotate. The recovery requires either a trust policy edit (which does not help if the role has already been assumed and a new persistent credential has been issued) or a complete role replacement. The recovery is days, not hours.
What to do in production
The minimum IAM pattern for a production Terraform estate:
- One role per environment for engineers (via SSO +
assume_role). - One role per state bucket for the Terraform engine itself.
- One role per CI environment for the CI runner (via OIDC with a tight trust policy).
- All IAM in Terraform, with console-side edits alerted as incidents.
- Permissions boundary on every role, with the boundary managed in Terraform and made mandatory in the module.
- Audit weekly: trust policies have no wildcards; conditions match the expected repo/ref; Access Analyzer has no findings.
Verification
Run aws iam get-role against each Terraform role and
confirm the trust policy has no Principal: "*" and a
Condition block scoped to the expected principal. Run
aws sts assume-role from the CI runner with the production
role ARN and confirm the assumption succeeds. Run the same
call from a fork PR and confirm the assumption fails with
AccessDenied. Run terraform plan against the IAM
workspace and confirm no drift.
Knowledge check · 7 questions
Q1. What is the role of the `Condition` block in an OIDC trust policy?
Q2. Why is a per-state role preferable to a single broad Terraform role?
Q3. A trust policy with `Principal: "*"` and `Action: "sts:AssumeRole"` is a credential-boundary failure however tight the identity-based policy is.
Q4. Which of the following are valid IAM patterns for a Terraform estate? (Select all that apply.)
Q5. What does a console-side IAM edit on a Terraform-managed role produce?
Q6. GitHub rotates the OIDC signing certificate. What is the production impact?
Q7. An on-call engineer uses the AWS console to widen the trust policy of the production Terraform role during a 03:00 incident. They forget to revert. The next morning, an automated `terraform apply` runs. What happens?
Passing score: 75%. Answers are checked in this browser.