pyrmit
Status: v0.x — experimental. API may change without notice.
A typed, fail-closed authorization engine for Python 3.12+. Strongly-typed generic policies, two-state decisions, and pluggable audit and entitlement providers. Optional adapters for Strawberry GraphQL, FastAPI, and SQLAlchemy.
Install
pip install pyrmit # core only
pip install "pyrmit[strawberry,fastapi,sqlalchemy]" # with adapters
The adapter extras pull in their respective frameworks; install only the ones you use.
Quickstart
from dataclasses import dataclass
from enum import StrEnum
from pyrmit import ALLOW, Decision, Entitlements, PolicyEngine, Principal, deny
class Action(StrEnum):
READ = "read"
@dataclass(frozen=True)
class Actor:
user_id: int
is_admin: bool
@dataclass(frozen=True)
class Article:
id: int
owner_id: int
is_published: bool
engine: PolicyEngine[Principal[Actor], Action, Article] = PolicyEngine()
@engine.policy(action=Action.READ, subject_type=Article)
def can_read_article(principal: Principal[Actor], article: Article) -> Decision:
if principal.actor.is_admin:
return ALLOW
if article.is_published:
return ALLOW
if article.owner_id == principal.actor.user_id:
return ALLOW
return deny("article_unpublished")
alice = Principal[Actor](
actor=Actor(user_id=42, is_admin=False),
entitlements=Entitlements.empty(),
)
hidden = Article(id=1, owner_id=99, is_published=False)
decision = engine.decide(principal=alice, action=Action.READ, subject=hidden)
assert decision.allowed is False
assert decision.reason == "article_unpublished"
This example is exercised verbatim by
tests/integration/typed_decisions/test_readme_quickstart.py
to make sure the README never drifts from real behavior.
Key properties
- Two-state Decision —
allowed: boolplus a stable machine-readablereason. No third state. - Fail-closed — missing policy denies (
policy_not_registered); a policy raising any exception denies (policy_error); audit-store failure denies (audit_unavailable) whenaudit_failure_mode="deny"(which requiresaudit_allows=Trueat engine construction so that ALLOW decisions are actually covered). A crashing policy is also logged at WARNING (withexc_info=True) on thepyrmit.core.enginelogger, sinceengine.decide/engine.adecidetreat it as noteworthy even though the caller sees a normal denyDecision. Becauseexc_info=Truecarries the raised exception's own message into the log record, policy bodies should avoid interpolating user-supplied or sensitive data into the exception messages they raise. - Defensively immutable —
Decision.detailandAuditEntry.metadataare wrapped inMappingProxyTypeat construction and reject non-primitive values at runtime. - Strict typing —
mypy --strictclean, noAnyin core, notype: ignore. The decorator's subject_type binding is enforced by the type checker (see the negative test undertests/typing/).
Adapters
Adapters live under pyrmit.adapters.* and are optional extras:
- Strawberry:
policy_guard(...)— single-extension field guard with pre-resolution / from-source / post-resolution loader phases. NULL denial short-circuits before the resolver runs.post_resolution_policy_guard(...)is the explicit, safer counterpart for redaction-style use; it refuses to run inside a mutation operation by default (read_only=True) because the resolver runs before the authorization decision, and it refuses to run on subscription operations unconditionally (regardless ofread_only) — a subscription resolver produces a stream, so there is no resolved value for a post-resolution guard to authorize; attach a pre-resolution guard instead. Pre-resolution guards (theload_subject/load_subject_from_sourcephases ofpolicy_guard) work on subscriptions: the decision is made before the stream starts, so a deny terminates the subscription with an error instead of ever opening it. ANULLdenial surface has no meaning for a stream (there is no single field value to redact), so a NULL-surfaced deny on a subscription falls closed toFORBIDDEN(it raises throughdeny_handler) rather than returningNone.PolicyGuardFactory(engine=..., principal_loader=...)is the recommended entry point for real schemas — it captures the engine and principal loader once and exposes.guard(...)/.post_resolution_guard(...)so individual fields don't restate the cross-cutting deps. The factory is generic —PolicyGuardFactory[PrincipalT, ActionT, SubjectT]— and its type parameters are normally inferred from theengineandprincipal_loaderarguments, so a factory built from aPolicyEngine[Principal[Actor], Action, Article]propagates those types onto every.guard(...)call: mypy rejects a wrongactionenum value or a subject loader whose element type doesn't matchsubject_type. Seeexamples/strawberry_graphql/example_di.pyfor a DI-style wiring.deny_handler:policy_guard,post_resolution_policy_guard, andPolicyGuardFactoryall accept an optionaldeny_handler: (Decision, DenialSurface) -> Exceptionhook, called for FORBIDDEN and NOT_FOUND denials (NULL never raises, so it's never called for that surface). Use it to raise a host application's own exception taxonomy — e.g. one carrying a structured error code for GraphQL clients — instead of pyrmit's built-inPermissionDenied/ResourceNotFound, which are plainExceptionsubclasses and do not carry anextensions.code. The default,default_deny_handler, preserves that historical behavior. On the factory,deny_handleris set once and applied to every guard it builds, with a per-call override on.guard(...)/.post_resolution_guard(...).denial_surface:guard(),post_resolution_guard(), and the barepolicy_guard()/post_resolution_policy_guard()functions all accept an optionaldenial_surface: DenialSurfaceoverride. It applies only to that one field attachment — the binding's own registered surface, and any other guard built against the same binding, is unaffected. The override governs how a denial surfaces; it does not touch absence: a guard whose surface is (or is overridden to)NULLstill raisesNOT_FOUNDwhen the subject loader returnsNone, because a missing subject is an absence, not a redactable value. With the defaultdeny_handler, that NOT_FOUND carries the constant messagenot_found— identical to a NOT_FOUND-surfaced denial — so restricted and missing resources stay indistinguishable; a customdeny_handlerreceivingDenialSurface.NOT_FOUNDMUST preserve that indistinguishability or it reopens the existence side channel.- The per-request principal cache is keyed by
(info.context, principal_loader)identity, not just by context: two guards on the same request built from factories with different principal loaders never share a cached principal, even though they share the same context object.
- FastAPI:
require_policy(...)— dependency factory that translates denials to HTTP responses; the NULL surface requires anull_mapper. - SQLAlchemy:
visibility_scope(...)decorator marks a function as the per-actor row-level visibility predicate for a model;verify_scope_applied(...)is a tripwire test helper that checks the predicate is genuinely present on a compiled query.
Development
uv sync --all-extras
uv run pytest -q
uv run mypy src tests
uv run ruff check
uv run ruff format --check
See CONTRIBUTING.md for project conventions
(PEP 695 generics, assertpy in tests, no Any outside the documented
carve-outs, etc.) and SECURITY.md for the disclosure
policy.
License
Apache-2.0. See LICENSE.
Release files for pyrmit 0.3.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 | |
|---|---|---|---|
| pyrmit-0.3.0.tar.gz | 47.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyrmit-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 107.1 kB
Release files / pyrmit-0.3.0.tar.gz
| Download URL | pyrmit-0.3.0.tar.gz |
|---|---|
| Size | 47.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8c9ba1557aacf0f33d201c117e47433f2bd165f18b98653a6d7052bbd6040495
|
|
BLAKE2b-256 checksum How to use checksums |
97421669e3b90596ff61c44d2419a9f0b29cbe316557d7b552194ba8ed83e3d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 3, 2026.
Transparency logRelease files / pyrmit-0.3.0-py3-none-any.whl
| Download URL | pyrmit-0.3.0-py3-none-any.whl |
|---|---|
| Size | 59.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b8e8f6571caee2087dd5b1c21e4ae8be1f8b40f61a104be3bb666cb649de1f4c
|
|
BLAKE2b-256 checksum How to use checksums |
e481436e95bba0a45dcd160a0f1f7ae629f0943aa4ab96ff252c32cdcc761a2b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
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 Jul 3, 2026.
Transparency log