The FastAPI/SQLAlchemy sibling of django-rls: the policy model, the session-variable context, and the generated DDL are intentionally the same; only the framework integration differs. The core is ORM-agnostic — policies, context, and DDL have zero framework dependencies; SQLAlchemy, FastAPI, and Alembic are optional adapters.
How it works
Models declare policies through a mixin, and a session dependency scopes every query to the caller’s identity:
class Document(Base, RLSMixin):
__tablename__ = "documents"
__rls_policies__ = [
TenantPolicy("tenant_isolation", column="tenant_id"),
]
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(index=True)
def identity(user = Depends(get_current_user)) -> dict:
return {"tenant_id": user.tenant_id} # you authenticate, it propagates
get_session = rls.session_dependency(identity=identity)
@app.get("/documents")
def list_documents(session: Session = Depends(get_session)):
return session.scalars(select(Document)).all() # Postgres filters by tenant
Async is identical — rls.async_session_dependency with an
AsyncSession. A request with no context sees no rows, never
everyone’s. Applying policies works three ways: rls.sync() at
startup, Alembic operation directives, or the standalone CLI
(fastapi-rls sync / plan / audit), which also reports drift between
declared policies and what’s live in the database.
Under the hood
- Policies compile to predicates like
tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer)— thecurrent_settingread wrapped in a scalar subquery so the planner evaluates it once per statement, not per row. - Identity is applied with
SET LOCALinside a per-request transaction; commit clears it, and a pool-checkout scrub is the defense-in-depth backstop.user_id/tenant_idcannot be silently overridden mid-request. TenantPolicy,UserPolicy, and injection-screenedCustomPolicycover the policy space, with per-operation scoping,RESTRICTIVEpolicies, role targeting, andinteger/bigint/uuid/textcontext types.- Table, column, role, and operation names are validated against a
strict identifier allowlist before any DDL;
FORCE ROW LEVEL SECURITYis applied by default. - Tested against real PostgreSQL — cross-tenant leak tests included — across Python 3.10–3.13 and PostgreSQL 13–17.
Published on PyPI as fastapi-rls (BSD-3-Clause), with docs at
fastapi-rls.com. Full story in
the deep dive.