Skip to Content
SdkMiddleware (Tenant Isolation)

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:

  1. X-Org-ID header — the explicit org switch sent by the dashboard (must match ^org_[a-f0-9]+$).
  2. JWT claims — the org_id / org_role claims on a Bearer token.
  3. 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 fieldDescription
org_idResolved organization identifier (org_<hex>).
user_idThe authenticated user (JWT sub / user_id).
org_roleThe user’s verified role within the org (from the DB).
is_b2bTrue 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.