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}// @tenantexpands, oncompose-schema, toorganizationId String, the relation toOrganization(onDelete: Restrict) and@@index([organizationId]). The back-relation onOrganizationis generated like theUserones.// @owned(optional, needs// @tenantin the same model) marks a record as belonging to one user: the model declares its ownuserId Stringfield and the marker adds no field. Compose fails on@ownedwithout@tenant, without auserId Stringfield, outside a model, or in a core model.// @unit(optional, needs// @tenantin the same model) addsunitId String?with a foreign key toUnitand an index. Null means an organization-level record. A model may carry both@ownedand@unit.- The generated fields are not edited by hand (
// @tenant:begin ... :endblocks are generated), and a model may not declareorganizationIdorunitIditself (it must declareuserIdfor@owned). compose-schemaalso writesTENANT_MODELS,UNIT_MODELSandOWNED_MODELSnext toSOFT_DELETE_MODELSinsoft-delete-models.generated.ts. Thenpnpm 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, thenNOT NULL; Keelmint’stodosmigration is the example).- What the Prisma
tenantScopeextension does for every model inTENANT_MODELS, on every operation: unique-where operations getorganizationIdadded at the top level; many-row operations getAND organizationId; creates stamporganizationIdfrom the context (a different value from the caller throws);softDelete/softDeleteManyandprisma.withDeletedare scoped too. A caller-supplied differentorganizationIdthrows. - A platform admin’s request spans organizations. A platform admin has no active organization; without
X-Organization-Idan@Entityroute’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 namesorganizationId(or theorganizationrelation) throws, because the row’s organization is not known up front; and a create, an upsert included, throwsTenantContextMissingError: 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 403ORGANIZATION_REQUIRED, never a 500. To a platform admin it answers 400ORGANIZATION_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
@ownedmodel:userId= the caller. On a@unitmodel: 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 aThings.Readroute on a@unitmodel sees only their direct units’ rows. *Anywidens. A route whose permission is<E>.ReadOne,<E>.Updateor<E>.Deletealso reaches what<E>.ReadAny,<E>.UpdateAnyor<E>.DeleteAnyreaches when the caller holds it, and a route whose own permission is a*Any(@Action('ReadAny')) uses it directly.<E>.Read,<E>.Createand 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
@unitrows included). - Held in units (that unit and its descendants, from the token): on an
@ownedmodel, the caller’s own records plus those of the members of those units; on a@unitmodel, 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
*Anythe reached units plus the caller’s own.
- Held organization-wide: every record of the organization (organization-level
- Writes. An
@ownedcreate must setuserIdto the caller, and an update may not setuserId(403OWNERSHIP_FORBIDDEN). A@unitcreate 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>.UpdateAnyheld organization-wide. Otherwise 403UNIT_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@ownedor@unitrecord, 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.runAsandrunAsPlatform(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@ownedrecord is created for the caller (OwnershipForbiddenErrorotherwise) and an update never sets itsuserId; 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 frommemberships,membership_unitsandunitson 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@ownedmodel’suserIdis a plain id, not a relation (keelmint’sTodoon 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@ownedor@unitmodels.@Entity({ owned: true })adds the entity’sReadAny,UpdateAnyandDeleteAnyto the permission catalog (see Permissions). The product’s Prisma client factory passes the generatedOWNED_MODELSandUNIT_MODELStoownershipScope(withownershipLookupsover the base client) and exposesunitModels, which is how a unit delete counts the rows that still use a unit (a wrong name fails the boot). Keelmint’sTodosController(@Entity({ units: true, owned: true })on the@ownedTodo) 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@ownedmodel, records whose owner is a live member of those units or their sub-units; on a@unitmodel, rows whoseunitIdis one of those units or below (a model with both markers filters byunitId).X-Filter-Member: <user id>,<user id>- on an@ownedmodel, 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/@unitignore the headers, and so doesX-Filter-Memberon 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,parseUuidListHeaderin@core/api-core) and recorded on the request’s tenant scope (TenantStore.filters);ownershipScopeapplies them. - Not cached: every response carries
Vary: X-Organization-Id, X-Filter-Unit, X-Filter-Member, and a filtered oneCache-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).
| Call | Use |
|---|---|
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). |
runAsPlatformswitches 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 inTENANT_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, nestedselect, 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 (readprisma.tododirectly, notuser.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@ownedor@unitrecords 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 (orPLATFORM_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(withunit/memberfilters) and the organization helpdesk queue match the people in reach with a relation filter on their memberships in the covered units (peopleInReachWherein@core/db), so the statement grows with the covered units, not their members. Measured up to 72,000 members in the subtree. @ownedreach by unit is an id list. An@ownedmodel’suserIdis a plain id, not a relation, so a unit-scoped*Anycaller’s query carries the members of the reached units asuserId IN (...)(one value each). Keelmint’sGET /api/todos/allanswers up to about 32,000 members in the caller’s subtree and fails from 36,000 (500). TheX-Filter-Unitheader on an@ownedmodel expands the same way.- The organization audit log for a unit-scoped caller lists its people twice (actors and person targets:
AuditEventhas 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 startsnode main.cjswith that default; raise it withNODE_OPTIONS=--max-http-header-size=<bytes>(see Configuration; in the composeenvironment:of theapiservice or indeploy/<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.