Skip to main content

pyrmit

ci PyPI version Python versions License

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: bool plus a stable machine-readable reason. 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) when audit_failure_mode="deny" (which requires audit_allows=True at engine construction so that ALLOW decisions are actually covered). A crashing policy is also logged at WARNING (with exc_info=True) on the pyrmit.core.engine logger, since engine.decide/engine.adecide treat it as noteworthy even though the caller sees a normal deny Decision. Because exc_info=True carries 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.detail and AuditEntry.metadata are wrapped in MappingProxyType at construction and reject non-primitive values at runtime.
  • Strict typing — mypy --strict clean, no Any in core, no type: ignore. The decorator's subject_type binding is enforced by the type checker (see the negative test under tests/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 of read_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 (the load_subject / load_subject_from_source phases of policy_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. A NULL denial 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 to FORBIDDEN (it raises through deny_handler) rather than returning None. 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 the engine and principal_loader arguments, so a factory built from a PolicyEngine[Principal[Actor], Action, Article] propagates those types onto every .guard(...) call: mypy rejects a wrong action enum value or a subject loader whose element type doesn't match subject_type. See examples/strawberry_graphql/example_di.py for a DI-style wiring.
    • deny_handler: policy_guard, post_resolution_policy_guard, and PolicyGuardFactory all accept an optional deny_handler: (Decision, DenialSurface) -> Exception hook, 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-in PermissionDenied / ResourceNotFound, which are plain Exception subclasses and do not carry an extensions.code. The default, default_deny_handler, preserves that historical behavior. On the factory, deny_handler is 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 bare policy_guard() / post_resolution_policy_guard() functions all accept an optional denial_surface: DenialSurface override. 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) NULL still raises NOT_FOUND when the subject loader returns None, because a missing subject is an absence, not a redactable value. With the default deny_handler, that NOT_FOUND carries the constant message not_found — identical to a NOT_FOUND-surfaced denial — so restricted and missing resources stay indistinguishable; a custom deny_handler receiving DenialSurface.NOT_FOUND MUST 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 a null_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)

Source distribution for pyrmit 0.3.0
File Size Uploaded
pyrmit-0.3.0.tar.gz 47.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyrmit 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

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