Tenancy
A tenant key on every table, for() to scope reads and writes, and the SQL that follows.
Tenancy keeps each tenant's rows apart inside one database. You declare it once on the schema. Every table then stores a tenant key, and every read and write through a scoped client is filtered to one tenant without you writing the filter.
Four terms are used on this page:
- Tenant key: the column that says which tenant owns a row. Here it is
tenantId, auuid. - Guarded: input cannot set the column. The tenant key is a guarded uuid: an insert takes it from the scope, and a
whereorsetthat names it is refused. Visibility covers guarded columns in general. - Scope: the client that
db.for({ tenantId })returns. Every call on it reads and writes that tenant's rows only. - Tenant filter: the SQL condition the scope adds, such as
t."tenantId" = $1.
The tenancy code lives in okmodel/tenancy and loads only when you import it.
The model
The program below declares column tenancy, inserts a note as one tenant, reads it back as that tenant and as another, and prints the SQL. plans is a global table, shared by every tenant.
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";
import { columnTenancy, global } from "okmodel/tenancy";
const notes = table("notes", { id: t.identity(), title: t.text() });
const plans = table(
"plans",
{ id: t.identity(), code: t.text() },
{ tenancy: global("plans are shared") },
);
export const app = schema({
tables: [notes, plans],
tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
});
const acme = "018f3b2c-7a11-7c3a-8e2d-6b4a9c1d0e5f";
const globex = "018f3b2c-7a11-7c3a-8e2d-6b4a9c1d0e60";
export async function isolate(url: string): Promise<void> {
await using db = connect(url, { schema: app });
const scoped = db.for({ tenantId: acme });
console.log("insert:", JSON.stringify(await scoped.notes.insert({ title: "Hello" })));
const find = scoped.notes.find({ where: { id: "1" }, limit: 1 });
const inspected = await find.inspect();
console.log("find sql:", inspected.sql.text);
console.log("find params:", JSON.stringify(inspected.sql.params));
console.log("find rule:", JSON.stringify(inspected.rules.find((rule) => rule.rule === "tenancy")));
console.log("acme rows:", (await find).length);
const other = db.for({ tenantId: globex });
console.log("globex rows:", (await other.notes.find({ where: { id: "1" }, limit: 1 })).length);
const update = await scoped.notes.update({ where: { id: "1" }, set: { title: "Hi" } }).sql();
console.log("update sql:", update.statements[0]?.text);
const shared = await db.plans.find({ limit: 1 }).inspect();
console.log("plans sql:", shared.sql.text);
console.log("plans rule:", JSON.stringify(shared.rules.find((rule) => rule.rule === "tenancy")));
}insert: {"id":"1","title":"Hello","tenantId":"018f3b2c-7a11-7c3a-8e2d-6b4a9c1d0e5f"}
find sql: select t."id", t."title", t."tenantId" from "notes" t where t."tenantId" = $1 and t."id" = $2 limit $3
find params: ["018f3b2c-7a11-7c3a-8e2d-6b4a9c1d0e5f","1","1"]
find rule: {"rule":"tenancy","contribution":"scoped","provenance":"planner"}
acme rows: 1
globex rows: 0
update sql: update "notes" as t set "title" = $1::text where t."tenantId" = $2 and t."id" = $3
plans sql: select t."id", t."code" from "plans" t limit $1
plans rule: {"rule":"tenancy","contribution":"global plans are shared","provenance":"catalog","source":"tenancy-model.ts:6"}- The insert names only
title. The scope settenantId, and the row comes back with it. - The read added
t."tenantId" = $1before yourwhere.inspect()lists that as thetenancyrule. - The same id read as
globexreturns no row. - The update is filtered the same way, so one tenant cannot change another's row.
planshas no tenant column and no filter. Itstenancyrule records the reason you gave.
These are the tables the schema creates. The tenant key is part of the primary key, and notes also gets a unique constraint on (id, tenantId). plans has no key:
create table "public"."notes" (
"id" bigint generated always as identity not null,
"tenantId" uuid not null,
"title" text not null,
constraint "notes_pkey" primary key ("id", "tenantId")
);
alter table "public"."notes" add constraint "notes_id_tenantId_key" unique ("id", "tenantId");
create table "public"."plans" (
"code" text not null,
"id" bigint generated always as identity not null,
constraint "plans_pkey" primary key ("id")
);Scoped and unscoped clients
| Client | Tenant tables | Global tables |
|---|---|---|
db | Not on the client: db.notes is a type error and undefined at run time. | Read and write. |
db.for({ tenantId }) | Read and write that tenant's rows. Insert takes the key from the scope. | Read and write. |
db.unscoped("reason") | Read and write every tenant's rows, with no tenant filter. Insert is refused, because there is no key to store. | Read and write. |
for() takes a uuid string. unscoped() needs a reason, which inspect() records. Use it for a support tool or a migration script, not for request handling.
Choose a tenancy mode
tenancy on schema() is one of three functions from okmodel/tenancy. Omit it and no table gains a key, for() is not on the client, and a table that sets tenancy is refused.
| Mode | Key | Enforcement |
|---|---|---|
columnTenancy({ key, type: "uuid" }) | One column. | The tenant filter on every call. |
compositeTenancy({ key: [...], type: "uuid" }) | Several columns, in order. for() takes every one. | The tenant filter on every call. |
rlsTenancy({ key, type: "uuid" }) | One column. | The tenant filter, plus row-level security (RLS) policies in Postgres. |
type is "uuid" in every mode.
Composite
A tenant inside a tenant, such as a workspace inside an organization:
import { connect } from "okmodel/pg/postgresjs";
import { compositeTenancy } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";
const documents = table("documents", {
id: t.identity(),
title: t.text(),
});
const app = schema({
tables: [documents],
tenancy: compositeTenancy({
key: ["organizationId", "workspaceId"],
type: "uuid",
}),
});
export async function add(url: string, organizationId: string, workspaceId: string, title: string) {
await using db = connect(url, { schema: app });
return await db.for({ organizationId, workspaceId }).documents.insert({ title });
}A unique index includes every key, so index(c.title).unique() stores (organizationId, workspaceId, title). An ordinary index is prefixed with the first key only.
Row-level security
Row-level security (RLS) is a Postgres feature: a policy on a table decides which rows a role can see or change. rlsTenancy() keeps the tenant filter and adds policies, so a query that bypasses okmodel is still limited to one tenant.
import { rlsTenancy } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";
const notes = table("notes", {
id: t.identity(),
title: t.text(),
});
export default schema({
tables: [notes],
tenancy: rlsTenancy({ key: "tenantId", type: "uuid" }),
});The migration creates two policies on notes: notes_tenant for the tenant set in the transaction setting app.tenant, and notes_unscoped_select for reads when app.unscoped is set. A view that exposes the key is created security_invoker, so the policies apply through it. unscoped() can read and cannot write. A key list and via() are refused.
On connect, okmodel checks that the role cannot skip the policies: it must not own the tables, be a superuser, or have BYPASSRLS (OKM1707), and each table must force RLS with the expected policies (OKM1708). A passing check is cached for that endpoint until its generation changes. A failed check is not cached, and the message names the endpoint.
What row-level security does not stop
The policies trust the transaction settings. A role that can run arbitrary SQL can set app.tenant to any tenant and read or write that tenant's rows. A reporting role can set app.unscoped and read every tenant through the select policy, although it still cannot write. RLS protects against a query that forgets the filter, not against a role that sets the settings on purpose. Tenant isolation covers where the filter applies and how unscoped and trusted leave it off.
Tables that differ from the default
A table with no tenancy option stores the schema's key. Two marks from okmodel/tenancy change that.
Global tables
global("reason") leaves the tenant key off the table. Every client reads and writes it with no tenant filter, as plans does in the model. The reason is stored and inspect() shows it. A global table cannot reference a tenant table.
Tables that reach the key through a parent
via("project.organization") says the table does not store the key. Its tenant is found by following to-one relations, each step a one(...) relation name, separated by ., up to three steps. The last table on the path stores the key. Every read and write adds an exists condition along that path, and an insert checks that the parent belongs to the scope's tenant.
import { connect } from "okmodel/pg/postgresjs";
import { one, schema, table, t } from "okmodel/pg";
import { columnTenancy, via } from "okmodel/tenancy";
const organizations = table("organizations", {
id: t.identity(),
name: t.text(),
});
const projects = table(
"projects",
{
id: t.identity(),
organizationId: t.bigint().references("organizations"),
name: t.text(),
},
{
relations: { organization: one("organizations") },
tenancy: via("organization"),
},
);
const tasks = table(
"tasks",
{
id: t.identity(),
projectId: t.bigint().references("projects"),
title: t.text(),
},
{
relations: { project: one("projects") },
tenancy: via("project.organization"),
},
);
const app = schema({
tables: [organizations, projects, tasks],
tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
});
export async function open(url: string) {
await using db = connect(url, { schema: app });
const scoped = db.for({ tenantId: "018f3b2c-7a11-7c3a-8e2d-6b4a9c1d0e5f" });
const org = await scoped.organizations.insert({ name: "Acme" });
const project = await scoped.projects.insert({ name: "Ship", organizationId: org.id });
return await scoped.tasks.insert({ title: "Write", projectId: project.id });
}organizations stores tenantId. projects reaches it through organization, and tasks through project.organization.
Uniques and indexes
The tenant key is added to constraints so that one tenant's values never collide with another's:
- The primary key and every unique constraint include the key, so a value is unique inside one tenant and two tenants may store the same value.
- A foreign key to another tenant table includes the key, so
set nullandset defaultare refused (References). - An ordinary index is prefixed with the key, so it serves the tenant filter. A unique index gains every key.
.unique({ global: "reason" }) leaves the key out of one unique constraint:
import { table, t } from "okmodel/pg";
export const notes = table("notes", {
id: t.identity(),
email: t.text().unique({ global: "login email" }),
title: t.text().unique(),
});email is unique across all tenants. title is unique inside one tenant.
The index prefix can be set at three levels. The nearest level wins:
| Level | Setting | Effect |
|---|---|---|
| Schema | indexes on the tenancy function, default true | false leaves every index as written. An index that does not start with the key is then refused (OKM1706). |
| Table | tenantIndexes: false | Leaves this table's indexes as written. Overrides the schema. |
| Index | .global("reason") | Leaves this index as written. Overrides the table. |
| Index | .scoped() | Prefixes this ordinary index. Overrides the table. |
An index that already names the key gets it moved to the front, not listed twice. Two indexes that become the same columns are refused (OKM1023). A using index is never prefixed; Indexes explains the .global("reason") call it needs. The catalog, okm generate, and describe() show the resolved columns.
import { columnTenancy } from "okmodel/tenancy";
import { index, schema, table, t } from "okmodel/pg";
const notes = table(
"notes",
{ id: t.identity(), email: t.text(), title: t.text() },
{
indexes: (columns) => [
index(columns.email).unique(),
index(columns.title).global("shared title"),
],
},
);
export default schema({
tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
tables: [notes],
});The email index is (tenantId, email), unique inside one tenant. The title index stays (title).
When you turn tenancy on for an existing project, an index whose columns already start with the key does not change. Any other index is replaced once: okm generate writes CREATE INDEX CONCURRENTLY for the new index, then DROP INDEX CONCURRENTLY for the old one, so the table is not locked for a rebuild. okm migrate check accepts that pair, and the next okm generate writes nothing.
Errors
The messages below use the tables on this page. Your table and key names replace notes and tenantId.
Declaring the schema
These are thrown by schema(), before any connection.
| Code | When | Message |
|---|---|---|
| OKM1060 | A table sets tenancy and the schema does not. | Table notes sets tenancy, and the schema does not. |
| OKM1060 | A table's tenancy is not global() or via(). | Table notes tenancy must be global("reason") or via(). |
| OKM1060 | An empty reason or path. | global() needs a reason. / via() needs a relation path. / unique() global needs a reason. |
| OKM1060 | A key type other than uuid. | columnTenancy() type text must be uuid. |
| OKM1060 | A composite key names a column twice. | compositeTenancy() key repeats organizationId. |
| OKM1060 | rlsTenancy() with a key list. | rlsTenancy() takes one key. Combining it with compositeTenancy or via() is not supported yet. |
| OKM1060 | An index setting with no schema tenancy. | Table notes sets tenantIndexes, and the schema has no tenancy. / Table notes index().global() needs tenancy. |
| OKM1012 | The table already declares the key column. | Table notes already declares tenantId, which tenancy adds. Rename the field or drop it from the table. |
| OKM1023 | Two indexes become the same after the prefix. | Index on notes (title) and (tenantId, title) both become (tenantId, title). |
| OKM1705 | A global table references a tenant table. | Table plans.code is global and references tenant table notes. Drop the reference, or make plans a tenant table. |
| OKM1705 | A via() step is not a to-one relation, or the path is too long, ends on a global table, or loops. | Table tasks tenancy via "project" has no to-one relation project. The message names the step. |
| OKM1706 | With the prefix off, an index does not start with the key. | Index on notes starts with title. Put tenantId first. |
Calls and connect
| Code | When | Message |
|---|---|---|
| OKM1701 | An insert on a tenant table without for(). | insert on notes has no tenant. Call for({ tenantId }) so the scope can set it. |
| OKM1701 | The root client reaches a tenant table, for example through an include from a global table. | Table notes is tenant-scoped. Call for({ tenantId }). |
| OKM1701 | for() gets no key, or a value that is not a string. | for() needs { tenantId } as a uuid. |
| OKM1210 | for() gets a string that is not a uuid. | uuid nope must be 8-4-4-4-12 hexadecimal digits, for example 01890c5a-8f0e-7c3a-9b2d-6e4f1a0b9c8d. |
| OKM1701 | A write through unscoped() under rlsTenancy(). | unscoped() cannot write under row-level security. |
| OKM1190 | An insert names the key. | Field notes.tenantId is the tenant key. The scope sets it. Input cannot. |
| OKM1704 | An update or a where names the key. | Field notes.tenantId is the tenant key. An update cannot change it. / Field notes.tenantId is the tenant key. A filter cannot set it. |
| OKM1705 | A via() insert names a parent in another tenant. | Insert on tasks did not find a parent in this tenant. |
| OKM1707 | Under rlsTenancy(), the role owns the tables, is a superuser, or has BYPASSRLS. | The connected role is a superuser, so row-level security would not apply. Endpoint primary. |
| OKM1708 | Under rlsTenancy(), a table does not force RLS or its policies differ. | Table notes does not force row-level security. Endpoint primary. |
| Next | You use it for |
|---|---|
| Multi-tenant guide | A full program that isolates two tenants and checks it with isolation(). |
| Tenant isolation | Where the filter applies and its limits. |
| Enums and picklists | One value, a list, or a Postgres enum. |