Cloud 30 min read

AWS OpenTofu #108: IAM Policies & Least Privilege

Eliminate wildcard security vulnerabilities. Master granular IAM policies using OpenTofu policy documents, understand Principal-Action-Resource-Effect mechanics, and enforce explicit deny guardrails.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide β€’ Part 8 of 15

AWS + OpenTofu Level 100: Infrastructure as Code from Zero

Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.

In a real-world cloud security incident, a junior developer needed a backend application running on EC2 to write user profile pictures to an Amazon S3 bucket.

Writing granular IAM policies seemed tedious, full of complex JSON syntax and ARN rules. To make the code β€œjust work,” someone copied an IAM policy snippet from a forum thread:

// 🚨 The Disaster Snippet
{
  "Effect": "Allow",
  "Action": "*",
  "Resource": "*"
}

The application worked, and the sprint was marked complete.

Three weeks later, an attacker discovered a Server-Side Request Forgery (SSRF) flaw in an open-source image parsing library used by the app. Using the local metadata service, the attacker extracted the EC2 instance’s temporary IAM credentials.

Because the role had wildcard access (Action: *, Resource: *):

  • The attacker downloaded every proprietary dataset across all 80 S3 buckets in the account.
  • They dropped all relational production databases in RDS.
  • They created 15 rogue IAM users with administrator keys.
  • They launched 50 GPU-heavy EC2 instances in unused regions to mine cryptocurrency, racking up a $42,000 AWS bill in 36 hours.

This catastrophic failure stems from violating the foundational law of cloud security: The Principle of Least Privilege.

In this lab, you will master the art of writing precise, enterprise-grade IAM policies using OpenTofu. You will dissect the PARE (Principal, Action, Resource, Effect) framework, replace fragile raw JSON with type-safe data "aws_iam_policy_document" constructs, implement non-negotiable Explicit Deny guardrails, and verify policy evaluation logic using the AWS CLI policy simulator.


🎯 What You Will Learn

AWS IAM Policy Evaluation EngineOpenTofu IAM As CodeEnterprise Security Architecture
β€’ The 4 pillars: Principal, Action, Resource, Effectβ€’ Declaring data "aws_iam_policy_document"β€’ The Principle of Least Privilege in practice
β€’ Evaluation logic: Default Deny vs Explicit Denyβ€’ Combining and merging policy documentsβ€’ S3 ARN scoping: Bucket vs Object distinctions
β€’ Identity-based vs Resource-based policiesβ€’ Custom managed policies via aws_iam_policyβ€’ Defense-in-Depth: Enforcing TLS encryption
β€’ Trust Policies (sts:AssumeRole) vs Permissionsβ€’ Attaching policies to roles and instance profilesβ€’ Simulating policies with aws iam simulate-principal-policy

πŸ’° Estimated Lab Cost

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
AWS IAM ServiceRoles, Policies, Attachments, Simulations$0.00$0.00 (Always 100% Free)
Amazon S3 Storage (Target)1 Empty test bucket (<1 hour)$0.00$0.00 (Zero stored bytes)
OpenTofu CLILocal binary execution$0.00$0.00 (Free & Open Source)
Total Estimated CostComplete Hands-On Session$0.00$0.00

[!NOTE] AWS IAM is Always Free: AWS charges $0.00 for IAM roles, policies, users, groups, and policy evaluation requests regardless of Free Tier expiration. The companion S3 bucket created in this lab is empty and destroyed at the end, resulting in an absolute $0.00 total cost.


🧠 Part 1: How AWS Evaluates IAM Policies

Whenever any identityβ€”whether a human engineer in the AWS Console, an automated OpenTofu pipeline, or an EC2 virtual serverβ€”makes an API request to AWS, the IAM Policy Evaluation Engine executes a strict decision algorithm:

+-------------------------------------------------------------------------------+
|                      AWS IAM POLICY EVALUATION FLOWCHART                      |
+-------------------------------------------------------------------------------+
                                        β”‚
                                        β–Ό
                             [ 1. DEFAULT DENY ]
                     (All requests are denied by default)
                                        β”‚
                                        β–Ό
                           [ 2. CHECK EXPLICIT DENY ]
                  Does ANY applicable policy say Effect: Deny?
                                   /         \
                             YES  /           \  NO
                                 β–Ό             β–Ό
                    [ ❌ ACCESS DENIED ]   [ 3. CHECK EXPLICIT ALLOW ]
                     (Deny ALWAYS wins!)  Does ANY policy say Effect: Allow?
                                                       /         \
                                                 YES  /           \  NO
                                                     β–Ό             β–Ό
                                        [ βœ… ACCESS GRANTED ]   [ ❌ ACCESS DENIED ]

The Three Golden Rules of IAM Evaluation:

  1. By default, all requests are denied: Unless an explicit Allow statement matches the request, the user or service receives an AccessDenied error.
  2. An explicit allow overrides the default deny: If an identity has an Allow for s3:GetObject on arn:aws:s3:::my-bucket/*, the request succeeds.
  3. An explicit deny overrides ALL allows: If ten policies grant Allow, but a single policy contains an Effect: Deny for that action, the request is instantly blocked.

πŸ“ Part 2: The PARE Framework Dissected

Every IAM statement is composed of four foundational elements: Principal, Action, Resource, Effect (PARE).

+───────────────────────────────────────────────────────────────────────────────+
| PRINCIPAL  β”‚ WHO is making the request? (User, Role, Service, Account)        |
|            β”‚ * Only required in Trust Policies and Resource-Based Policies    |
+───────────────────────────────────────────────────────────────────────────────+
| ACTION     β”‚ WHAT API operation is being attempted?                           |
|            β”‚ Example: "s3:GetObject", "ec2:RunInstances", "dynamodb:Query"    |
+───────────────────────────────────────────────────────────────────────────────+
| RESOURCE   β”‚ WHICH cloud object is being acted upon? (The ARN)                |
|            β”‚ Example: "arn:aws:s3:::customer-data-vault/*"                    |
+───────────────────────────────────────────────────────────────────────────────+
| EFFECT     β”‚ What is the verdict?                                             |
|            β”‚ MUST be either "Allow" or "Deny"                                 |
+───────────────────────────────────────────────────────────────────────────────+

The S3 ARN Scoping Trap

One of the most common mistakes when writing IAM policies for Amazon S3 is confusing the Bucket ARN with the Object ARN:

S3 API ActionOperates OnCorrect Resource ARN Syntax
s3:ListBucket
s3:GetBucketLocation
The Bucket itselfarn:aws:s3:::my-bucket
s3:GetObject
s3:PutObject
s3:DeleteObject
The Objects inside the bucketarn:aws:s3:::my-bucket/*

If your policy only specifies arn:aws:s3:::my-bucket, any application trying to call s3:PutObject will fail with AccessDenied! A robust policy targets both:

resources = [
  aws_s3_bucket.app_data.arn,           # For bucket-level APIs
  "${aws_s3_bucket.app_data.arn}/*"     # For object-level APIs
]

πŸ› οΈ Part 3: Raw JSON vs. aws_iam_policy_document

In early Terraform tutorials, policies were often written using raw multiline JSON strings or jsonencode():

# ❌ RAW JSON: Fragile, no compile-time syntax validation
resource "aws_iam_policy" "example" {
  name   = "example-policy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "s3:GetObect" # Typo in action! OpenTofu won't catch this!
      Resource = "*"
    }]
  })
}

In modern OpenTofu, professional engineers use the data "aws_iam_policy_document" data source:

# βœ… OPENTOFU POLICY DOCUMENT: Type-safe, validated, composable
data "aws_iam_policy_document" "example" {
  statement {
    sid       = "AllowS3Read"
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app_data.arn}/*"]
  }
}

Why aws_iam_policy_document is Superior:

  1. Compile-Time Validation: OpenTofu checks that keys (actions, resources, effect) match the official AWS schema before sending anything to AWS.
  2. HCL Interpolation: Directly reference resource ARNs (aws_s3_bucket.app_data.arn) without messy string concatenation.
  3. Policy Merging & Composition: You can merge multiple policy documents dynamically using source_policy_documents or override_policy_documents.

πŸ“ Step 1: Set Up Lab Directory Structure

Navigate to your ~/opentofu-labs repository and create the dedicated directory for Lab 108:

cd ~/opentofu-labs
mkdir -p lab-108-iam-least-privilege
cd lab-108-iam-least-privilege

We will structure our project using the standard multi-file layout:

lab-108-iam-least-privilege/
β”œβ”€β”€ providers.tf             # Engine & AWS Provider configuration
β”œβ”€β”€ variables.tf             # Target region & environment inputs
β”œβ”€β”€ data.tf                  # Discovers account ID & caller identity
β”œβ”€β”€ storage.tf               # Target S3 bucket for our policy rules
β”œβ”€β”€ policies.tf              # Granular IAM policy documents & explicit deny
β”œβ”€β”€ main.tf                  # Custom IAM policy resource & application role
└── outputs.tf               # Exported ARNs and simulation commands

βš™οΈ Step 2: Configure Providers, Variables & Data Sources

Create providers.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "OpenTofu-Level-100"
      Environment = var.environment
      ManagedBy   = "OpenTofu"
      LabPart     = "108"
    }
  }
}

Create variables.tf:

variable "aws_region" {
  type        = string
  description = "Target AWS region."
  default     = "ap-southeast-1"
}

variable "environment" {
  type        = string
  description = "Target deployment environment."
  default     = "dev"
}

variable "app_name" {
  type        = string
  description = "Application workload name."
  default     = "photo-uploader"
}

Create data.tf:

# Discovers our 12-digit AWS Account ID dynamically
data "aws_caller_identity" "current" {}

# Discovers the current region
data "aws_region" "current" {}

πŸͺ£ Step 3: Declare the Target S3 Bucket (storage.tf)

Before we can grant permissions to an S3 bucket, we need a concrete bucket resource to target.

Create storage.tf:

# Target S3 bucket that our application needs to access
resource "aws_s3_bucket" "app_storage" {
  bucket        = "tofu-iam-target-${data.aws_caller_identity.current.account_id}-108"
  force_destroy = true

  tags = {
    Name        = "tofu-iam-target"
    DataClass   = "ApplicationUploads"
  }
}

# Enforce default encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "app_storage" {
  bucket = aws_s3_bucket.app_storage.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

πŸ“œ Step 4: Write Granular Policy Documents (policies.tf)

Now let’s write our policies in policies.tf.

We will implement three specific security requirements:

  1. Trust Policy: Allow EC2 instances to assume this role.
  2. Granular Least-Privilege Allow: Allow reading and writing objects only inside this specific bucket.
  3. Explicit Deny Guardrails: Explicitly deny destructive object deletions (s3:DeleteObject), and deny any request that does not use TLS encryption (aws:SecureTransport = false).

Create policies.tf:

# ==============================================================================
# 1. TRUST POLICY: Who can assume this role?
# ==============================================================================
data "aws_iam_policy_document" "ec2_trust_policy" {
  statement {
    sid     = "AllowEC2ServiceAssume"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

# ==============================================================================
# 2. PERMISSION POLICY: Scoped Least Privilege with Explicit Deny
# ==============================================================================
data "aws_iam_policy_document" "app_s3_permissions" {

  # Statement 1: Allow listing the bucket contents (Bucket-Level API)
  statement {
    sid    = "AllowBucketListing"
    effect = "Allow"
    actions = [
      "s3:ListBucket",
      "s3:GetBucketLocation"
    ]
    resources = [
      aws_s3_bucket.app_storage.arn
    ]
  }

  # Statement 2: Allow reading and uploading objects (Object-Level API)
  statement {
    sid    = "AllowObjectReadWrite"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:PutObjectAcl"
    ]
    resources = [
      "${aws_s3_bucket.app_storage.arn}/*"
    ]
  }

  # Statement 3: EXPLICIT DENY on Object Deletions (Ransomware/Accident Guardrail)
  statement {
    sid    = "DenyObjectDeletion"
    effect = "Deny"
    actions = [
      "s3:DeleteObject",
      "s3:DeleteObjectVersion"
    ]
    resources = [
      "${aws_s3_bucket.app_storage.arn}/*"
    ]
  }

  # Statement 4: EXPLICIT DENY on Insecure HTTP Transport (Enforce HTTPS)
  statement {
    sid    = "DenyInsecureHTTP"
    effect = "Deny"
    actions = [
      "s3:*"
    ]
    resources = [
      aws_s3_bucket.app_storage.arn,
      "${aws_s3_bucket.app_storage.arn}/*"
    ]

    # Condition: If aws:SecureTransport is false (plain HTTP), immediately DENY!
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

Why Statement 3 & 4 Are So Powerful:

  • DenyObjectDeletion: Even if an attacker compromises the server or someone accidentally attaches another policy that says s3:*, the explicit Deny statement immediately trumps all allows. No file can ever be deleted through this role.
  • DenyInsecureHTTP: Enforces strict TLS encryption over the wire. Plaintext HTTP traffic is rejected at the API boundary.

πŸ”— Step 5: Declare IAM Resources & Attachments (main.tf)

Now create main.tf to assemble our role and custom managed policy:

# 1. The Application IAM Role
resource "aws_iam_role" "app_role" {
  name               = "${var.app_name}-role"
  description        = "Least-privilege role for ${var.app_name} workload"
  assume_role_policy = data.aws_iam_policy_document.ec2_trust_policy.json

  tags = {
    Name = "${var.app_name}-role"
  }
}

# 2. The Custom Managed IAM Policy
resource "aws_iam_policy" "app_policy" {
  name        = "${var.app_name}-storage-policy"
  description = "Granular S3 permissions for ${var.app_name} with explicit delete protection"
  policy      = data.aws_iam_policy_document.app_s3_permissions.json

  tags = {
    Name = "${var.app_name}-storage-policy"
  }
}

# 3. Attach Custom Policy to the Role
resource "aws_iam_role_policy_attachment" "app_attach" {
  role       = aws_iam_role.app_role.name
  policy_arn = aws_iam_policy.app_policy.arn
}

# 4. Attach SSM Core so we can still manage instances via Session Manager!
resource "aws_iam_role_policy_attachment" "ssm_attach" {
  role       = aws_iam_role.app_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# 5. The IAM Instance Profile
resource "aws_iam_instance_profile" "app_profile" {
  name = "${var.app_name}-instance-profile"
  role = aws_iam_role.app_role.name
}

Notice how clean main.tf is: it references .json outputs from our data sources (data.aws_iam_policy_document.<name>.json), keeping all policy logic safely isolated in policies.tf.


πŸ“€ Step 6: Expose Outputs & CLI Audit Commands (outputs.tf)

Create outputs.tf:

output "role_name" {
  description = "The name of the provisioned IAM Role."
  value       = aws_iam_role.app_role.name
}

output "role_arn" {
  description = "The full ARN of the IAM Role."
  value       = aws_iam_role.app_role.arn
}

output "policy_arn" {
  description = "The full ARN of the custom managed policy."
  value       = aws_iam_policy.app_policy.arn
}

output "target_bucket_name" {
  description = "The S3 bucket targeted by the policy."
  value       = aws_s3_bucket.app_storage.id
}

πŸš€ Step 7: Preview & Provision (tofu apply)

Initialize the directory:

tofu init
tofu fmt
tofu validate

Preview the plan:

tofu plan

Notice what OpenTofu displays:

OpenTofu will perform the following actions:

  # aws_iam_policy.app_policy will be created
  + resource "aws_iam_policy" "app_policy" {
      + arn         = (known after apply)
      + description = "Granular S3 permissions for photo-uploader with explicit delete protection"
      + name        = "photo-uploader-storage-policy"
      + policy      = jsonencode(...)
    }

  # aws_iam_role.app_role will be created
  + resource "aws_iam_role" "app_role" { ... }

Plan: 6 to add, 0 to change, 0 to destroy.

Apply the changes:

tofu apply -auto-approve
aws_iam_role.app_role: Creating...
aws_s3_bucket.app_storage: Creating...
aws_iam_role.app_role: Creation complete after 1s
aws_iam_instance_profile.app_profile: Creating...
aws_iam_instance_profile.app_profile: Creation complete after 1s
aws_s3_bucket.app_storage: Creation complete after 2s
aws_s3_bucket_server_side_encryption_configuration.app_storage: Creating...
aws_iam_policy.app_policy: Creating...
aws_s3_bucket_server_side_encryption_configuration.app_storage: Creation complete after 1s
aws_iam_policy.app_policy: Creation complete after 1s
aws_iam_role_policy_attachment.ssm_attach: Creating...
aws_iam_role_policy_attachment.app_attach: Creating...
aws_iam_role_policy_attachment.ssm_attach: Creation complete after 1s
aws_iam_role_policy_attachment.app_attach: Creation complete after 1s

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

All IAM roles, policies, attachments, and the target storage bucket are provisioned in under 4 seconds.


πŸ§ͺ Step 8: Live Evaluation with AWS IAM Policy Simulator

How do you prove that an IAM policy actually allows what you intended and strictly blocks what you forbade?

AWS provides the IAM Policy Simulator API, callable directly from the AWS CLI without needing to launch servers.

1. Test Permitted Action (s3:PutObject)

Let’s simulate our role attempting to upload a file to our target bucket:

ROLE_ARN=$(tofu output -raw role_arn)
BUCKET_NAME=$(tofu output -raw target_bucket_name)

aws iam simulate-principal-policy \
  --policy-source-arn $ROLE_ARN \
  --action-names s3:PutObject \
  --resource-arns "arn:aws:s3:::$BUCKET_NAME/photo.jpg" \
  --query "EvaluationResults[0].{Action:EvalActionName, Decision:EvalDecision}" \
  --output table

Output:

--------------------------------
|    SimulatePrincipalPolicy   |
+--------------+---------------+
|    Action    |   Decision    |
+--------------+---------------+
|  s3:PutObject|  allowed      |
+--------------+---------------+

The policy simulator confirms: Decision: allowed.

2. Test Forbidden Action: Deletion (s3:DeleteObject)

Now test what happens if this role attempts to delete an object:

aws iam simulate-principal-policy \
  --policy-source-arn $ROLE_ARN \
  --action-names s3:DeleteObject \
  --resource-arns "arn:aws:s3:::$BUCKET_NAME/photo.jpg" \
  --query "EvaluationResults[0].{Action:EvalActionName, Decision:EvalDecision}" \
  --output table

Output:

---------------------------------
|    SimulatePrincipalPolicy    |
+----------------+--------------+
|     Action     |   Decision   |
+----------------+--------------+
| s3:DeleteObject| explicitDeny |
+----------------+--------------+

Notice the decision: explicitDeny! Our explicit deny guardrail intercepted the request and blocked it with 100% certainty.

3. Test Unauthorized Resource (Accessing Someone Else’s Bucket)

What if this role attempts to access an unauthorized bucket (arn:aws:s3:::corporate-finance-vault)?

aws iam simulate-principal-policy \
  --policy-source-arn $ROLE_ARN \
  --action-names s3:GetObject \
  --resource-arns "arn:aws:s3:::corporate-finance-vault/salaries.csv" \
  --query "EvaluationResults[0].{Action:EvalActionName, Decision:EvalDecision}" \
  --output table

Output:

--------------------------------
|    SimulatePrincipalPolicy   |
+--------------+---------------+
|    Action    |   Decision    |
+--------------+---------------+
|  s3:GetObject|  implicitDeny |
+--------------+---------------+

Notice the decision: implicitDeny (Default Deny). Because our policy explicitly scoped the resource to our target bucket ARN (aws_s3_bucket.app_storage.arn/*), any attempt to read from other buckets is rejected automatically.


🧹 Step 9: Clean Teardown (tofu destroy)

Tear down all lab resources:

tofu destroy -auto-approve

Output:

aws_iam_role_policy_attachment.app_attach: Destroying...
aws_iam_role_policy_attachment.ssm_attach: Destroying...
aws_iam_instance_profile.app_profile: Destroying...
aws_s3_bucket_server_side_encryption_configuration.app_storage: Destroying...
aws_s3_bucket.app_storage: Destroying...
aws_iam_policy.app_policy: Destroying...
aws_iam_role.app_role: Destroying...
Destroy complete! Resources: 6 destroyed.

πŸ› οΈ Common Troubleshooting & Gotchas

1. The s3:ListBucket vs s3:GetObject 403 Forbidden Mystery

  • Symptom: An application can upload a file (PutObject), but cannot list the bucket or check if a file exists, throwing 403 Forbidden.
  • Cause: The policy author granted arn:aws:s3:::my-bucket/* for all actions. But s3:ListBucket is a bucket-level action and evaluates against arn:aws:s3:::my-bucket (without the /*).
  • Fix: Separate your policy statements into a bucket statement (Resource = arn:aws:s3:::my-bucket) and an object statement (Resource = arn:aws:s3:::my-bucket/*).

2. IAM Policy Size Limits

  • Gotcha: AWS IAM Managed Policies enforce a strict character limit of 6,144 characters. If you try to pack 50 distinct statements into a single policy, AWS will reject it.
  • Best Practice: Decompose policies by service or function (e.g., app-s3-policy, app-dynamodb-policy) and attach multiple smaller policies to the role.

3. Policy Propagation Latency

  • When you create an IAM role or policy, AWS replicates it across all global regions via eventual consistency. In rare cases, a server launching within 5 seconds of policy creation might experience transient AccessDenied errors for 10–15 seconds while global propagation finishes.

πŸ‹οΈ Hands-On Challenge

Before completing Module 03:

  1. Add IP-Range Restriction: In policies.tf, add a condition to the AllowObjectReadWrite statement that restricts uploads strictly to a specific IP CIDR block (such as your office or home public IP):
    condition {
      test     = "IpAddress"
      variable = "aws:SourceIp"
      values   = ["203.0.113.0/24"] # Example CIDR
    }
  2. Run tofu plan to see how OpenTofu updates the policy document in-place.
  3. Test it using aws iam simulate-principal-policy with the --context-entries parameter to simulate different client IP addresses!

πŸ“– Summary & What You Learned

With this lab, you have completed Module 03: Compute & Identity Security!

Here is what you have achieved:

  • The PARE Model: Mastered the anatomy of IAM policies across Principal, Action, Resource, and Effect.
  • Evaluation Mechanics: Understood the strict evaluation flow: Default Deny -> Explicit Allow -> Explicit Deny.
  • OpenTofu Policy Documents: Replaced raw, typo-prone JSON strings with type-safe data "aws_iam_policy_document" declarations.
  • Explicit Deny Guardrails: Implemented absolute guardrails against destructive file deletions and unencrypted HTTP traffic.
  • Simulation & Auditing: Validated policy behavior in the terminal using aws iam simulate-principal-policy, confirming allowed, explicitDeny, and implicitDeny outcomes.

πŸš€ What’s Next? (Module 04: Networking & Troubleshooting)

You now know how to provision secure storage and hardened compute with zero-trust IAM. But our EC2 instances have still been running in the AWS Default VPC.

In [AWS OpenTofu #109: VPC Fundamentals], we begin Module 04: Networking & Troubleshooting:

  • Design and provision a custom Virtual Private Cloud (VPC) from absolute zero.
  • Calculate CIDR blocks (/16, /24) and partition network subnets.
  • Deploy an Internet Gateway (IGW) and attach custom Route Tables.
  • Control network boundaries with precision.

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.