Skip to main content

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]
tip

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​

  1. TenantPolicy compiled to a predicate like tenant_id = (SELECT NULLIF(current_setting('rls.tenant_id', true), '')::integer).
  2. rls.sync() ran ENABLE/FORCE ROW LEVEL SECURITY and CREATE POLICY.
  3. The session dependency opened a transaction and ran SET LOCAL rls.tenant_id = … from your identity.
  4. PostgreSQL enforced the predicate on every statement; committing the transaction cleared the setting.

Run the complete example​

A full working app — model, policies, identity, endpoints — plus a docker-compose file that provisions PostgreSQL with a proper non-superuser role lives in examples/ in the repository:

git clone https://github.com/kdpisda/fastapi-rls && cd fastapi-rls
docker compose -f examples/docker-compose.yml up -d --wait
pip install "fastapi-rls[fastapi,psycopg]" uvicorn
DATABASE_URL="postgresql+psycopg://app_user:app_pass@localhost:5432/appdb" \
uvicorn examples.basic_app:app --reload

Read how policies work and the request context model next — or jump straight to the security model before shipping.

Written and maintained by · source on GitHub