Row-level authorization and multi-tenancy for FastAPI + SQLAlchemy: define a policy once as column expressions and get both yes/no checks and query filtering from the same rule.
Project description
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.
Drift between "can this actor do this?" and "which rows can they see?" is a data leak. Purview makes both come from one definition, so they cannot drift.
@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 now powers a filtered select(Post) and an
authorize(session, "read", post) check.
Why this exists
Authentication generalizes; authorization does not, because it is welded to your domain model and data layer. The hard, valuable part is data filtering: shaping queries so a user only ever loads rows they're allowed to see, pushed into SQL rather than filtered in Python after the fact. Oso solved this well and then deprecated its open-source library, leaving no idiomatic Python answer. Purview targets that gap for the FastAPI + SQLAlchemy stack specifically — in-process, async-first, no external policy service.
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 true
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from purview import Context, Policy, READ
from purview.sqlalchemy import install
class Base(DeclarativeBase): ...
class Org(Base): # the tenant root — global
__tablename__ = "org"
id: Mapped[int] = mapped_column(primary_key=True)
class Post(Base): # tenant-scoped (has the tenant column)
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
org_id: Mapped[int] = mapped_column()
author_id: Mapped[int] = mapped_column()
policy = Policy()
policy.global_model(Org) # opt Org out of tenant 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
Bind a request's session to its actor, then query normally — reads are filtered automatically:
async with async_session() as session:
pv.bind(session, Context(user_id=42, tenant_id=1, roles={"author"}))
posts = await session.scalars(select(Post)) # only org 1 + authored by 42
one = await session.get(Post, 99) # None if not visible
ok = await pv.authorize(session, "update", post) # yes/no for one object
ids = await pv.authorized_ids(session, "read", Post, [1, 2, 3]) # the allowed subset
Core concepts
One definition, two forms. A rule returns boolean ColumnElement predicates.
As a .where(...) they filter a collection; wrapped in
EXISTS (SELECT 1 ... AND <predicate>) they check a single object. The database
evaluates both, so relationship and join predicates work without re-implementing
SQL in Python.
Roles select predicates. An actor's tenant-scoped roles decide which
predicates apply for (action, model). Grants are OR-combined; no granting role
means no rows — default deny.
Tenancy is the session boundary. One session is bound to exactly one tenant.
A do_orm_execute hook scopes every read (including lazy and eager relationship
loads) to that tenant; a before_flush hook auto-stamps the tenant on inserts and
refuses writes that would cross the boundary.
Secure by default. Every mapped model is tenant-scoped automatically. Opt a
model out with policy.global_model(...). install() raises if a non-global
model lacks the tenant column — an unscoped table fails at startup, never leaks at
runtime.
read drives filtering. It is the action that shapes collections. Other
actions are instance-level checks; update/delete reuse the read predicate
unless you register a stricter rule for them.
Within-tenant default. A scoped model with no read rule is visible
tenant-wide (tenant isolation still applies). Pass install(..., strict=True) to
flip this to within-tenant default deny — every model then needs an explicit
rule to grant any access. The cross-tenant boundary is enforced identically in
both modes.
The enforcement boundary
Inside the boundary: ORM selects, session.get, relationship loads, and flushes
on a bound session.
Outside the boundary (documented, not enforced):
- Raw SQL and Core
text()— Purview shapes ORM statements, not hand-written SQL. - Implicit lazy loads under async — these raise
MissingGreenletin SQLAlchemy regardless; useselectinload(...)orawait obj.awaitable_attrs.x. Eager and awaitable lazy loads are filtered. - Unbound sessions — a session with no bound context is not filtered (this is how you seed and run migrations).
Escape hatch
One loud, greppable bypass for admin tooling and migrations:
from purview.sqlalchemy import bypass
with bypass(reason="nightly billing rollup"):
... # enforcement stands down on this task; the reason is logged at WARNING
FastAPI
from purview.fastapi import context_binder, authorize_or_403, install_error_handlers
install_error_handlers(app) # PurviewForbidden -> 403
bound = context_binder(pv, get_session, get_context) # binds the actor per request
@app.get("/posts")
async def list_posts(session: AsyncSession = Depends(bound)):
return (await session.scalars(select(Post))).all() # auto-filtered
@app.patch("/posts/{post_id}")
async def edit(post_id: int, session: AsyncSession = Depends(bound)):
post = await session.get(Post, post_id) # 404 if not visible
await authorize_or_403(pv, session, "update", post) # 403 if not permitted
...
See tests/examples/test_blog_app.py for a
complete, runnable app.
Documentation
- Design — the architecture and the keystone idea.
- Threat model — what is and isn't enforced, each guarantee mapped to the test that proves it.
- Migrating from Oso.
- Security policy · Contributing · Releasing.
How it compares
| Purview | Oso (OSS) | Cerbos | Casbin | |
|---|---|---|---|---|
| In-process (no network) | ✅ | ✅ | ❌ service | ✅ |
| SQL data filtering | ✅ | ✅ | ✅ | ❌ |
| One def → check + filter | ✅ | ✅ | ➖ | ❌ |
| SQLAlchemy 2.0 async | ✅ | ❌ | ✅ adapter | ➖ |
| Policy in native Python | ✅ | Polar DSL | YAML | model+CSV |
| Maintained | ✅ | deprecated 2023 | ✅ | ✅ |
Scope (v1)
In: row-level filtering, multi-tenancy as a structural concern, yes/no checks and query filtering from one definition, SQLAlchemy 2.0 async, FastAPI adapter.
Out (for now): field-level authorization (belongs in serialization), non-SQLAlchemy ORMs, a hosted policy service, Postgres RLS as a compile target.
Development
uv run --extra dev pytest # unit + integration + the example app
uv run --extra dev mypy # strict typing is a project invariant
uv run --extra dev ruff check .
Postgres fidelity is exercised in CI; set PURVIEW_TEST_POSTGRES_URL to run the
integration matrix against a local Postgres too.
Releases publish to PyPI on a version tag via Trusted Publishing (no stored token); the version is derived from the tag. See RELEASING.md.
License
MIT — 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 purview_authz-0.2.0.tar.gz.
File metadata
- Download URL: purview_authz-0.2.0.tar.gz
- Upload date:
- Size: 105.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a8547def792123cb0bfd6301dd5fc2466872d4ea99f428f24a49f8759740508f
|
|
| MD5 |
ae31c72ceb3cdafe9b13f4c80856eaa5
|
|
| BLAKE2b-256 |
734e05d7ab7691c7eef0a70da4ce02c79eccc1035d8e9417b9799b6ce29c1acb
|
Provenance
The following attestation bundles were made for purview_authz-0.2.0.tar.gz:
Publisher:
release.yml on jestatsio/purview
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
purview_authz-0.2.0.tar.gz -
Subject digest:
a8547def792123cb0bfd6301dd5fc2466872d4ea99f428f24a49f8759740508f - Sigstore transparency entry: 1724743257
- Sigstore integration time:
-
Permalink:
jestatsio/purview@c15298f35a88d927a87c4eab9d93b791ff04498c -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/jestatsio
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c15298f35a88d927a87c4eab9d93b791ff04498c -
Trigger Event:
push
-
Statement type:
File details
Details for the file purview_authz-0.2.0-py3-none-any.whl.
File metadata
- Download URL: purview_authz-0.2.0-py3-none-any.whl
- Upload date:
- Size: 27.9 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 |
2d9500e58e8fdb0f1aee60fbefa6f6815a27be5431cf2c666c6789c9cddbef5f
|
|
| MD5 |
202abe1fcf9fd5a95fb2c70f110bb7d2
|
|
| BLAKE2b-256 |
4094a79072cb5acb3e3f886932d441294a310659987e2e77c68586d6f52a1e67
|
Provenance
The following attestation bundles were made for purview_authz-0.2.0-py3-none-any.whl:
Publisher:
release.yml on jestatsio/purview
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
purview_authz-0.2.0-py3-none-any.whl -
Subject digest:
2d9500e58e8fdb0f1aee60fbefa6f6815a27be5431cf2c666c6789c9cddbef5f - Sigstore transparency entry: 1724743477
- Sigstore integration time:
-
Permalink:
jestatsio/purview@c15298f35a88d927a87c4eab9d93b791ff04498c -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/jestatsio
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c15298f35a88d927a87c4eab9d93b791ff04498c -
Trigger Event:
push
-
Statement type: