FastAPI Multi-Tenancy¶
Resolve the request tenant in FastAPI with an authorization dependency, then
inject a tenant-bound session with Depends(db_ext.provide_session()). Read the
Tenant Request Contract for
the precedence and failure rules shared by every framework, and create the
account tables from
Account Tables and User Lookup SQL.
How FastAPI Resolves the Tenant¶
FastAPI authentication usually runs inside route dependencies rather than outer
ASGI middleware. An authorization dependency verifies membership and records the
selected tenant with set_request_tenant(request, org_id); pass key= with a
session, connection, or bind key when several configurations are registered.
SQLSpec checks out the tenant connection only when a route resolves
Depends(db_ext.provide_session(...)), after the authorization dependency has
run. Endpoints that never request a session, such as /health, /login, and
FastAPI's /docs, never check one out.
Single-Database Example¶
Verify membership inside an async dependency with a lookup session opened by
config.provide_session(bypass_tenant=True), call set_request_tenant(...),
and inject the tenant session after it:
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from sqlspec.adapters.asyncpg import AsyncpgConfig, AsyncpgDriver
from sqlspec.extensions.fastapi import SQLSpecPlugin, set_request_tenant
app = FastAPI()
config = AsyncpgConfig(
connection_config={"dsn": "postgresql://tenant_app@localhost/app"},
driver_features={"tenant_strategy": "rls"},
extension_config={"fastapi": {"commit_mode": "autocommit"}},
)
db_ext = SQLSpecPlugin(config, app)
async def authorize_org(request: Request, org_id: str) -> str:
user = request.scope.get("user")
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
async with config.provide_session(bypass_tenant=True) as lookup:
allowed = await lookup.select_one_or_none(
"SELECT 1 FROM public.user_org_memberships "
"WHERE user_id = :uid AND org_id = :org",
uid=user.id,
org=org_id,
)
if allowed is None:
raise HTTPException(status_code=403, detail="Forbidden organization")
set_request_tenant(request, org_id)
return org_id
@app.get("/orgs/{org_id}/projects")
async def list_projects(
_org: Annotated[str, Depends(authorize_org)],
db: Annotated[AsyncpgDriver, Depends(db_ext.provide_session())],
) -> list[dict[str, Any]]:
return await db.select("SELECT id, name FROM public.projects")
request.scope["user"] holds the user your authentication middleware verified;
replace it with your own current-user dependency if authentication runs inside
FastAPI.
Separate Auth and Tenant Databases¶
With a dedicated authentication database, register both configurations on one
plugin. The authorization dependency reads memberships from auth_db, which has
no tenant strategy, and scopes its receipt to the tenant configuration with
key="tenant_db":
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from sqlspec.adapters.aiosqlite import AiosqliteConfig, AiosqliteDriver
from sqlspec.extensions.fastapi import SQLSpecPlugin, set_request_tenant
USERS: dict[str, tuple[str, ...]] = {"Bearer alice": ("acme", "globex")}
app = FastAPI()
db_ext = SQLSpecPlugin(
[
AiosqliteConfig(
bind_key="auth",
connection_config={"database": ":memory:"},
extension_config={
"fastapi": {"session_key": "auth_db", "connection_key": "auth_conn", "pool_key": "auth_pool"}
},
),
AiosqliteConfig(
bind_key="tenant",
connection_config={"database": ":memory:"},
extension_config={
"fastapi": {
"session_key": "tenant_db",
"connection_key": "tenant_conn",
"pool_key": "tenant_pool",
"commit_mode": "autocommit",
}
},
),
],
app,
)
async def authorize_org(
request: Request, org_id: str, auth_db: Annotated[AiosqliteDriver, Depends(db_ext.provide_session("auth_db"))]
) -> str:
await auth_db.select_one("SELECT 1 AS ok")
memberships = USERS.get(request.headers.get("authorization", ""))
if memberships is None:
raise HTTPException(status_code=401, detail="Authentication required")
if org_id not in memberships:
raise HTTPException(status_code=403, detail="Forbidden organization")
set_request_tenant(request, org_id, key="tenant_db")
return org_id
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/orgs/{org_id}/projects")
async def list_projects(
org_id: Annotated[str, Depends(authorize_org)],
db: Annotated[AiosqliteDriver, Depends(db_ext.provide_session("tenant_db"))],
) -> dict[str, Any]:
return await db.select_one("SELECT :org_id AS org", org_id=org_id)
The runnable example uses in-memory SQLite, which has no tenant strategy, so it
shows only the authorization and set_request_tenant flow; tenant isolation
applies once the tenant bind uses a tenant-enabled adapter such as
AsyncpgConfig(driver_features={"tenant_strategy": "rls"}).
Upfront Extractor¶
When ASGI middleware ahead of FastAPI already verifies the caller and stores the
user in scope["user"], set tenant_extractor in extension_config["fastapi"]
so routes can inject sessions without an authorization dependency. FastAPI
passes the ASGI scope; the extractor may be sync or async:
from typing import Any
from sqlspec.adapters.asyncpg import AsyncpgConfig
def verified_tenant(scope: dict[str, Any]) -> str | None:
user = scope.get("user")
if user is None or not getattr(user, "is_authenticated", False):
return None
selected = getattr(user, "default_org", None)
return selected if selected in getattr(user, "org_ids", ()) else None
config = AsyncpgConfig(
connection_config={"dsn": "postgresql://tenant_app@localhost/app"},
driver_features={"tenant_strategy": "rls"},
extension_config={
"fastapi": {
"tenant_extractor": verified_tenant,
"commit_mode": "autocommit",
}
},
)
A set_request_tenant(...) receipt still takes precedence over the extractor.
See Upfront Tenant Extractors
for the rules every extractor follows.
Public and Bypass Routes¶
Routes that do not depend on db_ext.provide_session() never check out a tenant
connection. When a public route needs the database before a tenant exists, mark
it with tenant_bypass from sqlspec.extensions.fastapi; its session skips
SQLSpec's tenant session setup while database RLS policies stay active. This
example continues the single-database example:
from sqlspec.extensions.fastapi import tenant_bypass
@app.get("/health")
@tenant_bypass
async def health(
db: Annotated[AsyncpgDriver, Depends(db_ext.provide_session())],
) -> dict[str, int]:
return {"ok": await db.select_value("SELECT 1")}
Streaming and Background Tasks¶
FastAPI holds the request lease open through StreamingResponse bodies and
background tasks attached to the response, and the tenant resolved for the
request stays in effect for both. Sessions opened inside them with
config.provide_session() use that tenant. For work that outlives the request,
pass the verified tenant identifier to the worker and open
config.provide_session(tenant=tenant_id) there; see
Tenant Operations.
Sync and Async Accessors¶
Depends(db_ext.provide_session()) is an async dependency for sync and async
configurations alike and checks out the request connection on first use.
Outside dependency injection, db_ext.get_session(request) on an async
configuration returns a session that opens the connection, under the request
tenant, on its first awaited call; await db_ext.get_session_async(request)
opens it immediately. Reading session.connection before the first awaited
call raises ImproperConfigurationError, and the synchronous
db_ext.get_connection(request) returns an async connection only once it is
open; use await db_ext.get_connection_async(request) before that.