An Eight Mile project · open source · MIT

iamdiff

Did this change widen access, and why? iamdiff works out what an AWS principal can actually do — its identity policies, its permissions boundary and the SCPs at every level of the organisation, composed the way AWS evaluates them — then diffs two of those sets and exits with a verdict a pipeline can gate on.

Runtime

Go 1.24+ · cobra

Cloud

AWS · IAM, Organizations

Catalogue

21,892 actions

Size

~4,500 LOC

one grant

allow → intersect → deny

identity policy Allowsunion · wildcards expanded via the cataloguepermissions boundaryintersects · never adds accessSCPs, one layer per levelroot → OU → account, each must allowexplicit Deny, anywhereconditional or partial → unless clausedroppednothing here permits itpartialgrant kept · gap notedremovedcovering, unconditionalEffectiveSetgrants · conditions · gaps

Why not a text diff

The diff that matters is not the one git shows.

Reordering statements, or replacing s3:Get* with the list it stands for, makes a large text diff and changes nothing. A one-character condition edit makes a trivial one and can open production. iamdiff compares sets of effective permissions, not JSON.

git diff

examples/before.json → after.json

  "Statement": [
    {"Effect": "Allow", "Action": "s3:Get*", "Resource": "arn:aws:s3:::sls2-assets/*"},
-   {"Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "*",
-    "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}}
+   {"Effect": "Allow", "Action": ["iam:PassRole", "dynamodb:DeleteItem"], "Resource": "*"},
+   {"Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "*"}
  ]

Two lines out, two in. Read quickly, it adds one statement and tidies another.

iamdiff

exit 2

$ iamdiff policy before.json after.json

Added (2):
  + MEDIUM dynamodb:DeleteItem                *
  + HIGH   iam:PassRole                       *

Condition changed - review manually (1):
  ~ HIGH   sts:AssumeRole                     *

VERDICT: widened

The tidy-up dropped the MFA requirement on sts:AssumeRole. A condition change is its own category, never silence.

Statements reordered

A large text diff. The same set of grants. Unchanged, exit 0.

s3:Get* written out in full

A large text diff. Both sides expand through the same catalogue to the same actions. Unchanged, exit 0.

One condition edited

A one-character text diff. The condition’s fingerprint changes. Indeterminate, exit 3, for a human to read.

And why not Access Analyzer

CheckNoNewAccess is a good gate: pass or fail on a single policy pair. It composes no permissions boundary and no SCPs, and it does not say what changed. iamdiff reads across every source and prints something a reviewer can take in at a glance.

How it decides

Four layers, and only the first one grants.

Identity allows are expanded into concrete grants. Everything that restricts them, the boundary, each SCP level and every explicit deny, stays a pattern and is matched against each grant, the way AWS evaluates a request. That composition is what no single policy document can tell you.

identity policy Allowsunion · wildcards expanded via the cataloguepermissions boundaryintersects · never adds accessSCPs, one layer per levelroot → OU → account, each must allowexplicit Deny, anywhereconditional or partial → unless clausedroppednothing here permits itpartialgrant kept · gap notedremovedcovering, unconditionalEffectiveSetgrants · conditions · gaps

One grant in, zero or more out: a layer that permits only part of a resource narrows the grant to that part, exactly. Everything below the first box restricts; nothing adds.

When a boundary or SCP level…

covers it

Kept. A conditional allow adds a require clause.

names part of it

Narrowed to exactly that part, decided by pattern inclusion.

overlaps it oddly

Kept as it is, and the result marked partial.

does not match

Dropped. Every layer has to permit it.

When an explicit deny…

covers it

Removed, when the deny carries no condition.

covers it, conditionally

Kept, with an unless clause.

covers part of it

Kept, with an unless “except …” carve-out.

Decisions

Three rules the repository writes down.

Conditions are opaqueADR-0002

A condition block is canonicalised and fingerprinted, never interpreted. Whether one condition implies another is a constraint-satisfaction problem, so a change to any clause is handed to a human rather than decided by the tool.

fingerprint = hex(sha256(canonical JSON)[:8])

Incomplete fails loudlyADR-0003

An unreadable source, a wildcard the catalogue cannot expand, an overlap no pattern can express: each marks the result partial, and partial outranks every other verdict. A member account that cannot read Organizations gets exit 4, not a clean pass.

VERDICT: incomplete # exit 4

Guardrails intersect per levelADR-0004

An action must be allowed at the root, at every OU on the path and at the account. SCPs attached at one level union; different levels intersect, so a broad policy at the root cannot rescue a narrow one lower down.

--guardrail root=full-access.json --guardrail ou-prod=deny-compute.json

Exit codes

A verdict a pipeline can gate on.

The exit code is the tool's public contract, and errors live outside the verdict range. A pipeline that fails on anything above 1 cannot mistake a crash for a widening, or a typo for a narrowing.

Code

policy · plan · roles

explain

0

unchanged

explain: permitted

1

narrowed only

explain: denied

2

widened

explain:

3

indeterminate · a condition changed

explain: permitted under a condition

4

incomplete · a source was unreadable

explain: incomplete

64

bad command line

explain: bad command line

70

failure at run time

explain: failure at run time

One verdict per run

A plan that touches several principals reports each, then exits with the worst of them. Incomplete outranks everything, because a partial evaluation cannot honestly claim that access did not widen.

incomplete

widened

indeterminate

narrowed

unchanged

Commands

Two need only files. Three can read AWS.

The diffing commands print text, JSON or markdown, and the markdown is shaped for a pull-request comment. The live ones take --profile and read IAM, Organizations and STS: attached and inline policies, group policies for a user, the permissions boundary and the SCPs at every level above the account.

iamdiff policy <before> <after>

Two policy documents or two snapshots, with --boundary and levelled --guardrail files around them.

AWS

no

iamdiff plan <plan.json>

What a Terraform plan would change, one section per principal. - reads stdin.

AWS

no

iamdiff roles <a> <b>

Two live principals, each an ARN, role/NAME or user/NAME.

AWS

yes

iamdiff collect <principal>

A principal’s policies, boundary and SCPs, written to a snapshot file.

AWS

yes

iamdiff explain <principal> --action <a>

Why one action is permitted or denied, layer by layer.

AWS

yes, or --from

iamdiff explain

exit 3

$ iamdiff explain role/deploy --action s3:DeleteObject --from snapshot.json

s3:DeleteObject on arn:aws:iam::123456789012:role/deploy

  identity   deploy-policy Allow
  boundary   ci-boundary   Allow
  guardrail  r-root        Allow
  guardrail  ou-prod       Allow
  identity   deploy-policy Deny (conditional)

PERMITTED (conditional: unless {"Bool":{"aws:MultiFactorAuthPresent":"false"}})

--verbose prints the statement behind each step, --resource narrows the question to one ARN, and --output json prints the whole trace.

What plan reads

aws_iam_policy

aws_iam_role

inline_policy, permissions_boundary, managed_policy_arns

aws_iam_{role,user,group}_policy

aws_iam_{role,user,group}_policy_attachment

aws_iam_policy_attachment

aws_organizations_policy

SCPs, diffed as their own principal

A policy computed at apply time, or attached by an ARN whose content the plan does not carry, cannot be evaluated: the result says so and exits 4.

--output markdown

the PR comment

### iamdiff: **widened**

| | Severity | Action | Resource |
|---|---|---|---|
| `+` | MEDIUM | `dynamodb:DeleteItem` | `*` |
| `+` | HIGH | `iam:PassRole` | `*` |
| `~` | HIGH | `sts:AssumeRole` | `*` |

One principal, across a change

A snapshot is what collect writes, and either side of policy can be one.

iamdiff collect role/deploy --out before.json

# apply the change

iamdiff collect role/deploy --out after.json

iamdiff policy before.json after.json

Read the source

Eight files, from the evaluator to the plug-in seam.

Excerpts straight from the repository: the AWS evaluation order, the deny pass, the IAM wildcard matcher, condition composition, the effective set, the verdict, severity and the provider interface.

iamdiff

internal/provider/aws

internal/glob

internal/model

internal/diff

internal/severity

internal/provider

internal/provider/aws/evaluate.go

// Evaluate applies AWS's own precedence rules and returns a flat,
// provider-neutral effective set.
//
// AWS composition, in order:
//  1. identity allows are unioned
//  2. the permissions boundary intersects -- it never adds access
//  3. guardrails intersect, one layer per organisation level, because
//     an action must be allowed at every level from the root down
//  4. any explicit deny anywhere wins outright
//
// Steps 2 to 4 are what distinguish this from a policy linter, and are
// the reason the answer cannot be derived from one document alone.
//
// Conditions stay opaque throughout. A conditional allow in a layer, a
// conditional deny, or a deny that removes only part of a grant's
// resources all become clauses of the grant's condition rather than a
// guess about whether access exists: the diff then reports a change to
// any of them as "condition changed", never as silence.
func (p *Provider) Evaluate(ctx context.Context, raw *provider.RawSet) (*model.EffectiveSet, error) {
	return p.evaluate(raw, nil)
}

func (p *Provider) evaluate(raw *provider.RawSet, obs observer) (*model.EffectiveSet, error) {
	out := model.NewEffectiveSet(raw.Principal)
	out.Catalogue = p.cat.Version()
	for _, g := range raw.Gaps {
		out.MarkGap(g)
	}

	set, err := p.compile(raw)
	if err != nil {
		return nil, err
	}
	for _, g := range set.gaps {
		out.MarkGap(g)
	}

	seen := map[string]bool{}
	for _, g := range set.identity {
		grants, gaps := set.resolve(g, obs)
		for _, gap := range gaps {
			if !seen[gap] {
				seen[gap] = true
				out.MarkGap(gap)
			}
		}
		for _, final := range grants {
			out.Add(final)
		}
	}
	return out, nil
}

The whole AWS evaluation order, stated in its own comment: identity allows union, the boundary and each organisation level intersect, and an explicit deny wins outright.

Run it

One go install, and the first run needs no AWS at all.

policy and plan read files, so the first useful run is two policy documents or a Terraform plan on disk. The live commands use the standard AWS credential chain and read-only calls.

iamdiff · offline

$

go install github.com/IbiliAze/iamdiff@latest

$

iamdiff policy before.json after.json

$

terraform show -json tfplan | iamdiff plan - --output markdown

Go 1.24 or newer  # what go.mod asks for

Working on it

make test

every package, with the race detector

make lint

golangci-lint

make layering

the CI guard: no core package may import a provider

make catalogue

regenerate the embedded AWS action catalogue

make demo

build, then diff the two files in examples/

In CI · GitHub Actions

- run: |
    terraform plan -out=tfplan
    terraform show -json tfplan > plan.json
- id: iamdiff
  run: |
    set +e
    iamdiff plan plan.json --output markdown > iamdiff.md
    echo "code=$?" >> "$GITHUB_OUTPUT"
- uses: marocchino/sticky-pull-request-comment@v3
  with:
    header: iamdiff
    path: iamdiff.md
- run: |
    case "${{ steps.iamdiff.outputs.code }}" in
      0|1) ;;
      *) echo "iamdiff: review required (exit ${{ steps.iamdiff.outputs.code }})"; exit 1 ;;
    esac

Exit 0 or 1 passes. Widened, indeterminate and incomplete ask for a human, and the markdown lands as one sticky comment on the pull request.

Current state

What is finished, and what is honestly not.

The offline diff, the Terraform plan diff, live collection, explain, all three output formats and the exit-code contract are built and tested, on AWS. What remains is a first release, the other clouds, and a few places the output could say more.

No release yet, so Homebrew does not work

release

The README lists brew install, and the GoReleaser config and release workflow are ready for it, but no tag has been cut and the tap does not exist. go install …@latest works today, and reports its version as dev.

AWS is the only provider

roadmap

Azure and GCP are designed for rather than built: the provider interface, the twelve-case conformance suite every cloud must pass and the layering guard exist, and --provider gcp exits 64.

Resource-based, session and resource control policies

out of scope

By decision, not omission. Complete means complete with respect to identity policies, the permissions boundary and service control policies, and a bucket policy that grants access is not in the answer.

Condition detail is only in the JSON

rough edge

Text and markdown print the ~ line for a condition change. The before and after condition summaries, with their fingerprints and the statement each came from, are in --output json.

The high-risk list is seven actions long

rough edge

Its package comment plans for about fifty per cloud. Everything else ranks by the catalogue’s access level, which is a sound default, and there is no way yet to add actions of your own.

Behind the project

Built by Eight Mile in London, as part of our security audit work — the same engineers who build and run systems like it for clients.