Module 8: Final Project — TaskFlow API
Multi-tenancy with Row-Level Security + mock auth
This capsule defines TaskFlow as a multi-tenant SaaS. You'll add the users and projects tables with tenant_id, configure Row-Level Security in PostgreSQL, implement a FastAPI dependency that sets app.tenant_id per request, and the first aggressive isolation test: tenant A trying to read tenant B's data even with "malicious" queries (with no WHERE tenant_id).
By the end you'll have secure multi-tenancy at the DB level. Even if the app code has a bug and omits the tenant filter, RLS protects on the backend.
Model: add users and projects
# app/models/user.py
from datetime import datetime, timezone
import uuid
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id"),
nullable=False,
)
email: Mapped[str] = mapped_column(String(200), unique=True)
name: Mapped[str] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
# app/models/project.py
from datetime import datetime, timezone
import uuid
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id"),
nullable=False,
)
name: Mapped[str] = mapped_column(String(200))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
Update app/models/__init__.py:
from app.models.tenant import Tenant
from app.models.user import User
from app.models.project import Project
__all__ = ["Tenant", "User", "Project"]
Generate the migration:
alembic revision --autogenerate -m "add users and projects with tenant_id"
alembic upgrade head
Enable RLS
RLS can't be declared directly in SQLAlchemy. You have to add it in a migration with raw SQL.
alembic revision -m "enable RLS on users, projects, tasks"
Edit the generated file:
# alembic/versions/XXX_enable_rls.py
def upgrade() -> None:
# Enable RLS on multi-tenant tables
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE projects ENABLE ROW LEVEL SECURITY")
# Create policies
op.execute("""
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
""")
op.execute("""
CREATE POLICY tenant_isolation_projects ON projects
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
""")
def downgrade() -> None:
op.execute("DROP POLICY tenant_isolation_users ON users")
op.execute("DROP POLICY tenant_isolation_projects ON projects")
op.execute("ALTER TABLE users DISABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE projects DISABLE ROW LEVEL SECURITY")
Apply:
alembic upgrade head
How the policies work
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
tenant_id::text: cast from UUID to text to compare.current_setting('app.tenant_id', TRUE): reads the value of theapp.tenant_idsetting from the current session. TheTRUEmeans "no error if it isn't set, return NULL".- If the condition isn't met, the row isn't visible to the
SELECT.
SET LOCAL app.tenant_id = 'xxx' sets the value only during the current transaction. Until the next SET or the COMMIT/ROLLBACK.
Important: superuser bypasses RLS
By default, superusers (postgres) bypass RLS. For tests, use a normal user:
CREATE ROLE app_user LOGIN PASSWORD 'app_password';
GRANT CONNECT ON DATABASE taskflow TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
And connect the app with app_user, not postgres. In docker-compose.yml:
DATABASE_URL: postgresql+asyncpg://app_user:app_password@pgbouncer:5432/taskflow
Dependency: set app.tenant_id per request
# app/deps.py
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import jwt
from app.database import SessionLocal
from app.config import settings
async def get_current_tenant_id(
authorization: str = Header(None, alias="Authorization"),
) -> str:
"""Decode the JWT and extract tenant_id."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing or invalid Authorization header")
token = authorization.removeprefix("Bearer ")
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
tenant_id = payload.get("tenant_id")
if not tenant_id:
raise HTTPException(401, "Token missing tenant_id")
return tenant_id
except jwt.PyJWTError:
raise HTTPException(401, "Invalid token")
async def get_db_with_tenant(
tenant_id: str = Depends(get_current_tenant_id),
) -> AsyncGenerator[AsyncSession, None]:
"""Session with `app.tenant_id` set for RLS."""
async with SessionLocal() as session:
# Set tenant_id on the session — RLS reads it
await session.execute(
text("SET LOCAL app.tenant_id = :tid"),
{"tid": tenant_id}
)
yield session
Endpoints use get_db_with_tenant as a dependency:
# app/main.py
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.deps import get_db_with_tenant
from app.models import Project
@app.get("/projects")
async def list_projects(
db: AsyncSession = Depends(get_db_with_tenant),
):
# RLS automatically filters by the tenant_id that was set
result = await db.execute(select(Project))
return result.scalars().all()
Note: the query is SELECT * FROM projects — with no WHERE tenant_id = .... RLS adds it automatically.
Mock auth
To avoid implementing full OAuth, a simple mock:
# app/routers/auth.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import jwt
from datetime import datetime, timezone, timedelta
from app.config import settings
router = APIRouter()
class LoginRequest(BaseModel):
email: str
tenant_id: str # Mock: the client declares its tenant
class LoginResponse(BaseModel):
access_token: str
@router.post("/auth/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""Mock login: in real production this would verify the password against the DB.
For the TaskFlow demo, it simply generates a JWT with the provided tenant_id.
"""
# In real production: verify email/password in the DB
# Here: we accept any valid login
payload = {
"sub": request.email,
"tenant_id": request.tenant_id,
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return LoginResponse(access_token=token)
Include the router in main:
# app/main.py
from app.routers import auth
app.include_router(auth.router)
Basic CRUD endpoints for projects
# app/routers/projects.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
import uuid
from app.deps import get_db_with_tenant, get_current_tenant_id
from app.models import Project
router = APIRouter()
class ProjectCreate(BaseModel):
name: str
class ProjectResponse(BaseModel):
id: uuid.UUID
name: str
tenant_id: uuid.UUID
@router.get("/projects")
async def list_projects(
db: AsyncSession = Depends(get_db_with_tenant),
):
result = await db.execute(select(Project))
return [ProjectResponse(id=p.id, name=p.name, tenant_id=p.tenant_id) for p in result.scalars()]
@router.post("/projects", status_code=status.HTTP_201_CREATED)
async def create_project(
data: ProjectCreate,
tenant_id: str = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db_with_tenant),
):
project = Project(name=data.name, tenant_id=uuid.UUID(tenant_id))
db.add(project)
await db.commit()
return ProjectResponse(id=project.id, name=project.name, tenant_id=project.tenant_id)
Include it in main:
from app.routers import projects
app.include_router(projects.router)
RLS isolation test — the critical test
This is the test of the module. If it passes, multi-tenancy is well implemented.
# tests/test_rls_isolation.py
import pytest
import uuid
from httpx import AsyncClient
from sqlalchemy import text
@pytest.mark.asyncio
async def test_tenant_a_cannot_read_tenant_b_data(
client: AsyncClient,
tenant_a_id: str,
tenant_b_id: str,
tenant_a_token: str,
tenant_b_token: str,
):
"""Tenant A must not be able to read Tenant B's projects, even with a 'malicious' query."""
# Tenant A creates a project
response_a = await client.post(
"/projects",
json={"name": "Tenant A's Secret Project"},
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
assert response_a.status_code == 201
project_a_id = response_a.json()["id"]
# Tenant B creates a project
response_b = await client.post(
"/projects",
json={"name": "Tenant B's Project"},
headers={"Authorization": f"Bearer {tenant_b_token}"}
)
assert response_b.status_code == 201
# Tenant A lists — sees only its own
response = await client.get(
"/projects",
headers={"Authorization": f"Bearer {tenant_a_token}"}
)
projects = response.json()
assert len(projects) == 1
assert projects[0]["id"] == project_a_id
assert projects[0]["name"] == "Tenant A's Secret Project"
# Tenant B lists — sees only its own
response = await client.get(
"/projects",
headers={"Authorization": f"Bearer {tenant_b_token}"}
)
projects = response.json()
assert len(projects) == 1
assert projects[0]["name"] == "Tenant B's Project"
@pytest.mark.asyncio
async def test_rls_blocks_direct_select_without_filter(db_session_tenant_a, db_session_tenant_b):
"""Even with raw SQL and no WHERE tenant_id, RLS filters."""
# Tenant B inserts a project
await db_session_tenant_b.execute(text("""
INSERT INTO projects (id, name, tenant_id)
VALUES (gen_random_uuid(), 'Tenant B Project', :tid)
"""), {"tid": tenant_b_id})
await db_session_tenant_b.commit()
# Tenant A does a SELECT with no filter — should NOT see B's project
result = await db_session_tenant_a.execute(text("SELECT name FROM projects"))
rows = result.fetchall()
names = [r[0] for r in rows]
assert "Tenant B Project" not in names
@pytest.mark.asyncio
async def test_without_tenant_id_set_no_rows_visible(db_no_tenant):
"""Without SET app.tenant_id, no row is visible (security default)."""
# Insert some projects (with a superuser that bypasses RLS)
# ... setup ...
# Query WITHOUT setting app.tenant_id
result = await db_no_tenant.execute(text("SELECT * FROM projects"))
rows = result.fetchall()
assert len(rows) == 0 # RLS blocks everything if tenant_id isn't set
These tests are aggressive: they attempt explicit ways to bypass. They pass only with RLS configured correctly.
Fixtures setup
# tests/conftest.py
import pytest
import asyncio
from testcontainers.postgres import PostgresContainer
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import text
from httpx import AsyncClient
import jwt
import uuid
from app.main import app
from app.config import settings
@pytest.fixture(scope="session")
def event_loop():
return asyncio.new_event_loop()
@pytest.fixture(scope="session")
def postgres_container():
with PostgresContainer("postgres:16") as pg:
yield pg
@pytest.fixture(scope="session")
async def setup_db(postgres_container):
"""Create the DB with the full schema + RLS enabled."""
db_url = postgres_container.get_connection_url().replace("psycopg2", "asyncpg")
settings.database_url = db_url
engine = create_async_engine(db_url)
async with engine.begin() as conn:
# Apply all migrations via Alembic or directly
from alembic.config import Config
from alembic import command
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(alembic_cfg, "head")
# Create app_user (no RLS bypass)
await conn.execute(text("CREATE ROLE app_user LOGIN PASSWORD 'pwd'"))
await conn.execute(text("GRANT ALL ON ALL TABLES IN SCHEMA public TO app_user"))
await conn.execute(text("GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_user"))
yield engine
await engine.dispose()
@pytest.fixture
async def tenant_a_id(setup_db):
"""Create a tenant for tests."""
engine = setup_db
async with engine.begin() as conn:
result = await conn.execute(
text("INSERT INTO tenants (name) VALUES ('Tenant A') RETURNING id")
)
return str(result.scalar())
@pytest.fixture
async def tenant_b_id(setup_db):
engine = setup_db
async with engine.begin() as conn:
result = await conn.execute(
text("INSERT INTO tenants (name) VALUES ('Tenant B') RETURNING id")
)
return str(result.scalar())
@pytest.fixture
def tenant_a_token(tenant_a_id):
payload = {"sub": "a@test.com", "tenant_id": tenant_a_id}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
@pytest.fixture
def tenant_b_token(tenant_b_id):
payload = {"sub": "b@test.com", "tenant_id": tenant_b_id}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
@pytest.fixture
async def client(setup_db):
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
Run the tests:
pytest tests/test_rls_isolation.py -v
If the 3 tests pass, multi-tenancy is well implemented.
Pitfalls and common mistakes
1. Forgetting to connect the app with a non-superuser.
If the app uses postgres (superuser), it bypasses RLS. The tests would "pass" locally but the protection doesn't exist. Verify the connection with app_user.
2. RLS without BYPASSRLS on superusers for migrations.
Migrations run with a superuser and modify tables. If RLS blocks, the migrations fail. By default, the superuser bypasses — that's what you want.
3. current_setting('app.tenant_id', TRUE) with FALSE instead of TRUE.
-- Without TRUE, error if the setting doesn't exist
USING (tenant_id::text = current_setting('app.tenant_id', FALSE))
-- With TRUE, returns NULL if it doesn't exist (safer)
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
TRUE means "missing_ok = TRUE". Recommended to avoid errors in initial setups.
4. SET app.tenant_id (without LOCAL) that persists across transactions.
SET app.tenant_id = '...'; -- Persists until the connection closes
SET LOCAL app.tenant_id = '...'; -- Only during this transaction
With PgBouncer transaction mode, connections are reused. SET (not SET LOCAL) can leak the tenant between requests. Always SET LOCAL.
5. Forgetting policies on INSERT/UPDATE.
USING (...) applies to SELECT/UPDATE/DELETE. For INSERT, use WITH CHECK:
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id::text = current_setting('app.tenant_id', TRUE))
WITH CHECK (tenant_id::text = current_setting('app.tenant_id', TRUE));
WITH CHECK prevents inserting with a tenant_id different from the one that was set.
6. Hardcoded JWT secret.
JWT_SECRET = "dev-secret" in the code is a demo. Real production: a rotated secret, stored securely.
7. FORCE ROW LEVEL SECURITY.
ALTER TABLE users FORCE ROW LEVEL SECURITY;
Makes the table's owner also subject to RLS. Useful if you want even the postgres user (in migration queries) not to bypass. By default, it doesn't.
Summary and next step
What you have now:
- The
usersandprojectstables withtenant_id. - RLS enabled with policies that filter by
current_setting('app.tenant_id'). app_user, a non-superuser that respects RLS.- A mock JWT for auth.
- A FastAPI dependency
get_db_with_tenantthat setsSET LOCAL app.tenant_id. - The
/projectsendpoints working with multi-tenancy. - An isolation test that proves tenant A doesn't see tenant B's data.
Commit:
git add .
git commit -m "feat: multi-tenancy with RLS + JWT mock + projects CRUD"
In the next capsule we add TaskFlow's central entity: tasks with basic CRUD, cursor pagination, and soft delete. You'll implement GET /tasks with an opaque cursor based on (created_at DESC, id DESC), DELETE /tasks/{id} that sets deleted_at, and a partial index on deleted_at IS NULL for fast queries.
Resources
- PostgreSQL Docs — Row Security Policies — official reference.
- PostgreSQL Docs —
current_setting— runtime parameters. - Supabase — Row Level Security — real case with RLS.
- Crunchy Data — Multi-tenancy with RLS — deep dive.
- PostgreSQL Wiki — RLS — history and patterns.
- GitLab — Multi-tenancy decisions — real architectural decisions.
- JWT.io — Decoder — for debugging tokens.
Capsule 03 of 08 — Module 8 — SQL Patterns for Production APIs Guide