Skip to main content
RunBook Academy

TerraformXIX · Security: Credentials, Secrets, and AuditProduction Terraform

IAM and Resource Policies in Terraform

Intermediate⏱ ~13 minbash

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

Not yet marked complete on this device.

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:

  1. Principal.Federated references the OIDC provider by ARN, not by URL. The provider must exist in IAM before the role can assume.
  2. Condition.StringEquals on aud matches the JWT’s audience claim to sts.amazonaws.com. Without this, a JWT intended for another service can be replayed.
  3. Condition.StringLike on sub matches the JWT’s subject claim to the specific repo and ref. The role is assumable only by workflows running on main or on tags starting with v. A pull request from a fork triggers a JWT with sub=repo:runbook/terraform-modules:pull_request:1, which does not match the ref:refs/heads/main or ref: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:

  1. All IAM resources (aws_iam_role, aws_iam_policy, aws_iam_role_policy_attachment) live in Terraform code under source control.
  2. Console edits to IAM are drift. The next terraform apply reverts them. The drift window is the incident window.
  3. Alerting on console-side IAM edits: CloudTrail’s iam:* events from userIdentity.type = "IAMUser" (a human, not a role) trigger a page to the on-call.
  4. Import for legacy IAM: terraform import aws_iam_role.prod brings 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:

  1. Principal: "*" in a trust policy. The role can be assumed by any AWS principal. The fix is to specify the principal explicitly and add a Condition block.

  2. OIDC trust policy without a Condition block. The role is assumable by any workflow in any repo in the organisation. The fix is the sub and aud conditions scoped to the specific repo and ref.

  3. Console-side IAM edit. A human uses the console to widen a policy during an incident, then forgets to revert. The next terraform apply reverts 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.

  4. 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.

  5. 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.

  6. 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:

  1. One role per environment for engineers (via SSO + assume_role).
  2. One role per state bucket for the Terraform engine itself.
  3. One role per CI environment for the CI runner (via OIDC with a tight trust policy).
  4. All IAM in Terraform, with console-side edits alerted as incidents.
  5. Permissions boundary on every role, with the boundary managed in Terraform and made mandatory in the module.
  6. 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

  1. Q1. What is the role of the `Condition` block in an OIDC trust policy?

  2. Q2. Why is a per-state role preferable to a single broad Terraform role?

  3. Q3. A trust policy with `Principal: "*"` and `Action: "sts:AssumeRole"` is a credential-boundary failure however tight the identity-based policy is.

  4. Q4. Which of the following are valid IAM patterns for a Terraform estate? (Select all that apply.)

  5. Q5. What does a console-side IAM edit on a Terraform-managed role produce?

  6. Q6. GitHub rotates the OIDC signing certificate. What is the production impact?

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