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
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.
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.
internal/provider/aws/rules.go
// resolve pushes one identity grant through every layer and then the // denies, returning what survives. A layer may narrow the grant to the // resources it actually permits, so one grant in can be several out. func (set *ruleset) resolve(g model.Grant, obs observer) (out []model.Grant, gaps []string) { lower := strings.ToLower(g.Action) type candidate struct { resource string parts []model.ConditionPart } current := []candidate{{resource: g.Resource, parts: []model.ConditionPart{model.Part(model.RoleRequire, g.Condition, "")}}} // … denies := set.denies.for_(lower) for _, c := range current { parts := c.parts denied := false for _, r := range denies { rel := r.relate(c.resource) switch { case rel.full && r.cond.Empty(): observe(obs, &layer{kind: r.origin.SourceKind, name: r.origin.SourceName}, "Deny", fmt.Sprintf("%s denies %s", describe(r), c.resource)) denied = true case rel.full: observe(obs, &layer{kind: r.origin.SourceKind, name: r.origin.SourceName}, "Deny (conditional)", fmt.Sprintf("%s denies %s under a condition", describe(r), c.resource)) parts = append(clone(parts), model.Part(model.RoleUnless, r.cond, "")) case len(rel.narrow) > 0 || rel.partial: observe(obs, &layer{kind: r.origin.SourceKind, name: r.origin.SourceName}, "Deny (partial)", fmt.Sprintf("%s denies part of %s", describe(r), c.resource)) parts = append(clone(parts), model.Part(model.RoleUnless, r.cond, "except "+r.resourceSummary())) } if denied { break } } if denied { continue } out = append(out, model.Grant{ Action: g.Action, Resource: c.resource, Effect: model.Allow, Condition: model.ComposeCondition(parts...), Origin: g.Origin, }) } return out, gaps }
Where a deny meets a surviving grant. Only an unconditional deny that covers the resource removes it; a conditional or partial one becomes an unless clause, so a change to it is visible.
internal/glob/glob.go
// Package glob matches the wildcard language of IAM policy documents: // '*' for any run of characters and '?' for exactly one. It is not a // shell glob; '[', ']' and '\' are ordinary characters. // // The one subtlety is where '*' may roam. The IAM documentation states // that a '*' which ends a colon-separated ARN segment, or ends the // pattern, expands beyond the colon boundaries, while a '*' followed by // anything else stays inside its segment. Action names carry a single // ':' before the name, so for them the rule changes nothing. package glob // … // Match reports whether s is in the language of pattern. With fold set, // matching ignores ASCII case, as IAM does for action names. func Match(pattern, s string, fold bool) bool { if fold { pattern, s = strings.ToLower(pattern), strings.ToLower(s) } return match(pattern, s) } // crosses reports whether the star at p[i] may match ':'. func crosses(p string, i int) bool { return i+1 == len(p) || p[i+1] == ':' } func match(p, s string) bool { n, m := len(p), len(s) prev := make([]bool, m+1) // prev[j]: p[i+1:] matches s[j:] cur := make([]bool, m+1) prev[m] = true for i := n - 1; i >= 0; i-- { switch c := p[i]; c { case '*': free := crosses(p, i) cur[m] = prev[m] for j := m - 1; j >= 0; j-- { cur[j] = prev[j] || ((free || s[j] != ':') && cur[j+1]) } case '?': cur[m] = false for j := m - 1; j >= 0; j-- { cur[j] = prev[j+1] } default: cur[m] = false for j := m - 1; j >= 0; j-- { cur[j] = s[j] == c && prev[j+1] } } prev, cur = cur, prev } return prev[0] }
The IAM wildcard language, which is not a shell glob. A * crosses a colon only where it ends a segment or the pattern, the rule ARNs are matched by.
internal/model/condition.go
// ComposeCondition folds several clauses into one opaque condition. // // Conditions stay opaque (ADR-0002): composition never reasons about // what a clause means, only about which clauses are present. The result // is canonical, so the same clauses in any order produce the same // fingerprint, and a change to any clause changes it. A lone "require" // clause with no note is returned unchanged, so a grant carrying only // its own statement's condition keeps that condition's fingerprint. func ComposeCondition(parts ...ConditionPart) Condition { var kept []ConditionPart for _, p := range parts { if p.Fingerprint == "" && p.Note == "" { continue } kept = append(kept, p) } if len(kept) == 0 { return Condition{} } if len(kept) == 1 && kept[0].Role == RoleRequire && kept[0].Note == "" { return Condition{Fingerprint: kept[0].Fingerprint, Summary: kept[0].Summary, Raw: nil} } sort.Slice(kept, func(i, j int) bool { if kept[i].Role != kept[j].Role { return kept[i].Role < kept[j].Role } if kept[i].Fingerprint != kept[j].Fingerprint { return kept[i].Fingerprint < kept[j].Fingerprint } return kept[i].Note < kept[j].Note }) canon, _ := json.Marshal(kept) var summary []string for _, p := range kept { switch { case p.Note != "" && p.Summary != "": summary = append(summary, p.Role+" "+p.Summary+" ("+p.Note+")") case p.Note != "": summary = append(summary, p.Role+" "+p.Note) default: summary = append(summary, p.Role+" "+p.Summary) } } return Condition{ Raw: canon, Fingerprint: hash(canon), Summary: strings.Join(summary, "; "), Parts: kept, } }
Conditions are never interpreted. Clauses are sorted and hashed, so the same clauses in any order agree, and a change to any one of them changes the fingerprint.
internal/model/model.go
// EffectiveSet is the fully resolved permission set for one principal. // // Partial must be set whenever any policy source could not be reached. // Reporting an incomplete result as though it were complete is the one // failure mode that would make the whole tool untrustworthy. type EffectiveSet struct { Principal Principal `json:"principal"` Grants map[GrantKey]Grant `json:"-"` Partial bool `json:"partial"` Gaps []string `json:"gaps,omitempty"` Catalogue string `json:"catalogue_version,omitempty"` } func NewEffectiveSet(p Principal) *EffectiveSet { return &EffectiveSet{Principal: p, Grants: map[GrantKey]Grant{}} } // Add inserts a grant, honouring universal deny precedence: an explicit // deny on a key is never overwritten by a later allow. Every cloud // examined shares this rule, so it belongs in the core rather than in // each provider. // // Two allows on the same key merge to the broader one: an unconditional // allow beats a conditional allow whatever order they arrive in, and two // different conditions compose as "any", since the grant holds under // either. Letting the later statement win would let a conditional grant // hide an unconditional one behind a condition-changed verdict. func (s *EffectiveSet) Add(g Grant) { k := g.Key() existing, ok := s.Grants[k] if !ok || existing.Effect == Deny { if !ok { s.Grants[k] = g } return } if g.Effect == Deny { s.Grants[k] = g return } switch { case existing.Condition.Empty(): return case g.Condition.Empty(): s.Grants[k] = g case existing.Condition.Fingerprint == g.Condition.Fingerprint: return default: merged := existing merged.Condition = Any(existing.Condition, g.Condition) s.Grants[k] = merged } } // MarkGap records an unreachable source and flags the set as partial. func (s *EffectiveSet) MarkGap(reason string) { s.Partial = true s.Gaps = append(s.Gaps, reason) }
The provider-neutral result. A deny on a key is never overwritten, and an unconditional allow beats a conditional one in whichever order the two arrive.
internal/diff/diff.go
type Verdict string const ( VerdictUnchanged Verdict = "unchanged" VerdictNarrowed Verdict = "narrowed" VerdictWidened Verdict = "widened" VerdictIndeterminate Verdict = "indeterminate" VerdictIncomplete Verdict = "incomplete" ) // Exit codes are part of the tool's public contract. Changing them is a // breaking change for every pipeline that consumes the tool. const ( ExitUnchanged = 0 ExitNarrowed = 1 ExitWidened = 2 ExitIndeterminate = 3 ExitIncomplete = 4 ) // … // Verdict collapses the result into a single judgement. // // Precedence is deliberate: incompleteness outranks everything, because // a partial evaluation cannot honestly claim that access did not widen. func (r Result) Verdict() Verdict { switch { case r.Partial: return VerdictIncomplete case len(r.Added) > 0: return VerdictWidened case len(r.Changed) > 0: return VerdictIndeterminate case len(r.Removed) > 0: return VerdictNarrowed default: return VerdictUnchanged } }
The public contract. Incomplete outranks widened, because a partial evaluation cannot honestly claim that access did not widen.
internal/severity/severity.go
// Package severity ranks a permission change. // // Most of the signal comes free from the catalogue's own access levels. // The curated high-risk list is the small opinionated part that gives // the tool a point of view: roughly fifty actions per cloud where the // access level understates the blast radius. package severity // … // HighRisk is hand-curated. Each entry is an action whose catalogue // access level does not convey how dangerous it is. var HighRisk = []string{ "iam:*", "sts:AssumeRole", "kms:ScheduleKeyDeletion", "kms:DisableKey", "organizations:LeaveOrganization", "ec2:TerminateInstances", "s3:PutBucketPolicy", } // Classify ranks an action using the curated list first, then the // catalogue's access level. func Classify(c catalogue.Catalogue, action string, extra []string) Rank { for _, p := range append(append([]string{}, HighRisk...), extra...) { if matches(p, action) { return High } } if c == nil { return Low } switch c.AccessLevel(action) { case catalogue.LevelPermissionsMgmt: return High case catalogue.LevelWrite: return Medium default: return Low } }
The curated high-risk list first, then the catalogue’s access level. The package comment plans for about fifty actions per cloud; seven are listed today.
internal/provider/provider.go
// Document is an opaque policy payload. Only the owning provider knows // how to parse Body; the envelope exists so collection and evaluation // can be tested independently of each other. // // Target names the point a guardrail is attached to (an organisation // root, unit or account). Guardrails attached to different targets // intersect, because every level must permit an action; guardrails that // share a target union. Inherited is provenance only: the path of // targets between the organisation root and the principal. type Document struct { Kind model.SourceKind `json:"kind"` Name string `json:"name"` Body json.RawMessage `json:"body"` Target string `json:"target,omitempty"` Inherited []string `json:"inherited,omitempty"` } // RawSet is everything collected for one principal, before evaluation. type RawSet struct { Principal model.Principal `json:"principal"` Documents []Document `json:"documents"` Gaps []string `json:"gaps,omitempty"` } // Provider is the contract every cloud plug-in implements. type Provider interface { Name() string Collect(ctx context.Context, sel Selector) (*RawSet, error) Evaluate(ctx context.Context, raw *RawSet) (*model.EffectiveSet, error) } // … // OfflineLoader is implemented by providers that can build a RawSet from // local documents with no credentials. This powers `iamdiff policy`, // which is the fastest path to a useful first release. type OfflineLoader interface { FromDocuments(p model.Principal, docs []Document) (*RawSet, error) }
The plug-in seam. A cloud implements three methods; optional capabilities, such as building a set from local documents, are detected by type assertion.
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
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
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
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
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
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.