Chapter 8: Row-Level Security in Postgres via SQLAlchemy. Per-row tenant isolation enforced at the DB level — even if your app code forgets the WHERE clause.
Why RLS
Shared-DB multi-tenancy means every query needs WHERE tenant_id = $1. Forget once; data leaks across tenants. RLS fixes this at the DB.
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::bigint);
Now SELECT/UPDATE/DELETE only see rows where tenant_id matches the session-set value. WHERE clauses become redundant for safety.
Schema
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(nullable=False)
email: Mapped[str]
__table_args__ = (
Index("ix_users_tenant_email", "tenant_id", "email"),
)
tenant_id always present. Indexed for performance.
Migration to enable RLS
def upgrade():
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint)
""")
USING for SELECT/UPDATE/DELETE. WITH CHECK for INSERT/UPDATE.
Setting tenant per request
import contextvars
current_tenant: contextvars.ContextVar[int | None] = contextvars.ContextVar("tenant", default=None)
@event.listens_for(engine.sync_engine, "before_cursor_execute")
def set_tenant(conn, cursor, statement, parameters, context, executemany):
tid = current_tenant.get()
if tid is not None:
cursor.execute(f"SET LOCAL app.tenant_id = '{tid}'")
# In FastAPI middleware
@app.middleware("http")
async def tenant_middleware(request: Request, call_next):
tenant_id = await resolve_tenant(request)
token = current_tenant.set(tenant_id)
try:
return await call_next(request)
finally:
current_tenant.reset(token)
Per-request: set contextvar; events apply at query time. SET LOCAL resets at transaction end.
Alternative: Depends-based
async def get_db(request: Request) -> AsyncSession:
async with AsyncSessionLocal() as session:
if tenant_id := getattr(request.state, "tenant_id", None):
await session.execute(text("SET LOCAL app.tenant_id = :tid"), {"tid": tenant_id})
yield session
Set at session start. Cleaner if you don’t need cross-cutting events.
Bypass for admin / system
For ops that need cross-tenant access:
class SystemRole(str, enum.Enum):
ADMIN = "admin"
async def get_admin_db() -> AsyncSession:
async with AsyncAdminSessionLocal() as session: # uses BYPASSRLS role
yield session
Create a Postgres role with BYPASSRLS:
CREATE ROLE app_admin WITH LOGIN BYPASSRLS PASSWORD '...';
Used only by admin paths; audit every use.
RLS for INSERT
CREATE POLICY tenant_insert ON users FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
Inserts that specify a different tenant_id are rejected.
Application doesn’t need WHERE
Once RLS is set:
users = await session.execute(select(User)) # already tenant-filtered
Postgres adds the predicate. No code change needed.
You can still add WHERE tenant_id = ... for clarity or planner hints; it’ll match what RLS enforces.
Performance
RLS predicates must be in indexes:
__table_args__ = (
Index("ix_users_tenant_email", "tenant_id", "email"),
)
For queries by email, the planner uses tenant_id + email because RLS adds the tenant_id predicate.
EXPLAIN to verify: index used; partition pruned (if combined with partitioning).
Test bypass attempts
CI test:
async def test_rls_isolation(db_session):
# Tenant 1 creates a user
await db_session.execute(text("SET LOCAL app.tenant_id = '1'"))
db_session.add(User(tenant_id=1, email="[email protected]"))
await db_session.commit()
# Tenant 2 shouldn't see it
await db_session.execute(text("SET LOCAL app.tenant_id = '2'"))
rows = (await db_session.execute(select(User).where(User.email == "[email protected]"))).scalars().all()
assert rows == []
Verify RLS is enforced. Catches misconfigured policies.
Common mistakes
1. App role with BYPASSRLS
Defeats the purpose. App role should NOT have BYPASSRLS.
2. Forgetting WITH CHECK
Without it, INSERTs can specify any tenant_id. Add WITH CHECK or trigger validation.
3. RLS without indexes
Tenant_id added to every query; no index → seq scans. Always index tenant_id (composite with other predicates).
4. Per-connection (not LOCAL) settings
SET app.tenant_id (without LOCAL) persists across the connection’s life in pool. Use SET LOCAL.
5. Mixing RLS and explicit WHERE
Both is fine; they AND together. Just be aware.
Schema-per-tenant alternative
If RLS adds unwanted complexity:
async def get_db_for_tenant(tenant_id: int) -> AsyncSession:
schema = f"tenant_{tenant_id}"
async with AsyncSessionLocal() as session:
await session.execute(text(f"SET search_path TO {schema}, public"))
yield session
Each tenant has its own schema. Migrations run per schema; isolation is physical.
Tradeoff: more schemas; more ops. Limited to ~hundreds of tenants.
DB-per-tenant
For strictest isolation (compliance, large enterprise):
async def get_db(tenant_id: int) -> AsyncSession:
creds = await secrets.get(f"tenant_db/{tenant_id}")
engine = await get_or_create_engine(creds.dsn)
async with AsyncSession(engine) as session:
yield session
Each tenant: own DB. Most expensive; most isolated.
See Multi-Tenancy Patterns .
What’s next
Chapter 9: Postgres-specific upsert and bulk ops.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .