CyberNugget

L46 · AWS IAM in depth

Module 7: Cloud, Containers, and Kubernetes · ~20 min · Lesson 46 of 71

🎯 What you'll learn

  • How principals, policies and ARNs fit together, and how to read any IAM policy
  • How roles work: trust policy vs permissions policy, AssumeRole, and temporary credentials
  • The condition keys worth knowing, and three ways they quietly fail
  • A repeatable loop for getting a role to least privilege with IAM Access Analyzer

🔥 Why this matters

It's 6 p.m. and the nightly reconciliation DAG fails with AccessDenied. Someone attaches s3:* and kms:* on * to the execution role "for now", and the DAG goes green. A year later that role can read every bucket in the account, including customer statements, and any engineer who can edit a DAG can use it. IAM is the row of the shared responsibility model (L45) that's always yours, and it drifts toward "everything" one late fix at a time.

1. Principals, actions, resources and ARNs

Every AWS API call is an L27-style question: can this principal perform this action on this resource, in this context? A principal is an identity that can make a request:

  • The root user: the account's owner identity. Lock it away and don't use it for daily work.
  • IAM users: long-term identities with passwords or access keys. Avoid them for people.
  • IAM roles: identities with no long-term credentials. You assume a role to get a temporary role session. People (via Identity Center), pods, Lambda functions and MWAA all work this way.
  • Service principals, such as airflow.amazonaws.com: AWS services acting for you.

Resources are named with ARNs (Amazon Resource Names): arn:partition:service:region:account-id:resource. Some fields are empty: S3 bucket ARNs have no region or account (arn:aws:s3:::example-recon-inbound), and IAM ARNs have no region.

A policy is a JSON document of statements:

 {
   "Version": "2012-10-17",   ◀── policy language version
   "Statement": [{
     "Sid": "ReadDaily",        ◀── label for humans
     "Effect": "Allow",         ◀── Allow or Deny
     "Action": "s3:GetObject",  ◀── service:Operation
     "Resource":                ◀── which ARNs
       "arn:aws:s3:::example-recon-inbound/daily/*",
     "Condition": {             ◀── optional: only when...
       "Bool": {"aws:SecureTransport": "true"}}
   }]
 }

Access key IDs tell you what kind of credential you're looking at. Their prefixes are documented:

Prefix Meaning
AKIA Long-term access key for an IAM user (or root): valid until someone deletes it
ASIA Temporary key from STS: comes with a session token and expires
AROA / AIDA Unique ID of a role / a user (seen in UserId, not a key)

An AKIA key in a repo is an emergency; an ASIA key is a problem with a deadline.

2. Roles: two policies and a token service

A role has two separate policies, and mixing them up is the most common IAM confusion:

              ┌────────── role: recon-reader ──────────┐
              │                                        │
  WHO may     │  TRUST POLICY (resource policy on the  │
  become it?  │  role): Principal = role/recon-ci in   │
              │  account 222233334444                  │
              │                                        │
  WHAT can    │  PERMISSIONS POLICY (identity policy): │
  it do?      │  s3:GetObject on example-recon-        │
              │  inbound/daily/*                       │
              └────────────────────────────────────────┘

Assuming the role goes through STS (AWS Security Token Service):

 recon-ci (acct 222233334444)        STS          S3 (acct 1111...)
     │                                │                    │
     │ 1. AssumeRole(RoleArn=         │                    │
     │    ...role/recon-reader,       │                    │
     │    RoleSessionName=run-0412)   │                    │
     │───────────────────────────────▶│ 2. caller's policy │
     │                                │    allows it? and  │
     │                                │    trust policy    │
     │                                │    trusts caller?  │
     │ 3. ASIA... key, secret,        │                    │
     │    session token, expiry       │                    │
     │◀───────────────────────────────│                    │
     │ 4. GetObject, signed with temp keys (SigV4, L17)    │
     │────────────────────────────────────────────────────▶│
     │    decided by recon-reader's permissions policy     │

Facts worth memorizing (from the STS docs):

  • A session lasts 1 hour by default, from 15 minutes up to the role's maximum (1 to 12 hours). Role chaining, using one role's session to assume another, is capped at 1 hour.
  • Cross-account, both sides must allow: the caller's identity policy must allow sts:AssumeRole on the role, and the role's trust policy must trust the caller.
  • A trust policy with "Principal": {"AWS": "arn:aws:iam::222233334444:root"} doesn't mean the root user. It means the whole account: any principal there whose own policy allows it.
  • RoleSessionName appears in CloudTrail. Make it meaningful (a run ID or a user).

If your organization uses IAM Identity Center, it's this same machine: each permission set becomes an AWSReservedSSO_... role in each account, and the portal calls STS for you (L24's deep dive covers sessions and offboarding). Pods get roles through IRSA or EKS Pod Identity (L50).

3. How a request is judged, and conditions

The evaluation order (explicit deny wins, SCPs and boundaries only limit, effective access is the intersection) isn't repeated here (see L27's deep dive). Focus instead on conditions: the ABAC part of IAM, where most real-world guardrails live.

Condition key Typical use
aws:PrincipalOrgID Only principals from your AWS Organization
aws:SourceVpce Only through your VPC endpoint (bucket policies)
aws:SecureTransport Deny requests that aren't over TLS
aws:RequestedRegion Keep work in approved Regions
aws:PrincipalTag/... Match the caller's tags (ABAC, L27)
aws:MultiFactorAuthPresent Require MFA for sensitive actions
s3:prefix, kms:ViaService Service-specific keys: which folder, which calling service

Three documented ways conditions fail quietly:

  1. Missing keys don't match. If a key isn't in the request, the condition is false. A Deny using "Bool": {"aws:MultiFactorAuthPresent": "false"} never fires for long-term access keys, because they don't send that key at all. Use BoolIfExists in deny statements like this one.
  2. ForAllValues with no values is true. An Allow with ForAllValues:StringEquals on a multi-valued key matches a request that sends no values. Pair it with a Null check.
  3. aws:SourceIp and VPC endpoints. Requests that arrive through a VPC endpoint don't carry your public IP, so an IP-based rule won't match them. Use aws:SourceVpce or aws:VpcSourceIp.

4. Least privilege is a loop, not a guess

Nobody writes the perfect policy first time. Treat it as a cycle:

 ┌─▶ 1. start narrow ──▶ 2. run in dev ──▶ 3. read AccessDenied
 │    (named ARNs)         (real job)         and CloudTrail
 │                                                │
 │   6. remove unused ◀── 5. validate ◀── 4. generate a draft
 │      (last accessed,     (Access         (Access Analyzer,
 │       unused-access      Analyzer         from CloudTrail)
 │       findings)          policy checks)
 └──────────────┘

IAM Access Analyzer helps at several steps:

  • Policy generation drafts a policy from up to 90 days of CloudTrail activity for a role.
  • Policy validation flags errors, security warnings and over-broad statements as you write.
  • External access findings list resources shared outside your account or organization.
  • Unused access findings list roles, keys and permissions nobody has used. This and custom policy checks (for example, "does this change grant new access?") are paid features.

IAM's last accessed data (per service, and per action for some services) is free and shows what a role actually used.

Some permissions are dangerous even when narrow: iam:PassRole, iam:CreatePolicyVersion, iam:PutRolePolicy, iam:UpdateAssumeRolePolicy, lambda:UpdateFunctionCode. Each can turn a small role into a big one; the second deep dive explains why.

🧪 Worked example: shrinking the reconciliation DAG's role

Before, after a year of late fixes:

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

Generating a policy from 90 days of CloudTrail shows the DAG only reads daily/ files, writes reports, and uses one KMS key through S3. After (made-up names):

{
  "Version": "2012-10-17",
  "Statement": [
    {"Sid": "ListDailyOnly",
     "Effect": "Allow",
     "Action": "s3:ListBucket",
     "Resource": "arn:aws:s3:::example-recon-inbound",
     "Condition": {"StringLike": {"s3:prefix": "daily/*"}}},
    {"Sid": "ReadDaily",
     "Effect": "Allow",
     "Action": "s3:GetObject",
     "Resource": "arn:aws:s3:::example-recon-inbound/daily/*"},
    {"Sid": "WriteReports",
     "Effect": "Allow",
     "Action": "s3:PutObject",
     "Resource": "arn:aws:s3:::example-recon-reports/out/*"},
    {"Sid": "KeyOnlyViaS3",
     "Effect": "Allow",
     "Action": ["kms:Decrypt", "kms:GenerateDataKey"],
     "Resource":
       "arn:aws:kms:us-east-1:111122223333:key/recon-key-id",
     "Condition": {"StringEquals": {
       "kms:ViaService": "s3.us-east-1.amazonaws.com"}}}
  ]
}

What each change does:

Change What it stops
ListBucket on the bucket ARN, Get/Put on object ARNs Listing and reading other folders; S3 checks these at different ARN levels, a classic gotcha
Reports bucket is write-only A compromised DAG can't read past reports back out
One key, kms:ViaService = S3 Using the key directly to decrypt data taken from elsewhere (L19)
No kms:* No kms:ScheduleKeyDeletion or kms:PutKeyPolicy from a DAG

Then run validation, deploy to dev, and watch for AccessDenied before prod. The key's own key policy must allow this role too, since KMS key policies always need an explicit allow.

🛠 Try it (optional, ~5 min)

Two read-only commands, on your own laptop, if you have the AWS CLI set up (use whatever profile you normally use).

aws sts get-caller-identity
aws configure list

get-caller-identity needs no IAM permission at all (AWS documents that it works even under an explicit deny), so it's a safe "who am I?". Sample output:

{
    "UserId": "AROAEXAMPLEID123456:dev@example.com",
    "Account": "111122223333",
    "Arn": "arn:aws:sts::111122223333:assumed-role/
            AWSReservedSSO_DataEngineer_0123abcd/dev@example.com"
}

(Arn wrapped for width.) Read it with this lesson's vocabulary: AROA... is the role's unique ID, assumed-role means a temporary role session, and the part after the last / is the session name. aws configure list makes no AWS call; it shows the last characters of the key and where the credentials came from (the exact labels vary by CLI version). If the source is a long-lived shared-credentials-file key rather than SSO, ask why.

🧠 Key takeaways

  • A role has a trust policy (who can assume it) and a permissions policy (what it can do).
  • STS turns an AssumeRole into temporary ASIA keys; prefer them to long-term AKIA keys.
  • A trust policy naming :root trusts the whole account, not the root user.
  • Conditions fail quietly when keys are missing; know IfExists, Null and ForAllValues.
  • Least privilege is a loop: start narrow, generate from CloudTrail, validate, remove unused.

📖 Words to know

Term Meaning
Principal An identity that can make an AWS request: root, user, role session, or service
ARN Amazon Resource Name, arn:partition:service:region:account:resource
Role session Temporary credentials obtained by assuming a role
Trust policy The resource policy on a role that says who may assume it
STS Security Token Service: issues temporary credentials
Role chaining Using one role session to assume another; capped at 1 hour
Condition key A request attribute a policy can test, like aws:PrincipalOrgID
IAM Access Analyzer AWS tool for external and unused access findings, policy validation and generation

⏭ Next time

L47 is a case study: in 2019, one over-privileged IAM role and an SSRF bug let an attacker copy data on about 100 million people from a bank's S3 buckets.

Deep dive 1

Cross-account roles and the confused deputy: ExternalId, SourceArn and PrincipalOrgID

The vendor problem

A cloud cost-reporting vendor, CostLens (made up), asks you to create a role in your account that its AWS account 444455556666 can assume. Its system then reads your billing data. It serves thousands of customers from the same account.

Here's the catch. Another CostLens customer could type your role ARN into their CostLens settings. CostLens would assume your role, with its own legitimate credentials, and show them your data. CostLens is a confused deputy (the same pattern as L40's deep dive, now between AWS accounts): a trusted party tricked into using its access for someone else.

 attacker (CostLens customer B)
   │ 1. "my role is arn:aws:iam::111122223333:role/costlens"
   ▼
 CostLens (acct 444455556666)
   │ 2. AssumeRole(your role)    trust policy: "444455556666"
   ▼                             ✔ matches: CostLens IS trusted
 your account 111122223333 ──▶ 3. your billing data to customer B

Worked example: the ExternalId fix

The vendor generates a unique external ID for each customer and always sends it when assuming that customer's role. Your trust policy requires it:

{
  "Effect": "Allow",
  "Principal": {"AWS": "arn:aws:iam::444455556666:root"},
  "Action": "sts:AssumeRole",
  "Condition": {"StringEquals": {
    "sts:ExternalId": "cl-7f3a9c2e-customer-a"}}
}

Customer B can enter your role ARN, but CostLens will call AssumeRole with B's external ID, and your trust policy rejects it. Per AWS's docs, the external ID isn't a secret; what matters is that the vendor generates it and customers can't choose it. If a vendor lets you type your own, it doesn't protect anything. And here :root is acceptable because the condition, not the principal, does the narrowing.

The same problem with AWS services

AWS services act as deputies too. When S3 sends event notifications to your SNS topic, the topic policy trusts the S3 service, which serves every account. Without a condition, a bucket in someone else's account could be set up to publish to your topic. AWS's fix is the pair aws:SourceArn and aws:SourceAccount:

{
  "Effect": "Allow",
  "Principal": {"Service": "s3.amazonaws.com"},
  "Action": "SNS:Publish",
  "Resource": "arn:aws:sns:us-east-1:111122223333:recon-files",
  "Condition": {
    "ArnLike": {
      "aws:SourceArn": "arn:aws:s3:::example-recon-inbound"},
    "StringEquals": {"aws:SourceAccount": "111122223333"}
  }
}

Sharing with your own organization

For "any account in our company may read this bucket", don't list account IDs that change. Use aws:PrincipalOrgID:

"Condition": {"StringEquals": {
  "aws:PrincipalOrgID": "o-exampleorg1"}}

New accounts in the organization get access automatically, and nobody outside does, even if a policy elsewhere is too generous. Resource control policies (L27) can enforce these conditions across every account at once.

🧠 Extra takeaways

  • A shared vendor account that assumes customer roles needs a per-customer, vendor-generated ExternalId.
  • An ExternalId the customer can choose provides no protection.
  • When a service principal is trusted, add aws:SourceArn and aws:SourceAccount.
  • Use aws:PrincipalOrgID instead of lists of account IDs for organization-wide sharing.
Deep dive 2

Iam:PassRole and other privilege-escalation paths

What "passing a role" means

Many AWS resources run as a role: a Lambda function, an EC2 instance profile, an ECS task, a Glue job, an MWAA environment, a CloudFormation stack. When you create one, you hand it a role ARN. IAM checks iam:PassRole on that ARN: "may this caller give this role to a service?"

iam:PassRole isn't an API you call. It's a permission checked during another call, so CloudTrail shows CreateFunction, not a PassRole event. That's one reason it's easy to over-grant and hard to spot.

The escalation

A developer role was given iam:PassRole on * "so deployments work", plus Lambda permissions:

 dev role: lambda:CreateFunction, lambda:InvokeFunction,
           iam:PassRole on *
   │
   │ 1. create function, Role = ...role/platform-admin
   │    (PassRole on * allows passing ANY role)
   ▼
 Lambda function runs as platform-admin
   │
   │ 2. invoke it: code reads Secrets Manager, attaches
   │    AdministratorAccess to the dev role, etc.
   ▼
 dev role is now effectively admin

The developer never had admin. They had permission to make something else run as admin.

More paths in the same family

Rhino Security Labs published a widely cited list of 21 IAM privilege-escalation methods in 2018, and more have been found since. The ones most likely to be granted by accident:

Permission Why it escalates
iam:PassRole + create a compute resource Run code as a stronger role (above)
iam:CreatePolicyVersion Rewrite a policy attached to you and set it as default
iam:AttachRolePolicy, iam:PutRolePolicy Add any permissions to a role you can use
iam:UpdateAssumeRolePolicy Add yourself to a powerful role's trust policy
iam:CreateAccessKey on other users Mint keys for someone with more access
lambda:UpdateFunctionCode Replace the code of a function that already has a strong role

Worked example: scoping PassRole

The safe version names the roles and the service:

{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::111122223333:role/app/payments/*",
  "Condition": {"StringEquals": {
    "iam:PassedToService": "lambda.amazonaws.com"}}
}

Now the developer can only pass roles under the /app/payments/ path, and only to Lambda. Two more guardrails complete it:

  • If teams create their own roles, require a permissions boundary on every role they create (the iam:PermissionsBoundary condition key on iam:CreateRole), so nothing they make can exceed the boundary (L27's deep dive).
  • Find existing paths with tools: Access Analyzer custom policy checks (paid) can flag statements that grant iam:PassRole broadly; open-source PMapper (Principal Mapper) and Cloudsplaining graph or list escalation risks. Check each project's maintenance status before relying on it.

🧠 Extra takeaways

  • iam:PassRole on * lets the holder run code as any role in the account.
  • Scope PassRole to role ARNs or paths, plus iam:PassedToService.
  • Treat IAM-writing permissions (CreatePolicyVersion, Put/AttachRolePolicy, UpdateAssumeRolePolicy) as admin.
  • Delegate role creation only with an enforced permissions boundary.

The quiz for this lesson and the Tests section need JavaScript.