Terraform Lesson 55 of 89

Terraform on AWS: IAM (Users, Roles, Policies, Instance Profiles) & S3 (Buckets, Policies, Encryption, Versioning)

Two questions decide whether anything you build on AWS is secure: who is allowed to do this? and is the data locked down? The first is IAM — the identity and permission engine that gates every single API call in the account. The second, for the most common data store on the planet, is S3 — and the difference between a private, encrypted, versioned bucket and the next headline breach is about fifteen lines of Terraform that most people never write. This lesson teaches both halves and the place they meet: an EC2 workload that assumes an IAM role, carries it via an instance profile, and reads objects from a KMS-encrypted bucket whose own policy and Block Public Access settings lock everyone else out.

We treat IAM and S3 as one story because in real infrastructure they are inseparable. An IAM role with s3:GetObject is useless if the bucket denies it; a perfectly hardened bucket is useless if the role can’t reach it; and an object encrypted with a customer KMS key stays unreadable until the caller also holds kms:Decrypt — the single most-missed permission in all of AWS. You will learn to reason about the whole chain: identity-based policy on the role, resource-based policy on the bucket, the KMS key policy behind the encryption, and the trust policy that lets EC2 become the role in the first place. Then you will build every piece with Terraform and prove it works with the aws CLI.

This is a relentlessly hands-on lesson. It assumes you already know core Terraform — HCL, resources, variables, for_each, state, the plan/apply loop — from the course’s foundation tier, and that you can already authenticate the AWS provider and point Terraform at a remote S3/DynamoDB backend, which the Terraform on AWS: getting started, provider authentication & the S3/DynamoDB backend lesson sets up. Here we take that starting point and build the identity-and-storage core that almost every later AWS lesson reuses.

What you’ll build

The scenario is the one every application team hits in week one: you have an EC2-hosted service that needs to read configuration files, model artifacts, or user uploads from S3, and you must do it without baking an access key into the AMI, without making the bucket public, and without granting the box more than the one read it actually needs. That is the canonical AWS pattern — workload identity via an instance profile, least-privilege via a scoped IAM policy, and a bucket hardened by default — and getting it right once gives you a template you will copy into every service afterwards.

Concretely, by the end you will have produced a small root module — versions.tf, providers.tf, variables.tf, main.tf, outputs.tf — that creates, in one terraform apply: a customer-managed KMS key (with rotation) for encryption; a private S3 bucket whose name is globally unique, with versioning on, SSE-KMS default encryption, all four Block Public Access flags on, ACLs disabled (BucketOwnerEnforced), a lifecycle rule that tiers objects to Infrequent Access then Glacier and expires old versions, and a bucket policy that denies any non-TLS request and explicitly allows just the app role; an IAM role whose trust policy lets EC2 assume it; a least-privilege identity policy — built with aws_iam_policy_document — granting exactly s3:GetObject, s3:ListBucket, and kms:Decrypt; and an instance profile that hands that role to an EC2 instance. Then you will init, plan, apply, verify each control with the aws CLI, and destroy cleanly — with the two destroy traps (a non-empty versioned bucket, and a KMS key that only schedules deletion) called out.

Why Terraform for identity and storage rather than the console, a pile of aws commands, or CloudFormation? Because IAM and S3 are exactly where click-ops rots: a bucket made “temporarily” public in the console three years ago is still public; an IAM policy pasted by hand drifts from its twin in the next account; nobody can diff what’s live against what was intended. Terraform gives you a declarative, reviewed, plan-before-apply description of every policy statement and every bucket flag, with a state file that detects drift and a module system that makes the pattern reusable across accounts. Here is the honest comparison for this task — provisioning and continuously governing IAM identities and S3 buckets:

Approach Declarative? Idempotent Plan preview Drift detection Policy as reviewed code Best for
AWS Console No (click-ops) No No None No Learning, one-off inspection
aws CLI scripts No (imperative) Rarely No None No Glue, quick fixes, bootstrap
CloudFormation Yes (JSON/YAML) Yes Change sets Drift detection (manual) Yes (AWS-only) AWS-only shops, StackSets
CDK Imperative → CFN Yes cdk diff Via CloudFormation Yes (in a real language) Teams wanting a programming language
Terraform (aws) Yes (HCL) Yes terraform plan plan / refresh Yes, multi-cloud Repeatable, reviewable, portable IaC

The architecture you are wiring has five stages left to right — Terraform authors the policy, the role carries it, EC2 wears the role, S3 stores the data, and KMS encrypts it — and the diagram below is the mental model to keep open for the rest of the lesson.

Terraform on AWS IAM and S3 architecture: Terraform uses the aws_iam_policy_document data source to render a least-privilege JSON granting s3:GetObject and kms:Decrypt; that policy is attached to an IAM role whose assume_role_policy trust policy lets the EC2 service principal assume it; an aws_iam_instance_profile wraps the role and is handed to an EC2 instance via iam_instance_profile; the instance calls s3:GetObject against a private S3 bucket that has versioning enabled and all four Block Public Access flags on, governed by a bucket policy that denies non-TLS traffic; and every object is encrypted at rest with a customer-managed KMS key using SSE-KMS, gated by both the key policy and the caller's kms:Decrypt permission

Reading it left to right: Terraform renders a least-privilege JSON with aws_iam_policy_document, attaches it to an IAM role whose trust policy admits the EC2 service, wraps the role in an instance profile handed to an EC2 instance, which then reads a private, versioned, Block-Public-Access-locked S3 bucket — every object encrypted with a KMS CMK and every request forced onto TLS by the bucket policy. The six badges mark the decisions that trip people up: building policies with the data source instead of raw JSON, the role’s trust policy, the instance-profile-to-EC2 hop, Block Public Access as the default, SSE-KMS plus versioning, and least privilege spelled out (including the kms:Decrypt that everyone forgets). Each is a section below.

The IAM model: identities, policies and how a request is evaluated

Before any HCL, you have to be able to reason about IAM the way AWS’s authorization engine does, because every “AccessDenied” you will ever debug is the engine telling you it followed its rules and you didn’t. IAM has exactly two kinds of thing — principals (who is calling) and policies (what is allowed) — and a small, deterministic algorithm that combines them.

A principal is an identity that makes a request: an IAM user, an IAM role session (the temporary credentials you get after assuming a role), the account root, or an AWS service acting on your behalf (like ec2.amazonaws.com). A policy is a JSON document of statements, each of which says Allow or Deny some Actions on some Resources, optionally for some Principals and under some Conditions. The crucial split is where the policy is attached, because that determines its type:

Policy type Attached to Answers Terraform resource Example
Identity-based (managed) User, group, role “What can this identity do?” aws_iam_policy + _attachment Reusable S3ReadOnly policy on many roles
Identity-based (inline) One user/group/role “What can only this identity do?” aws_iam_role_policy A one-off grant that lives and dies with the role
Resource-based The resource itself “Who may touch this resource?” aws_s3_bucket_policy, KMS key policy, aws_sqs_queue_policy A bucket policy naming a cross-account role
Trust policy A role (special resource-based) “Who may assume this role?” assume_role_policy on aws_iam_role Let EC2 or an OIDC provider become the role
Permissions boundary User or role (a ceiling) “What is the most this identity could ever do?” permissions_boundary arg Cap what a delegated admin can grant
Service control policy (SCP) An Organizations OU/account “What is allowed anywhere in this account?” aws_organizations_policy Org-wide guardrail: deny leaving a region

The whole IAM surface, expressed as Terraform resources, is worth pinning up front — this is the IAM resource map you will reach for constantly:

Terraform resource AWS object What it is Notes / gotchas
aws_iam_user IAM user A long-lived human/service identity Prefer roles + SSO; users mean long-lived keys
aws_iam_group IAM group A bucket of users sharing policies Groups can’t be principals; they only hold users
aws_iam_access_key Access key Long-lived secret for a user ⚠️ Avoid; rotate if unavoidable; never for workloads
aws_iam_role IAM role An assumable identity with a trust policy The workhorse — for services, cross-account, federation
aws_iam_policy Customer-managed policy A standalone, reusable, versioned policy Attach to many identities; ARN-referenced
aws_iam_role_policy Inline role policy A policy embedded in one role No separate ARN; deleted with the role
aws_iam_role_policy_attachment Attachment link Binds a managed policy ARN to a role Use for both customer and AWS-managed policies
aws_iam_role_policies_exclusive Attachment guard Declares the full set of inline policies Detects/removes out-of-band policies (drift)
aws_iam_instance_profile Instance profile Container that lets EC2 hold a role One role per profile; EC2 references the profile
aws_iam_openid_connect_provider OIDC provider Trust anchor for federated tokens Basis of GitHub-Actions OIDC and EKS IRSA
data.aws_iam_policy_document (none — renders JSON) The idiomatic policy builder Not a resource; a data source that emits .json

How a request is actually evaluated

When a principal calls an API, AWS gathers every policy that could apply — the identity-based policies on the caller, any resource-based policy on the target, the permissions boundary, any session policy, and any SCP — and runs a fixed decision:

Step Rule Outcome
1 Explicit Deny in any applicable policy Denied — an explicit deny always wins, full stop
2 SCP does not allow the action Denied (org guardrail)
3 Permissions boundary does not allow it Denied (ceiling)
4 No policy contains an explicit Allow Denied (implicit deny — the default)
5 An explicit Allow exists and nothing denies Allowed

Two consequences shape every policy you write. First, everything is denied by default — IAM is deny-by-default, so a policy only ever adds permissions; you never need to “allow everything then subtract.” Second, an explicit Deny is absolute — you cannot override it with an Allow anywhere, which is exactly why the aws:SecureTransport deny in a bucket policy is bulletproof. There is one important asymmetry between identity- and resource-based policies for same-account access: if the caller and the resource are in the same account, an Allow in either the identity policy or the resource policy is sufficient. For cross-account access, you need an Allow in both — the identity policy in the caller’s account and the resource policy in the resource’s account. That single rule explains most cross-account S3 and KMS puzzles.

Writing policies the idiomatic way: aws_iam_policy_document

An IAM policy is JSON, and you can produce that JSON three ways in Terraform. Only one of them is the right default. Here they are, worst to best:

# 1) Heredoc raw JSON — brittle: no validation, manual escaping, string interpolation
resource "aws_iam_policy" "bad" {
  name   = "read-bucket"
  policy = <<-EOT
    {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Action": "s3:GetObject",
        "Resource": "${aws_s3_bucket.app.arn}/*"
      }]
    }
  EOT
}

# 2) jsonencode() — HCL-native, no escaping, but still a hand-built object you must get right
resource "aws_iam_policy" "ok" {
  name = "read-bucket"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "s3:GetObject"
      Resource = "${aws_s3_bucket.app.arn}/*"
    }]
  })
}

# 3) aws_iam_policy_document data source — validated, typed, composable (the idiomatic way)
data "aws_iam_policy_document" "good" {
  statement {
    sid       = "ReadObjects"
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app.arn}/*"]
  }
}
resource "aws_iam_policy" "good" {
  name   = "read-bucket"
  policy = data.aws_iam_policy_document.good.json
}

The three approaches, weighed:

Approach Validation Escaping Composability Version pinned When to use
Heredoc JSON None — errors surface only at apply Manual (quotes, commas) None You must hard-code "2012-10-17" Never, except pasting a policy verbatim once
jsonencode() HCL syntax only, not IAM semantics Automatic Low (it’s one object) You set Version yourself Tiny trust policies; quick snippets
aws_iam_policy_document HCL + structural IAM checks Automatic Highsource_policy_documents, override_policy_documents, for_each on statements Adds Version automatically Default for every non-trivial policy

Why the data source wins in practice: it lets Terraform interpolate real ARNs and account IDs as first-class values (no string-concatenation bugs), it can merge documents (source_policy_documents to layer a base policy, override_policy_documents to patch statements by sid), and you can generate statements with dynamic "statement" blocks. The document’s arguments map one-to-one onto IAM’s JSON, which is exactly why it’s easy to read:

aws_iam_policy_document element IAM JSON key Meaning Notes
statement { } (repeatable) Statement[] One permission rule Repeat the block per rule
sid Sid Statement label Unique within a policy; handy for overrides
effect Effect "Allow" or "Deny" Defaults to Allow if omitted
actions / not_actions Action / NotAction API actions this covers e.g. ["s3:GetObject","s3:ListBucket"]
resources / not_resources Resource / NotResource ARNs the actions apply to Object ARNs need the /* suffix
principals { } / not_principals { } Principal Who (resource-based & trust only) type = AWS / Service / Federated + identifiers
condition { } Condition When it applies test, variable, values

Conditions are where least-privilege gets surgical, and a handful of operators cover most real policies:

Condition operator Common key What it constrains Example use
Bool aws:SecureTransport Request over TLS or not Deny plaintext access to a bucket
StringEquals s3:x-amz-server-side-encryption Exact string match Force SSE-KMS on PutObject
StringLike s3:prefix Wildcard match Restrict ListBucket to one prefix
ArnLike aws:SourceArn ARN pattern Scope a service principal to one source
IpAddress aws:SourceIp CIDR match Office-IP-only access
StringEquals aws:PrincipalOrgID Caller’s Org Allow only identities in your Organization
DateGreaterThan aws:CurrentTime Time window Time-boxed break-glass access

IAM roles, trust policies and instance profiles

A role is an identity nobody logs into — it has no password and no long-lived keys. Instead it has a trust policy (assume_role_policy) that names who may assume it, and when they do, AWS STS hands them temporary credentials that expire in an hour or so. This is the beating heart of secure AWS: EC2 instances, Lambda functions, EKS pods, CI pipelines, and cross-account access all work by assuming a role and getting short-lived creds — no secrets at rest.

The trust policy is separate from and orthogonal to the permission policies. A role has exactly one trust policy answering who can become me, and zero-or-more permission policies (managed and/or inline) answering what I can do once assumed. The trust policy’s principals block is what varies by use case:

Use case Principal type identifiers Trust policy action Notes
EC2 instance Service ec2.amazonaws.com sts:AssumeRole Delivered to the box via an instance profile
Lambda function Service lambda.amazonaws.com sts:AssumeRole Set as the function’s role
ECS task Service ecs-tasks.amazonaws.com sts:AssumeRole Task role vs execution role
Cross-account AWS arn:aws:iam::<acct>:root or a role ARN sts:AssumeRole Pair with an Allow in the other account
GitHub Actions (OIDC) Federated The OIDC provider ARN sts:AssumeRoleWithWebIdentity Condition on sub/aud claims; no stored secret
EKS pods (IRSA) Federated The cluster OIDC provider ARN sts:AssumeRoleWithWebIdentity Condition on the ServiceAccount subject

Two of those — GitHub-Actions OIDC and EKS IRSA (IAM Roles for Service Accounts) — deserve a note because they are the modern way to give CI and Kubernetes AWS access without any secret at rest: an external OIDC identity provider issues a short-lived token, and the trust policy accepts it if a Condition matches the token’s sub claim. IRSA in particular is a whole topic of its own — the OIDC provider, the ServiceAccount annotation, the pod-identity webhook — and this course covers it in a dedicated EKS lesson; here we focus on the EC2 case, which is the simplest trust policy of all.

Here is the EC2 trust policy, built with the data source and wired to a role:

data "aws_iam_policy_document" "ec2_trust" {
  statement {
    sid     = "EC2AssumeRole"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "app" {
  name               = "kloudvin-dev-app"
  assume_role_policy = data.aws_iam_policy_document.ec2_trust.json  # the TRUST policy
}

Attaching permissions: managed vs inline

Once the role exists, you grant it permissions. There are two shapes, and choosing correctly matters for reuse and for drift:

Method Terraform Reusable across identities? Drift behaviour Use when
Managed policy + attachment aws_iam_policy then aws_iam_role_policy_attachment Yes — one policy ARN, many roles Attachment is its own resource; extra attachments aren’t removed unless you use ..._exclusive The permission set is shared or you want to version it
Inline policy aws_iam_role_policy No — lives inside one role Deleted with the role; tightly coupled A one-off grant that should never outlive the role
AWS-managed policy aws_iam_role_policy_attachment to an AWS ARN Yes (AWS owns it) You don’t control its contents Rarely — most are too broad; prefer your own

The rule of thumb: customer-managed policy + attachment for anything reusable or reviewed on its own; inline only for a grant so specific it should die with the role. Reach for AWS-managed policies (arn:aws:iam::aws:policy/...) sparingly — AmazonS3FullAccess is the opposite of least privilege.

Instance profiles: how EC2 wears a role

Here is the subtlety that confuses everyone: EC2 cannot reference an IAM role directly. It references an instance profile, which is a thin container holding exactly one role. Terraform makes the profile explicit (the console hides it by auto-creating one behind the scenes):

resource "aws_iam_instance_profile" "app" {
  name = "kloudvin-dev-app"
  role = aws_iam_role.app.name   # wraps exactly one role
}

# ... and the EC2 instance references the PROFILE, not the role:
# resource "aws_instance" "app" {
#   iam_instance_profile = aws_iam_instance_profile.app.name
#   ...
# }
Concept What it is Why it matters
Instance profile Container wrapping one IAM role The only thing EC2 can attach; console auto-creates it, Terraform makes it explicit
One-role limit A profile holds exactly one role To change the role, change the profile’s role
Credential delivery Metadata service (IMDS) vends temp creds The AWS SDK on the box finds them automatically — no keys in code
IMDSv2 Session-token-protected metadata Enforce http_tokens = "required" on the instance to block SSRF creds theft

Once the profile is attached, any AWS SDK on that instance discovers the role’s temporary credentials via the Instance Metadata Service and refreshes them automatically. No access key ever touches the box — which is the entire point.

Users, groups, access keys — and why to avoid them

You can create aws_iam_user + aws_iam_access_key, but for workloads you almost never should. Long-lived access keys are the number-one source of AWS credential leaks (committed to git, baked into images, pasted into Slack). The modern posture:

Identity Credential Lifetime Use for Instead prefer
IAM user + access key Long-lived secret Until rotated (often never) Legacy break-glass only IAM Identity Center (SSO) for humans
IAM role (service) STS temp creds ~1 hour, auto-refreshed EC2/Lambda/ECS workloads This — always
IAM role (OIDC) Federated STS token Minutes CI pipelines, EKS pods This — no secret at rest
Root user Everything Forever Almost nothing Lock it, MFA it, never automate it

And a permissions boundary is the guardrail that lets you delegate safely: it is a managed policy set as a ceiling on a role or user, so even if someone attaches AdministratorAccess, the effective permission is the intersection of the identity policy and the boundary. You attach one with the permissions_boundary argument on aws_iam_role/aws_iam_user, and it is how platform teams let application teams create their own roles without being able to escalate.

S3 buckets and the modern split resources

Now the storage half. The single most important thing to know about S3 in Terraform is that the aws_s3_bucket resource changed shape in AWS provider v4 (2022), and every tutorial written before then is wrong today. The old resource was a monolith: versioning, encryption, lifecycle, logging, ACLs, CORS, and website config were all arguments inside aws_s3_bucket. The new model splits every one of those into its own resource. This is the change that breaks people migrating old code, and the thing to internalize before writing a single bucket.

Configuration ❌ Old (pre-v4, monolithic argument) ✅ New (v4+, separate resource)
Versioning versioning { enabled = true } block aws_s3_bucket_versioning
Encryption server_side_encryption_configuration { } block aws_s3_bucket_server_side_encryption_configuration
Public access block (separate resource, unchanged) aws_s3_bucket_public_access_block
Bucket policy (separate resource, unchanged) aws_s3_bucket_policy
Lifecycle lifecycle_rule { } block aws_s3_bucket_lifecycle_configuration
Object ownership / ACLs acl = "private" argument aws_s3_bucket_ownership_controls + aws_s3_bucket_acl
Access logging logging { } block aws_s3_bucket_logging
Static website website { } block aws_s3_bucket_website_configuration
CORS cors_rule { } block aws_s3_bucket_cors_configuration
Replication replication_configuration { } block aws_s3_bucket_replication_configuration

Why the split is actually better, once you get past the surprise: each concern is now independently planned, import-able, and for_each-able; teams can own different resources; and a change to a lifecycle rule no longer re-reads the entire bucket. The bucket resource itself becomes almost empty — just the name and a couple of top-level settings:

resource "aws_s3_bucket" "app" {
  bucket        = local.bucket_name  # globally unique across ALL of AWS
  force_destroy = true               # ⚠️ dev-only: lets destroy empty the bucket
}

Two arguments carry weight here. bucket must be globally unique across every AWS account on earth and DNS-compatible (3–63 chars, lowercase, no underscores) — pick a naming scheme that won’t collide (we suffix with the account ID below). force_destroy controls whether terraform destroy will empty the bucket first; leave it false in production so Terraform refuses to nuke data, set it true in throwaway demos so you can tear down cleanly. The split resources you attach to it, each doing one job:

Split resource Configures Key argument(s) Why you want it
aws_s3_bucket_versioning Object version history versioning_configuration { status = "Enabled" } Rollback, accidental-delete and ransomware recovery
aws_s3_bucket_server_side_encryption_configuration Default encryption sse_algorithm, kms_master_key_id, bucket_key_enabled Encrypt at rest; SSE-KMS for auditable, controllable keys
aws_s3_bucket_public_access_block The public-access firewall four block_* / *_public_* booleans The single most important S3 security control
aws_s3_bucket_ownership_controls ACL behaviour object_ownership BucketOwnerEnforced disables ACLs entirely
aws_s3_bucket_policy Resource-based policy policy (JSON) TLS-only, cross-account, service access
aws_s3_bucket_lifecycle_configuration Tiering & expiry rule { transition / expiration } Cut cost by moving cold data to IA/Glacier
aws_s3_bucket_logging Server access logs target_bucket, target_prefix Audit trail of who read what

Encryption: SSE-S3 vs SSE-KMS

S3 can encrypt every object at rest, and it always should. The choice is which key:

Mode sse_algorithm Key owner Auditable / revocable Cost Use when
SSE-S3 AES256 AWS-managed No per-request audit Free Baseline; low-sensitivity data
SSE-KMS (AWS-managed key) aws:kms (no key id) AWS-managed CMK aws/s3 Some (CloudTrail) KMS request charges Better than S3 with little effort
SSE-KMS (customer key) aws:kms + kms_master_key_id You Yes — key policy, rotation, revoke KMS request charges Sensitive data; compliance — our choice
DSSE-KMS aws:kms:dsse You Yes (double layer) Higher Regulatory double-encryption mandates
SSE-C (customer-provided key) You, off-AWS You hold the key Free (S3 side) You must keep keys entirely outside AWS

We use SSE-KMS with a customer-managed key because it gives an auditable kms:Decrypt trail in CloudTrail and lets you revoke access by editing the key policy — powerful, but it introduces the trap the whole lesson circles back to: reading a KMS-encrypted object requires kms:Decrypt on the key in addition to s3:GetObject. Two gates, not one. And always set bucket_key_enabled = true: an S3 Bucket Key caches a data key so S3 doesn’t call KMS on every single object, cutting KMS request costs by up to 99% on hot buckets.

Lifecycle: tier cold data down, expire old versions

A lifecycle configuration moves objects to cheaper storage classes as they age and deletes them when they’re spent. On a versioned bucket you also manage the noncurrent (old) versions, or they accumulate forever and quietly cost money:

Storage class Retrieval ~Relative cost Typical transition Min duration
S3 Standard Instant 1.0× Day 0 (default)
Standard-IA Instant ~0.55× After 30 days 30 days
One Zone-IA Instant ~0.44× Non-critical, reproducible 30 days
Glacier Instant Retrieval Instant ~0.25× Archive, rare but fast reads 90 days
Glacier Flexible Retrieval Minutes–hours ~0.15× Archive after 90 days 90 days
Glacier Deep Archive Hours ~0.04× Compliance cold store 180 days

The lifecycle resource maps those into rules. Note the two footguns baked into the resource: each rule requires a filter {} (empty means “whole bucket”) in provider v5, and if you set noncurrent_version_* actions you should add a depends_on on the versioning resource so ordering is deterministic:

resource "aws_s3_bucket_lifecycle_configuration" "app" {
  bucket     = aws_s3_bucket.app.id
  depends_on = [aws_s3_bucket_versioning.app]

  rule {
    id     = "archive-and-expire"
    status = "Enabled"
    filter {}                                   # whole bucket; a required block in v5

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
    expiration {
      days = 365                                # expire current versions after a year
    }

    noncurrent_version_transition {
      noncurrent_days = 30
      storage_class   = "STANDARD_IA"
    }
    noncurrent_version_expiration {
      noncurrent_days = 90                      # purge old versions after 90 days
    }
  }
}

Block Public Access and disabling ACLs

Block Public Access (BPA) is the seatbelt. It is four independent booleans that, when all true, make it impossible for any ACL or bucket policy to expose the bucket — even if someone later writes a public policy by mistake, BPA overrides it:

Flag Blocks Effect when true
block_public_acls New public ACLs Rejects PUTs that set a public ACL
ignore_public_acls Existing public ACLs Ignores any public ACL already present
block_public_policy New public bucket policies Rejects a bucket policy that grants public access
restrict_public_buckets Cross-account + anonymous via policy Only the bucket owner & AWS services can use a public policy

Set all four to true on every bucket unless you are deliberately hosting a public website (and even then, front it with CloudFront + OAC instead). Pair BPA with aws_s3_bucket_ownership_controls set to BucketOwnerEnforced, which disables ACLs entirely — the modern default — so access is governed only by IAM and bucket policies, never by the legacy per-object ACL system that caused a decade of leaks.

Bucket policy vs IAM policy — which governs access?

Both can grant access to a bucket, and knowing which to use is a senior-level distinction:

Identity (IAM) policy Bucket (resource) policy
Attached to The role/user The bucket
Answers “What can this identity reach?” “Who may touch this bucket?”
Same-account access Sufficient on its own Sufficient on its own
Cross-account access Needed in caller’s account Also needed in bucket’s account
Best for Per-workload permissions TLS-only, cross-account, org-wide, service grants
Public/anonymous Can’t (no principal) The only place to express it (behind BPA)

Our demo uses both, deliberately: the identity policy on the role grants the read (sufficient for same-account), and the bucket policy adds two defense-in-depth statements — a blanket Deny on any non-TLS request, and an explicit Allow naming just the app role (which becomes load-bearing the day another account needs in). That layering is the pattern you want in production.

Hands-on: build it with Terraform

Time to build the whole thing end to end. Create a directory and these five files. ⚠️ This provisions real resources. KMS keys and S3 have costs (small here — see the cost section) and you will destroy at the end. Authenticate the AWS provider first (the getting-started lesson covers aws configure, SSO, and the remote backend).

Here is what each file owns:

File Contents
versions.tf Terraform + AWS provider version pins (and, in real projects, the S3 backend)
providers.tf The aws provider block with region and default tags
variables.tf Inputs: region, project, environment, optional permissions boundary
main.tf KMS key, S3 bucket + all split resources, IAM role/policy/profile, bucket policy
outputs.tf Bucket name/ARN, role ARN, instance-profile name, KMS key ARN

versions.tf

terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"   # allow 5.x, refuse 6.0; pin tighter in prod
    }
  }

  # In a real project, state lives in S3 with a DynamoDB lock (see the getting-started lesson):
  # backend "s3" {
  #   bucket         = "kloudvin-tfstate"
  #   key            = "aws/iam-s3/terraform.tfstate"
  #   region         = "ap-south-1"
  #   dynamodb_table = "kloudvin-tflock"
  #   encrypt        = true
  # }
}

providers.tf

provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project     = var.project
      Environment = var.environment
      ManagedBy   = "Terraform"
    }
  }
}

variables.tf

variable "region" {
  type        = string
  description = "AWS region to deploy into."
  default     = "ap-south-1"
}

variable "project" {
  type        = string
  description = "Project prefix for names and tags."
  default     = "kloudvin"
}

variable "environment" {
  type        = string
  description = "Environment (dev/stg/prod)."
  default     = "dev"
}

variable "permissions_boundary_arn" {
  type        = string
  description = "Optional permissions-boundary policy ARN to cap the role."
  default     = null
}

main.tf

data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}

locals {
  name        = "${var.project}-${var.environment}"
  # Bucket names are GLOBAL: suffix with the account id for uniqueness.
  bucket_name = "${var.project}-${var.environment}-${data.aws_caller_identity.current.account_id}"
  account_arn = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"
}

# ─────────────────────────── KMS key for SSE-KMS ───────────────────────────
# Default key policy: give the account root full control so IAM policies can
# delegate. Restricting this too far is the #1 way to lock yourself out of a key.
data "aws_iam_policy_document" "kms" {
  statement {
    sid       = "EnableIAMUserPermissions"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]
    principals {
      type        = "AWS"
      identifiers = [local.account_arn]
    }
  }
}

resource "aws_kms_key" "s3" {
  description             = "SSE-KMS CMK for ${local.bucket_name}"
  deletion_window_in_days = 7      # ⚠️ deletion is SCHEDULED, min 7 days
  enable_key_rotation     = true   # annual automatic rotation
  policy                  = data.aws_iam_policy_document.kms.json
}

resource "aws_kms_alias" "s3" {
  name          = "alias/${local.name}-s3"
  target_key_id = aws_kms_key.s3.key_id
}

# ─────────────────────────── S3 bucket + hardening ─────────────────────────
resource "aws_s3_bucket" "app" {
  bucket        = local.bucket_name
  force_destroy = true   # ⚠️ dev-only: empties the (versioned) bucket on destroy
}

resource "aws_s3_bucket_ownership_controls" "app" {
  bucket = aws_s3_bucket.app.id
  rule { object_ownership = "BucketOwnerEnforced" }  # ACLs off — modern default
}

resource "aws_s3_bucket_public_access_block" "app" {
  bucket                  = aws_s3_bucket.app.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "app" {
  bucket = aws_s3_bucket.app.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "app" {
  bucket = aws_s3_bucket.app.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.s3.arn
    }
    bucket_key_enabled = true   # cache data key → far fewer KMS calls
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "app" {
  bucket     = aws_s3_bucket.app.id
  depends_on = [aws_s3_bucket_versioning.app]

  rule {
    id     = "archive-and-expire"
    status = "Enabled"
    filter {}

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
    expiration {
      days = 365
    }
    noncurrent_version_transition {
      noncurrent_days = 30
      storage_class   = "STANDARD_IA"
    }
    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}

# ─────────────────────────── IAM role (EC2 trust) ──────────────────────────
data "aws_iam_policy_document" "ec2_trust" {
  statement {
    sid     = "EC2AssumeRole"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "app" {
  name                 = "${local.name}-app"
  assume_role_policy   = data.aws_iam_policy_document.ec2_trust.json
  permissions_boundary = var.permissions_boundary_arn
}

# ──────────────────── Least-privilege identity policy ──────────────────────
data "aws_iam_policy_document" "app_s3_read" {
  statement {
    sid       = "ReadObjects"
    effect    = "Allow"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app.arn}/*"]   # objects → /* suffix
  }
  statement {
    sid       = "ListBucketOnly"
    effect    = "Allow"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.app.arn]          # the bucket itself, no /*
  }
  statement {
    sid       = "DecryptWithCMK"
    effect    = "Allow"
    actions   = ["kms:Decrypt"]
    resources = [aws_kms_key.s3.arn]             # the MOST-forgotten statement
  }
}

resource "aws_iam_policy" "app_s3_read" {
  name   = "${local.name}-s3-read"
  policy = data.aws_iam_policy_document.app_s3_read.json
}

resource "aws_iam_role_policy_attachment" "app_s3_read" {
  role       = aws_iam_role.app.name
  policy_arn = aws_iam_policy.app_s3_read.arn
}

# ─────────────────────────── Instance profile ──────────────────────────────
resource "aws_iam_instance_profile" "app" {
  name = "${local.name}-app"
  role = aws_iam_role.app.name
}

# ─────────────── Bucket policy: TLS-only + explicit role allow ──────────────
data "aws_iam_policy_document" "bucket" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.app.arn, "${aws_s3_bucket.app.arn}/*"]
    principals {
      type        = "AWS"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }

  statement {
    sid       = "AllowAppRoleRead"
    effect    = "Allow"
    actions   = ["s3:GetObject", "s3:ListBucket"]
    resources = [aws_s3_bucket.app.arn, "${aws_s3_bucket.app.arn}/*"]
    principals {
      type        = "AWS"
      identifiers = [aws_iam_role.app.arn]
    }
  }
}

resource "aws_s3_bucket_policy" "app" {
  bucket     = aws_s3_bucket.app.id
  policy     = data.aws_iam_policy_document.bucket.json
  depends_on = [aws_s3_bucket_public_access_block.app]  # BPA must land first
}

outputs.tf

output "bucket_name" {
  description = "The globally unique bucket name."
  value       = aws_s3_bucket.app.id
}

output "bucket_arn" {
  value = aws_s3_bucket.app.arn
}

output "role_arn" {
  value = aws_iam_role.app.arn
}

output "instance_profile_name" {
  description = "Attach this to an EC2 instance via iam_instance_profile."
  value       = aws_iam_instance_profile.app.name
}

output "kms_key_arn" {
  value = aws_kms_key.s3.arn
}

Step 1 — terraform init

terraform init

You should see the AWS provider download and Terraform has been successfully initialized! If you uncommented the S3 backend, you’ll also see it configure remote state.

Step 2 — terraform plan

terraform plan

Terraform prints a diff. Read the summary line — you should see 13 resources to add (the KMS key + alias, the bucket + six split resources, the role + policy + attachment + instance profile, and the bucket policy) and nothing to change or destroy:

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

Changes to Outputs:
  + bucket_arn            = (known after apply)
  + bucket_name           = "kloudvin-dev-123456789012"
  + instance_profile_name = "kloudvin-dev-app"
  + kms_key_arn           = (known after apply)
  + role_arn              = (known after apply)

Notice bucket_name is known now (we built it from the account id) while ARNs are (known after apply). Read the plan for the bucket policy and confirm the Deny on aws:SecureTransport is present.

Step 3 — terraform apply

terraform apply    # review, then type: yes

⚠️ This creates billable resources. Watch the ordering Terraform derives from the dependency graph: the KMS key first (the encryption config needs its ARN), the bucket, then the split resources, the role and its policy in parallel, and finally the bucket policy (after BPA). On success:

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

Outputs:
bucket_arn            = "arn:aws:s3:::kloudvin-dev-123456789012"
bucket_name           = "kloudvin-dev-123456789012"
instance_profile_name = "kloudvin-dev-app"
kms_key_arn           = "arn:aws:kms:ap-south-1:123456789012:key/8f1c…"
role_arn              = "arn:aws:iam::123456789012:role/kloudvin-dev-app"

Step 4 — verify every control with the aws CLI

Don’t trust; verify. Each command below confirms one of the controls you declared:

BUCKET=$(terraform output -raw bucket_name)

# Versioning is Enabled
aws s3api get-bucket-versioning --bucket "$BUCKET"
# → { "Status": "Enabled" }

# Default encryption is SSE-KMS with our key
aws s3api get-bucket-encryption --bucket "$BUCKET" \
  --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault'
# → { "SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "arn:aws:kms:...:key/8f1c..." }

# All four Block Public Access flags are true
aws s3api get-public-access-block --bucket "$BUCKET" \
  --query 'PublicAccessBlockConfiguration'
# → all four true

# The lifecycle rule exists
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" \
  --query 'Rules[0].[ID,Status]'
# → [ "archive-and-expire", "Enabled" ]

# The instance profile wraps the role
aws iam get-instance-profile --instance-profile-name "$(terraform output -raw instance_profile_name)" \
  --query 'InstanceProfile.Roles[0].RoleName'
# → "kloudvin-dev-app"

Now prove the encryption is real by round-tripping an object (your own admin creds can write; the app role only reads):

echo "hello kloudvin" > demo.txt
aws s3 cp demo.txt "s3://$BUCKET/demo.txt"

aws s3api head-object --bucket "$BUCKET" --key demo.txt \
  --query '[ServerSideEncryption, SSEKMSKeyId]'
# → [ "aws:kms", "arn:aws:kms:...:key/8f1c..." ]   ← encrypted with our CMK

Finally, prove least privilege without launching an instance, using the policy simulator — the app role may GetObject but not PutObject or DeleteObject:

ROLE=$(terraform output -raw role_arn)
aws iam simulate-principal-policy --policy-source-arn "$ROLE" \
  --action-names s3:GetObject s3:PutObject s3:DeleteObject \
  --resource-arns "$(terraform output -raw bucket_arn)/demo.txt" \
  --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
# → GetObject   allowed
#   PutObject   implicitDeny
#   DeleteObject implicitDeny

That is the whole thesis in one command: the role can do exactly the one thing it needs, and nothing else. On a real EC2 instance carrying this instance profile, the AWS SDK would find these credentials via IMDS automatically and the same GetObject would succeed with no keys anywhere on the box — launching that instance (with a security group and key pair) is the subject of the Terraform on AWS: security groups, EC2 & key pairs lesson.

Step 5 — terraform destroy (⚠️ empty the bucket first)

terraform destroy    # review, then type: yes

Because we set force_destroy = true, Terraform empties the (versioned) bucket — deleting every object version and delete marker — before removing it. Without force_destroy, destroy fails with BucketNotEmpty and you must purge it manually first:

# Manual purge for a versioned bucket when force_destroy = false:
aws s3api delete-objects --bucket "$BUCKET" --delete "$(aws s3api list-object-versions \
  --bucket "$BUCKET" --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' --output json)"
# repeat the same for DeleteMarkers[]  →  then re-run terraform destroy

One more surprise in the output: the KMS key is not deleted immediately — it enters pending deletion for deletion_window_in_days (7 here; 7–30 allowed). This is a safety feature so you can’t instantly destroy data you can no longer decrypt. If you tore down by mistake, aws kms cancel-key-deletion --key-id <id> brings it back within the window.

Variables, outputs and making it reusable

The demo is already parameterised by region, project, and environment. The natural next step is to turn “one bucket + one role” into a repeatable pattern — either many buckets in one config with for_each, or a reusable module.

To manage several buckets from one map, drive aws_s3_bucket (and each split resource) with for_each:

variable "buckets" {
  description = "Map of logical name → settings."
  type = map(object({
    versioning = bool
    expire_days = number
  }))
  default = {
    uploads  = { versioning = true,  expire_days = 365 }
    logs     = { versioning = false, expire_days = 90 }
  }
}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "${var.project}-${var.environment}-${each.key}-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_versioning" "this" {
  for_each = { for k, v in var.buckets : k => v if v.versioning }
  bucket   = aws_s3_bucket.this[each.key].id
  versioning_configuration { status = "Enabled" }
}

Two idioms to notice: you reference the parent by key (aws_s3_bucket.this[each.key].id), and you can filter the map (if v.versioning) so the versioning resource only exists for buckets that want it. The same for_each pattern extends to encryption, BPA, and lifecycle.

For production, you often shouldn’t roll your own at all — the community modules are battle-tested and encode exactly the hardening we did by hand:

Need Registry module What it gives you
Hardened S3 bucket terraform-aws-modules/s3-bucket/aws Every split resource behind one call: versioning, SSE, BPA, lifecycle, policy, logging, ownership
Assumable IAM role terraform-aws-modules/iam/aws//modules/iam-assumable-role Role + trust policy + attachments with sane inputs
OIDC/IRSA role terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks The federated trust for EKS pods
Standalone policy terraform-aws-modules/iam/aws//modules/iam-policy A managed policy from a document

When to use a module vs roll your own:

Roll your own (this lesson) Registry module
Understanding You see every resource Abstracted behind inputs
Boilerplate More Much less
Edge cases You handle them Usually pre-handled
Version risk You pin the provider You also pin the module
Best for Learning; unusual requirements Production; standard hardening at scale

The honest guidance: build it by hand once (which you just did) so you understand every knob, then adopt terraform-aws-modules/s3-bucket for real fleets so you’re not re-deriving Block Public Access in twenty repos.

Common mistakes and troubleshooting

IAM and S3 generate a specific, recognizable set of failures. This table is the one to keep open mid-incident:

Symptom Likely cause Fix
AccessDenied on s3:GetObject of an encrypted object Role has s3:GetObject but not kms:Decrypt on the CMK Add a kms:Decrypt statement on the key ARN (our DecryptWithCMK)
AccessDenied on s3:ListBucket Listing is a bucket action; you granted only object (/*) perms Add s3:ListBucket on the bucket ARN without /*
is not authorized to perform: sts:AssumeRole Trust policy names the wrong principal Fix assume_role_policy — service ec2.amazonaws.com, or the right account/OIDC provider
BucketAlreadyExists / BucketAlreadyOwnedByYou Bucket names are global; someone (maybe you) took it Choose a unique name — suffix with account id / random id
BucketNotEmpty on destroy Bucket (esp. versioned) still holds objects/versions Set force_destroy = true, or purge versions + delete markers first
Bucket policy apply → AccessDenied / policy rejected block_public_policy sees the policy as public, or order wrong Ensure the policy isn’t public; depends_on the BPA resource
KMS AccessDenied even though IAM allows it Restrictive key policy doesn’t allow the principal KMS is doubly-gated: fix the key policy and the IAM policy
Can’t delete/administer the KMS key Key policy removed the account-root kms:* statement You may be locked out — open a support case; always keep the root statement
Error acquiring the state lock A prior run crashed holding the DynamoDB lock Confirm no live apply, then terraform force-unlock <ID>
Old tutorial’s versioning { } block errors You’re on provider v4+ where it’s a separate resource Move to aws_s3_bucket_versioning etc. (the split)
MalformedPolicy: Policy document should not specify a resource You put resources in a trust policy Trust policies have no Resource; only principals + sts:AssumeRole
Objects readable by a colleague you didn’t grant A leftover public ACL, or ACLs still enabled Set BucketOwnerEnforced (ACLs off) + all four BPA flags

The nastiest three deserve prose. The kms:Decrypt trap is the single most common S3 support puzzle: the object is encrypted with a customer key, s3:GetObject succeeds at the S3 layer, but S3 then calls KMS on the caller’s behalf to unwrap the data key, and that call is denied because the role’s policy never mentioned the key. The symptom is a confusing AccessDenied on GetObject even though your S3 permissions are obviously correct. Always grant kms:Decrypt (and kms:GenerateDataKey if the workload writes encrypted objects) on the exact key ARN. The trust-vs-permission confusion is next: people put their s3:GetObject grant in the assume_role_policy and wonder why nothing works — the trust policy only answers who may assume the role and only ever contains sts:AssumeRole* with principals; permissions go in attached policies. The KMS lockout is the scariest: a well-meaning “least privilege” edit to a key policy that removes the account-root kms:* statement can make the key unmanageable and its data unrecoverable, because the key policy is the root of trust for the key — always keep the EnableIAMUserPermissions statement granting the account root kms:*.

Cost, cleanup and production notes

Left running, this stack costs very little, but the pieces are worth knowing so nothing surprises you:

Resource Charge model Rough cost (ap-south-1) Notes
IAM (role, policy, profile, users, groups) Free ₹0 IAM itself is never billed
S3 storage Per GB-month by class ~$0.025/GB (Standard) IA/Glacier far cheaper; you pay per version on versioned buckets
S3 requests Per 1,000 requests fractions of a cent GET/PUT/LIST priced separately
KMS customer key Per key-month + per request ~$1/key-month + $0.03/10k requests bucket_key_enabled slashes request count
KMS key deletion Free, but scheduled ₹0 Pending-deletion window 7–30 days
Data transfer out Per GB egress varies Free within region to most AWS services

The dominant cost is the KMS key (~$1/month) and, over time, accumulated object versions on a versioned bucket — which is exactly why the lifecycle rule’s noncurrent_version_expiration matters. To clean up completely: terraform destroy (with force_destroy = true the bucket empties itself), then remember the KMS key sits in pending-deletion for the window — that’s expected and free.

Five production-hardening notes to carry forward:

Area Do this Why
State Remote S3 backend + DynamoDB lock, encrypt = true IAM/S3 state contains ARNs and policy text; never local
Least privilege Scope actions and resources; never s3:* or Resource "*" The whole point; use the policy simulator to verify
Tagging default_tags for owner/cost-center/env Attribution, cost allocation, and policy-by-tag
Drift & guardrails Scan plans with tfsec/Checkov; aws_..._exclusive for attachments Catch a public bucket or over-broad policy before apply
KMS hygiene enable_key_rotation = true, keep the root key-policy statement Auto-rotation for compliance; never lock yourself out

Cheat-sheet

The resources for this topic, at a glance:

Resource / data source Purpose
data.aws_iam_policy_document Build IAM/bucket/trust/key JSON idiomatically
aws_iam_role + assume_role_policy An assumable identity + its trust policy
aws_iam_policy + aws_iam_role_policy_attachment Reusable managed policy, attached
aws_iam_role_policy Inline policy that dies with the role
aws_iam_instance_profile The container EC2 attaches to hold a role
aws_iam_user / _group / _access_key Long-lived identities — avoid for workloads
aws_s3_bucket (+ force_destroy) The bucket shell (name + destroy behaviour)
aws_s3_bucket_versioning Turn on version history
aws_s3_bucket_server_side_encryption_configuration SSE-S3 / SSE-KMS default encryption
aws_s3_bucket_public_access_block The four public-access firewall flags
aws_s3_bucket_ownership_controls BucketOwnerEnforced disables ACLs
aws_s3_bucket_policy Resource-based policy (TLS-only, cross-account)
aws_s3_bucket_lifecycle_configuration Tier to IA/Glacier + expire versions
aws_kms_key / aws_kms_alias Customer CMK for SSE-KMS + a friendly alias

Commands you’ll live in:

Command Does
terraform init / plan / apply / destroy The core loop
aws s3api get-bucket-versioning|-encryption|-lifecycle-configuration Verify each S3 control
aws s3api get-public-access-block Confirm BPA flags
aws s3api head-object Confirm an object’s SSE-KMS encryption
aws iam get-instance-profile Confirm the profile wraps the role
aws iam simulate-principal-policy Prove least privilege without a workload
aws kms cancel-key-deletion Rescue a key from pending deletion
terraform force-unlock <ID> Clear a stuck state lock

Interview and exam questions

1. What is the difference between an identity-based and a resource-based policy? An identity-based policy is attached to a principal (user/group/role) and says what that identity can do; a resource-based policy is attached to the resource (bucket, KMS key, queue) and says who may touch it. For same-account access, an Allow in either is enough; for cross-account you need both.

2. Walk through IAM’s evaluation logic. Gather all applicable policies (identity, resource, boundary, session, SCP). An explicit Deny anywhere wins. Otherwise the action must be allowed by any SCP and the permissions boundary, and there must be an explicit Allow — with no Allow, the implicit deny applies. Deny-by-default; explicit-deny-always-wins.

3. Why use aws_iam_policy_document instead of a heredoc or jsonencode()? It validates structure, interpolates real ARNs/account-ids as typed values (no string-concat bugs), adds Version automatically, and composes documents via source_policy_documents/override_policy_documents and dynamic statements. Heredoc has no validation; jsonencode is fine for tiny docs but doesn’t compose.

4. What is a trust policy and how does it differ from a permission policy? The trust policy (assume_role_policy) answers who may assume this role — it contains sts:AssumeRole* and principals, never a Resource. Permission policies (attached separately) answer what the role can do once assumed. They are orthogonal.

5. Why can’t EC2 use an IAM role directly, and what bridges the gap? EC2 attaches an instance profile, a container that holds exactly one role. The console hides it by auto-creating one; in Terraform you create aws_iam_instance_profile explicitly and set iam_instance_profile on the instance. IMDS then vends the role’s temporary credentials.

6. A role has s3:GetObject but gets AccessDenied reading an object. Why? The bucket uses SSE-KMS with a customer key and the role lacks kms:Decrypt on that key. S3 must call KMS to unwrap the data key; that call is denied. Add kms:Decrypt (and kms:GenerateDataKey for writes) on the key ARN.

7. What are the four Block Public Access flags and why does BPA override a bucket policy? block_public_acls, ignore_public_acls, block_public_policy, restrict_public_buckets. BPA is evaluated as an overriding control: even a public-granting bucket policy or ACL is neutralized when the flags are on, which is why it’s the definitive S3 safety net.

8. Explain the S3 “split resources” change and why it matters for Terraform. Since AWS provider v4, configuration that used to be arguments inside aws_s3_bucket (versioning, encryption, lifecycle, ACL, logging, CORS, website, replication) moved into separate resources (aws_s3_bucket_versioning, etc.). Old tutorials break on v4+; you must use the split resources, which also plan and import independently.

9. How do you safely let a delegated team create roles without privilege escalation? Attach a permissions boundary — a managed policy set as a ceiling via permissions_boundary. Effective permissions become the intersection of the identity policy and the boundary, so even attaching AdministratorAccess can’t exceed the boundary.

10. (Terraform Associate 003) You add aws_s3_bucket_versioning to a bucket already managed by aws_s3_bucket and plan shows the bucket unchanged. Is that expected? Yes — in the split model the versioning is a distinct resource that references the bucket by id; adding it plans one new resource and leaves the bucket resource itself untouched. (On very old code you’d instead be removing an inline versioning block.)

11. (Terraform Associate 003) terraform destroy fails with BucketNotEmpty. What are your options? Either set force_destroy = true on the aws_s3_bucket so Terraform empties it (including all versions and delete markers) before deletion, or manually purge objects, versions, and delete markers with aws s3api delete-objects and re-run destroy.

12. Why does destroying a KMS key not delete it immediately? KMS schedules key deletion for a mandatory 7–30 day window (deletion_window_in_days) so you can’t instantly and irreversibly lose the ability to decrypt data. Within the window, aws kms cancel-key-deletion reverses it.

Key takeaways

TerraformawsIAMS3aws_iam_policy_documentIAM rolesinstance profileKMSSSE-KMSS3 versioningBlock Public Accessleast-privilegebucket policyIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments