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.
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 Engine | OpenTofu IAM As Code | Enterprise 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
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| AWS IAM Service | Roles, 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 CLI | Local binary execution | $0.00 | $0.00 (Free & Open Source) |
| Total Estimated Cost | Complete 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:
- By default, all requests are denied: Unless an explicit
Allowstatement matches the request, the user or service receives anAccessDeniederror. - An explicit allow overrides the default deny: If an identity has an
Allowfors3:GetObjectonarn:aws:s3:::my-bucket/*, the request succeeds. - An explicit deny overrides ALL allows: If ten policies grant
Allow, but a single policy contains anEffect: Denyfor 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 Action | Operates On | Correct Resource ARN Syntax |
|---|---|---|
s3:ListBuckets3:GetBucketLocation | The Bucket itself | arn:aws:s3:::my-bucket |
s3:GetObjects3:PutObjects3:DeleteObject | The Objects inside the bucket | arn: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:
- Compile-Time Validation: OpenTofu checks that keys (
actions,resources,effect) match the official AWS schema before sending anything to AWS. - HCL Interpolation: Directly reference resource ARNs (
aws_s3_bucket.app_data.arn) without messy string concatenation. - Policy Merging & Composition: You can merge multiple policy documents dynamically using
source_policy_documentsoroverride_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:
- Trust Policy: Allow EC2 instances to assume this role.
- Granular Least-Privilege Allow: Allow reading and writing objects only inside this specific bucket.
- 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 sayss3:*, the explicitDenystatement 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, throwing403 Forbidden. - Cause: The policy author granted
arn:aws:s3:::my-bucket/*for all actions. Buts3:ListBucketis a bucket-level action and evaluates againstarn: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
AccessDeniederrors for 10β15 seconds while global propagation finishes.
ποΈ Hands-On Challenge
Before completing Module 03:
- Add IP-Range Restriction:
In
policies.tf, add a condition to theAllowObjectReadWritestatement 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 } - Run
tofu planto see how OpenTofu updates the policy document in-place. - Test it using
aws iam simulate-principal-policywith the--context-entriesparameter 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, confirmingallowed,explicitDeny, andimplicitDenyoutcomes.
π 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.