Terraform’s IAM capabilities are often used to grant broad permissions, but the real power lies in meticulously scoping them down to the absolute minimum required.

Let’s see how this plays out with a practical example. Imagine we need to grant a Terraform user the ability to create and manage S3 buckets, but only in a specific region and with specific naming conventions.

resource "aws_iam_role" "terraform_executor" {
  name = "terraform-executor-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::123456789012:root" # Replace with your AWS Account ID
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "s3_bucket_management" {
  name = "s3-bucket-management-policy"
  role = aws_iam_role.terraform_executor.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid = "AllowS3BucketCreationInUsEast1"
        Effect = "Allow"
        Action = [
          "s3:CreateBucket"
        ]
        Resource = "arn:aws:s3:::my-specific-app-bucket-*" # Example pattern
        Condition = {
          "StringLike" = {
            "s3:LocationConstraint" = "us-east-1"
          }
        }
      },
      {
        Sid = "AllowS3BucketManagement"
        Effect = "Allow"
        Action = [
          "s3:ListBucket",
          "s3:GetBucketLocation",
          "s3:DeleteBucket",
          "s3:PutBucketPolicy",
          "s3:GetBucketPolicy",
          "s3:DeleteBucketPolicy",
          "s3:PutBucketVersioning",
          "s3:GetBucketVersioning",
          "s3:PutBucketTagging",
          "s3:GetBucketTagging",
          "s3:PutBucketPublicAccessBlock",
          "s3:GetBucketPublicAccessBlock"
        ]
        Resource = "arn:aws:s3:::my-specific-app-bucket-*" # Matches the creation pattern
      },
      {
        Sid = "AllowS3ObjectManagement"
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:DeleteObject",
          "s3:ListBucketMultipartUploads",
          "s3:AbortMultipartUpload",
          "s3:ListMultipartUploadParts",
          "s3:GetObjectAcl",
          "s3:PutObjectAcl"
        ]
        Resource = "arn:aws:s3:::my-specific-app-bucket-*-/*" # Objects within the bucket
      }
    ]
  })
}

The core problem this solves is the common anti-pattern of granting s3:* on * to a Terraform execution role. This is a massive security hole, allowing Terraform to potentially manage any S3 bucket in your account, including sensitive ones. By defining granular Action and Resource elements, and leveraging Condition blocks, we drastically reduce the blast radius.

Internally, AWS IAM evaluates these policies. When Terraform attempts an action, IAM checks if the role’s policies permit that Action on the specified Resource, and if any Condition blocks are met. The arn:aws:s3:::my-specific-app-bucket-* pattern is crucial; it uses a wildcard (*) to allow buckets starting with my-specific-app-bucket-, but only those. The s3:LocationConstraint condition for CreateBucket ensures that new buckets can only be created in us-east-1.

The AssumeRolePolicy is what allows an entity (like a user or another service) to assume this terraform_executor role. In this example, we’re allowing the root AWS account to assume it, which is a starting point but would typically be refined to a specific IAM user or role for production environments.

The Resource element is where the magic of least privilege truly shines. Instead of *, we specify the exact ARNs or patterns that the role should be able to act upon. For S3, this means specifying the bucket ARN. Notice the difference between arn:aws:s3:::my-specific-app-bucket-* (for bucket-level operations) and arn:aws:s3:::my-specific-app-bucket-*-/* (for object-level operations within those buckets).

One aspect often overlooked is the interplay between different Action types and their corresponding Resource requirements. For instance, s3:CreateBucket operates on a bucket ARN directly, but its Condition for LocationConstraint is a bucket-level attribute. However, actions like s3:PutObject require the Resource to include the wildcard for objects (/*) within the bucket. If you forget the /* for object actions, Terraform will be able to create buckets but won’t be able to upload or download files into them.

The next step in hardening this is to restrict the Principal in the AssumeRolePolicy to a more specific IAM user or role that will actually be executing Terraform.

Want structured learning?

Take the full Terraform course →