Tenant isolation
Multi-tenancy is not something your app implements. It is something the platform does to your tables, whether or not you thought about it.
The injected columns
tenant_id and org_id are added by the platform to every entity you declare. Never declare them yourself — the schema rejects the names, and the platform indexes and enforces them itself.
Your reads and writes only ever see the calling tenant's rows. The scoping is applied by the platform, not by your code, so there is no query you can write — from your function, from the generated client, or through a capability — that forgets it. This is the one safety property you get for free and cannot opt out of.
What that means concretely:
- A
list()with nowherereturns this tenant's rows, not every tenant's. - A
get(id)for a row belonging to another tenant returnsnull, not the row. - An
update()ordelete()naming another tenant's id affects nothing. - A
create()writes the calling tenant's id whether or not you thought to set it.
You do not write a tenant filter, and you should not try to: there is no column for you to pass, and a where: { tenant_id: … } is not the shape the client accepts.
Uniqueness is per tenant, and you have to say so
unique: true on a column is a global unique constraint across the table, which means across every tenant. That is almost never what you want: two customers may both have a location called main.
For per-tenant uniqueness, name tenant_id first in a composite unique index:
"locations": {
"columns": {
"id": { "type": "uuid", "pk": true, "default": "gen_random_uuid()" },
"code": { "type": "text", "notNull": true }
},
"indexes": [
{ "columns": ["tenant_id", "code"], "unique": true }
]
}
Indexes are created after the injection, which is why naming the injected column here is legal — and it is the only place it is.
Where a request's tenant comes from
You do not resolve the tenant either. A request reaching your function already carries the tenant context, injected by the gateway:
- The caller sends
X-Revenexx-Tenant: <slug>along with their credential, as every call to the gateway does. - The gateway resolves it, authorises the app for that tenant, and invokes your function with the tenant identity in the invocation context.
createDb({ adapter: 'runtime', context })reads that context. The router exposes the same thing asc.tenant.
app.get('/serials/summary', async (c) => {
c.log(`summary for ${c.tenant}`);
const rows = await db.device_serials.list({ limit: 200 });
return c.json({ tenant: c.tenant, count: rows.length });
});
There is no credential for you to store, forward or rotate, and nothing in your app should accept a tenant as a request parameter. A tenant named in a body or a query string is an instruction from the caller; the tenant in the context is a fact from the gateway. Trust the second one.
What isolation does not cover
Scope dimensions are a separate mechanism. Markets and channels slice rows within one tenant, they are opt-in per entity, and they are open by default. See Scoping.
Another app's data is not yours by virtue of the same tenant. Sharing a tenant grants you nothing across the app boundary — you still declare a capability grant and go through the gateway.
Person-level permissions are not tenant isolation. Whether this operator may perform an operation is provides_permissions and the view gates in cockpit.json, not this.
The mock adapter does not enforce it. An in-memory store has one tenant, so a test can never catch an isolation mistake — which is fine, because there is no isolation mistake available to you to make. It also means a mock will not reproduce a 409 from the composite unique index above. See Adapters.
Next steps
- Schema reference — where indexes are declared.
- Scoping — slicing rows within a tenant.
- The typed client — the query surface that inherits all of this.