Skip to main content
RunBook Academy

TerraformXIX · Security: Credentials, Secrets, and AuditProduction Terraform

Least Privilege for the Terraform Execution Role

Intermediate⏱ ~14 minbashtfsec

What you'll learn

  • Bound the Terraform execution role by environment, by resource ARN prefix, and by tag
  • Distinguish the role boundary (permissions boundary), the resource boundary (ARN prefix), and the SCP (org-wide ceiling)
  • Audit a Terraform role with IAM Access Analyzer and `iam:GenerateServiceLastAccessedDetails`
  • Recognise the production signals of an over-privileged Terraform role
  • Quantify the cost of over-granting in terms of blast radius and incident recovery time

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.

A Terraform execution role is the AWS IAM role that Terraform assumes to issue API calls against your infrastructure. The role has a policy. The policy is the authorisation boundary. The credential is OIDC or SSO; the blast radius is the policy.

The production question is not “what permissions does Terraform need?” (it needs whatever the configuration declares). The question is “what is the smallest set of permissions that lets this configuration apply, broken out by environment, by resource ARN, and by tag?” Every action that is not in that set is a permissions gap that turns an incident into a compromise.

This lesson covers the three boundaries (role boundary, resource boundary, SCP), the policy shape that respects them, the audit that proves they are respected, and the cost of over-granting.

The three boundaries

A Terraform execution role is bounded along three orthogonal axes:

                          SCP (org ceiling)
                  "what the role cannot do at all"
                              |
                              v
              Permissions boundary (role ceiling)
              "what the role cannot do even if the
               inline policy says so"
                              |
                              v
              Identity-based policy (the role's actual
              permissions; must be a subset of both)
                              |
                              v
              Resource-based policies (per-resource
              gates; for example, S3 bucket policy denies
              deletion outside the role)

A correct production setup tightens all three. A common mistake tightens one and ignores the other two.

The SCP (organisational ceiling)

Service Control Policies are AWS Organisations-level deny-by-default rules. A production SCP for Terraform roles looks like:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDangerousActionsForTerraformRoles",
      "Effect": "Deny",
      "Action": [
        "iam:CreateUser",
        "iam:AttachUserPolicy",
        "iam:CreateAccessKey",
        "organizations:*",
        "account:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/Role": "terraform"
        }
      }
    }
  ]
}

The SCP denies the actions even if the role’s identity-based policy allows them. The aws:PrincipalTag/Role condition limits the SCP to Terraform roles; it does not affect other workloads. The SCP is the org-wide safety net.

The permissions boundary (role ceiling)

A permissions boundary is a managed policy attached to the role that defines the maximum permissions the role can ever have. The role’s identity-based policy is intersected with the boundary; only actions allowed by both are effective.

resource "aws_iam_policy" "terraform_boundary" {
  name   = "terraform-permissions-boundary"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "AllowOnlyTerraformActions"
        Effect   = "Allow"
        Action   = [
          "ec2:*",
          "s3:*",
          "iam:*",
          "rds:*",
          "lambda:*"
        ]
        Resource = "*"
      },
      {
        Sid    = "DenyIAMPrivilegeEscalation"
        Effect = "Deny"
        Action = [
          "iam:AttachUserPolicy",
          "iam:AttachRolePolicy",
          "iam:PutUserPolicy",
          "iam:PutRolePolicy",
          "iam:CreateAccessKey"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_role" "terraform_prod" {
  name                 = "terraform-prod"
  permissions_boundary = aws_iam_policy.terraform_boundary.arn
  # ...
}

The boundary allows everything Terraform needs; it denies the specific privilege-escalation paths that a misconfigured inline policy could open.

The resource boundary (ARN prefix)

The identity-based policy is the most specific layer. It names the actions and the ARNs. A production policy for an S3-only Terraform workspace looks like:

resource "aws_iam_role_policy" "terraform_prod_s3" {
  name = "terraform-prod-s3"
  role = aws_iam_role.terraform_prod.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:ListBucket",
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject"
        ]
        Resource = [
          "arn:aws:s3:::prod-tfstate-${var.region}",
          "arn:aws:s3:::prod-tfstate-${var.region}/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem",
          "dynamodb:DescribeTable"
        ]
        Resource = "arn:aws:dynamodb:${var.region}:${var.account}:table/prod-tflock"
      }
    ]
  })
}

Note the explicit bucket ARN prefix in Resource. The role can read and write only the production state bucket. It cannot list or read any other bucket in the account. The blast radius is the state bucket, not the account.

Tag-based scoping for multi-environment roles

For multi-environment estates (dev, staging, prod) the production pattern is one role per environment, with tag conditions that scope the actions to the environment’s tag. Which condition key you reach for depends on whether the resource exists yet.

ec2:ResourceTag/<key> matches the tags already on a resource, so it applies to actions against something that is already there — terminating, stopping, modifying:

data "aws_iam_policy_document" "terraform_prod_existing" {
  statement {
    sid    = "ActOnProdTaggedInstances"
    effect = "Allow"
    actions = [
      "ec2:TerminateInstances",
      "ec2:StopInstances",
      "ec2:ModifyInstanceAttribute"
    ]
    resources = ["arn:aws:ec2:eu-west-1:123456789012:instance/*"]
    condition {
      test     = "StringEquals"
      variable = "ec2:ResourceTag/Environment"
      values   = ["prod"]
    }
  }
}

ec2:RunInstances is different, because it creates the instance. There is no existing resource to carry a tag, so ec2:ResourceTag on the instance does nothing useful there. The tags that matter are the ones in the request, and the keys for those are the global aws:RequestTag/<key> and aws:TagKeys:

data "aws_iam_policy_document" "terraform_prod_launch" {
  # The request has to reach the AMI, subnet, security group,
  # and key pair; those are pre-existing, so no request-tag
  # condition applies to them.
  statement {
    sid       = "LaunchUsingProdNetwork"
    effect    = "Allow"
    actions   = ["ec2:RunInstances"]
    resources = [
      "arn:aws:ec2:eu-west-1::image/*",
      "arn:aws:ec2:eu-west-1:123456789012:subnet/*",
      "arn:aws:ec2:eu-west-1:123456789012:network-interface/*",
      "arn:aws:ec2:eu-west-1:123456789012:security-group/*",
      "arn:aws:ec2:eu-west-1:123456789012:key-pair/*"
    ]
  }

  # The instance and volume are created by the call, so the
  # condition is on the tags carried in the request.
  statement {
    sid       = "LaunchOnlyProdTaggedInstances"
    effect    = "Allow"
    actions   = ["ec2:RunInstances"]
    resources = [
      "arn:aws:ec2:eu-west-1:123456789012:instance/*",
      "arn:aws:ec2:eu-west-1:123456789012:volume/*"
    ]
    condition {
      test     = "StringEquals"
      variable = "aws:RequestTag/Environment"
      values   = ["prod"]
    }
  }

  # Tagging at launch is a separate action. ec2:CreateAction
  # confines it to the RunInstances call.
  statement {
    sid       = "TagOnLaunchOnly"
    effect    = "Allow"
    actions   = ["ec2:CreateTags"]
    resources = ["arn:aws:ec2:eu-west-1:123456789012:*/*"]
    condition {
      test     = "StringEquals"
      variable = "ec2:CreateAction"
      values   = ["RunInstances"]
    }
  }
}

The role can launch an instance only when the request tags it Environment=prod, and it can act on an existing instance only when that instance already carries the tag. Both conditions are enforced by AWS on every API call.

The audit: prove the role is least-privilege

Two AWS tools audit a Terraform role.

IAM Access Analyzer

Access Analyzer reviews the role’s policy and reports grants that are overly broad. A policy with Action: "s3:*" and Resource: "*" is flagged as “inferred access to all S3 resources”. The report is the proof that the policy is not least-privilege.

# READ-ONLY
aws accessanalyzer list-findings \
    --analyzer-arn arn:aws:access-analyzer:eu-west-2:123456789012:analyzer/OrganisationAnalyzer \
    --filter '{"resource": {"contains": "terraform-prod"}}'

Service-last-accessed details

iam:GenerateServiceLastAccessedDetails produces a report of which AWS services the role has actually used in the trailing 90 days. Services with no activity are candidates for removal.

# CONFIGURATION — generates a job; READ-ONLY result
aws iam generate-service-last-accessed-details \
    --arn arn:aws:iam::123456789012:role/terraform-prod

JOB_ID=$(...)
aws iam get-service-last-accessed-details --job-id "$JOB_ID"

The report returns:

Service: ec2          Last accessed: 2026-08-13T03:14:07Z
Service: s3           Last accessed: 2026-08-13T03:14:09Z
Service: iam          Last accessed: 2026-08-12T19:01:22Z
Service: rds          Last accessed: 2026-04-02T11:00:00Z   # 4 months ago
Service: kms          Last accessed: 2026-03-15T09:00:00Z   # 5 months ago

rds and kms have not been used in months. They are candidates for removal. Every action removed is a path closed in the next incident.

simulate-principal-policy

Simulate the policy against a specific API call to confirm the action is allowed.

# READ-ONLY
aws iam simulate-principal-policy \
    --policy-source-arn arn:aws:iam::123456789012:role/terraform-prod \
    --action-names s3:GetObject \
    --resource-arns arn:aws:s3:::prod-tfstate-eu-west-2/tfstate/prod/terraform.tfstate
{
    "EvaluationResults": [
        {
            "EvalActionName": "s3:GetObject",
            "EvalResourceName": "arn:aws:s3:::prod-tfstate-eu-west-2/tfstate/prod/terraform.tfstate",
            "EvalDecision": "allowed"
        }
    ]
}

Run the same simulation with s3:GetObject against arn:aws:s3:::other-bucket/key:

{
    "EvalActionName": "s3:GetObject",
    "EvalResourceName": "arn:aws:s3:::other-bucket/key",
    "EvalDecision": "implicitDeny"
}

implicitDeny is the correct answer. The role cannot read the other bucket. The blast radius is bounded.

How to validate the configuration

Run the audit and confirm:

  1. Access Analyzer reports zero findings for the Terraform roles. A finding means a grant is too broad.
  2. Service-last-accessed has no service used within 90 days that is still in the policy. Any such service is a removal candidate.
  3. simulate-principal-policy returns allowed for the actions and ARNs the configuration needs, and implicitDeny for everything else.

If any check fails, the policy is not yet least-privilege.

Production failure modes

Six failures account for most production incidents:

  1. AdministratorAccess on a Terraform role. The role was granted AdministratorAccess in 2022 to unblock a failing apply. The blast radius is the entire account. The fix is a permissions boundary and an explicit identity-based policy, broken out per environment.

  2. Wildcard resource in a narrowly-scoped action. The policy has Action: "ec2:RunInstances" and Resource: "*" because the engineer did not know which AMI ARN would be used. The role can launch instances in any environment. The fix is an explicit ARN list for the pre-existing resources the call touches, plus an aws:RequestTag condition on the instance and volume ARNs the call creates.

  3. Permissions boundary missing. The inline policy is tight; the boundary is not set. A future engineer widens the inline policy; nothing bounds them. The fix is a permissions boundary attached to the role and a Terraform module pattern that makes the boundary mandatory.

  4. No tag condition on cross-environment roles. The same role manages dev and prod. The policy is Action: "ec2:*". The role can terminate a prod instance from a terraform apply against the dev workspace. The fix is one role per environment, with a tag condition.

  5. Audit runs but no one acts on findings. Access Analyzer reports 14 findings. The team triages 2 and ignores the rest. Six months later, a leaked credential exploits one of the unaddressed findings. The fix is an SLA on finding triage (for example, critical findings within 24 hours) and a quarterly review of open findings.

  6. Service-last-accessed never reviewed. The policy has not been audited in two years. It includes services the role stopped using in 2024. The fix is a quarterly review, with removal of unused services from the policy.

Security and performance implications

The performance cost of a tight policy is zero; the AWS control plane evaluates the policy at the same speed regardless of how many statements it contains, up to the documented limits (six policies per role, 6,144 characters per policy).

The security cost of a tight policy is operational: every new resource type the configuration declares may need a new action. A policy that does not allow rds:CreateDBInstance produces a terraform apply that fails with an AccessDenied. The fix is either to add the action (with the corresponding ARN scoping) or to redesign the configuration so the resource is managed by a different workspace.

The cost of over-granting is incident scope. A leaked AdministratorAccess key requires an account-wide audit, a forced rotation of every IAM user, and a credential reset for every workload that uses IAM. The recovery time is measured in days. A leaked role with a tight policy (Action: "s3:*" on the state bucket) requires a state bucket audit and a state-file rotation. The recovery time is measured in hours. The difference is the cost of the original policy decision.

What to do in production

The minimum audit stack for a production Terraform estate:

  1. Permissions boundary attached to every Terraform role, managed in Terraform, with the boundary mandatory in the module pattern.
  2. SCP at the org root denying the IAM and account actions that a Terraform role should never perform.
  3. Tag-based resource scoping for cross-environment roles, with one role per environment.
  4. Access Analyzer enabled at the org level, with findings reviewed weekly and triaged within an SLA.
  5. Service-last-accessed audit quarterly, with unused services removed from the policy.
  6. simulate-principal-policy run as part of the pre-merge CI check on any change to the role module.

Verification

Run Access Analyzer against the Terraform roles and confirm zero findings. Run simulate-principal-policy against the actions and ARNs the configuration actually needs and confirm allowed. Run it against an unrelated ARN and confirm implicitDeny. Generate the service-last-accessed report and confirm the policy contains no service unused for more than 90 days. Confirm the SCP denies iam:CreateAccessKey and organizations:* even when called under the Terraform role.

Knowledge check · 7 questions

  1. Q1. What is the role of a permissions boundary on a Terraform IAM role?

  2. Q2. Which of the following is the strongest argument against `Action: "*"` and `Resource: "*"` on a Terraform role?

  3. Q3. A tag condition on a Terraform role (for example `ec2:ResourceTag/Environment = prod`) is sufficient to scope the role to the production environment.

  4. Q4. Which AWS tools audit whether a Terraform role is least-privilege? (Select all that apply.)

  5. Q5. What does `iam:GenerateServiceLastAccessedDetails` tell you about a Terraform role?

  6. Q6. Service Control Policies (SCPs) deny actions at which level?

  7. Q7. A leaked Terraform CI credential is discovered at 03:00. The role is `Action: "*"` and `Resource: "*"`. The team rotates the credential. What remains?

Passing score: 75%. Answers are checked in this browser.