Skip to content

Tenant data

Most of your product’s data belongs to one customer organization. Mark such a model as tenant data and every query on it is scoped to the organization of the current request - and refused when there is none. Read this before you add your first model; the limits at the end are the rules you must not break.

Mark a model

In the product’s .prisma file, add the markers in the model:

model Todo {
id String @id @default(uuid())
title String
userId String // the owner: a scalar id, not a relation (see Limits below)
// @base
// @tenant
// @owned
}
model Shelf {
id String @id @default(uuid())
name String
// @base
// @tenant
// @unit
}
  • // @tenant expands, on compose-schema, to organizationId String, the relation to Organization (onDelete: Restrict) and @@index([organizationId]). The back-relation on Organization is generated like the User ones.
  • // @owned (optional, needs // @tenant in the same model) marks a record as belonging to one user: the model declares its own userId String field and the marker adds no field. Compose fails on @owned without @tenant, without a userId String field, outside a model, or in a core model.
  • // @unit (optional, needs // @tenant in the same model) adds unitId String? with a foreign key to Unit and an index. Null means an organization-level record. A model may carry both @owned and @unit.
  • The generated fields are not edited by hand (// @tenant:begin ... :end blocks are generated), and a model may not declare organizationId or unitId itself (it must declare userId for @owned).
  • compose-schema also writes TENANT_MODELS, UNIT_MODELS and OWNED_MODELS next to SOFT_DELETE_MODELS in soft-delete-models.generated.ts. Then pnpm nx run <name>-db:migrate-dev; the migration of an existing table needs a backfill (add the column nullable, fill with the built-in organization, then NOT NULL; Keelmint’s todos migration is the example).
  • What the Prisma tenantScope extension does for every model in TENANT_MODELS, on every operation: unique-where operations get organizationId added at the top level; many-row operations get AND organizationId; creates stamp organizationId from the context (a different value from the caller throws); softDelete/softDeleteMany and prisma.withDeleted are scoped too. A caller-supplied different organizationId throws.
  • A platform admin’s request spans organizations. A platform admin has no active organization; without X-Organization-Id an @Entity route’s tenant scope is every live organization (TenantStore.organizations = 'all'), and with a comma-separated list it is those organizations (The platform admin). Under either, reads, updates and deletes reach the rows of those organizations; an update or upsert that names organizationId (or the organization relation) throws, because the row’s organization is not known up front; and a create, an upsert included, throws TenantContextMissingError: a create always needs one organization (X-Organization-Id: <id>).
  • Fail closed: a tenant query with no organization in context (and neither a platform admin’s organization set nor platform scope) throws TenantContextMissingError, answered 403 ORGANIZATION_REQUIRED, never a 500. To a platform admin it answers 400 ORGANIZATION_REQUIRED: they are never refused an organization, their request lacks the one target a create or a one-organization route needs.

Who reaches which records: @owned and @unit

Inside a request, a second extension (ownershipScope, chained after tenantScope, so both filters apply) narrows every top-level query on an @owned or @unit model to the records the caller reaches for the route’s permission:

  • Plain permissions reach the caller’s own records. On an @owned model: userId = the caller. On a @unit model: the rows of the units the caller is a direct member of (not their sub-units, not their parents; never organization-level rows). This holds for a permission held organization-wide too: an org admin calling a Things.Read route on a @unit model sees only their direct units’ rows.
  • *Any widens. A route whose permission is <E>.ReadOne, <E>.Update or <E>.Delete also reaches what <E>.ReadAny, <E>.UpdateAny or <E>.DeleteAny reaches when the caller holds it, and a route whose own permission is a *Any (@Action('ReadAny')) uses it directly. <E>.Read, <E>.Create and every other action never widen: a list that should show everyone’s records needs its own @Action('ReadAny') route.
    • Held organization-wide: every record of the organization (organization-level @unit rows included).
    • Held in units (that unit and its descendants, from the token): on an @owned model, the caller’s own records plus those of the members of those units; on a @unit model, the rows of those units plus the caller’s direct ones. A member who left the organization is in no unit, so their records stay in reach of organization-wide holders only.
    • A model with both markers: plain permissions reach the caller’s own records (whatever their unit), a *Any the reached units plus the caller’s own.
  • Writes. An @owned create must set userId to the caller, and an update may not set userId (403 OWNERSHIP_FORBIDDEN). A @unit create or update may target the caller’s direct units and every unit where they hold <E>.UpdateAny (reading or deleting a unit’s rows is no licence to write there); no unit (organization level) only with <E>.UpdateAny held organization-wide. Otherwise 403 UNIT_FORBIDDEN. A relation write (unit: { connect }, user: { connect }) is refused: write the scalar id.
  • No recorded permission, no rows. A route without one (@Authenticated(), @Public()) reaches no @owned or @unit record, and a request without a signed-in caller neither. A helper route for such data must be an @Entity() route with a permission.
  • Not filtered: TenantContext.runAs and runAsPlatform (jobs, plan usage counters, account deletion, seeds). Platform admins reach every record: only the filter headers they send narrow their queries. In one organization (X-Organization-Id: <id>) the write checks still apply to them - an @owned record is created for the caller (OwnershipForbiddenError otherwise) and an update never sets its userId; over several organizations nothing is created and no write check runs. A record out of reach looks like no record: Keelmint’s todo routes answer 404, as for another organization’s id.
  • Lookups. Reach by unit needs the members of the reached units and the caller’s direct units, and the filter headers the units below the filter units and their members (ownershipLookups: membersOf, directUnitsOf, unitsUnder, membersOfUnits); the extension reads them from memberships, membership_units and units on the bare client (outside any interactive $transaction) and memoizes them per request. The members of the reached units go into the query as a list of user ids (userId IN (...)): an @owned model’s userId is a plain id, not a relation (keelmint’s Todo on purpose), so there is nothing to join through - see Measured limits.
  • A controller that serves unit admins declares @Entity({ units: true }), so a grant held in a unit passes the guard; only set it when every route’s data is @owned or @unit models. @Entity({ owned: true }) adds the entity’s ReadAny, UpdateAny and DeleteAny to the permission catalog (see Permissions). The product’s Prisma client factory passes the generated OWNED_MODELS and UNIT_MODELS to ownershipScope (with ownershipLookups over the base client) and exposes unitModels, which is how a unit delete counts the rows that still use a unit (a wrong name fails the boot). Keelmint’s TodosController (@Entity({ units: true, owned: true }) on the @owned Todo) is the example.

Filter headers

Every @Entity route takes two headers that narrow a list (or any read, update or delete) below the caller’s reach, from anyone:

  • X-Filter-Unit: <unit id>,<unit id> - on an @owned model, records whose owner is a live member of those units or their sub-units; on a @unit model, rows whose unitId is one of those units or below (a model with both markers filters by unitId).
  • X-Filter-Member: <user id>,<user id> - on an @owned model, records of those owners.

Rules:

  • Intersected with the reach. A value outside it (a parent or sibling unit of a unit admin, another organization’s unit) shrinks the result, possibly to nothing, never 403. Both headers together must both hold.
  • Over several organizations (a platform admin’s 'all' or list scope), each unit counts in its own organization: a user who is in a filter unit of one organization does not bring their records of another.
  • Reads, updates and deletes only. A create is never filtered (a filter narrows what is seen, not where a row may go). Models without @owned / @unit ignore the headers, and so does X-Filter-Member on a @unit-only model.
  • Validation: comma-separated uuids, at most 50 per header; anything else is 400 VALIDATION_FAILED. They are parsed on every route (OrganizationOverrideGuard, parseUuidListHeader in @core/api-core) and recorded on the request’s tenant scope (TenantStore.filters); ownershipScope applies them.
  • Not cached: every response carries Vary: X-Organization-Id, X-Filter-Unit, X-Filter-Member, and a filtered one Cache-Control: no-store (The platform admin).
  • A platform admin narrows organizations with X-Organization-Id (one id or a list, The platform admin); nobody else may send it.

The core admin lists (GET /api/members, the audit and helpdesk lists) keep their query filters (organization[], unit[], member[]); the headers are how a product route gets the same filters without DTO changes. On the client, injectLevelFilters sends them (Your product).

Acting without a request: TenantContext

TenantContext is in @core/shared/server (it uses AsyncLocalStorage, so server only).

CallUse
TenantContext.runRequest(getScope, fn)Opened by RequestContextMiddleware for every request. The organization and the caller’s grants are read lazily, because the guards set req.user afterwards.
TenantContext.runAs(organizationId, fn)Code acting for one organization with no request: tests, a job for one tenant. No ownership or unit filter.
TenantContext.runAsPlatform(fn)Cross-organization. Only for platform code: the platform pages, account-deletion hooks, seeds, jobs that sweep every tenant.
organizationId(), requireOrganizationId()Read the request’s one organization (the second throws without one, also under a platform admin’s every-organization or list scope: call it where code needs exactly one, e.g. before a create).
  • runAsPlatform switches the tenant filter off. It is greppable on purpose and every use is reviewed. Never use it to “make a query work” in tenant code: pass the organization (runAs) or fix the missing context.
  • A Prisma query is lazy. The scope helpers start a thenable result inside the scope, so they return a plain Promise: that result cannot go into $transaction([...]). Open the scope around the whole $transaction (or use the callback form).
  • Account deletion deletes the account’s data in every organization (the user-soft-delete hooks run under runAsPlatform); removing a member from one organization leaves their data with the organization.
  • Core models (UserInvitation, Ticket, AuditEvent, Membership, Unit) are not in TENANT_MODELS: their services filter by organization explicitly, because their rules differ (platform staff read across organizations; some organizations are null).

Limits

  • Relation reads and writes are not scoped. Like the soft-delete filter, the extension only sees top-level wheres. include, nested select, relation filters (some, every) and nested writes bypass it, and raw SQL is not scoped at all. So: product code never reaches a tenant model through a relation of a non-tenant model (read prisma.todo directly, not user.todos), and a tenant model’s foreign keys to other tenant models are plain scalar ids, checked in the service, not relations traversed across tenants. The same holds for the ownership filter: never reach @owned or @unit records through a relation or raw SQL.

  • Core’s links hold in the database too. Triggers (libs/core/db/prisma/same-organization.sql, in every product’s first migration) refuse a write that puts a unit under another organization’s unit, or gives a membership or an invitation another organization’s role or unit (or PLATFORM_ADMIN). The API checks all of this first; a write the triggers refuse is a bug and answers 500. Your own tenant models’ foreign keys are yours to check.

Measured limits

Measured with a load-test organization (Postgres 16, API and database on one machine; to repeat it, see Load testing): a unit admin whose grant covers a subtree of 781 units, members spread over it, 5,000 audit events and 3,000 tickets.

  • Prisma sends at most 32,766 bound values per statement to Postgres. A query over more answers 500 INTERNAL_ERROR (Prisma’s “query parameter limit … exceeded”).
  • Reach as a subquery: no ceiling from the members. GET /api/members (with unit/member filters) and the organization helpdesk queue match the people in reach with a relation filter on their memberships in the covered units (peopleInReachWhere in @core/db), so the statement grows with the covered units, not their members. Measured up to 72,000 members in the subtree.
  • @owned reach by unit is an id list. An @owned model’s userId is a plain id, not a relation, so a unit-scoped *Any caller’s query carries the members of the reached units as userId IN (...) (one value each). Keelmint’s GET /api/todos/all answers up to about 32,000 members in the caller’s subtree and fails from 36,000 (500). The X-Filter-Unit header on an @owned model expands the same way.
  • The organization audit log for a unit-scoped caller lists its people twice (actors and person targets: AuditEvent has no relations, its target is polymorphic), plus the subtree’s units, invitations and tickets. It answers at 13,500 members in the caller’s subtree and fails at 14,850 (with 3,000 tickets). Organization-wide callers have no list and are not limited.
  • Speed. At 10,000 members in the subtree a unit admin’s member list and helpdesk queue run at about 50 requests per second (p50 about 200 ms), todos at about 34 and the audit log at about 18 (p50 about 550 ms); an organization-wide caller’s are faster.
  • Access token size. A unit-scoped grant puts every covered unit into the access token (unitGrants), about 56 bytes per unit. Node’s default 16 KB limit on the request headers is reached somewhere between 150 and 300 covered units, depending on the other claims: past it every request answers 431 before any handler runs. The API image starts node main.cjs with that default; raise it with NODE_OPTIONS=--max-http-header-size=<bytes> (see Configuration; in the compose environment: of the api service or in deploy/<product>/.env). The deploy’s Caddy accepts much larger headers by default; another proxy may not (nginx: 8 KB per header line). Keep unit-scoped grants to subtrees of at most about 150 units, or raise the limits.

Test it

Every organization-scoped route needs a row in the tenant-isolation suite (apps/keelmint/api-e2e/src/api/tenant-isolation.spec.ts): two organizations with data, and ids from the other one must answer 404 while lists never contain them. A guard at the bottom of the suite fails when a route of the app has neither a row nor an ALLOWED entry with its reason. More in Testing.