diff
Compare two policies or catalogs, list the permissions, roles, scopes and grants that changed, run fixtures through both with --impact, and exit 1 on a breaking change.
permdock diff a b reads two versions of a policy and reports what changed between them: permissions, scopes, roles, and every grant. It classifies each change as breaking or not and exits 1 when any is breaking, so a CI step can hold a pull request that takes access away without anyone reading the TypeScript diff. With --impact it also runs the project's fixtures through both policies and prints who loses or gains what.
Usage
permdock diff permissions.catalog.json src/policy.ts # committed catalog vs the working tree
permdock diff src/policy.ts src/policy.next.ts --json # two modules, machine-readable
permdock diff src/policy.ts src/policy.next.ts --impact # plus the fixtures' outcomes
permdock diff a.catalog.json b.catalog.json # two catalogs from two commitsEach input is either a permissions.catalog.json (any file ending in .json, validated with parseCatalog) or a module that exports policy, which the command turns into a catalog in memory with buildCatalog. A catalog written by permdock collect with policy configured carries a grants section (catalog); one written without the policy does not, and diff then compares permissions, scopes and roles only and says so.
| Flag | Meaning |
|---|---|
--impact | Load the fixtures and evaluate each one against both policies; both inputs must be modules. A fixture that was granted and no longer is counts as breaking (access-lost) |
--fixtures <file> | The fixture file for --impact; defaults to rls.fixtures in the config, then rls.fixtures.json. The format is the one rls verify reads: { subject: { id, roles?, tenant?, memberships? }, row, newRow?, action } |
--json | Print the report as JSON instead of text |
What is breaking
A change is breaking when it can take a decision from granted to something else for some subject. The classification reads the grants' structure and does not evaluate rows, so it is conservative: a condition that changed is reported as narrowing even when it widened, because without data the command cannot tell. --impact is the precise answer for the subjects and rows the fixtures name.
| Kind | Trigger |
|---|---|
permission-removed | A permission key in a is absent from b |
alias-removed | A key a lists in renamedFrom is neither a key nor a renamedFrom entry in b: stored custom roles, scopes and SQL that still name it now deny |
level-removed | A level a lists on a permission is gone from that permission in b: stored custom roles that pick it now deny the permission (levels) |
scope-removed | A declared scope in a is absent from b |
role-removed | A role in a is absent from b; its grants are not listed again |
allow-removed | An allow with no counterpart in b (same permission, role, grantee and scope), on a permission and role that still exist |
allow-narrowed | An allow whose counterpart gained or changed a where or check, gained or changed an approval, lost fields, started later or ends earlier (validFrom / validUntil), gained or changed a limit, changed purpose, or became non-portable |
deny-added | A deny in b with no counterpart in a |
deny-changed | A deny whose body changed in any way, since a looser deny condition denies more rows |
delegation-removed | A policy delegation in a (same from and to) is absent from b; the agents it covered lose every delegated permission |
delegation-narrowed | A delegation whose counterpart lost permission keys or whose validity starts later or ends earlier |
access-lost | With --impact: a fixture that was granted under a and is denied or approval-required under b |
Not breaking: a renamed permission, whose old key b lists in renamedFrom (a's grants are compared under the new key, and the text form prints old → new (renamed)); an added permission, level, scope, role, allow or delegation; a removed deny; an approval or condition removed from an allow; a wider field list or validity window; more permission keys on a delegation; a role declaration change (assignable, assigns, min, max and so on), which is listed under roles as ~ name: declaration changed but is not an access change on its own.
Output
The text form lists each section with + for added, - for removed and ~ for changed entries, then impact when requested and breaking (n) with one line per breaking change:
roles
- auditor
~ admin: declaration changed
grants
+ allow post.archive (role member)
+ deny post.read (role member)
- allow post.publish (role member)
~ allow post.delete (role member): approval added
impact (2 fixture(s) change outcome)
u1 post.publish: granted → denied
u1 post.delete: granted → approval-required
breaking (5)
role-removed: role auditor no longer exists
allow-removed: allow post.publish (role member) removed
deny-added: deny post.read (role member) added
allow-narrowed: allow post.delete (role member): approval added
access-lost: u1 loses post.publish: granted → denied--json prints { a, b, permissions, levels, scopes, roles, grants?, delegations?, impact?, breaking }: permissions has added, removed and renamed ({ from, to }); levels has added and removed as { permission, level } for permissions on both sides, printed as key@level; a and b carry each input's path and catalog fingerprint; grants.added and grants.removed are catalog grant entries, grants.changed pairs before and after with the list of changes; delegations has the same three lists over the catalog's delegations section and is present whenever grants is; impact rows are { action, subject, tenant?, before, after }; breaking entries are { kind, permission?, role?, detail }.
Exit codes
| Code | Meaning |
|---|---|
0 | No breaking change (there may be non-breaking ones) |
1 | At least one breaking change, including access-lost from --impact |
2 | Usage error: fewer or more than two inputs, a missing file, an invalid catalog, --impact over a catalog file, or unreadable fixtures |
Why
Every other command reads one policy; a review needs two. The catalog already is the policy's JSON twin that CI commits, and reviewers were reading its diff by hand, so the command compares catalogs rather than inventing a second format, and the catalog gained grants and delegations sections so that the diff could see conditions, approvals, validity and standing delegations instead of only keys and role names. Breaking is defined structurally rather than by evaluation because a diff must run with no subjects, rows or stores on a build machine; the conservative rule ("a changed condition may have narrowed") errs toward holding the pull request, and --impact with the project's own fixtures gives the exact answer when the structure alone is ambiguous. The fixture format is the one rls verify already uses so a team writes its subjects and rows once. The command never evaluates a real subject, like the rest of the CLI (CLI).
Related
Last updated on