Quick start
This walks through a minimal multi-tenant app: declare a policy, apply it to the database, and scope every request to the caller's tenant.
1. Declare policies on your models
Add RLSMixin to a model and list its policies in __rls_policies__. The mixin
derives the table name and registers each policy for you.
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from fastapi_rls import TenantPolicy
from fastapi_rls.adapters.sqlalchemy import RLSMixin
class Base(DeclarativeBase):
pass
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)
title: Mapped[str]
If your declarative base doesn't propagate __init_subclass__ (some custom base
setups don't), call collect_policies(Base) once at startup to register every
model's policies explicitly. See the SQLAlchemy guide.
2. Apply the policies to the database
Build one RLS facade and let it reconcile your registry with the database.
sync() enables and forces RLS on each table and creates every registered
policy, in a single transaction.
from fastapi_rls import RLS
rls = RLS(engine=engine) # your SQLAlchemy Engine
rls.sync() # ENABLE + FORCE RLS and CREATE every registered policy
Prefer migrations or a CLI? The same reconciliation is available:
# Standalone CLI, no Alembic required:
fastapi-rls sync --url "$DATABASE_URL" --policies myapp.models
# Inside an Alembic migration:
import fastapi_rls.alembic_ops # noqa: F401 — registers op.enable_rls / op.create_policy
def upgrade():
op.enable_rls("documents")
op.create_policy(
TenantPolicy("tenant_isolation", column="tenant_id", table="documents")
)
3. Wire the request context
The session dependency owns the transaction and applies your identity with
SET LOCAL. You provide an identity callable that returns the context mapping —
fastapi-rls never authenticates; it propagates the principal you resolve.
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.orm import Session
from fastapi_rls import RLS
rls = RLS(engine=engine)
def identity(user=Depends(get_current_user)) -> dict:
return {"tenant_id": user.tenant_id}
get_session = rls.session_dependency(identity=identity)
app = FastAPI()
@app.get("/documents")
def list_documents(session: Session = Depends(get_session)):
# PostgreSQL filters by tenant. No WHERE clause required.
return session.scalars(select(Document)).all()
That's the whole integration. Every query on that session is transparently scoped to the caller's tenant. A request with no context sees no rows — never everyone's.
4. Async is identical
from sqlalchemy.ext.asyncio import AsyncSession
rls = RLS(async_engine=async_engine)
get_session = rls.async_session_dependency(identity=identity)
@app.get("/documents")
async def list_documents(session: AsyncSession = Depends(get_session)):
result = await session.scalars(select(Document))
return result.all()
What just happened
TenantPolicycompiled to a predicate liketenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer).rls.sync()ranENABLE/FORCE ROW LEVEL SECURITYandCREATE POLICY.- The session dependency opened a transaction and ran
SET LOCAL rls.tenant_id = …from youridentity. - PostgreSQL enforced the predicate on every statement; committing the transaction cleared the setting.
Read how policies work and the request context model next — or jump straight to the security model before shipping.