PermissionDiff
Prove your code didn't just hand the wrong person the keys.
PermissionDiff is a CI-native tool that shows exactly how a code change alters effective authorization. It answers: “Did this pull request accidentally give a user, tenant, role, or service access it did not have before?”
PermissionDiff generates meaningful authorization cases from subjects, resources, actions, and context values you declare. It checks explicit invariants and records baseline cases so a candidate authorizer evaluates the same exact corpus. Security verdicts are deterministic Python decisions—never LLM judgments.
CRITICAL — Cross-tenant access must be denied
support(acme) → read_invoice → invoice(globex)
actual: ALLOW
repro: .permissiondiff/failures/PD-0001.json
PermissionDiff is at v0/alpha maturity. The core workflow is tested and usable, but public interfaces may evolve before 1.0. It supports Python 3.12, 3.13, and 3.14 and is licensed under Apache-2.0.
Install
Add the published package to an existing uv project:
uv add permissiondiff
uv run permissiondiff --help
For a standalone CLI, install it with uv:
uv tool install permissiondiff
Or install it in an active virtual environment with pip:
python -m pip install permissiondiff
Five-minute quickstart
After standalone CLI installation (or use uv run permissiondiff inside a uv project):
permissiondiff init demo
cd demo
permissiondiff test
init creates a runnable config and a deliberately vulnerable authorizer. The test reports a minimized cross-tenant reproduction under .permissiondiff/failures/ and exits 1 because the tenant-isolation invariant fails.
Authorizer interface
Point PermissionDiff at one ordinary decision function:
from permissiondiff import Action, Context, Decision, Resource, Subject
def authorize(
subject: Subject,
action: Action,
resource: Resource,
context: Context,
) -> Decision:
if subject.tenant != resource.tenant:
return Decision.DENY
if action.name == "read_invoice" and subject.role in {"support", "admin"}:
return Decision.ALLOW
return Decision.DENY
The result must be exactly Decision.ALLOW or Decision.DENY. Crashes, hangs, and invalid return values are explicit evaluation errors and exit 3; PermissionDiff never fails open.
Configuration
The YAML describes real entities rather than asking a fuzzer to invent arbitrary application objects:
authorizer: auth:authorize
subjects:
- {id: alice, tenant: acme, role: support}
- {id: bob, tenant: globex, role: support}
resources:
- {id: invoice-acme, type: invoice, tenant: acme, owner_id: alice}
- {id: invoice-globex, type: invoice, tenant: globex, owner_id: bob}
actions: [read_invoice, refund]
contexts:
amounts: {min: 0, max: 1000, boundaries: [499, 500, 501]}
invariants:
- tenant_isolation
- role_boundary: {action: refund, allowed_roles: [admin]}
- ownership: {actions: [read_invoice]}
fail_on:
invariant_violation: true
newly_allowed: true
newly_denied: false
generation: {max_examples: 64}
execution: {timeout_seconds: 2}
Hypothesis combines declared entities and explores numeric boundaries. With the same PermissionDiff and Python versions, configuration, seed, exact corpus, and deterministic authorizer, findings are semantically reproducible. Canonical JSON, stable case fingerprints, stable finding ordering, and fixed IDs make review practical. A nondeterministic authorizer remains nondeterministic; PermissionDiff does not conceal that.
Test and invariant workflow
uv run permissiondiff test --config permissiondiff.yaml --seed 42
uv run permissiondiff explain PD-0001
Built-in invariants cover tenant isolation, action-specific role boundaries, and ownership. A custom deterministic invariant can be declared as:
invariants:
- custom: {name: refund_limit, callable: invariants:refund_limit}
The callable receives (AuthorizationCase, Decision) and returns bool or InvariantResult.
Least-privilege review
mine reports the authorizer's effective grant surface — each distinct role × action ×
resource-type × tenant-relation × ownership pattern it allows — and flags broad grants (those
crossing a tenant boundary or reaching a non-owned resource) for tightening:
uv run permissiondiff mine --config permissiondiff.yaml
Policy engines and agents
Wrap an external policy engine as the authorizer with the adapter toolkit in
permissiondiff.adapters (from_boolean, from_decision, or http_authorizer for OPA-style
endpoints); runnable example adapters for OPA, OpenFGA, Auth0 FGA, Cedar, and SpiceDB live under
examples/adapters/.
Adapters make read-only decision calls — point them at a non-production policy instance.
For agent / on-behalf-of principals, declare delegated_by: [id, ...] on a subject. PermissionDiff
enforces the least-privilege intersection rule: a delegated principal allowed where any delegator
is denied (on the identical case) is a critical privilege-escalation finding.
Baseline and diff workflow
Create a baseline on trusted code:
uv run permissiondiff snapshot --config permissiondiff.yaml \
--output .permissiondiff/main.json --seed 42
The snapshot stores every exact input case, its baseline decision, a stable fingerprint, the corpus seed, and schema/package versions. On candidate code, replay it:
uv run permissiondiff diff --config permissiondiff.yaml \
--baseline .permissiondiff/main.json
Or skip the snapshot file entirely and diff against a git ref — PermissionDiff evaluates the baseline authorizer as it existed at that ref in a temporary, auto-removed worktree:
uv run permissiondiff diff --config permissiondiff.yaml --git-ref main
Classification is exhaustive:
| Baseline | Candidate | Result |
|---|---|---|
| DENY | DENY | unchanged_denied |
| ALLOW | ALLOW | unchanged_allowed |
| DENY | ALLOW | newly_allowed |
| ALLOW | DENY | newly_denied |
A newly allowed path is security-sensitive, not automatically a vulnerability. fail_on decides what blocks CI. Invariants can still catch a vulnerability already present in the baseline.
The complete machine report is .permissiondiff/report.json by default.
CI
name: permissiondiff
on: [pull_request]
jobs:
authorization:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --all-groups
- run: uv run permissiondiff diff --baseline .permissiondiff/main.json
Exit codes are stable: 0 pass, 1 configured policy/invariant failure, 2 configuration or snapshot error, 3 evaluation/runtime error.
The repository's thin composite action invokes the same CLI without duplicating its logic:
- uses: abishekgiri/permissiondiff@v0
with:
config: permissiondiff.yaml
baseline: .permissiondiff/main.json
Check out the calling repository before this step and make sure the baseline is present in CI.
Use @v0.2.0 to pin the latest release; @v0 follows compatible v0 releases. The
published-consumer smoke test
exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
Security and limitations
Only point PermissionDiff at decision logic with no live side effects. Never use an authorizer that issues refunds, deletes data, sends messages, or contacts production.
Authorizers run in a timeout-controlled subprocess to isolate crashes and hangs from the main CLI. This subprocess is not a security sandbox. Imported Python code has the operating-system permissions of the user running PermissionDiff and may be malicious. Use trusted code and isolated CI environments.
Snapshots and reproductions may contain sensitive identifiers. Treat them accordingly. v0 supports local Python authorizers and explicit domains; it does not include Git worktree orchestration, remote policy engines, live agent/tool interception, a dashboard, or least-privilege mining.
Report vulnerabilities privately as described in the security policy. For bugs and feature requests, use GitHub Issues.
Development
uv sync --all-groups
uv run ruff check .
uv run ruff format --check .
uv run mypy src tests
uv run pytest
uv run pytest --cov=permissiondiff --cov-report=term-missing
uv build
The roadmap is deliberately short: prove authorization diffs are useful, then consider policy-engine adapters, Git-aware orchestration, agent principals/delegation, safe shadow interception, and least-privilege reduction with proof.
See the contribution guide for the contribution workflow and the changelog for release history.
Metadata
Release files for permissiondiff 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| permissiondiff-0.2.0.tar.gz | 28.7 kB | With provenance | Details for permissiondiff-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| permissiondiff-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for permissiondiff-0.2.0-py3-none-any.whl |
Total release size: 66.4 kB
Release files / permissiondiff-0.2.0.tar.gz
| Download URL | permissiondiff-0.2.0.tar.gz |
|---|---|
| Size | 28.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d62be6892f6bdb303440012334bbb9b89360ea3aebc567da57860a68da8c844c
|
|
BLAKE2b-256 checksum How to use checksums |
4f337f59d60f5109dfb917b8fb191682480285f330c9c6549c4f604d43fc25b7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logRelease files / permissiondiff-0.2.0-py3-none-any.whl
| Download URL | permissiondiff-0.2.0-py3-none-any.whl |
|---|---|
| Size | 37.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6a1d32d6c9a21be441be126400f95cebff3b4d44e97565929f1b253c06cf58e0
|
|
BLAKE2b-256 checksum How to use checksums |
7538e7cbfb0f9700523ee45cf860a09025b95eef73db6c478a4e11682076c9e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logSecurity for permissiondiff 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for permissiondiff-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.