Least Privilege #
Least privilege is a simple security principle: give access only as large as needed, no more. In the Terraform context, this means the IAM role used to run Terraform doesn’t need AdministratorAccess just because it’s easier to configure. Excessive permissions are a real risk — if Terraform credentials are compromised, the attacker gets access as wide as the granted permissions. Designing minimal but sufficient permissions for Terraform is a far more valuable investment than it looks.
flowchart TD
A["Admin Access"] -->|"credentials leak"| B["Full Account\nCompromise"]
C["Least Privilege"] -->|"credentials leak"| D["Limited\nDamage"]
style A fill:#ef4444,stroke:#dc2626,color:#fff
style B fill:#ef4444,stroke:#dc2626,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#10b981,stroke:#059669,color:#fffWhy AdministratorAccess Is Dangerous #
THE RISK OF ADMINISTRATOR ACCESS:
If TerraformCIRole has AdministratorAccess:
- Compromised → an attacker can delete all resources
- Compromised → an attacker can create new resources (crypto mining, etc.)
- Compromised → an attacker can create a new IAM user with full access
- A developer mistypes destroy → the entire infrastructure is lost
- A pipeline bug → can affect resources outside the managed scope
With least privilege:
- Compromised → an attacker can only access what Terraform needs
- A developer mistypes → only the resources Terraform manages are affected
- A pipeline bug → the blast radius is limited to the configuration scope
Strategy: Different Permissions for Plan and Apply #
Plan only reads — it doesn’t need write access. Apply needs write. Giving the same permissions to both is an unnecessary waste of privilege.
// IAM Policy for TerraformPlanRole (used during plan)
// Read-only access only
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"rds:Describe*",
"s3:GetObject",
"s3:ListBucket",
"iam:Get*",
"iam:List*",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
// IAM Policy for TerraformApplyRole (used during apply)
// Write access only for resources managed by Terraform
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:*",
"rds:*",
"s3:*",
"elasticloadbalancing:*"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "ap-southeast-1"
}
}
},
{
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:PassRole"
],
"Resource": "arn:aws:iam::*:role/app-*"
// Only roles with the "app-" prefix can be created/modified
}
]
}
Limiting Scope with Conditions and Resource ARNs #
// Restrict more tightly with Resource ARNs and Conditions
{
"Version": "2012-10-17",
"Statement": [
// EC2: only can manage instances created by Terraform (with the ManagedBy tag)
{
"Effect": "Allow",
"Action": ["ec2:TerminateInstances", "ec2:StopInstances"],
"Resource": "arn:aws:ec2:ap-southeast-1:123456789:instance/*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/ManagedBy": "terraform"
}
}
},
// S3: only can modify specific buckets (not all buckets)
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::production-app-*",
"arn:aws:s3:::production-app-*/*"
]
},
// Prevent privilege escalation: Terraform can't create users with larger access
{
"Effect": "Deny",
"Action": [
"iam:CreateUser",
"iam:AttachUserPolicy",
"iam:PutUserPolicy"
],
"Resource": "*"
}
]
}
Using IAM Access Analyzer to Find the Needed Permissions #
Instead of guessing which permissions are needed, use IAM Access Analyzer to analyze CloudTrail logs and generate the right policy.
# 1. Run Terraform with temporary AdministratorAccess
# while CloudTrail records all API calls
# 2. Generate a policy from CloudTrail using IAM Access Analyzer
aws iam generate-policy --cloudtrail-arn arn:aws:cloudtrail:... \
--start-time 2024-01-01T00:00:00Z \
--end-time 2024-01-02T00:00:00Z \
--iam-entity-arn arn:aws:iam::123456789:role/TerraformTestRole
# 3. Review the generated policy — IAM Access Analyzer shows
# only the permissions actually used
# 4. Apply the stricter policy, test again
# 5. Iterate until Terraform can run with minimal permissions
Permissions for Teams: The Right RBAC #
Not everyone on the team needs the same permissions to interact with Terraform.
TEAM PERMISSION MATRIX:
Developer (regular engineer):
- Read the .tf configuration in the repository ✓
- Read the plan output in the PR ✓
- Can't apply ✗
- Can't access state directly ✗
- AWS access: read-only in development, none in production
Senior Engineer / Tech Lead:
- Everything a developer can do ✓
- Can review and approve PRs ✓
- Can trigger applies in staging ✓
- Can't apply directly in production ✗ (must go through the pipeline)
Infrastructure / DevOps Engineer:
- Everything a senior engineer can do ✓
- Can apply in production through the pipeline ✓
- Can access state (for debugging) ✓
- Can modify the pipeline configuration ✓
On-call / SRE (emergency access):
- Temporary elevated access during incidents
- All actions audited and reviewed post-incident
- Access expires automatically after N hours
CI/CD Pipeline:
- Plan: read-only AWS access ✓
- Apply: write access limited to managed resources ✓
- Can't modify IAM roles or the pipeline configuration ✗
# Configuring IAM groups and permissions via Terraform
resource "aws_iam_group" "terraform_readers" {
name = "terraform-readers"
}
resource "aws_iam_group_policy_attachment" "terraform_readers" {
group = aws_iam_group.terraform_readers.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# Developers: only read access to the development account
resource "aws_iam_group" "developers" {
name = "developers"
}
resource "aws_iam_group_policy" "developers_dev_access" {
name = "dev-access"
group = aws_iam_group.developers.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["sts:AssumeRole"]
Resource = "arn:aws:iam::${var.dev_account_id}:role/DeveloperRole"
# Can only assume a role in the dev account, not in production
}]
})
}
Monitoring and Alerting for Permission Violations #
# CloudWatch alarm to detect unauthorized access attempts
resource "aws_cloudwatch_metric_alarm" "unauthorized_api_calls" {
alarm_name = "terraform-unauthorized-api-calls"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = "1"
metric_name = "ErrorCount"
namespace = "CloudTrailMetrics"
period = "300"
statistic = "Sum"
threshold = "1"
alarm_description = "Alert when there's a denied (unauthorized) API call"
alarm_actions = [aws_sns_topic.security_alerts.arn]
}
# CloudWatch Logs metric filter for AccessDenied
resource "aws_cloudwatch_log_metric_filter" "unauthorized_api" {
name = "unauthorized-api-calls"
log_group_name = aws_cloudwatch_log_group.cloudtrail.name
pattern = "{ ($.errorCode = \"*UnauthorizedAccess*\") || ($.errorCode = \"AccessDenied\") }"
metric_transformation {
name = "ErrorCount"
namespace = "CloudTrailMetrics"
value = "1"
}
}
flowchart LR
A["Plan Role\n(read-only)"] --> B["Plan"]
C["Apply Role\n(scoped write)"] --> D["Apply"]
E["State Role\n(S3+DynamoDB)"] --> F["State"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#3b82f6,stroke:#1e40af,color:#fff
style C fill:#10b981,stroke:#059669,color:#fff
style D fill:#10b981,stroke:#059669,color:#fff
style E fill:#f59e0b,stroke:#d97706,color:#fff
style F fill:#f59e0b,stroke:#d97706,color:#fffIAM Policy Simulation and Testing #
# Test whether the IAM policy is sufficient before deploying
# AWS IAM Policy Simulator
# Simulate: can the role perform a specific action?
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789:role/TerraformRole \
--action-names ec2:RunInstances s3:PutObject \
--output table
# Check effective permissions
aws iam list-attached-role-policies --role-name TerraformRole
aws iam list-role-policies --role-name TerraformRole
Gradual Permission Tightening #
# Strategy: start with broad access, tighten gradually
# Step 1: Run Terraform with verbose logging
TF_LOG=DEBUG terraform apply 2>&1 | grep "API" > api-calls.log
# Step 2: Analyze the needed API calls
cat api-calls.log | awk '{print $NF}' | sort | uniq -c | sort -rn
# Step 3: Create an IAM policy from the API calls
# Can use iamlive or iamzero
# Step 4: Test with the stricter policy
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123:role/TerraformRole \
--action-names ec2:RunInstances ec2:TerminateInstances
Terraform-Specific IAM Permissions #
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::terraform-state",
"arn:aws:s3:::terraform-state/*"
]
},
{
"Sid": "TerraformStateLock",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:*:*:table/terraform-state-lock"
}
]
}
Summary #
AdministratorAccessisn’t a “safe because it’s easier” solution — it’s a real risk if credentials are compromised or there’s an operational mistake.- Separate plan and apply permissions — plan only needs read access, apply needs write. Different IAM roles for each context.
- Limit scope with Resource ARNs and Conditions —
ec2:TerminateInstancesrestricted to instances taggedManagedBy=terraformis far safer thanec2:*.- IAM Access Analyzer can analyze CloudTrail logs to produce a minimal policy covering all API calls Terraform actually uses.
- The right RBAC — developers are read-only, senior engineers can apply to staging, only infrastructure engineers can apply to production through the pipeline.
- Monitor unauthorized access — a CloudWatch alarm on
AccessDeniedgives an early signal if someone tries to access beyond the allowed scope.
← Previous: Secret Exposure Risk Next: Common Security Mistakes →