Skip to content

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.

  • 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.
  • Actors — Org members, org admins, and platform admins (sys_admin) see different org lists and capabilities.
  • Rules — Routes either embed org_id in the path or require X-ORG-ID when the handler needs explicit tenant scope.
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.


FieldTypeNotes
org_idUUID7Primary key
namestringInternal short name (set at creation, read-only via org_admin)
display_namestring | nullHuman-visible name
domainstringUnique domain slug (read-only for org_admin; sys_admin only)
tierstringSubscription tier (e.g. free, starter); governs quota limits
statusstringactive or inactive; inactive orgs are excluded from user listings
descriptionstring | nullFree-form description
contact_emailstring | nullValidated email
websitestring | nullURL
logo_urlstring | nullURL
countrystring | nullCountry code
timezonestring | nullIANA timezone
is_deletedbooleanSoft delete
created_atISO-8601 stringUTC timestamp
FieldTypeNotes
user_idUUID7Foreign key
org_idUUID7Foreign key
is_adminbooleanOrg admin flag — tracked separately from RBAC roles

Key-value store for org-level runtime overrides (stored in the database, broadcast via RabbitMQ when changed).

FieldTypeNotes
org_idUUID7Owning org
keystringSetting key (e.g. max_tokens, default_model)
valuestringSetting value
overridablebooleanWhether lower-tier overrides can replace this value

Quota limits enforced per org based on its tier.

FieldTypeMeaning
max_orchestratorsintMaximum orchestrator instances per org
max_central_pointsint | nullMaximum central point aliases
max_membersintMaximum org members
max_messages_per_monthintMonthly chat message limit
max_messages_per_dayintDaily chat message limit
rate_limit_rpmintAPI requests per minute
rate_chat_limit_rpmintChat requests per minute
rate_limit_burstintBurst allowance above rpm limit
max_llm_configsintMaximum LLM configuration entries
MethodPathPermissionDescription
GET/api/orgsAuthenticatedList accessible orgs (sys_admin sees all; others see own)
GET/api/orgs/{org_id}cadence:org:readGet org profile
PATCH/api/orgs/{org_id}/profilecadence:org:writeUpdate 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.

MethodPathPermissionDescription
POST/api/admin/orgscadence:system:orgs:createCreate a new organization
GET/api/admin/orgscadence:system:adminList all orgs including deleted
GET/api/admin/orgs/{org_id}cadence:system:adminGet any org by ID
PATCH/api/admin/orgs/{org_id}cadence:system:adminFull org update (name, domain, tier, status)
GET/api/admin/orgs/{org_id}/quotacadence:system:tiers:readGet tier quota limits for an org
MethodPathPermissionDescription
GET/api/orgs/{org_id}/userscadence:org:members:readList active org members
POST/api/orgs/{org_id}/memberscadence:org:members:writeAdd an existing user to the org
PATCH/api/orgs/{org_id}/users/{user_id}/membershipcadence:org:members:writeToggle admin flag
DELETE/api/orgs/{org_id}/users/{user_id}cadence:org:members:writeRemove a user from the org
MethodPathPermissionDescription
GET/api/orgs/{org_id}/settingscadence:org:settings:readList all org-level settings
POST/api/orgs/{org_id}/settingscadence:org:settings:writeCreate or update a setting
GET/api/orgs/{org_id}/orchestrator-defaultscadence:org:settings:readGet org orchestrator defaults
PUT/api/orgs/{org_id}/orchestrator-defaultscadence:org:settings:writeSet org orchestrator defaults
  1. AuthenticationMiddleware validates the JWT signature or hashes the X-API-KEY value to look up the key row. Invalid credentials are rejected with 401 before any session work.

  2. TenantContextMiddleware runs next. For JWTs, it reads jti and 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 id apikey:{key_row.id}, scopes from the key (sanitized), and org membership from the user’s memberships in the database.

  3. The route declares dependencies such as Depends(roles_allowed(PERM)) or Depends(authenticated). Handlers that work on a specific org usually call org_context(security, org_id) so non–system admins must be members of that org.

  4. Some endpoints (for example chat completion) read X-ORG-ID in the handler and call session.has_permission(..., org_id) with that value. The handler then runs services that query only that org’s data.

Tenant context middleware dispatch
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).

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).

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.

Organization listing for sys_admin vs regular users
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)

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.

ui/app/composables/useAuth.ts
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.

SymptomCauseFix
400 Missing organization (header: X-ORG-ID)Chat or org-header route called without X-ORG-IDAdd X-ORG-ID: <org_id> to the request
403 on org-scoped actionCaller not a member or lacks the required permission in that orgVerify membership and role; check GET /api/me/orgs
404 after correct org_id in pathOrg doesn’t exist, or is soft-deleted and the route excludes deletedVerify org exists via GET /api/admin/orgs/{org_id} (sys_admin)
API key rejected for orgScope or membership does not include the target orgGrant org membership or scopes that cover the operation
Wrong data after UI org switchCookie still pointing at the old org; cached composable stateClick the org switcher or call selectOrg(orgId) explicitly; clear cookies if stuck
409 Organization domain already existsDomain is already in useChoose a unique domain value for the new org
Org member can’t read settingsHas org_member role which lacks cadence:org:settings:readAssign org_admin or a custom role with that permission