Engineering least privilege

Lesson 2 of 4 in Designing and Reviewing Access.

Everyone agrees with least privilege and almost nobody arrives at it by writing a policy from first principles. The reason is structural: you cannot know in advance which API calls an application makes. The SDK calls things you did not call explicitly, the console calls Describe and List operations nothing in your code mentions, and a service integration adds its own. So the first draft is always wrong, and the usual response — attach AdministratorAccess to unblock the release, intending to narrow it later — is how over-permission becomes permanent.

The exam's preference is consistent and worth internalising: evidence over guessing. When a question offers "write a policy listing the actions the application needs" against "generate a policy from CloudTrail history and iterate", the second is the expected answer. AWS gives you four evidence sources, and they answer different questions.

The narrowing loop — a policy gets smaller by iteration, not by inspiration

  1. Start wide but bounded: a broad managed policy, plus a permissions boundary

    A boundary is what makes starting wide acceptable — the blast radius is capped even while the policy is loose. Never start wide with no boundary and a promise to tighten later.

  2. Run a representative period and collect evidence

    Long enough to include the monthly job, the quarterly close, the incident runbook. A week of traffic produces a policy that breaks at month end.

  3. Generate a candidate policy from CloudTrail with IAM Access Analyzer

    Produces the services and actions actually called, with resource-level detail where the service supports it.

  4. Review against intent, not just against usage

    Add what is seasonal and genuinely needed; remove what was used but should not have been. Generated policies describe behaviour, and behaviour is not always correct.

  5. Test with the IAM policy simulator before attaching

    Custom mode on the draft: assert that the expected calls are allowed and the sensitive ones are denied.

  6. Gate the change: Access Analyzer custom policy checks in CI

    CheckNoNewAccess against the previous version, CheckAccessNotGranted for forbidden actions. A narrowing change that widens anything fails the build.

  7. Deploy, then watch unused-access findings for the next round

Last accessed information (Access Advisor) is the cheapest place to start. IAM records which services a principal has used and when, and for supported services it goes down to the action. It is visible per user, role, group, and policy in the console, available through GetServiceLastAccessedDetails, and can be generated organization-wide from the management account — which is how you find the twelve accounts still carrying permissions for a service nobody has used in a year. It answers "is this grant doing any work?"

Access Analyzer policy generation answers the next question: "what would a minimal grant look like?" Point it at a role and a CloudTrail trail covering a representative window, and it writes a candidate policy from the calls that actually happened. Treat the output as a first draft — anything the principal did not do during the window is missing, and a policy generated from a quiet week will break during the month-end job.

The IAM policy simulator answers "will this break?" before you attach anything. It evaluates identity-based policies, permissions boundaries, resource-based policies and SCPs together, including conditions and resource scoping, and returns allow or deny per action and resource — without making a real request. Know its gaps: it does not evaluate RCPs, it uses no production context values, and results can diverge for VPC endpoint policies, role chaining, and multiple resource policies on one resource.

Custom policy checks answer "is this change safe to merge?" — CheckNoNewAccess fails a build when an edit grants anything new, CheckAccessNotGranted fails it when a forbidden action becomes reachable, and CheckNoPublicAccess fails it when a resource policy would expose the resource. These turn policy review from an opinion into a gate.

On managed policies: AWS managed policies are a reasonable starting point and a poor destination. They are deliberately generous, AWS can widen them without telling you, and ReadOnlyAccess in particular grants read access to data as well as metadata — including s3:GetObject, which is rarely what "read-only for an auditor" was meant to mean. Customer managed policies are the destination: versioned, reviewable, and yours.

Four evidence sources, four different questions
ToolThe question it answersWhat it will not tell you

Last accessed information (Access Advisor)

"Is this grant being used at all, and when was it last used?"

Which resources were touched, or what a minimal policy would look like. Action-level detail exists only for supported services.

Access Analyzer policy generation

"What policy would cover what this principal actually did?"

Anything outside the observation window — the quarterly job, the incident runbook, the failover path.

IAM policy simulator

"Given these policies, would this specific call be allowed?"

RCP effects, real production condition values, and some advanced paths (VPC endpoint policies, role chaining, multiple resource policies).

Access Analyzer unused access findings

"Which roles, credentials, and permissions have gone unused past my tracking period?"

Whether an unused permission is unused because it is rarely needed — deletions and seasonal jobs look identical to abandonment.

Why "start wide, narrow later" works only with a boundary

The pattern fails in practice because the narrowing never happens — the release shipped, the ticket closed. A permissions boundary changes the economics: the policy can stay loose while the ceiling is tight, so an un-narrowed policy is a cleanliness problem rather than an exposure. Combine it with an expiry: a DateLessThan condition on aws:CurrentTime in the loose policy makes the temporary grant genuinely temporary.

The sensitive actions to exclude explicitly

Whatever evidence says, a few actions should never appear in an application policy without a deliberate decision, because each is a privilege-escalation path: iam:PassRole unscoped, iam:AttachRolePolicy, iam:PutRolePolicy, iam:CreatePolicyVersion, iam:SetDefaultPolicyVersion, iam:UpdateAssumeRolePolicy, iam:CreateAccessKey, s3:PutBucketPolicy, lambda:AddPermission, kms:PutKeyPolicy, sts:AssumeRole on a wildcard resource. CheckAccessNotGranted in CI is the mechanical way to keep them out.

Resource-level versus action-level narrowing

Narrowing actions and narrowing resources are different wins. s3:* on one bucket is still able to rewrite that bucket’s policy; s3:GetObject on * can read every bucket in the account. Do both, and prefer resource-level ARNs wherever the service supports them — some actions genuinely only accept *, and knowing which is part of reading a generated policy honestly.

What "least privilege" means for a human versus a workload

A workload’s needs are knowable and stable, so its policy should end up narrow and stay narrow — generated, reviewed, gated in CI. A human’s needs are exploratory and change weekly, so chasing the same narrowness produces either constant tickets or a wildcard. For humans the levers are different: short sessions, MFA conditions on destructive actions, permission sets per duty rather than per resource, and unused-access review to remove what stopped being needed.

Round 0 — the unblocking policy

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "*",
    "Resource": "*"
  }]
}

What shipped on the Friday. It works, nothing is blocked, and the ticket to narrow it was closed as "done — deployed". The only defensible version of this round has a permissions boundary attached alongside it and an expiry condition on the policy itself:

{
  "Effect": "Allow",
  "Action": "*",
  "Resource": "*",
  "Condition": {
    "DateLessThan": { "aws:CurrentTime": "2026-11-15T00:00:00Z" }
  }
}

Now the loose grant expires whether or not anyone remembers it.

Round 1 — generated from CloudTrail

Access Analyzer policy generation, run over four weeks of activity:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "*"
    }
  ]
}

A large improvement — four services instead of all of them — and still not least privilege. The resources are wildcards, so the application can read every bucket and decrypt with every key in the account. Note what generation gave you for free: kms:GenerateDataKey and logs:CreateLogStream, two calls nobody would have listed from memory.

Round 2 — resources pinned, intent reviewed

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ObjectIO",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::claims-intake/incoming/*"
    },
    {
      "Sid": "ListTheOneBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::claims-intake",
      "Condition": {
        "StringLike": { "s3:prefix": "incoming/*" }
      }
    },
    {
      "Sid": "QueueConsume",
      "Effect": "Allow",
      "Action": ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
      "Resource": "arn:aws:sqs:eu-west-2:111122223333:claims-intake"
    },
    {
      "Sid": "UseTheClaimsKeyViaS3Only",
      "Effect": "Allow",
      "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
      "Resource": "arn:aws:kms:eu-west-2:111122223333:key/8e7c0b1a-claims",
      "Condition": {
        "StringEquals": { "kms:ViaService": "s3.eu-west-2.amazonaws.com" }
      }
    },
    {
      "Sid": "OwnLogsOnly",
      "Effect": "Allow",
      "Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:eu-west-2:111122223333:log-group:/aws/lambda/claims-intake:*"
    }
  ]
}

Every wildcard that could be replaced was. Two additions came from review rather than evidence: the s3:prefix condition, so listing cannot enumerate other prefixes, and kms:ViaService, so a stolen credential cannot call Decrypt directly. Neither appeared in the generated policy, because the application never did anything that required them — which is the point about reviewing against intent.

Round 3 — gated and watched

The policy is now narrow. Keeping it narrow is a different job:

  • CheckNoNewAccess in the pull-request pipeline, with this version as the reference, so a future edit that widens anything fails the build.
  • CheckAccessNotGranted against the escalation list — iam:PassRole, iam:AttachRolePolicy, s3:PutBucketPolicy, kms:PutKeyPolicy — so none of them can creep in.
  • Unused access findings with a tracking period matching the business cycle, to catch the day PutObject stops being needed.
  • Last accessed information reviewed at each audit, which is how you discover that the SQS queue was decommissioned six months ago and nobody removed the grant.

The policy got smaller four times and will get smaller again. That is the whole technique: least privilege is a loop, not a document.

Key terms: Last accessed information (Access Advisor), Access Analyzer policy generation, IAM policy simulator, Custom policy check, Permissions boundary

Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.