Multi-tenancy
Multi-tenancy
This guide takes you from an empty app to a feature whose data is kept apart per tenant by the database itself. The rules behind each step are in the package READMEs; the guide links to them instead of repeating them.
Goal
At the end you will have:
- a
notesfeature where each workspace sees only its own notes; - a database that refuses a query for another workspace's rows, even one written by hand;
- routes that answer 400 for a malformed workspace id and 403 for a workspace the caller is not a member of;
- a server that refuses to start in production when it could bypass that isolation;
- a command that copies one workspace out of the database and back in.
What a tenant is. A tenant is the owner of a set of rows. Pick it from who owns the data:
| The data belongs to | Tenant | Tenant list table |
|---|---|---|
| an organisation, team or customer | the workspace | your own workspaces table |
| one person (a diary, settings, drafts) | the user | users from @spfn/auth/server, with a guard that resolves the signed-in user |
When not to use it. Public data that everyone reads, and rows shared by several people who do not belong to one workspace (a direct message between two users, a shared document with a list of collaborators), are ordinary tables. Check access in the app, in the route or the service, as you would without tenancy.
Before you start
- PostgreSQL 14 or later.
- An app on
@spfn/core0.3.0-beta.35 andspfn0.3.0-beta.21 or later. @spfn/authset up as in the Authentication guide; the examples read the signed-in user withgetAuth(c).psql, to create the roles and check each step.
The examples use a schema named acme_notes (createSchema('@acme/notes')), an owner role
app_owner and an app role app_user. Use your own names.
Steps
1. Create two database roles
Row-level security (RLS) is a PostgreSQL feature: a policy attached to a table filters every
query on it, so a query that forgets its WHERE tenant_id = … still sees only one tenant's rows.
PostgreSQL does not apply a policy to the table's owner, to a superuser, or to a role with
BYPASSRLS. So the role that creates the tables and the role the server connects as must be
different:
| Role | Connects through | Does |
|---|---|---|
owner (app_owner) |
DATABASE_OWNER_URL |
runs spfn db migrate, backup, restore, drop; owns every table |
app (app_user) |
DATABASE_URL (and DATABASE_WRITE_URL / DATABASE_READ_URL if you use them) |
runs the server; owns nothing, is not a superuser, has no BYPASSRLS |
As a superuser (on a local install, usually postgres), create both roles and the database:
CREATE ROLE app_owner LOGIN PASSWORD 'owner-password';
CREATE ROLE app_user LOGIN PASSWORD 'app-password' NOSUPERUSER NOBYPASSRLS;
CREATE DATABASE acme OWNER app_owner;
Then, connected to acme as app_owner, give the app role read and write access to everything
the owner creates from now on, in every schema — your own, @spfn/auth's and the drizzle
schema where migrations are recorded (the server reads it at boot to check for pending
migrations):
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner GRANT USAGE ON SCHEMAS TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner GRANT USAGE ON SEQUENCES TO app_user;
Default privileges only cover objects created afterwards. In a database whose tables already
exist, grant them per schema as well, and move ownership off the app role if it created them —
see Database roles in the @spfn/core db
README.
Set both URLs in .env.server:
DATABASE_URL=postgresql://app_user:app-password@localhost:5432/acme
DATABASE_OWNER_URL=postgresql://app_owner:owner-password@localhost:5432/acme
DATABASE_OWNER_URL is read only by the four spfn db commands above; the server never uses
it. Without it, they fall back to DATABASE_URL
(config reference).
Check:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'app_user';
-- app_user | f | f
2. Declare the tenancy and its tables
defineTenancy names the tenant list table and the rule for who may act for a tenant.
tenancy.table builds a table that belongs to one tenant: it adds the tenant_id column, a
foreign key to the tenant list, a unique key on (tenant_id, id) and the RLS policy. Do not
declare the tenant column yourself; every tenancy table needs an id.
Keep the tenant list and the membership table in a module of their own, so the repository that
authorize calls does not import the module that calls it:
// src/server/entities/workspaces.ts
import { createSchema, foreignKey, id } from '@spfn/core/db';
import { users } from '@spfn/auth/server';
import { text } from 'drizzle-orm/pg-core';
export const schema = createSchema('@acme/notes');
// The tenant list: one row per workspace. An ordinary table.
export const workspaces = schema.table('workspaces', {
id: id(),
name: text('name').notNull(),
});
// Who belongs to which workspace. Also ordinary: authorize reads it before any tenant is set.
export const workspaceMembers = schema.table('workspace_members', {
id: id(),
workspaceId: foreignKey('workspace', () => workspaces.id),
userId: foreignKey('user', () => users.id),
});
// src/server/entities/notes.ts
import { defineTenancy, id, timestamps } from '@spfn/core/db';
import { getAuth } from '@spfn/auth/server';
import { text } from 'drizzle-orm/pg-core';
import type { PgTable } from 'drizzle-orm/pg-core';
import { memberRepository } from '../repositories/member.repository';
import { schema, workspaces } from './workspaces';
export const tenancy = defineTenancy({
tenants: workspaces,
authorize: (c, workspaceId) => memberRepository.isMember(workspaceId, Number(getAuth(c).userId)),
});
export const authors = tenancy.table(schema, 'authors', {
id: id(),
name: text('name').notNull(),
});
export const notes = tenancy.table(schema, 'notes', {
id: id(),
authorId: tenancy.reference('author', () => authors), // required
reviewerId: tenancy.reference('reviewer', () => authors, { optional: true }), // may be null
parentId: tenancy.reference('parent', (): PgTable => notes, { optional: true }), // a reply
title: text('title').notNull(),
...timestamps(),
});
Export both modules from your entities file (src/server/entities/config.ts by default), as
for any other table.
tenancy.reference('author', () => authors) creates an author_id column whose foreign key is
(tenant_id, author_id) → authors (tenant_id, id): a note can only point at an author of the
same workspace. The target is a function, so it may name a table defined further down; a
reference to the table itself (parentId above) spells out the return type (): PgTable.
| Reference | Column | When the referenced row is deleted |
|---|---|---|
| required (default) | NOT NULL |
the referencing rows are deleted too (ON DELETE CASCADE) |
{ optional: true } |
nullable | the delete is refused while a row still points at it (NO ACTION) |
tenancy.reference accepts only tables of the same tenancy. To point at an ordinary table, use
foreignKey as usual.
The memberRepository used by authorize is an ordinary repository:
// src/server/repositories/member.repository.ts
import { BaseRepository } from '@spfn/core/db';
import { workspaceMembers } from '../entities/workspaces';
class MemberRepository extends BaseRepository
{
async isMember(workspaceId: number, userId: number): Promise<boolean>
{
return await this._findOne(workspaceMembers, { workspaceId, userId }) !== null;
}
}
export const memberRepository = new MemberRepository();
Data owned by one person. Use users as the tenant list, and let the guard (step 5) resolve
the signed-in user:
import { getAuth, users } from '@spfn/auth/server';
export const personal = defineTenancy({
tenants: users,
authorize: (c, userId) => userId === Number(getAuth(c).userId),
});
Check: the server type-checks, and pnpm spfn db generate (next step) does not throw. A
reference to a table outside the tenancy, a declared tenant_id column or a table without id
throws there with the table's name.
3. Generate and apply the migration
pnpm spfn db generate
pnpm spfn db migrate
db generate writes the migration; db migrate applies it, connected as the owner through
DATABASE_OWNER_URL. For each tenancy table the SQL contains:
- the table with a
tenant_id bigint NOT NULLcolumn andUNIQUE (tenant_id, id); ENABLE ROW LEVEL SECURITY;- a foreign key from
tenant_idto the tenant list, and one composite foreign key per reference; - the policy
<table>_tenant_isolation, which admits a row only when itstenant_idequals the connection'sapp.tenant_idsetting.
The full SQL for notes is in the
Tenancy section
of the db README.
Check, as the app role (psql "$DATABASE_URL"):
SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'acme_notes';
-- authors | authors_tenant_isolation
-- notes | notes_tenant_isolation
SELECT * FROM acme_notes.notes;
-- ERROR: unrecognized configuration parameter "app.tenant_id" ← no tenant set: refused
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
SELECT count(*) FROM acme_notes.notes; -- workspace 1's notes only
COMMIT;
The ERROR is the point: a connection that has not named a tenant gets an error, never another
tenant's rows.
4. Write the repository
A repository for tenancy tables extends TenantRepository instead of BaseRepository. Every
method takes the tenant id first and puts the tenant condition into the query, and the
repository sets app.tenant_id for you, so the policy and the query agree.
// src/server/repositories/note.repository.ts
import { TenantRepository } from '@spfn/core/db';
import type { InferInsertModel } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { authors, notes } from '../entities/notes';
type NewNote = Omit<InferInsertModel<typeof notes>, 'tenantId'>;
class NoteRepository extends TenantRepository
{
async list(workspaceId: number, after?: number)
{
return await this._findManyKeyset(workspaceId, notes, { cursorColumn: notes.id, after, limit: 50 });
}
async listWithAuthor(workspaceId: number)
{
return await this._select(workspaceId, { title: notes.title, author: authors.name })
.from(notes)
.innerJoin(authors, eq(notes.authorId, authors.id));
}
async create(workspaceId: number, data: NewNote)
{
return await this._create(workspaceId, notes, data as InferInsertModel<typeof notes>);
}
async rename(workspaceId: number, id: number, title: string)
{
return await this._updateOne(workspaceId, notes, { id }, { title });
}
}
export const noteRepository = new NoteRepository();
Where each call runs depends on whether a transaction is open:
- Outside a transaction, each call opens a short transaction of its own, sets the tenant and commits: a read on the read connection (read-only), a write on the write connection.
- Inside a transaction (
Transactional()on the route, orrunInTransaction), the first call sets the tenant on that transaction, and every later call must name the same tenant. Naming another throwsTenantMismatchErrorbefore any SQL runs.
Outside a request — a script, a job — name the tenant when you open the transaction:
import { runInTransaction } from '@spfn/core/db';
await runInTransaction(async () =>
{
const note = await noteRepository.create(42, { authorId: 7, title: 'Imported' });
await noteRepository.rename(42, note.id, 'Imported (1)');
}, { tenant: 42 });
this.db and this.readDb are still there for queries the methods cannot express, but nothing
scopes them: inside a transaction bound to a tenant the policy shows that tenant's rows only, and
anywhere else they fail with the policy error. The method list and its rules are in
TenantRepository.
Check: in development (NODE_ENV unset, local or development), a hand-written query on
a tenant table without a tenant_id condition logs a warning with its SQL. A clean log for your
feature means every query names its tenant.
5. Guard the routes
tenancy.guard reads the tenant id for the request, checks it, runs your authorize and binds
the rest of the request to that tenant. tenancy.of(c) returns it in the handler.
// src/server/routes/notes.ts
import { route } from '@spfn/core/route';
import { Transactional } from '@spfn/core/db';
import { Type } from '@sinclair/typebox';
import { tenancy } from '../entities/notes';
import { noteRepository } from '../repositories/note.repository';
export const listNotes = route.get('/workspaces/:workspaceId/notes')
.use([tenancy.guard({ param: 'workspaceId' })])
.handler(async (c) => noteRepository.list(tenancy.of(c)));
export const createNote = route.post('/workspaces/:workspaceId/notes')
.input({ body: Type.Object({ authorId: Type.Number(), title: Type.String() }) })
.use([tenancy.guard({ param: 'workspaceId' }), Transactional()])
.handler(async (c) =>
{
const { body } = await c.data();
return noteRepository.create(tenancy.of(c), body);
});
For data owned by one person, resolve the tenant from the session instead of a parameter:
export const listDiaries = route.get('/me/diaries')
.use([personal.guard({ resolve: (c) => Number(getAuth(c).userId) })])
.handler(async (c) => diaryRepository.list(personal.of(c)));
Register the routes in your router as usual. The guard answers:
| Request | Answer |
|---|---|
the id is not a positive integer (/workspaces/abc/notes) |
400, authorize does not run |
authorize returns anything but true |
403 |
| a handler calls a repository with a tenant other than the guarded one | TenantMismatchError, a 500: a bug in the handler, not in the request |
The guard opens no transaction, so reads keep going to the read replica. Add Transactional()
to routes that write; the order of the two does not matter. Details:
tenancy.guard and tenancy.of.
Check: as a member of workspace 1, GET /workspaces/1/notes answers your notes,
GET /workspaces/2/notes answers 403, and GET /workspaces/0/notes answers 400.
6. Start the server
When the app declares tenancy tables, the server checks at startup which role its write and read
connections use. If that role owns a tenancy table, is a superuser or has BYPASSRLS, the policy
does not apply to it and every query would see every tenant:
- in production (
NODE_ENV=production) the server logsRefusing to start: Tenancy tables are not isolated: …and does not start; - anywhere else it logs the same finding once as a warning and starts, because local
development often connects as the
postgressuperuser.
The message names the role, the reason and the tables, never the connection string. If the check itself cannot run, it logs one warning and the server starts. See the boot sequence in the server README.
Check: with DATABASE_URL pointing at app_user, pnpm spfn dev starts without the
Tenancy tables are not isolated warning.
7. Export one tenant and import it elsewhere
pnpm spfn db tenant export 42
# → backups/tenant-42-<time>/ manifest.json and one .copy file per tenant table
pnpm spfn db tenant import backups/tenant-42-<time> # target has no rows for tenant 42
pnpm spfn db tenant import backups/tenant-42-<time> --replace # replace the rows it has
The export reads every tenant table in one snapshot as the app role (DATABASE_READ_URL, else
DATABASE_URL). It covers tenancy.table tables and tenantKey() tables alike, and nothing
else: not the tenant list row, not auth data. The import runs in one transaction as the owner
(DATABASE_OWNER_URL, else DATABASE_URL) and writes nothing if any check fails. The tenant must
already exist in the target's tenant list table — create workspace 42 there first.
Put one tenant back to an earlier point in time, leaving the others as they are:
- Restore a copy of the production database to that point, outside spfn (your provider's point-in-time recovery).
- Point the environment at the copy and run
spfn db tenant export 42. - Point the environment at production and run
spfn db tenant import <folder> --replace. - Only tenant 42's rows change; every other tenant's rows stay as they were.
The checks the import makes are listed in the CLI README.
Check: the import prints one line per table with its row count, then
✅ Tenant 42 imported.
Done when
- The server's
DATABASE_URLrole owns no table, is not a superuser and has noBYPASSRLS;DATABASE_OWNER_URLpoints at the owner. - Every table that belongs to a tenant is a
tenancy.table, and every reference between them is atenancy.reference. -
pg_policieslists a<table>_tenant_isolationpolicy for each of them. - Their repositories extend
TenantRepository. - Every route that touches them has
tenancy.guard, and handlers take the tenant fromtenancy.of(c), never from the request body. - Another workspace's id answers 403, a malformed one 400.
-
spfn devstarts without the isolation warning. - An export of one tenant imports into a fresh database with the same row counts.
Reference
Where a TenantRepository call runs
For a tenancy.table, each call follows one of six rules
(R1–R6 in the db README):
| Rule | When | What happens |
|---|---|---|
| R1 | a read, no transaction open | a short read-only transaction on the read connection sets the tenant, runs the query, commits |
| R2 | a write, no transaction open | the same, on the write connection |
| R3 | a transaction is open and has no tenant yet | the tenant is set on it; the transaction now belongs to that tenant |
| R4 | the open transaction belongs to this tenant | the query runs |
| R5 | the open transaction belongs to another tenant | TenantMismatchError, no SQL |
| R6 | the request's guard authorized another tenant | TenantMismatchError, no SQL |
The tenant setting ends with its transaction, so a pooled connection never carries it to the next
request. A table with only tenantKey() (no policy) skips R1–R3 and keeps R5 and R6.
Errors
| Error | Where | Meaning |
|---|---|---|
400 ValidationError |
tenancy.guard |
the tenant id is not a positive integer |
403 ForbiddenError |
tenancy.guard |
authorize did not return true |
TenantMismatchError (500) |
TenantRepository, runInTransaction |
a call named a tenant other than its transaction's or its request's; source is 'transaction' or 'guard' |
unrecognized configuration parameter "app.tenant_id" |
PostgreSQL | a query on a tenancy table on a connection that never set a tenant |
invalid input syntax for type bigint: "" |
PostgreSQL | the same, on a pooled connection where an earlier transaction had set one: no tenant is set now |
tenancy.of(c) was called on a route without tenancy.guard() |
tenancy.of |
add the guard to the route |
TenancyRoleError |
server start, production | the app role escapes the policy (step 6) |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The policy error from a script or job | a hand-written query, or a call outside a transaction bound to a tenant | use TenantRepository methods, or wrap the work in runInTransaction(fn, { tenant }) |
| Every query sees every tenant | the app role owns the tables, or is a superuser / BYPASSRLS |
connect as app_user; move ownership with ALTER TABLE … OWNER TO app_owner or REASSIGN OWNED BY app_user TO app_owner |
permission denied for table … after a migration |
the app role has no privilege on a new table | set the default privileges of step 1, or grant on the table |
The server logs permission denied for schema drizzle at boot and starts without checking migrations |
the app role cannot read the drizzle schema, where migrations are recorded |
grant USAGE on schema drizzle and SELECT on its tables to the app role |
spfn db backup or restore fails on a tenancy table |
it ran as the app role, to which the policy applies | set DATABASE_OWNER_URL |
| A workspace with notes cannot be deleted | the tenant foreign key is NO ACTION |
delete or move the tenant's rows first |
| An author cannot be deleted | an optional reference still points at it | clear the reference first |
.prepare() throws |
a prepared statement would run outside R1–R6 | call the query without .prepare() |
rightJoin, fullJoin, union … throw in _select |
the tenant condition cannot be placed safely | write that query on this.readDb, inside a transaction bound to the tenant |
tenant import refuses: tenant not in its tenant list |
the target lacks the tenant row | create the workspace (or user) first |
tenant import refuses: tenant already has rows |
the target holds rows for it | add --replace |
tenant import --replace refuses, naming foreign keys |
a table outside the export would be changed by ON DELETE CASCADE / SET NULL / SET DEFAULT |
handle those rows yourself first |