Skip to content

Purview

Row-level authorization and multi-tenancy for FastAPI + SQLAlchemy. Define a policy once as SQLAlchemy column expressions and get both yes/no checks and query filtering from the same rule — so the check and the filter can never disagree.

@policy.rule(Post, "read")
def read_post(ctx: Context) -> list[ColumnElement[bool]]:
    rules = []
    if ctx.has_role("author"):
        rules.append(Post.author_id == ctx.user_id)   # authors see their own
    if ctx.has_role("org_admin"):
        rules.append(true())                           # admins see the whole tenant
    return rules                                       # OR-combined; empty = deny

That one rule powers a filtered select(Post) and an authorize(session, "read", post) check.

Install

pip install purview-authz            # core + SQLAlchemy
pip install "purview-authz[fastapi]" # plus the FastAPI adapter

The distribution is purview-authz; the import package is purview. Requires Python 3.11+ and SQLAlchemy 2.0+.

Quickstart

from sqlalchemy import select, true
from purview import Context, Policy, READ
from purview.sqlalchemy import install

policy = Policy()
policy.global_model(Org)               # opt the tenant root out of scoping

@policy.rule(Post, READ)
def read_post(ctx: Context):
    return [Post.author_id == ctx.user_id] if ctx.has_role("author") else []

pv = install(Base, policy, tenant_column="org_id")   # wires the guards; validates models

async with async_session() as session:
    pv.bind(session, Context(user_id=42, tenant_id=1, roles={"author"}))
    posts = await session.scalars(select(Post))          # filtered automatically
    ok    = await pv.authorize(session, "update", post)  # yes/no for one object

Core ideas

  • One definition, two forms. A boolean ColumnElement filters a collection (.where) and checks one object (EXISTS). The database evaluates both.
  • Tenancy is the session boundary. One session, one tenant; reads, writes, and attaches are all guarded. See the threat model.
  • Secure by default. Every model is tenant-scoped; install() refuses to start if a model lacks its tenant column. Purview.audit() flags any model left visible tenant-wide, and install(warn_on_unfiltered=True) warns on the documented unfiltered sharp edges.
  • Inspectable. Purview.explain(session, "read", Post) shows the exact predicate the guard applies — compiled SQL, contributing rules, effective roles — with no database round-trip.
  • Composable rules. Role hierarchies (Policy.role_implies) and predicate helpers (owned_by, in_values) keep policies declarative.

Learn more