Skip to main content

scope-parity

ci

parity checks between a route registry and a permission registry.

the most common authorization defect is not a bypassed check. it is a route that ships with no check attached: a new endpoint added in a hurry, the decorator forgotten, and nothing errors because a missing check looks exactly like working code. scope-parity takes the list of routes and the list of scopes and reports the mismatches, so CI fails instead of a human noticing.

install

pip install scope-parity

python 3.11 or newer. no runtime dependencies. the fastapi adapter and the pytest plugin are extras, installed only when wanted:

pip install "scope-parity[fastapi]"
pip install "scope-parity[pytest]"

changes are recorded in CHANGELOG.md. how to report a vulnerability, and what counts as one, is in SECURITY.md.

the checks

  • route-unprotected (error): a route declares no scope and is not marked public.
  • route-unknown-scope (error): a route declares a scope name that is not in the scope registry. usually the other half of a half-applied rename.
  • scope-unused (warning): a scope no route declares. often a rename that was only half applied, and a sign the route it used to protect may now be declaring nothing.
  • exemption-missing-reason (error): a route was marked public, or a scope routeless, without a reason.
  • route-unclassifiable (error): an adapter found the route protected by a security scheme but could not determine which scopes it requires. only produced in triage mode; the default adapter behaviour is to refuse the whole run instead. an error, not a warning, because the route is unaccounted for and parity cannot be proven while it stays that way.

scope names match exactly. ":" is a naming convention, not a hierarchy. a route declaring "orders" does not satisfy "orders:read", and the reverse does not hold either. whether one scope implies another is your authorizer's policy. honoring it here would let a broad scope mask the renames this tool exists to catch.

exemptions are deliberate and carry a reason. a genuinely public route gets public=Exemption("..."). a scope that governs a CLI command or a background job instead of an endpoint gets routeless=Exemption("..."). an exemption with an empty reason is itself a finding.

usage

from scope_parity import Exemption, Registry, Route, Scope, run_checks

registry = Registry(
    routes=[
        Route("GET", "/orders", "list_orders", scopes={"orders:read"}),
        Route("POST", "/orders", "create_order", scopes={"orders:write"}),
        Route("GET", "/health", "health", public=Exemption("load balancer probe")),
    ],
    scopes=[
        Scope("orders:read", "view orders"),
        Scope("orders:write", "create and edit orders"),
        Scope("billing:export", "export invoices", routeless=Exemption("runs as a batch job")),
    ],
)

for finding in run_checks(registry):
    print(finding.severity.value, finding.code, finding.subject, finding.message)

run_checks returns a list of Finding objects and never prints. a finding has a machine-readable code, a severity, the subject it concerns, and a message. fail your build if any finding has severity error, or on any finding at all if you prefer.

the cli

point scope-parity at the module that holds your registry:

scope-parity myapp.scopes:registry

the part after the colon is an attribute name and defaults to registry, so scope-parity myapp.scopes means the same thing. the attribute must be a Registry, or a zero-argument callable that returns one. the current directory goes on sys.path first, so the command works from a repo root without installing your application. instead of an argument, the target can live in pyproject.toml:

[tool.scope-parity]
target = "myapp.scopes:registry"

what a run with findings prints:

error    route-unprotected    DELETE /orders/{id} (delete_order) declares no scope and is not marked public
error    route-unknown-scope  GET /invoices (list_invoices) declares scope 'billing:view', which is not in the scope registry
warning  scope-unused         scope 'billing:read' is not declared by any route and is not marked routeless

4 routes and 3 scopes checked: 2 errors, 1 warning

errors sort before warnings, and the summary line prints on clean runs too, so a step that produced no output is never confused with one that did not run. severities are coloured when stdout is a terminal, never when piped or redirected, and NO_COLOR is honored.

the flags:

  • --fail-on {error,warning,never}: the lowest severity that makes the run exit 1. error is the default; warnings print but do not fail. warning fails on any finding. never reports without failing.
  • --format {human,json}: json prints one document with every finding (code, severity, subject, message) and the summary counts, for CI to consume.
  • --triage-unclassifiable: report routes the fastapi adapter cannot classify as findings instead of refusing the run. see triage mode below. requires the fastapi extra.
  • --version.

the exit codes:

  • 0: the checks ran and no finding met the failing severity.
  • 1: the checks ran and at least one finding met it.
  • 2: the run could not be performed. bad arguments, a target that did not import, an attribute that is missing or not a Registry, or a registry with no routes. an import error is not a clean run, and neither is an empty registry: a run that checked nothing must not look like a pass.

the fastapi adapter

hand-writing a registry that mirrors your real routes is itself a defect waiting to happen: the mirror drifts from the app, and a checker reading a stale mirror reports a clean run on an app that changed. the adapter removes the duplication by reading the routes from the live app object. the scope registry stays hand-written, because it is the source of truth the app is checked against; inferring it from what the routes declare would leave scope-unused and route-unknown-scope with nothing to catch.

install the extra:

pip install "scope-parity[fastapi]"

write a small module that builds the registry from your app, and point the CLI at it:

from scope_parity import Scope
from scope_parity.fastapi import from_fastapi

from myapp.main import app

registry = from_fastapi(app, scopes=[
    Scope("orders:read", "view orders"),
    Scope("orders:write", "create and edit orders"),
])
scope-parity myapp.parity:registry

the adapter reads scopes from the same security dependency tree fastapi's openapi generator reads: Security(scheme, scopes=[...]) declared as a parameter, in a route's dependencies, or in a router's dependencies, which apply to everything mounted under the router, included sub-routers too. two markers cover apps that authorize some other way: @declares("orders:read") on an endpoint adds scopes, and @public("reason") marks it deliberately public and becomes the route's exemption. plain Depends() is not treated as authorization: the adapter cannot tell a database session from a hand-rolled auth check, so a route whose only protection is a plain dependency needs a marker.

a route the adapter cannot classify, one that carries a security scheme but declares no scope anywhere, is neither reported as unprotected nor waved through. from_fastapi raises instead, naming every such route and how to resolve it, and through the CLI that is exit code 2: the run could not be performed. this is the same distinction the empty-registry exit code draws.

routes that are not fastapi api routes, such as the documentation routes and starlette mounts, are outside the adapter and skipped.

triage mode

raising is right for a pipeline and wrong for adoption. pointing the tool at an existing app with two hundred routes and thirty unclassifiable ones yields one exception and no report on the other hundred and seventy, which makes incremental fixing impossible. triage mode turns each unclassifiable route into a route-unclassifiable error instead, so the run produces the full report and the count shrinks as routes get fixed:

scope-parity myapp.parity:registry --triage-unclassifiable

from a library or a parity module, the same choice is the on_unclassifiable argument:

registry = from_fastapi(app, scopes=scopes, on_unclassifiable="finding")

the escape hatch is deliberately loud. every unclassifiable route is a printed error, the findings are errors and fail the run under the default --fail-on, and the summary line carries the count whenever the mode is on, zero included:

7 routes and 3 scopes checked: 2 errors, 1 warning, 2 unclassifiable

unclassifiable findings are errors, so they are inside the error count as well as counted on their own. the json summary always has the unclassifiable field. what triage mode changes is exit 2 with no report into exit 1 with a full report; it never turns unclassifiable routes into a pass.

one scoping rule to know: --triage-unclassifiable only affects from_fastapi calls that did not pass on_unclassifiable themselves. the flag sets the default the adapter falls back on while your module imports; a call that names its own on_unclassifiable has nothing to fall back on, so the flag does not touch it. this is deliberate. a command-line flag that silently overrode an explicit argument written in your own code would be surprising, and the explicit argument is the stronger statement of intent.

supported fastapi versions

the adapter reads fastapi internals, and fastapi 0.141 rewrote them: included routers stay nested in app.routes and the dependant grew a different scope layout. the supported range is 0.100 to 0.141, and the dependency specifier says fastapi>=0.100,<0.142 because a range this project has not tested is not a range it claims. ci runs the suite against both sides of the refactor and the boundaries of the range; the upper bound moves when a new version joins the matrix and passes.

the pytest plugin

a separate CI step is easy to forget to add. the plugin runs the check inside the test suite that already runs, as one extra collected test named scope-parity. install the extra:

pip install "scope-parity[pytest]"

installing changes nothing by itself. the plugin collects its test only when pyproject.toml asks for it:

[tool.scope-parity]
target = "myapp.parity:registry"
pytest = true

target is the same key the CLI reads, but target alone stays CLI-only; pytest = true is the explicit opt-in. the policy is the CLI's, not a second one: fail_on may be set in the same table with the same values and the same default, findings that meet it fail the test with a report naming each one, and a run that could not be performed, a broken target or an empty registry, fails the test the way the CLI would exit 2.

FAILED scope-parity - Failed: scope-parity found:
error    route-unprotected  DELETE /orders/{id} (delete_order) declares no scope ...

suites that build the registry themselves, for example around a fastapi app fixture, can use the assertion helper instead of the collected test:

from scope_parity.pytest_plugin import assert_parity

def test_scope_parity(app):
    assert_parity(from_fastapi(app, scopes=SCOPES))

assert_parity fails the test the same way and returns the findings list when it passes, so non-failing warnings can still be asserted on.

trust boundary

scope-parity imports your application to read its routes, so it runs module-level code. it is for code you trust, the same as a test suite. it is not a scanner for untrusted input.

status

released: 1.0.0 on pypi, all five planned stages built. the data model, the checks, the CLI, the fastapi adapter with triage mode, the pytest plugin, and CI that tests python 3.11 to 3.13, the supported fastapi range, and the tool against its own demo registry in examples/. releases are published from a tag by CI via trusted publishing, and a weekly job tests the newest fastapi against the version cap so the cap moves when it can. the core imports nothing outside the standard library; fastapi and pytest are optional extras, one for the adapter and one for the plugin.

license

MIT.

Metadata

Release files for scope-parity 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for scope-parity 1.0.0
File Size Uploaded
scope_parity-1.0.0.tar.gz 29.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for scope-parity 1.0.0
File Interpreter ABI Platform
scope_parity-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 49.7 kB

Release files / scope_parity-1.0.0.tar.gz

Download URL scope_parity-1.0.0.tar.gz
Size 29.0 kB
Tags Source
SHA-256 checksum
How to use checksums
f3328de98fc89f972ed3d015a183904544158fbddf27f89c11c43b4f578a9fe7
BLAKE2b-256 checksum
How to use checksums
fefe9ef73354390000a020810d24da61bedaae6019032d6618444acc1f699a05
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 29, 2026.

Transparency log

Release files / scope_parity-1.0.0-py3-none-any.whl

Download URL scope_parity-1.0.0-py3-none-any.whl
Size 20.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ea5f32961d66b3df64d438c527cc3bbf1830836fa5ccdbe96e6cf06fe1c63a3d
BLAKE2b-256 checksum
How to use checksums
ca5b86e068fb2d66af65778dcaf16e2c484a9c89fe4f20a4112cd011d8d66d1d
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 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page