Skip to main content

Typed, fail-closed authorization engine for Python 3.12+ with optional Strawberry, FastAPI, and SQLAlchemy adapters.

Project description

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 Decisionallowed: 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 immutableDecision.detail and AuditEntry.metadata are wrapped in MappingProxyType at construction and reject non-primitive values at runtime.
  • Strict typingmypy --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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyrmit-0.3.0.tar.gz (47.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyrmit-0.3.0-py3-none-any.whl (59.8 kB view details)

Uploaded Python 3

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

Hashes for pyrmit-0.3.0.tar.gz
Algorithm Hash digest
SHA256 8c9ba1557aacf0f33d201c117e47433f2bd165f18b98653a6d7052bbd6040495
MD5 17dfc7000fdcd39c4cff27b5b35a1247
BLAKE2b-256 97421669e3b90596ff61c44d2419a9f0b29cbe316557d7b552194ba8ed83e3d8

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyrmit-0.3.0.tar.gz:

Publisher: release.yml on cahna/pyrmit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for pyrmit-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b8e8f6571caee2087dd5b1c21e4ae8be1f8b40f61a104be3bb666cb649de1f4c
MD5 2c3dcc496a02b7de0c70f6f9d92d8699
BLAKE2b-256 e481436e95bba0a45dcd160a0f1f7ae629f0943aa4ab96ff252c32cdcc761a2b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyrmit-0.3.0-py3-none-any.whl:

Publisher: release.yml on cahna/pyrmit

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page