DEV Community

Shieldly
Shieldly

Posted on

Why Reviewing IAM Policies One at a Time Misses the Real Risk

Ask most teams how they review IAM, and the answer is some version of: pull up the policy, read the Action and Resource blocks, check for wildcards, move to the next policy. That catches the obvious problems. It misses the ones that matter most.

The reason is simple: permissions don't live in one policy. They live in chains.

A Policy That Looks Fine in Isolation

Take a developer role with this statement:

{
  "Effect": "Allow",
  "Action": "sts:AssumeRole",
  "Resource": "arn:aws:iam::111111111111:role/deploy-pipeline"
}
Enter fullscreen mode Exit fullscreen mode

Reviewed alone, this looks reasonable. The developer can't read S3, can't touch DynamoDB, can't do much of anything directly. sts:AssumeRole on a single named role looks tightly scoped.

Now look at what deploy-pipeline can do:

{
  "Effect": "Allow",
  "Action": "s3:*",
  "Resource": "arn:aws:s3:::prod-customer-data/*"
}
Enter fullscreen mode Exit fullscreen mode

The developer's own policy never mentions S3. But the developer can assume deploy-pipeline, and deploy-pipeline has full S3 access to a bucket holding customer data. Reviewed separately, both policies pass. Reviewed as a chain, the developer effectively has s3:* on customer data, two hops away from anything their own policy admits to.

Where Chains Actually Form

A few patterns account for most of the chains we see:

Role assumption chains. sts:AssumeRole on Role A, and Role A can assume Role B, and Role B has the permissions that matter. Each hop looks narrow. The composite doesn't.

PassRole handoffs. A CI/CD identity can't launch an EC2 instance directly, but it can pass a role to EC2 via iam:PassRole, and that role can. The actual capability lives in the passed role, not the identity that triggered it.

Trust policy loops. Role A trusts Role B's account, Role B trusts Role C's account, and nobody mapped the full trust graph across three AWS accounts. Each individual trust relationship was approved by someone. The transitive closure wasn't reviewed by anyone.

Resource policy plus identity policy. An identity policy grants s3:GetObject on *. A bucket policy in a different account grants read access to that same identity by ARN. Neither policy alone looks broad. Together, the identity can read a bucket its own team never provisioned.

Why Manual Review Doesn't Catch This

None of these chains are hidden exactly — they're spread across multiple documents that nobody reads side by side. A policy review checklist evaluates one JSON document against a rubric. It doesn't ask "what can this identity reach two or three hops out." That question requires building a graph: every role, every trust relationship, every AssumeRole and PassRole statement, resolved against every other policy in the account.

At a handful of roles, a sharp reviewer can hold that graph in their head. At the hundreds of roles a mid-sized AWS account accumulates after a couple of years, nobody can. The review still happens — it's still a checkbox in the audit — it just stops catching the escalation paths that go more than one hop deep.

What to Actually Check

If you're auditing IAM manually, a few questions catch a disproportionate share of chain risk:

  • For every sts:AssumeRole statement, what can the assumed role do, and what can it assume?
  • For every iam:PassRole statement, is it scoped with iam:PassedToService, or can the role be passed to anything?
  • Do any cross-account trust policies exist that nobody outside the original approver has looked at recently?
  • Does any identity policy combine with a resource-based policy (S3 bucket policy, KMS key policy, Lambda resource policy) to grant more than either grants alone?

None of these require exotic tooling to check by hand for a small number of roles. They require deliberately looking one hop past the policy in front of you, which most reviews don't budget time for.

Where Automation Actually Helps

This is the specific problem AI-Powered analysis is well suited for: not spotting an obvious wildcard, but resolving a graph of AssumeRole and PassRole relationships across every policy in an account and flagging where the composite permission exceeds what any single policy discloses.

Shieldly's free demo at shieldly.io/app/iam traces trust relationships and PassRole targets when you paste a policy, so you can see what a role can reach beyond its own statements, not just what it explicitly grants. No signup needed for the demo.

For teams: the CLI (@shieldly/cli), GitHub Action, CDK Construct (@shieldly/cdk-guard), and VS Code extension surface the same findings in CI and in your editor. Team plan adds scheduled auto-scanning across an account so chain risk gets caught as roles evolve, not just at the moment someone remembers to run an audit.


A policy that passes review in isolation can still be one AssumeRole away from a problem. The chain is the thing worth auditing, not just the link in front of you.

Top comments (0)