IAM and Least Privilege
IAM is where Terraform most often grants too much, because a broad policy makes the AccessDenied go away and a tight one takes thought. Managing roles, policies, and instance profiles as code cuts both ways: the same config that enforces least privilege across an estate can, with a single "*", hand out admin on every account at once.
The core skill is building policy documents correctly. A policy is JSON, but you should never type that JSON as a string — one stray quote or a botched interpolation produces an invalid document AWS rejects at apply, or worse, a valid document that grants something you didn't mean. Build it with the aws_iam_policy_document data source or jsonencode, and the structure is checked for you.
Roles, Policies, and Instance Profiles
Three resources do the work. An aws_iam_role is an identity something can assume; an aws_iam_policy is a permission document; an aws_iam_role_policy_attachment binds the two. For an EC2 instance there's a fourth piece — an aws_iam_instance_profile, the wrapper that lets a launch template hand a role to an instance. Miss the instance profile and the role exists but never reaches the instance.
Building Policy Documents
The aws_iam_policy_document data source builds the JSON from HCL blocks, so the document is syntactically valid by construction and references resolve to real ARNs. Each statement names the actions and the specific resources they apply to. The example below grants read access to one bucket and nothing else — note the resources are concrete ARNs, not "*".
data "aws_iam_policy_document" "app_s3_read" { statement { sid = "ReadAppBucket" actions = ["s3:GetObject", "s3:ListBucket"] resources = [ aws_s3_bucket.app.arn, "${aws_s3_bucket.app.arn}/*", ] } } resource "aws_iam_policy" "app_s3_read" { name = "app-s3-read" policy = data.aws_iam_policy_document.app_s3_read.json }
ListBucket applies to the bucket ARN itself while GetObject applies to the objects under it (/*) — two different resource shapes the document gets right. If you prefer, jsonencode on an HCL object produces the same valid JSON; what matters is that the structure is generated, never hand-concatenated.
Least Privilege in Practice
Least privilege is the discipline of scoping every statement to specific actions and specific resource ARNs instead of reaching for "*". The difference is concrete: Action = "s3:*" on Resource = "*" lets a compromised web instance read, overwrite, and delete every bucket in the account; the scoped version above lets it read exactly one. Treat every "*" in a committed policy as a finding that has to be justified in review, not a default.
Assume-Role Trust Policies
A role has two policies pulling in opposite directions: the permission policy says what the role can do, and the trust policy (assume_role_policy) says who is allowed to become it. Forget the trust policy and you get AccessDenied when the service tries to assume the role — the permissions are irrelevant if nothing can assume it. The trust document is built the same way, with a principals block naming the EC2 service or, for cross-account access, a specific account or role ARN.
data "aws_iam_policy_document" "ec2_assume" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ec2.amazonaws.com"] } } } resource "aws_iam_role" "web" { name = "web-instance-role" assume_role_policy = data.aws_iam_policy_document.ec2_assume.json } resource "aws_iam_role_policy_attachment" "web_s3" { role = aws_iam_role.web.name policy_arn = aws_iam_policy.app_s3_read.arn } resource "aws_iam_instance_profile" "web" { name = "web-instance-profile" role = aws_iam_role.web.name }
The instance profile is what the launch template's iam_instance_profile in the previous topic pointed at — this is the resource that closes that loop. Because the attachment references the role by name and the policy by ARN, Terraform orders them correctly: role, then policy, then attachment, then profile.
Managed vs Inline Policies
AWS-managed policies are convenient and a frequent trap: attaching AmazonS3FullAccess to grant one bucket's worth of reads hands out the whole service. Customer-managed policies like the one above are the right default for anything sensitive — they say exactly what's granted and they live in your code and review. Reach for an AWS-managed policy only when its scope genuinely matches the need; otherwise write the narrow one.
- Granting
Action = "*"onResource = "*"to make a permissions error disappear, handing a compromised instance the whole account. - Hand-writing IAM JSON as a string, which produces an invalid document on the first stray quote or, worse, a valid one granting more than intended.
- Forgetting the
assume_role_policytrust document, so every attempt to assume the role returns AccessDenied no matter how the permissions are set. - Attaching a broad AWS-managed policy like
AmazonS3FullAccesswhen a scoped custom policy for one bucket was the right call. - Defining a role for an EC2 instance but omitting the
aws_iam_instance_profile, so the role exists but never reaches the instance.
- Build every policy document with
aws_iam_policy_documentorjsonencode— never string-built JSON. - Scope each statement to specific actions and specific resource ARNs; treat any
"*"as a finding to justify in review. - Define trust policies explicitly with a named principal, and scope cross-account assumption to the exact account or role ARN.
- Prefer narrowly-scoped customer-managed policies over broad AWS-managed ones for anything sensitive.
- Wrap an instance's role in an
aws_iam_instance_profileand reference attributes so Terraform orders role, policy, attachment, and profile correctly.
Knowledge Check
Why build IAM policies with aws_iam_policy_document instead of a JSON string?
- The document is valid by construction and ARN references resolve, avoiding broken or unintentionally broad JSON
- A raw JSON string in the
policyargument is rejected outright by the AWS provider - The data source encrypts the rendered policy document before it is sent to the IAM API
- It is the only supported way to attach one shared policy document to more than a single role or user at the same time
A role has the right permission policy but every attempt to assume it returns AccessDenied. What is most likely missing?
- The trust policy (
assume_role_policy) naming the principal allowed to assume the role - A second permission policy attached to the role granting it the
sts:GetCallerIdentityaction - The role's
max_session_durationset below the caller's requested session length - An
aws_iam_userresource that the role must depend on before it can be assumed
What does least privilege mean concretely for an S3-reading instance role?
- Granting only
s3:GetObjectands3:ListBucketon the one bucket's ARN, nots3:*on* - Attaching the
AmazonS3FullAccessmanaged policy but only to the one instance role that needs it - Using inline policies on the role instead of customer-managed ones, regardless of how broad the actions are
- Granting full access but enabling CloudTrail to audit it afterward
An EC2 instance's role exists and has the right policy, but the instance can't use it. What is the likely cause?
- There is no
aws_iam_instance_profilewrapping the role for the launch template to attach - The role's permission policy was built with
jsonencodeinstead of the policy-document data source - The attached policy is customer-managed rather than one of the AWS-managed policies
- The role was created in a different availability zone than the one the instance runs in
You got correct