Identity, Secrets & Encryption

Anatomy of a Policy

Take one workload, write down what it actually needs, then read what its policy actually grants. The gap between those two lists is the lesson — and the blast radius is how you measure it.

▶ Run the lab

The question this answers

Infrastructure question

How do I read a policy and tell, in under a minute, whether it grants more than the workload needs?

Application requirement

An image worker reads uploads and writes thumbnails. It must never delete customer records and must never read billing data. Somebody has to be able to confirm from the policy alone that both of those are true.

What it provides

A readable structure — effect, action, resource, condition — that makes an over-broad grant visible to a reviewer instead of visible to an attacker.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

Four parts, and the one that does the damage

Every policy statement has the same four parts. The effect is allow or deny. The action is what may be done. The resource is what it may be done to. The condition is an optional narrowing — source network, request attribute, resource tag, time.

Action and resource are where policies go wrong, and they go wrong asymmetrically. A wildcard action (storage:*) is at least visible: a reviewer sees the asterisk and asks about it. A wildcard *resource* hides better, because it often looks specific. arn:…:user-media/* reads like "the uploads bucket" and grants the entire bucket including every other prefix. secret/* reads like "our secrets" and grants every secret in the account, including the ones another team created last week.

The condition is the part teams skip and the part that buys the most for the least effort. A read grant restricted to requests originating inside your virtual network is dramatically less useful to an attacker who has the credential but not the network position. A write grant conditioned on a resource tag prevents a service from touching resources it was never assigned. Conditions cost one line and shrink blast radius more than most policy rewrites do.

A policy document is worth reading as monospace text at least once, because that is how the wildcards become visible. The block below is one real-shaped policy for one workload.

policy: image-worker
  statement 1  effect: ALLOW
               action:   storage:GetObject
               resource: user-media/uploads/*          <- prefix, not bucket
               condition: source_vpc = vpc-app

  statement 2  effect: ALLOW
               action:   storage:PutObject
               resource: user-media/thumbnails/*       <- different prefix
               condition: source_vpc = vpc-app

  statement 3  effect: DENY                            <- guardrail; beats any allow
               action:   storage:DeleteObject
               resource: *

  statement 4  effect: DENY
               action:   db:*, secrets:*
               resource: *

reads as: "may read an upload and write a thumbnail, from inside the app network,
           and may never delete an object, touch a database or read a secret."
One workload, written out. Note where the wildcards are, and where they are not.

Needed versus allowed, on the same panel

The single most useful review technique in this module is to write the two lists next to each other. On the left, what the workload does — derived from the code, or from the audit trail of what it actually called last month. On the right, what the policy permits. Any line on the right without a partner on the left is unearned privilege.

The panel below is the well-scoped version. The needed list and the allowed list correspond one to one, the denies are guardrails rather than corrections, and the network condition means a stolen credential is not immediately usable from outside. A reviewer can approve this in five seconds, which is the real bar: policies that take fifteen minutes to reason about do not get reviewed, they get approved.

Note what the denies buy. DeleteObject denied at the account level means that even if a later engineer adds a broad allow to fix an unrelated problem, deletion is still impossible for this identity — because an explicit deny beats any allow. That is the mechanism guardrails are built from, and it is why they belong on the identities you are least able to review often.

Needed and allowed, line for line
image-workercontainerleast privilege
on bucket user-media, prefixes uploads/ and thumbnails/
Allowed
  • storage:GetObject on user-media/uploads/* (source_vpc = vpc-app)
  • storage:PutObject on user-media/thumbnails/* (source_vpc = vpc-app)
Actually needed
  • read the uploaded original from uploads/
  • write the generated thumbnail to thumbnails/
Explicitly denied
  • storage:DeleteObject on *
  • db:* on * (cannot read or delete customer records)
  • secrets:* on * (cannot read billing credentials)

Blast radius: A stolen credential used from inside the application network can read user uploads and write objects into the thumbnails prefix. It cannot delete anything, cannot reach any database, and cannot read any secret. Used from outside the network the condition fails and it grants nothing at all.

The same workload, after eight months of incidents

Policies do not start broad. They become broad, one urgent fix at a time, and each individual step is defensible. The worker needs to read a second prefix, so someone drops the prefix and grants the bucket. A migration needs deletes for one afternoon, so the deny is removed and never restored. A new feature needs one secret, so secrets:GetSecretValue on * gets added because finding the exact ARN at 23:40 was hard.

Nobody made a bad decision. The end state is still a workload that can read every secret in the account and delete any object in any bucket, and no single change request would have been rejected on review. This is why permission drift needs a scheduled owner rather than a review gate: the gate passes every time.

Two mechanical defences work. Generate policies from infrastructure code so that widening a permission is a reviewed diff rather than a console click, and it reverts on the next apply (Drift: When the File and Reality Disagree). And use the audit trail to compare granted permissions against permissions actually exercised over a 90-day window — the gap is a concrete, unarguable list of grants to remove.

ChangeWhy it happenedWhat it actually grantedBlast radius after
uploads/*user-media/*The worker needed a second prefixEvery object in the bucket, including other tenants' originalsRead all user media
Removed the DeleteObject denyA one-afternoon migration needed deletesPermanent delete on every bucketDestroy all user media
Added secrets:GetSecretValue on *A new feature needed one secret at 23:40Every secret in the accountDatabase, payment provider, signing keys
Added db:Connect on *Debugging a data issue in stagingEvery database the role can reach, including productionFull customer data access
Removed the source_vpc conditionA batch job ran outside the networkThe credential now works from anywhereA stolen credential is usable from the internet
How a least-privilege policy becomes an administrator, one reasonable change at a time

Key points

  • Effect, action, resource, condition. Wildcards in the resource are more dangerous than wildcards in the action because they hide better.
  • Write needed and allowed side by side; every allowed line without a matching needed line is unearned privilege.
  • A condition — source network, resource tag, request attribute — is one line and shrinks blast radius more than most rewrites.
  • An explicit deny beats any allow, which makes deny statements the right tool for guardrails on identities you cannot review often.
  • Policies drift wide through a series of individually reasonable changes; the defence is generation from code plus a periodic granted-versus-used comparison.
  • A policy a reviewer cannot judge in five seconds will be approved rather than reviewed.

The loop, answered

Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.

How it works
  • The policy engine collects every applicable statement — identity policies, resource policies, group policies, boundaries and organization-level constraints.
  • Any matching explicit deny ends the evaluation immediately, regardless of how many allows exist.
  • Otherwise at least one allow must match the exact action and the exact resource, with every condition satisfied.
  • If nothing matches, the default deny applies and the caller receives an access-denied error naming the action.
  • The decision is written to the audit trail, which is what makes the granted-versus-used comparison possible later.
What you still own
  • Own a review habit that reads the resource line first, and asks what the wildcard actually matches.
  • Own the guardrail denies for destructive and cross-domain actions, applied at a level individual teams cannot edit.
  • Own a scheduled granted-versus-used report; without one, permissions only ever grow.
  • Own the emergency-widening runbook, including the ticket that removes the grant afterwards. The removal is the part that does not happen by itself.
  • Own policy generation from infrastructure code, so a console change shows up as drift rather than as the new normal.
How it fails
  • A resource wildcard matching far more than intended — user-media/* instead of user-media/uploads/* is one character and an entire bucket.
  • A deny that blocks a legitimate action and produces a confusing failure, because deny wins and nothing explains which statement fired.
  • A condition on a source address that breaks when the workload moves zones or the egress address changes.
  • Permission drift: eight months of individually reasonable widenings ending at effective administrator.
  • A policy so long that reviewers approve it without reading, which is functionally the same as having no review.
  • Emergency access granted during an incident and never revoked, which is the single most common finding in an access audit.
How it scales
  • Policy count grows with workloads × environments; hand-writing stops being viable long before it stops being attempted.
  • Generation from infrastructure code is the mechanism that scales, because the policy lives next to the workload that justifies it.
  • Broad shared roles are the alternative that appears to scale and actually just merges every blast radius into one.
  • Review attention is the resource that runs out first — which is an argument for short policies rather than for fewer of them.
Security
  • The gap between needed and allowed is exactly the attacker's working set after a compromise.
  • Deny statements are the strongest control available, because nothing downstream can grant around them.
  • Conditions turn a stolen credential into a credential that only works from one network, which defeats a large class of exfiltration.
  • Separate destructive actions from read actions in policy design; they are different powers and are almost never needed by the same workload.
  • Every widening should be traceable to a change and an author. See Audit Trails and Least Privilege in Infrastructure.
Cost shape
  • No direct cost. The expenditure is review time, and it is the cheapest security spend available.
  • The indirect cost of a broad policy is incident scope: a compromise with a wildcard policy takes days to bound instead of minutes.
  • Audit-log analysis for granted-versus-used has a real query cost at scale — budget it as a recurring control, not a one-off.
What to watch
  • Access-denied errors by identity and action, which reveal both a missing grant and an attacker probing the edges of one.
  • Permissions granted but never exercised in 90 days — the actionable list for narrowing.
  • Policy diffs as first-class events: who widened what, in which change, and with what justification.
  • Use of any identity holding a wildcard resource grant, which should be rare enough to be individually interesting.
  • The signal that lies: no access-denied errors at all. That usually means the policy is too broad, not that it is correct.
Simpler alternatives
  • Provider-managed predefined roles for common workloads. Broader than a hand-written policy, reviewed by the provider, and far better than a wildcard written under pressure.
  • Start from a deny-everything guardrail plus a small allow list, and let deployment failures tell you what is actually needed — noisier at first, and it converges on a genuinely minimal policy.
  • Generate the policy from observed calls in a staging environment, then review it. Faster than reasoning from source, and it catches the code paths nobody remembered.
  • For a two-resource prototype, one simple scoped policy and no taxonomy at all. Policy engineering should be proportional to what there is to protect.
What adopting this costs
  • Narrow policies buy small blast radii; they cost deployment failures whenever the workload legitimately needs something new.
  • Conditions buy strong contextual limits; they cost fragility when the context legitimately changes — a new zone, a new egress address.
  • Guardrail denies buy irreversibility in the right direction; they cost the ability to make a fast exception during an incident.
  • Generated policies buy reviewability and drift correction; they cost a slower path for urgent changes, which is both the point and genuinely painful at 03:00.

Needed vs granted: an IAM policy evaluated

thumbnail-worker: needs two things, was granted more
A workload identity attached to an image-resize worker. Toggle the statements on its policy and watch the same six attempted actions re-evaluate.
Granted (toggle)
The worker has to read the original the queue message points at. This is one of the two statements it genuinely needs.
Where the resized image goes. Note it cannot read back what it wrote — it does not need to.
The lazy fix for one access-denied error. It grants read over every bucket in the account, including the ones nobody thought about when this was pasted in.
An explicit deny. It is evaluated first and it beats every allow above it, including the admin wildcard — which is what makes it a real boundary rather than a suggestion.
Needed by the code
GetObject on uploads/*
PutObject on thumbnails/*
Attempted actionNeeded?ResultWhy
GetObject uploads/2026/cat.jpg
read the original
yesALLOWallowed by g1
The job it exists to do.
PutObject thumbnails/2026/cat_256.jpg
write the thumbnail
yesALLOWallowed by g2
The other half of the job.
GetObject uploads/legal/master-agreement.pdf
read a legal document
noDENYexplicit deny · d1 ← beats every allow
Same bucket, different prefix. The worker never needs this, and the compliance team assumed nothing could reach it.
GetObject invoices/2026-Q1.csv
read another bucket entirely
noALLOWallowed by g3
A different bucket in the same account. Only a wildcard resource reaches it.
PutObject uploads/2026/cat.jpg
overwrite its own input
noDENYno matching allow · implicit deny
If the worker can write where it reads, a compromised worker can poison the input of every other consumer of that bucket.
DeleteObject uploads/2026/cat.jpg
delete a customer upload
noDENYno matching allow · implicit deny
Irreversible unless versioning is on. Nothing in the code calls delete.
statements
4
needs met
2/2
reachable beyond the job
1
verdict
broad
It works, and it reaches 1 thing it has no business reaching. Nothing in the application changed — the same two calls still succeed — which is exactly why over-permission is invisible in testing. It only shows up as blast radius after the worker is compromised. The Deny on uploads/legal/* is evaluated before any allow and cannot be overridden, so the legal prefix stays unreachable even under the admin wildcard. That ordering — explicit deny, then explicit allow, then implicit deny — is why an explicit deny is the only statement you can rely on as a boundary. Its limit: it protects one prefix, and it does not shrink anything else the identity holds.
broadneeds met 2/2 · reachable beyond the job: 1AWS-FLAVORED

What people believe, and what is true

Claim

Wildcards are fine as long as the action is narrow.

Reality

A narrow action on every resource in the account is still a large grant. secrets:GetSecretValue on * reads every secret you have.

Claim

The policy was reviewed when it was written.

Reality

It was reviewed in its original form. What is running now is the original plus every urgent widening since, and nothing reviewed the sum.

Claim

No access-denied errors means the permissions are right.

Reality

It usually means they are too broad. A correctly-scoped system produces occasional denials when something new is attempted.

Apply it