← 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:

db-per-tenant

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.

shared-db

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:

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.

Worker
edge gate
Directory DO
identity · gate
Workspace DO
acme's SQLite
↑  each Workspace DO holds exactly one tenant's database — the isolation is structural  ↑

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.

orion ✦ a belt of stars · built on datastar