Multi-tenancy
Organization isolation, X-ORG-ID context, membership model, tier quotas, and tenant settings.
Intended audience: Stakeholders, Business analysts, Solution architects, Developers, Testers
Learning outcomes by role
Stakeholders
- Explain org isolation and tier quotas as commercial and operational boundaries for one shared Cadence deployment.
Business analysts
- Specify org membership rules, admin versus member behaviors, and header/path conventions for user stories.
Solution architects
- Map X-ORG-ID, path org_id, and middleware session hydration to integration and zero-trust network designs.
Developers
- Apply org_context, RBAC dependencies, and tenant settings when implementing or extending org-scoped APIs.
Testers
- Verify 400/403 paths for wrong or missing org scope, membership, and tier limits across org-scoped routes.
Each organization is a separate customer space: users only see orgs they belong to, and APIs require the correct org in the path or X-ORG-ID so data does not mix.
Multi-tenancy means many organizations share one Cadence deployment while their data and orchestrator instances stay isolated. Another org’s data is not returned or updated when the caller lacks membership and permissions for the org in scope. Treat X-ORG-ID as which tenant account the caller is acting for; enforcement combines RBAC, org_context (membership), and org-scoped queries in services.
Summary for stakeholders
Section titled “Summary for stakeholders”- Commercial unit — Each organization is the billable/contractual boundary for quotas (
TierQuota) and feature limits. - Data separation — Cross-org leakage is blocked by membership checks and permissions, not by separate databases per customer.
Business analysis
Section titled “Business analysis”- Actors — Org members, org admins, and platform admins (
sys_admin) see different org lists and capabilities. - Rules — Routes either embed
org_idin the path or requireX-ORG-IDwhen the handler needs explicit tenant scope.
Architecture overview
Section titled “Architecture overview”flowchart TD
R[Incoming request] --> TC[TenantContextMiddleware]
TC -->|Bearer JWT| JWTD[Load session from Redis via jti]
TC -->|After API key validated| APIKEY[Build session from key row]
JWTD --> STATE[Session on request.state]
APIKEY --> STATE
STATE --> DEP[authenticated or roles_allowed]
DEP --> ORG{Org needed?}
ORG -->|URL /api/orgs/org_id/...| PATH[org_id from path]
ORG -->|Some routes| HEADER[X-ORG-ID header]
ORG -->|Missing when required| ERR400[400 Bad Request]
PATH --> CHECK[Permission plus org_context]
HEADER --> CHECK
CHECK -->|ok| HANDLER[Handler for that org]
CHECK -->|denied| ERR403[403 Forbidden]
HANDLER --> RESULT[Response]
TenantContextMiddleware runs after AuthenticationMiddleware on the inbound request (see How the platform works). It sets request.state.session to a BearerTokenSession (Redis) or ApiKeyTokenSession (synthetic). Route handlers use Depends(authenticated), Depends(roles_allowed(PERM)), and org_context(security, org_id) from shared route helpers to enforce membership and build TenantContext.
Data model
Section titled “Data model”Organization
Section titled “Organization”| Field | Type | Notes |
|---|---|---|
org_id | UUID7 | Primary key |
name | string | Internal short name (set at creation, read-only via org_admin) |
display_name | string | null | Human-visible name |
domain | string | Unique domain slug (read-only for org_admin; sys_admin only) |
tier | string | Subscription tier (e.g. free, starter); governs quota limits |
status | string | active or inactive; inactive orgs are excluded from user listings |
description | string | null | Free-form description |
contact_email | string | null | Validated email |
website | string | null | URL |
logo_url | string | null | URL |
country | string | null | Country code |
timezone | string | null | IANA timezone |
is_deleted | boolean | Soft delete |
created_at | ISO-8601 string | UTC timestamp |
UserOrgMembership
Section titled “UserOrgMembership”| Field | Type | Notes |
|---|---|---|
user_id | UUID7 | Foreign key |
org_id | UUID7 | Foreign key |
is_admin | boolean | Org admin flag — tracked separately from RBAC roles |
TenantSetting
Section titled “TenantSetting”Key-value store for org-level runtime overrides (stored in the database, broadcast via RabbitMQ when changed).
| Field | Type | Notes |
|---|---|---|
org_id | UUID7 | Owning org |
key | string | Setting key (e.g. max_tokens, default_model) |
value | string | Setting value |
overridable | boolean | Whether lower-tier overrides can replace this value |
TierQuota
Section titled “TierQuota”Quota limits enforced per org based on its tier.
| Field | Type | Meaning |
|---|---|---|
max_orchestrators | int | Maximum orchestrator instances per org |
max_central_points | int | null | Maximum central point aliases |
max_members | int | Maximum org members |
max_messages_per_month | int | Monthly chat message limit |
max_messages_per_day | int | Daily chat message limit |
rate_limit_rpm | int | API requests per minute |
rate_chat_limit_rpm | int | Chat requests per minute |
rate_limit_burst | int | Burst allowance above rpm limit |
max_llm_configs | int | Maximum LLM configuration entries |
API reference
Section titled “API reference”Org profile
Section titled “Org profile”| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/orgs | Authenticated | List accessible orgs (sys_admin sees all; others see own) |
GET | /api/orgs/{org_id} | cadence:org:read | Get org profile |
PATCH | /api/orgs/{org_id}/profile | cadence:org:write | Update mutable profile fields (org_admin) |
PATCH /api/orgs/{org_id}/profile accepts only: display_name, description, contact_email,
website, logo_url, country, timezone. Fields name, domain, tier, and status
are read-only for org admins.
Admin org management (cadence:system:*)
Section titled “Admin org management (cadence:system:*)”| Method | Path | Permission | Description |
|---|---|---|---|
POST | /api/admin/orgs | cadence:system:orgs:create | Create a new organization |
GET | /api/admin/orgs | cadence:system:admin | List all orgs including deleted |
GET | /api/admin/orgs/{org_id} | cadence:system:admin | Get any org by ID |
PATCH | /api/admin/orgs/{org_id} | cadence:system:admin | Full org update (name, domain, tier, status) |
GET | /api/admin/orgs/{org_id}/quota | cadence:system:tiers:read | Get tier quota limits for an org |
Membership management
Section titled “Membership management”| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/orgs/{org_id}/users | cadence:org:members:read | List active org members |
POST | /api/orgs/{org_id}/members | cadence:org:members:write | Add an existing user to the org |
PATCH | /api/orgs/{org_id}/users/{user_id}/membership | cadence:org:members:write | Toggle admin flag |
DELETE | /api/orgs/{org_id}/users/{user_id} | cadence:org:members:write | Remove a user from the org |
Org settings
Section titled “Org settings”| Method | Path | Permission | Description |
|---|---|---|---|
GET | /api/orgs/{org_id}/settings | cadence:org:settings:read | List all org-level settings |
POST | /api/orgs/{org_id}/settings | cadence:org:settings:write | Create or update a setting |
GET | /api/orgs/{org_id}/orchestrator-defaults | cadence:org:settings:read | Get org orchestrator defaults |
PUT | /api/orgs/{org_id}/orchestrator-defaults | cadence:org:settings:write | Set org orchestrator defaults |
How it works — request lifecycle
Section titled “How it works — request lifecycle”-
AuthenticationMiddlewarevalidates the JWT signature or hashes theX-API-KEYvalue to look up the key row. Invalid credentials are rejected with401before any session work. -
TenantContextMiddlewareruns next. For JWTs, it readsjtiand loads the session from Redis (session:{jti}). For API keys (after the key row was loaded in the previous middleware), it builds a synthetic session: stable idapikey:{key_row.id}, scopes from the key (sanitized), and org membership from the user’s memberships in the database. -
The route declares dependencies such as
Depends(roles_allowed(PERM))orDepends(authenticated). Handlers that work on a specific org usually callorg_context(security, org_id)so non–system admins must be members of that org. -
Some endpoints (for example chat completion) read
X-ORG-IDin the handler and callsession.has_permission(..., org_id)with that value. The handler then runs services that query only that org’s data.
async def dispatch(self, request: Request, call_next): request.state.session = None
api_row = getattr(request.state, "api_key_row", None) if api_row is not None: session = await self._session_from_api_key(request, api_row) request.state.session = session request.state.token_jti = session.jti return await call_next(request)
bearer = self._extract_bearer(request) if bearer: jti = self._decode_jwt_jti(bearer) if jti: session_store = getattr(request.app.state, "session_store", None) if session_store: session = await session_store.get_session(jti) if session: request.state.session = session request.state.token_jti = jti
return await call_next(request)How it works — permission and org context
Section titled “How it works — permission and org context”Path-scoped org routes (for example GET /api/orgs/{org_id}) use Depends(roles_allowed(PERM)), which requires a logged-in session and checks that the caller holds the needed permission according to authorization middleware rules.
Membership in the org: Many handlers call org_context(security, org_id) from shared route helpers:
- System administrators may act for any org id.
- Everyone else must be a member of that org or the API returns 403.
- The result is a
TenantContext(user_id,org_id, admin flags, full session) used for the rest of the handler.
Header-based org: Endpoints such as POST /api/chat/completion require X-ORG-ID and check permissions for that org inside the handler (for example CHAT_USE).
How it works — API key session
Section titled “How it works — API key session”ApiKeyTokenSession is built in TenantContextMiddleware._session_from_api_key: org membership and admin flags come from the database for the key’s user; allowed actions come from the key’s scopes, after sanitize_api_key_flat_scopes removes the highest-risk platform permissions.
When the API checks has_permission(permission, org_id) for an API-key session, the user must belong to that org and the scope list must allow the permission (wildcards are expanded the same way as for interactive users).
How it works — sys_admin org visibility
Section titled “How it works — sys_admin org visibility”sys_admin callers bypass org membership checks and can access any org. GET /api/orgs
returns all orgs (including soft-deleted) for sys_admin with role = "sys_admin", while
regular users receive only their own memberships with their actual role.
if security.session.is_sys_admin: orgs = await tenant_service.list_orgs(include_deleted=True) ...orgs = await tenant_service.list_orgs_for_user(security.user_id)UI walkthrough
Section titled “UI walkthrough”In the Nuxt management app, the active organization is stored in a cookie so the client can send X-ORG-ID on requests that need it (for example chat). Your own frontend should follow the same idea: pick one org per session or screen, then pass it in the header when the API expects it.
const currentOrgId = useCookie<string | null>(COOKIE_SESSION_CONTEXT, { default: () => null })
const currentOrg = computed<OrgAccessResponse | null>(() => { if (!currentOrgId.value) return null return orgList.value.find((o) => o.org_id === currentOrgId.value) ?? null})
function selectOrg(orgId: string): void { currentOrgId.value = orgId router.push('/dashboard')}When a user with is_sys_admin = true logs in, currentOrgId is set to null — admins
work at the system level and must explicitly select an org when needed.
Verification and quality
Section titled “Verification and quality”Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
400 Missing organization (header: X-ORG-ID) | Chat or org-header route called without X-ORG-ID | Add X-ORG-ID: <org_id> to the request |
403 on org-scoped action | Caller not a member or lacks the required permission in that org | Verify membership and role; check GET /api/me/orgs |
404 after correct org_id in path | Org doesn’t exist, or is soft-deleted and the route excludes deleted | Verify org exists via GET /api/admin/orgs/{org_id} (sys_admin) |
| API key rejected for org | Scope or membership does not include the target org | Grant org membership or scopes that cover the operation |
| Wrong data after UI org switch | Cookie still pointing at the old org; cached composable state | Click the org switcher or call selectOrg(orgId) explicitly; clear cookies if stuck |
409 Organization domain already exists | Domain is already in use | Choose a unique domain value for the new org |
| Org member can’t read settings | Has org_member role which lacks cadence:org:settings:read | Assign org_admin or a custom role with that permission |