Typed, fail-closed authorization engine for Python 3.12+ with optional Strawberry, FastAPI, and SQLAlchemy adapters.
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyrmit-0.3.0.tar.gz.
File metadata
- Download URL: pyrmit-0.3.0.tar.gz
- Upload date:
- Size: 47.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c9ba1557aacf0f33d201c117e47433f2bd165f18b98653a6d7052bbd6040495
|
|
| MD5 |
17dfc7000fdcd39c4cff27b5b35a1247
|
|
| BLAKE2b-256 |
97421669e3b90596ff61c44d2419a9f0b29cbe316557d7b552194ba8ed83e3d8
|
Provenance
The following attestation bundles were made for pyrmit-0.3.0.tar.gz:
Publisher:
release.yml on cahna/pyrmit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrmit-0.3.0.tar.gz -
Subject digest:
8c9ba1557aacf0f33d201c117e47433f2bd165f18b98653a6d7052bbd6040495 - Sigstore transparency entry: 2051308710
- Sigstore integration time:
-
Permalink:
cahna/pyrmit@3800efae8b4e57762eb3c69fec632ba48875a5c6 -
Branch / Tag:
refs/tags/0.3.0 - Owner: https://github.com/cahna
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3800efae8b4e57762eb3c69fec632ba48875a5c6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyrmit-0.3.0-py3-none-any.whl.
File metadata
- Download URL: pyrmit-0.3.0-py3-none-any.whl
- Upload date:
- Size: 59.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b8e8f6571caee2087dd5b1c21e4ae8be1f8b40f61a104be3bb666cb649de1f4c
|
|
| MD5 |
2c3dcc496a02b7de0c70f6f9d92d8699
|
|
| BLAKE2b-256 |
e481436e95bba0a45dcd160a0f1f7ae629f0943aa4ab96ff252c32cdcc761a2b
|
Provenance
The following attestation bundles were made for pyrmit-0.3.0-py3-none-any.whl:
Publisher:
release.yml on cahna/pyrmit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrmit-0.3.0-py3-none-any.whl -
Subject digest:
b8e8f6571caee2087dd5b1c21e4ae8be1f8b40f61a104be3bb666cb649de1f4c - Sigstore transparency entry: 2051309028
- Sigstore integration time:
-
Permalink:
cahna/pyrmit@3800efae8b4e57762eb3c69fec632ba48875a5c6 -
Branch / Tag:
refs/tags/0.3.0 - Owner: https://github.com/cahna
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3800efae8b4e57762eb3c69fec632ba48875a5c6 -
Trigger Event:
push
-
Statement type: