Middleware (Tenant Isolation)
Note: Tenant isolation is not shipped in the client SDKs (
curate-me,@curate-me/sdk). It is applied server-side by the platform’s B2B API and gateway. This page documents the real backend middleware so you understand how requests are scoped — but you do not install or register it from the SDK. When you call the SDK or proxy through the gateway, isolation is enforced for you automatically based on your API key / JWT.
The B2B API runs every request through TenantIsolationMiddleware, a Starlette
BaseHTTPMiddleware that enforces multi-tenant data isolation. It identifies the
calling organization, validates membership, and pins org context onto
request.state so downstream route handlers and database queries are scoped to
the correct tenant.
Source: services/backend/src/middleware/tenant_isolation.py.
Registration
The middleware is registered on the FastAPI application at startup — there is no “orchestrator plugin” model:
from src.middleware.tenant_isolation import TenantIsolationMiddleware
app.add_middleware(TenantIsolationMiddleware)It runs in one of three modes (mixed — the default, b2b, or b2c),
controlling whether a JWT is required and how org context is enforced for a given
path prefix.
How org context is extracted
For B2B routes, the middleware resolves the organization from up to three sources, in priority order:
X-Org-IDheader — the explicit org switch sent by the dashboard (must match^org_[a-f0-9]+$).- JWT claims — the
org_id/org_roleclaims on a Bearer token. - URL path parameter — e.g.
/api/v1/organizations/{org_id}/....
If more than one source supplies an org id and they disagree, the request is
rejected with 403 (cross-org access is denied — sources are never silently
preferred). Membership and role are always re-verified against the database
(organization_members); the JWT role claim alone is never trusted, so a removed
user’s still-valid token does not retain access.
On success, the middleware sets:
request.state field | Description |
|---|---|
org_id | Resolved organization identifier (org_<hex>). |
user_id | The authenticated user (JWT sub / user_id). |
org_role | The user’s verified role within the org (from the DB). |
is_b2b | True for org-scoped B2B requests. |
Access is also written to an access_logs collection for audit compliance.
Accessing tenant context in a route
Route handlers read the resolved context from request.state via the two helper
functions exported alongside the middleware:
from fastapi import Request
from src.middleware.tenant_isolation import (
get_tenant_context,
require_tenant_context,
)
@router.get("/resource")
async def get_resource(request: Request):
# Returns (org_id, user_id, org_role); any may be None
org_id, user_id, org_role = get_tenant_context(request)
...
@router.post("/resource")
async def create_resource(request: Request):
# Raises HTTPException (400/401/403) if any context is missing
org_id, user_id, org_role = require_tenant_context(request)
...Tenant-scoped database queries
To keep org-scoped collections isolated, build queries with TenantScopedQuery
rather than hand-writing filters. It injects the current org_id so a missing
org context fails closed instead of leaking across tenants:
from src.middleware.tenant_isolation import TenantScopedQuery
# .filter(...) merges org_id into an existing filter
query = TenantScopedQuery(request)
workflows = await db.workflows.find(
query.filter({"status": "active"})
).to_list(100)
# .scoped(collection) enforces org_id on ALL operations
# (find / update / delete) without caller cooperation
coll = TenantScopedQuery(request).scoped(db["items"])
items = await coll.find({"status": "active"}).to_list(100)query.filter() raises HTTPException(400) if there is no org context, ensuring
an unscoped query can never reach an org-scoped collection by accident.
Relationship to the gateway
When you proxy LLM traffic through the gateway (point your OpenAI/Anthropic SDK at
https://api.curate-me.ai/v1/openai with the X-CM-API-Key header), the gateway
derives org context from your API key and applies its governance chain — budgets,
rate limits, PII scanning, model allowlists, and cost recording — on the server.
You do not register any middleware yourself.
For the full request lifecycle and policy stages, see the governance chain documentation.