Multi-tenancy
The architecture has two hard requirements, and they turn out to be the same mechanism: any number of institutions can be added without new code or schema changes, and each institution's data stays isolated. Isolation is enforced inside Postgres, not in application code, so an application bug cannot leak cross-tenant rows.
The model: shared schema + row-level security
Every tenant-scoped table carries tenant_id and has one RLS policy of the same shape:
tenant_id = nullif(current_setting('app.current_tenant_id', true), '')::uuid
That policy is generated once for all 19 tenant-scoped tables from a single array in
packages/db/src/schema/index.ts:
export const TENANT_SCOPED_TABLES = [
"module_entitlements", "custom_field_definitions", "departments", "programmes",
"branches", "batches", "staff_categories", "class_sections", "subjects", "persons",
"person_addresses", "person_sensitive_identifiers", "student_enrollments",
"staff_employment", "faculty_subject_assignments", "attendance_delegations",
"attendance_records", "staff_attendance_records", "audit_log",
] as const;
Deliberately not in that list: tenants itself, module_definitions (platform-seeded, not
tenant-writable), the six global reference tables, and all seven Better Auth tables (Better Auth
manages its own multi-tenancy through organization/member). Adding a table here is the only
way to get isolation, which is why extending the schema always means editing this array.
The nullif(..., '') is not defensive noise. A pooled connection that has had
app.current_tenant_id set at least once reverts to an empty string — not NULL — once that
transaction ends, which is a documented Postgres quirk for custom GUCs on reused sessions. A plain
current_setting(..., true)::uuid cast then throws invalid uuid syntax instead of cleanly
evaluating false-for-every-row, which on a pooled connection reused across requests could surface as
a 500 rather than a clean deny. NULLIF makes both the never-set and set-then-reverted cases
resolve to the same safe deny.
Setting the tenant: withTenant()
Every tenant-scoped request runs inside withTenant() (packages/db/src/client.ts), which opens a
transaction, sets the session variable with SET LOCAL, and then runs the callback:
export async function withTenant<T>(tenantId: string, fn: (tx: Db) => Promise<T>): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.current_tenant_id', ${tenantId}, true)`);
return fn(tx as unknown as Db);
});
}
SET LOCAL is scoped to the transaction, so it never leaks across pooled connections. The raw db
object is exported but documented as off-limits for tenant-scoped tables.
The full enforcement chain
Four independent layers, each of which would need to fail for a cross-tenant read to happen:
tenantMiddleware(apps/api/src/middleware/tenant.ts) — resolves the Better Auth session, reads itsactiveOrganizationId, maps that to atenantsrow viatenants.betterAuthOrgId, and puts the resolved tenant id on the request context. It does not grant data access; it only resolves which tenant and who.requirePermission({ ... })(apps/api/src/middleware/require-permission.ts) — calls Better Auth's ownauth.api.hasPermissionagainst the roles and statements declared inapps/api/src/auth.ts, so the permission model lives in exactly one place. Returns 403 on failure.withTenant()— sets the GUC. A route that forgets this gets zero rows rather than all rows.- RLS policies in Postgres — the actual guarantee. Independent of the application entirely.
Handlers must still pass the tenant id into withTenant() themselves; layers 1 and 2 do not
implicitly scope the query.
The bug that made RLS a no-op
This is the most important thing to understand about this codebase's history, and it is documented
in full in docs/M0_REMAINING_TASKS.md and in the comments on schema/index.ts.
The only Postgres role configured for the app was campus — the POSTGRES_USER of the official
Postgres image, which is a superuser. Postgres superusers bypass RLS unconditionally, and
FORCE ROW LEVEL SECURITY does not override that (it only affects a table's owner for non-superuser
roles). So every policy above was silently inert for every real request the system had ever served.
It was caught while writing rls.test.ts, and fixed by:
- Adding a genuinely restricted runtime role,
campus_app, withNOSUPERUSER NOBYPASSRLS, created idempotently bymigrate.tscallingappRoleSetupSql(). - Switching
packages/db/src/client.ts— and therefore every app route — to connect as it via a newRUNTIME_DATABASE_URL. - Reserving
DATABASE_URL(the owning role) formigrate/generate/seed/ import scripts only. - Hardening the policy expression with the
NULLIFabove.
The regression test is designed to fail against the old superuser role and pass against
campus_app, so this specific mistake cannot return silently.
Pre-filtering: the part that is easy to get wrong
For graph and vector queries there is a second, subtler leak surface. Tenant filtering must be a pre-filter — applied before traversal or similarity search runs — never a post-filter on results. Post-filtering lets tenant A's query score against tenant B's vectors or graph before the results are discarded, which is a real leak surface even though nothing is returned.
This is enforced structurally rather than by convention: there are exactly two sanctioned entry
points, and both take tenantId as a required parameter.
// packages/db/src/graph-query-service.ts — the ONLY sanctioned way to run AGE Cypher
runTenantScopedCypher(tx, { tenantId, cypher, cypherParams })
// packages/db/src/vector-query-service.ts — the ONLY sanctioned way to run pgvector search
tenantScopedVectorSearch(tx, { tenantId, table, embeddingColumn, queryEmbedding, limit })
In runTenantScopedCypher, tenantId is injected as a mandatory bound parameter and the example
query matches on it inside the pattern:
MATCH (s:Person {tenantId: $tenantId, personType: 'student'})
MATCH (s)-[:HAS_ATTENDANCE_FLAG]->(:AttendanceRisk {tenantId: $tenantId})
...
In tenantScopedVectorSearch, tenant_id is part of the WHERE clause that runs before the
<=> ANN operator does its scan.
Both services also run inside a withTenant() transaction, so Postgres RLS is a second,
independent enforcement layer on the underlying AGE and pgvector storage tables.
Two caveats worth carrying forward:
- The abstraction is load-bearing, and it is a convention. Any code path that calls
cypher()or queries an embeddings table directly reintroduces exactly the risk the research warned about. The technical architecture doc suggests an automated lint or review rule for this; none exists yet. tenantScopedVectorSearchcurrently has no callers and there is no embeddings table. The pre-filter guarantee is only as meaningful as what actually goes through it — today, that is the graph service only.
Onboarding a new institution
By design, this is a data operation rather than an engineering one:
- One row in
tenants(slug, name,regulatoryFramework) - One Better Auth
organizationrow, withtenants.betterAuthOrgIdpointing at it - One
module_entitlementsrow listing which of the 20 modules are licensed - An admin user in that organization
No schema change, no new deployment, no new code path. RLS policies and the shared query services apply to the new tenant from its first write.
The graduation path
If a specific institution ever needs physical isolation — rare, but real for some enterprise
contracts — the same schema can be deployed into a dedicated Postgres instance for that tenant.
Shared-schema is the default, not the only option. The rls.test.ts and
docs/TECHNICAL_ARCHITECTURE.md §3 verification steps describe the two-layer test to run on any
second tenant: confirm RLS blocks a cross-tenant read, and confirm a pre-filtered graph/vector
query never touches the other tenant's rows. They are different enforcement layers, so both need
checking.