Overview
Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates.
Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro treats it as a runtime primitive: drop the tenant() mixin on a table, and the framework guarantees cross-tenant data leakage is structurally impossible for reads. Writes get a typed TenantMismatch error if you forget the explicit check.
The model
Subject (tenantId: 'acme')
│
▼
┌─────────────────────────┐
│ Queries (read) │ ← runtime AND-merges
│ ctx.store.select(...) │ WHERE tenantId = subject.tenantId
└─────────────────────────┘
┌─────────────────────────┐
│ Mutations (write) │ ← you call
│ ctx.store.insert(...) │ assertOwnTenant(input.tenantId, subject)
└─────────────────────────┘Reads are auto-scoped because the subject + table mixin contain enough information. Writes are NOT auto-scoped because the input gets to propose a tenant — your code decides whether to honour it (typical: never) or assert against the subject (typical: always).
What's in this section
- The tenant() mixin — how it works under the hood, when scoping kicks in, when it doesn't
- Edge cases — public queries, cross-tenant admins, anonymous subjects, x-tenant header resolution, vector + storage isolation
The shortest possible end-to-end
Schema:
import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
export const notes = table('notes', {
id: id(),
title: text(),
}).with(tenant())Query (auto-scoped):
export const listNotes = defineQuery({ name: 'notes.list', input: Schema.Struct({}) })
export default async (_input, ctx) => ctx.store.select('notes').all()
// SQL: SELECT * FROM notes WHERE tenantId = $1 (with subject.tenantId)Mutation (explicit gate):
import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
export const createNote = defineMutation({
name: 'notes.create',
input: Schema.Struct({ tenantId: Schema.String, title: Schema.String }),
error: TenantMismatch,
})
export default async (input, ctx) => {
assertOwnTenant(input.tenantId, ctx.subject)
return ctx.store.insert('notes', input)
}If a client posts { tenantId: 'their-tenant', title: 'hack' } while their cookie's subject says tenantId: 'acme', the mutation throws TenantMismatch. The audit log records it; the client sees a typed error variant.
Why the asymmetry
Reads always have a single answer: "what does this subject see?" → AND the tenant. Auto-scopable.
Writes have an open question: "which tenant should this go in?" The input gets to claim — sometimes legitimately (impersonation by an admin, switching active tenant). Your code decides whether to honour the claim. The framework can't auto-enforce because legitimate exceptions exist.
Defaulting to "auto-reject mismatching writes" would still work for 99% of mutations + would make the 1% impossible. We picked "assert explicitly" instead — slightly more code, full flexibility.
Tenant scoping covers more than just the database
| Surface | Scoped by |
|---|---|
ctx.store selects |
tenant() mixin's subscription filter |
*.query.ts subscriptions |
Same — mixin applies inside the query's read tracker |
| Vector search (pgvector) | Same |
@voltro/plugin-storage keys |
Prefix convention: <tenantId>/<key> |
@voltro/plugin-search indexes |
Per-tenant index (or filter, depending on backend) |
| Workflows + agents | Inherit calling subject |
| AI audit log | ctx.subject.tenantId recorded on every call |
The mixin is the lever — every adjacent plugin reads from the same subject + the same column.
Isolation model
The framework ships two isolation topologies. The default is shared-schema: one DB, one schema, a tenantId column kept apart by the tenant() mixin's WHERE filter. Opt into namespace isolation for physical separation — a per-request namespace, resolved from the request's tenant, into which every table reference is qualified. The runtime API is identical across both: the tenant() mixin, handler code, and ctx.store calls don't change. Only store resolution differs.
Opting in
// app.config.ts
export default {
type: 'api' as const,
name: 'myApi',
store: 'postgres' as const,
tenancy: { isolation: 'namespace' }, // default: 'shared-schema'
}Or via env — VOLTRO_TENANT_ISOLATION=namespace — which overrides the config field. The same flag is read by voltro dev and voltro serve, so the topology can't drift between dev and prod.
One mechanism, per-dialect mapping
Namespace isolation is one mechanism — a per-request namespace tenant_<sanitised-id>, derived from subject.tenantId — mapped to each dialect's native physical container:
| Dialect | Namespace is a… | Table reference |
|---|---|---|
| postgres / mssql | schema | tenant_<id>.todos |
| mysql / mariadb | database (SCHEMA ≡ DATABASE — this is database-per-tenant) | tenant_<id>.todos |
| sqlite | attached database (ATTACH DATABASE '<id>.db' AS tenant_<id>) |
tenant_<id>.todos |
Database-per-tenant falls out of the same seam for free — only the namespace id differs; the mapping to a physical container is a per-dialect detail. Isolation is physical: it no longer depends on a predicate being present, so a query that forgets the tenant filter — or a table that never carried the tenant() mixin at all — still cannot read another tenant's rows.
Postgres fast-path — SET LOCAL search_path
On postgres each request runs inside a transaction whose first statement is SET LOCAL search_path TO "tenant_<id>". Because it's SET LOCAL (transaction-scoped), the setting resets at commit — mandatory on a pooled connection, where a bare SET search_path would persist and leak into the next request that checks out the same connection. The other dialects qualify identifiers directly (tenant_<id>.todos).
Fail closed on a missing tenant
A request with no resolvable tenant does NOT fall back to a shared or default namespace (which could read another tenant's data) — it fails closed: the store refuses the operation and throws TenantNamespaceUnresolved. The tenant id is sanitised into a safe identifier (tenant_<id>, [a-z0-9_] only); anything that could break out of an identifier position is rejected or escaped before it reaches SQL.
Provisioning a tenant's namespace
When namespace isolation is on, the auto-migrate DDL fans out per tenant: it creates the container (CREATE SCHEMA / CREATE DATABASE / ATTACH DATABASE) and runs the table DDL inside it. Provision a new tenant's namespace eagerly at migrate time or lazily on first use via provisionTenantNamespace(tables, namespace, sqlLayer, dialect) from @voltro/database/sql.
CDC namespace tagging
The postgres LISTEN/NOTIFY payload carries the writing schema (TG_TABLE_SCHEMA) so a write in tenant A's namespace doesn't spuriously wake tenant B's subscriptions on the same-named table. mariadb's binlog already carries the database name; mysql / mssql / sqlite emit through the framework's own path, which already knows the namespace. A spurious wake is not a leak — the re-query runs against the woken subscription's OWN namespace — so suppressing cross-namespace wakes is purely a wasted-work optimization.