← the tour · concept
concept viii
Multi-tenancy, by construction
Isolation isn't a WHERE clause you can forget. It's the connection.
The single most common SaaS data leak is a forgotten WHERE tenant_id = ?. One
join, one shortcut, one copy-pasted query without the predicate — and a user reads another
customer's rows. orion's answer is to make that class of mistake structurally
impossible, not a discipline you maintain. To belt/tenant.ts, a tenant
is just an opaque string key and a Store bound to it —
nothing more. What a tenant is (org, workspace, team, account) stays entirely your
policy. The belt never learns the word "organization."
The goal is one sentence, true by construction, on Node, Deno, and Cloudflare alike: every query is scoped to the right tenant, and you cannot reach another tenant's data even by accident.
Two isolation models — same feature code
orion supports both shapes a multi-tenant SaaS ever needs, and you don't choose globally or forever:
Physical isolation
One SQLite file per tenant on Node/Deno; one Durable Object per tenant on Cloudflare.
ctx.store is the tenant's database. Cross-tenant access is impossible to
express — there is no handle to another tenant's rows. Feature code is unchanged.
Row-level scope
One file, a tenant column. The predicate is generated into every statement by
tenantScope — you can't omit it. A dev oracle throws on any raw query that
touches a tenant table without the predicate. Safe by default, not by memory.
The realization that makes db-per-tenant cheap: every orion query is already
sql.all(ctx.store). Provisioning ctx.store per tenant
needs zero changes to feature code — and it's literally how orion on
Cloudflare already works, where getByName(key) routes to a Durable Object per
tenant whose SQLite no other tenant can address.
Shared-DB is for when tenants are many-and-tiny or you need cheap cross-tenant queries
(admin dashboards, billing roll-ups). The feature code that calls tenantScope
never knows which model it's running on — so a team can pool most tenants in a shared DB
and graduate the largest ones to their own file, with no rewrites.
A resource swap, not middleware
createRouter has one optional, backward-compatible hook — resolve(ctx)
— that runs before each handler. It swaps ctx.store and ctx.bus to
the tenant's resources, or returns a Response to fail closed. That's the whole mechanism.
import { createRouter } from "../../belt/http.ts"
import { sqliteStore } from "../../belt/store.ts"
// 1. Resolve an opaque key from the request
const key = pathPrefix("/w")(ctx) // "/w/acme/todos" → "acme"
// 2. Provision — choose your isolation model behind one seam
const stores = storePerTenant({ open: (k) => sqliteStore(`data/${k}.db`), migrate })
// — or —
const stores = sharedStores(tenantGuard(db, { tables: ["todos"], column: "org_id" }))
// 3. Wire the resolve hook — a resource swap, not a middleware function
createRouter({ …, resolve: (ctx) => {
ctx.store = stores.get(key)
ctx.bus = tenantBus(ctx.bus, key) // namespace bus per tenant in-process
}})
// 4. Feature code is unchanged — ctx.store IS the tenant
todos.all(ctx.store) // cannot return another tenant's rows
The hook does not put a policy object on Ctx. Resolving and binding a tenant's
store is mechanism — it grants nothing. Membership and roles are the law, enforced
by requireMember and can inside handlers that need them.
By construction, not by discipline
The Rails ecosystem's multi-tenancy saga is thread-locals (CurrentAttributes)
leaking the "current tenant" across reused workers, background jobs, and tests. A request
sets the ambient tenant; a job picked up from the queue inherits the wrong one; a leaked
context touches data it shouldn't. orion can't reproduce that bug:
- No ambient current-tenant. The key is baked into
ctx.store, built fresh per request, never reused. There is nothing to leak across requests —belt/tenant.tsholds no request state. - Fail closed. If
resolvecan't find a tenant, it returns a Response (default 404) — never the unscoped database. The only way to query across tenants is the explicit, greppablewithoutTenant(() => …). - DB-per-tenant. A handler physically holds tenant A's database handle. There is no query that can return tenant B's rows — a different handle is a different file.
- Shared-DB scope.
tenantScopegenerates the predicate into every statement; the devtenantGuardoracle throws on any raw query that touches a tenant table without it; the tenant column is immutable (a row can't be moved between tenants through the scope). - Binding is not access. Resolving a store is mechanism. A user holding a store for a workspace they're not a member of is still refused — membership is a separate check, in the handler, enforced by your policy code.
Shared-DB: the predicate is generated, never remembered
When shared-DB is the right fit, tenantScope is the surface you write against.
Every operation — select, insert, update, delete — has the tenant predicate baked in at
generation time, not injected as an afterthought:
const t = tenantScope(ctx.store, key, { column: "workspace_id" })
t.all("todos")
// → select * from todos where workspace_id = ?
t.insert("todos", { title })
// workspace_id injected automatically — you can't omit it, even accidentally
t.update("todos", id, { done: true })
// workspace_id is immutable in SET — a row can't move tenants
// dev guard: raw SQL on a tenant table without the predicate throws loudly
ctx.store.query("select * from todos")
// ↑ Error: tenant leak guard: query touches tenant table "todos" without "workspace_id"
The guard is a heuristic string check — a dev tripwire, honest about what it is. It lives in dev only; production gets the raw store, zero overhead.
Tenant-scoped RBAC: a grant's resource is the tenant key
Authorization is already store-keyed for multi-tenancy. The example models the full membership shape:
// user → account ("Work","Personal") → member(workspace, role) → workspace
// A role is loaded per workspace into (action, resource = workspace key) grants
// so the same account is owner of "acme" and member of "globex"
can(ctx, ws, "todo:delete") // checks grants scoped to THIS tenant key
Because authz is watchable, a role change repaints every affected region in
every open tab without a refresh. Promote a member mid-session and their delete buttons
appear. The grant's resource is the tenant key — so a permission literally cannot apply
outside the tenant it was issued for.
On Cloudflare: db-per-tenant is the platform
On Cloudflare, db-per-tenant isn't a choice you make — it's the shape that falls out of
Durable Objects. getByName(key) routes to a Workspace DO per tenant; that DO's
SQLite is the tenant's database; no other tenant can address it. The Worker is a thin
edge gate: it resolves identity and membership, then forwards a verdict as trusted headers
to the DO. The DO is a pure data plane — binding is not access, at the edge.
edge gate → Directory DO
identity · gate → Workspace DO
acme's SQLite
The todos slice is the same loop as every other orion feature — the only delta is
getByName(workspace) instead of a fixed node. Backup, export, and GDPR delete
for one customer are a single file operation. Per-tenant residency is a routing rule in
open(key).
Migration fan-out (db-per-tenant)
A schema change must reach every tenant's database. storePerTenant migrates a
tenant lazily on first open — fine for active tenants. Dormant ones need an explicit pass
before they're touched. TenantStores.migrateAll(keys) covers them: open,
migrate, close, no pooling, with the key list coming from your directory:
// after a deploy — run before traffic, or as a deploy script
const keys = await directory.allWorkspaceKeys()
await stores.migrateAll(keys)
// open → migrate → close, each in turn. Never pools a dormant tenant.
The belt doesn't own the tenant list. Your directory — a workspaces table in the control-plane store — supplies the keys. The belt does the mechanical fan-out.
The named escape
Admin dashboards and billing roll-ups genuinely need to read every tenant. That must be explicit, greppable, and auditable — never the default:
// the ONLY sanctioned way across tenants — visible in every grep and code review
const totals = withoutTenant(() => store.query("select count(*) from todos"))
// inside this block the dev guard stands down (reentrant counter, synchronous)
Cross-tenant is the only thing shared-DB does better than db-per-tenant: one query vs. a fan-out. If cross-tenant analytics is a core workload, that argues for row-level — or a roll-up store fed by the journal.