Tenant scoping and isolation
How tenant scope is derived and enforced, and what is NOT protecting you.
A tenant in ErmisAI is a string. Every durable row and every runtime state key is filed under it, and every query that should stay inside one workspace has to say so itself.
There is no row-level security. No CREATE POLICY, no ENABLE ROW LEVEL SECURITY anywhere in
drizzle/ or src/. The application connects as one Postgres role with full access to every
tenant's rows. A SELECT you write without a tenant_scope_id predicate returns every tenant's
data, and nothing in the database will stop it. Isolation is application-level filtering plus
per-route gates — nothing else.
The tenant scope id
Two shapes, and only two:
| Shape | Meaning |
|---|---|
org:<clerkOrgId> | A Clerk organization is active on the session — the shared newsroom |
user:<clerkUserId> | No active org — the user's personal workspace |
A signed-in user with no organization is not "unscoped". They are the sole owner of their own
tenant (src/lib/auth/clerk-session.ts:217). There is no third state.
resolveTenantScopeIdFromSessionClaims (src/lib/auth/clerk-session.ts:334-356) is the resolver
almost every route should call:
- If the compact Clerk org claim
o.idis present, returnorg:<id>. - Otherwise, for a live session, return
user:<lower-cased userId>. - The
clerk_usersDB snapshot is consulted only for claim-less contexts — background jobs and webhooks. A comment atclerk-session.ts:343-348records why: falling through to the snapshot for live sessions made Clerk's org switcher cosmetic, because the server kept resolving the org scope and the personal workspace became unreachable.
Note the casing asymmetry: user ids are lower-cased (normalizeSessionUserId,
clerk-session.ts:91-99), organization ids are only trimmed (getString,
clerk-session.ts:60-69). Clerk ids are mixed case — a real fixture in this repo is
user_3CRBRtElWWumw8Gk5rRxZE9RGc8 (tests/ai-runtime-telemetry.vitest.ts:27).
The pattern for a tenant-scoped route
Every API handler carries its own gate; middleware matchers are defence-in-depth only
(src/proxy.ts:3-6). The canonical order is authenticate, resolve session metadata, check a
capability, then derive the scope — as in src/app/api/stories/route.ts:57-71:
const { userId, sessionClaims } = await auth()
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const sessionMetadata = await getResolvedClerkSessionMetadata(userId, sessionClaims)
if (!canEditStories(sessionMetadata.appRole, sessionMetadata.tenantRole)) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
const tenantScopeId = await resolveTenantScopeIdFromSessionClaims(userId, sessionClaims)The scope comes from verified session claims. It never comes from a query parameter, a request body, or a header.
The three resolvers, and how they differ
src/lib/platform/shared/tenant-scope.ts exports two more resolvers that platform code passes an
explicit scope into. They do not behave the same way:
| Function | Explicit override | Returns | Notes |
|---|---|---|---|
resolveTenantScopeIdFromSessionClaims (clerk-session.ts:334) | not accepted | org:<id> or user:<id> | derives from claims only |
resolveTenantScopeId (tenant-scope.ts:110-135) | authorized, then returned verbatim | scope or null | falls back to the DB snapshot; returns null if the repository is unavailable |
resolveScopedTenantScopeId (tenant-scope.ts:137-161) | authorized, then returned lower-cased | scope, or user:<id> as last resort | never returns null for a signed-in user |
Both of the latter run assertCallerAuthorizedForTenantScope before trusting an override.
The explicit-override guard
assertCallerAuthorizedForTenantScope (tenant-scope.ts:50-108) is defence-in-depth against a
future caller forwarding client input as a scope:
user:scopes authorize only that same user, with no database round trip.org:scopes authorize only a caller holding atenant_membershipsrow for that org.- An unrecognized scope shape is refused rather than trusted.
Failures:
| Condition | Status | Code | Message |
|---|---|---|---|
| Scope belongs to another user or org | 403 | TENANT_SCOPE_FORBIDDEN | You are not authorized for the requested workspace. |
| Membership repository unavailable, production | 503 | TENANT_SCOPE_UNVERIFIABLE | Workspace access could not be verified. Please retry. |
The 503 is deliberate: the repository disables itself for a 60-second cooldown after one error
(createRepositoryAvailability, src/lib/runtime/repository-availability.ts:11, 43-53), so passing
through on unavailability would silently switch the guard off for the rest of that window. Only the
local/test fallback mode, which has no membership snapshots at all, passes through unchecked
(tenant-scope.ts:83-85).
A null actor disables the guard completely. assertCallerAuthorizedForTenantScope returns
immediately when userId is falsy (tenant-scope.ts:54-56), because cron and queue contexts pass
server-trusted scopes with no user. That is also how the admin console reaches tenant data. If you
write a code path that forwards a client-supplied scope with a null actor, there is no isolation
left in the stack below you — your own route gate is the whole protection.
Do not assume scope ids are case-normalized. The guard lower-cases both the scope and the user
id before the lookup (tenant-scope.ts:58-64, :97), and getTenantMembershipSnapshot compares
with an exact eq (src/lib/db/clerk-user-repository.ts:988-999). The Clerk webhook writes
tenant_memberships with Clerk's ids verbatim — resolveTenantScopeId at
clerk-user-repository.ts:152-154, called at :552-557 — and nothing in that file lower-cases
anything. The unit test covering the guard mocks the repository
(tests/tenant-scope-authorization.vitest.ts:29), so it cannot catch a mismatch between the two
sides. Normalize explicitly on both ends of any new comparison.
Scope without a Clerk session: the integration API key
The three published integration endpoints have no session at all. resolveApiKeyAccess(rawKey)
hashes the presented x-ermis-api-key and returns { keyId, userId, tenantScopeId } from the
matching tenant_integration_api_keys row (src/lib/platform/integrations/keys-and-wordpress.ts:466-496).
The scope is a property of the key, not of the request.
The CMS export then calls getTenantStoryDetail(authorization.userId, item.id, authorization.tenantScopeId)
(src/lib/platform/integrations/public-cms-story-export.ts:133) — passing the key owner's user id
alongside the explicit scope, so the membership guard above does run on this path rather than being
bypassed by a null actor. The endpoints themselves are documented in
Public integration API.
The relational layer
17 of the 25 tables in src/lib/db/schema.ts carry a tenant_scope_id column. The scope is part of
the key, not an afterthought:
| Table | How the scope keys it |
|---|---|
tenant_newsrooms (schema.ts:667) | tenant_scope_id is the primary key |
tenant_webhook_configs (schema.ts:753) | tenant_scope_id is the primary key |
tenant_subscriptions (schema.ts:236) | unique |
tenant_memberships (schema.ts:393) | composite PK with clerk_user_id |
editorial_story_drafts (schema.ts:579) | composite PK with story_id |
editor_queue_items (schema.ts:659) | composite PK with user_id, story_id |
story_chat_sessions, story_chat_session_payloads (schema.ts:491, :519) | composite PK with clerk_user_id, story_id, chat_id |
billing_invoices, billing_portal_sessions, billing_checkout_sessions, tenant_integration_api_keys, tenant_wordpress_connections, tenant_webhook_logs, ai_usage_metering_outbox | indexed, scope-leading |
ai_usage_ledger (schema.ts:792), data_erasure_requests (schema.ts:916) | nullable — platform-scoped and user-subject rows exist |
The eight tables with no scope column are billing_plans, clerk_users, clerk_webhook_events,
billing_webhook_events, source_catalog_entries, source_catalog_dataset_versions,
platform_state, and platform_state_hash. The first six are platform-global by design. The last
two are covered below.
Composite primary keys do most of the work here: an insert or upsert that forgets the scope fails loudly rather than writing into the wrong tenant. Prefer that shape for new tables.
The key-value layer
platform_state has a single-column primary key — key (schema.ts:873). There is no scope
column and no constraint. Isolation in the KV layer is entirely a key-naming convention.
buildScopedPlatformKey(domain, id) produces platform:<domain>:<lower-cased id>
(src/lib/platform/shared/runtime-state.ts:10-12, 18-20). Most per-tenant keys pass the tenant
scope id as that second segment. Some builders interpolate the scope directly without normalizing —
buildEditorQueueStateKey and buildStoryNotesStateKey (runtime-state.ts:22-27) — so both a
lower-cased and a verbatim form can exist in the same keyspace.
Payloads are zod-validated on read and on write, but resolveLocalPlatformStateSchemaByKey falls
through to z.unknown() for any key family it does not match
(src/lib/platform/shared/state-schemas.ts:381). A typo'd key family gets no schema enforcement and
no warning.
Keys and rows that are deliberately not tenant-scoped
Not everything under platform: is per-tenant. These are global on purpose, and reading them is
reading across tenants:
| Key or row | What it is |
|---|---|
platform:admin:delivery-logs | Bounded 500-entry cross-tenant "latest deliveries" mirror for the admin dashboard. src/lib/platform/shared/delivery-log.ts:5-18, 35 calls it "explicitly recent-N, not a ledger" — per-tenant raw logs and exact per-day counters live under separate scoped keys |
platform:admin:review-status-events, platform:admin:delivery-failed-events | Global 500-entry event mirrors read by the notifications stream (delivery-log.ts:37-38) |
platform:editorial:article-drafts (no suffix) | Legacy global review-runtime fallback using defaultContentLocale (local-platform-data.ts:720-751). The tenant path is platform:editorial:article-drafts:<scope> (:259) |
platform:integrations-api-keys:global | One global array in the state-fallback path only. Legacy records without a tenantScopeId are pinned to the owner's personal scope, not their current org, so an old key cannot float into a workspace it was never minted for (keys-and-wordpress.ts:50-62) |
platform:story-feed-cache:v1:<scopeKey> | The persisted feed cache. scopeKey is the ordered set of selected source ids plus the content locale (getFeedScopeKey, rss-aggregation.ts:3438-3457) — not a tenant id. Two tenants with identical source selections and the same content locale share one cache entry. It holds ingested and synthesized wire content, never drafts |
ai_usage_ledger rows with tenant_scope_id IS NULL | Platform-scoped pipeline runs, billing scope platform |
Who you are: two independent role axes
Isolation answers which tenant. Roles answer what you may do inside it, and the two axes resolve
from different sources on purpose (clerk-session.ts:251-275):
appRole— the platform staff claimsuper_admin|editor|ops|unassigned— is always read from the live Clerk JWT and never from the DB snapshot, so a demoted staff member loses access on the next token refresh rather than when a webhook catches up.tenantRole—owner|admin|member— is DB-authoritative, read fromtenant_membershipsfor the active scope.
The tenant-role fallback is least-privilege: no org means owner of your own personal scope, Clerk
org:admin means owner, and any unrecognized custom Clerk org role resolves to member
(clerk-session.ts:209-217). A comment there records that defaulting to owner was a fail-open
gate. super_admin bypasses every tenant capability check (hasAdminBypass,
src/lib/auth/newsroom-roles.ts:51-53); editor and ops get no tenant-side bypass at all.
The full capability matrix lives in Team, roles and permissions.
Story ids collide across tenants
Story ids are content-derived, not tenant-derived. An article id is an FNV-1a hash of
sourceId|url|publishedAt|headline (rss-aggregation.ts:1288-1296, 1583-1586); a cluster id hashes
the earliest member article (:2108); the story takes the cluster id (:3261). The same wire item
in two newsrooms produces the same story-<hash> on both.
That is why:
editorial_story_draftsis keyed(tenant_scope_id, story_id), neverstory_idalone.- Every admin review mutation requires a
tenantScopeIdin the request body and returns400 {"error":"tenantScopeId is required"}without one (src/app/api/admin/review/[storyId]/approve/route.ts:13-18, 38-40). The comment there states the reason directly. /admin/review/[storyId]rendersMissing tenant scope. Open this story from the review queue so the tenant context is carried along - admin review acts on tenant data.when the?tenantScopeId=query parameter is absent (admin/review/[storyId]/page.tsx:41-50). A bare story-id deep link is not usable.
Cross-tenant surfaces that exist on purpose
| Surface | Gate | What crosses |
|---|---|---|
/admin/review queue | canAccessAdminSurface(appRole, 'review') — super_admin, editor | listReviewQueue reads across tenants and every item carries its own tenantScopeId (local-platform-data.ts:2726-2731). It is repository-backed only — no unscoped in-memory fallback |
/api/admin/review/[storyId]/approve, /reject, /return-to-compose | same | Delegates to the tenant review runtime with a null actor, so the scope guard does not run and the client-supplied tenantScopeId is trusted after the role check (local-platform-data.ts:2768-2786) |
/api/notifications/events | canAccessAdminSurface(appRole, 'review') / 'deliveries' | A platform reviewer receives review events for all tenants; a delivery operator receives all delivery-failure events. Everyone else is filtered to their own scope (src/app/api/notifications/events/route.ts:56-91) |
/admin/tenants | tenants surface — super_admin only | Lists tenant scope ids directly; there is no human-readable tenant name |
Details of each screen are in The operations console.
Erasure depends on the key convention
The GDPR tenant purge deletes 16 tables by tenant_scope_id, then finds the tenant's KV slice with
LIKE '%<escaped scope id>%' against platform_state and platform_state_hash
(src/lib/db/data-erasure-repository.ts:122, 257-268). A blank scope would degrade that to
LIKE '%%' and wipe every tenant, so purgeTenantData refuses an empty scope outright
(:115-120).
The practical consequence for anyone adding state: if your key does not embed the tenant scope id, erasure will not find it. The feed cache is correctly exempt — it holds no tenant-authored content — but a new per-tenant key named without the scope would silently survive a purge.
Checklist for anything tenant-scoped
tests/api-route-auth-guard.vitest.ts fails CI if a new route.ts carries no recognized auth token; the allowlist is exactly /api/health and /api/openapi/public-integrations.src/lib/auth/newsroom-roles.ts before doing any work — the session gives you appRole and tenantRole together.resolveTenantScopeIdFromSessionClaims. Never read it from request input.tenant_scope_id in the primary key or a unique constraint, and lead every index with it.buildScopedPlatformKey so the scope is embedded and erasure can find it, and register a schema in state-schemas.ts so it is not silently unvalidated.null actor anywhere, confirm the scope is server-derived — that call has no guard behind it.Route surfaces, proxy and auth gates
Where the per-route gate convention comes from and how the proxy composes Clerk with next-intl.
Database schema and runtime state
The 25 tables, the platform_state contract, and the rule for choosing a side.
Security controls
CSP, SSRF, rate limiting, secret handling, and the gaps the product does not claim to cover.
GDPR erasure runbook
What a purge deletes, the grace window, and the manual backstops.
The operations console
The eight admin surfaces and the role matrix that gates them.
API error and status reference
Every domain error code, including the tenant-scope 403 and 503.
