Μετάβαση στο περιεχόμενο
ErmisAI

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:

ShapeMeaning
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:

  1. If the compact Clerk org claim o.id is present, return org:<id>.
  2. Otherwise, for a live session, return user:<lower-cased userId>.
  3. The clerk_users DB snapshot is consulted only for claim-less contexts — background jobs and webhooks. A comment at clerk-session.ts:343-348 records 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:

FunctionExplicit overrideReturnsNotes
resolveTenantScopeIdFromSessionClaims (clerk-session.ts:334)not acceptedorg:<id> or user:<id>derives from claims only
resolveTenantScopeId (tenant-scope.ts:110-135)authorized, then returned verbatimscope or nullfalls back to the DB snapshot; returns null if the repository is unavailable
resolveScopedTenantScopeId (tenant-scope.ts:137-161)authorized, then returned lower-casedscope, or user:<id> as last resortnever 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 a tenant_memberships row for that org.
  • An unrecognized scope shape is refused rather than trusted.

Failures:

ConditionStatusCodeMessage
Scope belongs to another user or org403TENANT_SCOPE_FORBIDDENYou are not authorized for the requested workspace.
Membership repository unavailable, production503TENANT_SCOPE_UNVERIFIABLEWorkspace 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:

TableHow 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_outboxindexed, 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 rowWhat it is
platform:admin:delivery-logsBounded 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-eventsGlobal 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:globalOne 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 NULLPlatform-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 claim super_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.
  • tenantRoleowner | admin | member — is DB-authoritative, read from tenant_memberships for 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_drafts is keyed (tenant_scope_id, story_id), never story_id alone.
  • Every admin review mutation requires a tenantScopeId in the request body and returns 400 {"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] renders Missing 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

SurfaceGateWhat crosses
/admin/review queuecanAccessAdminSurface(appRole, 'review') — super_admin, editorlistReviewQueue 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-composesameDelegates 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/eventscanAccessAdminSurface(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/tenantstenants surface — super_admin onlyLists 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

Gate the route yourself. 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.
Check a capability from src/lib/auth/newsroom-roles.ts before doing any work — the session gives you appRole and tenantRole together.
Derive the scope with resolveTenantScopeIdFromSessionClaims. Never read it from request input.
For a new table, put tenant_scope_id in the primary key or a unique constraint, and lead every index with it.
For a new state key, build it with 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.
If you pass a null actor anywhere, confirm the scope is server-derived — that call has no guard behind it.

Σε αυτή τη σελίδα