Multi-tenancy
A Kumiko app serves many tenants from one process and one database. A tenant is, for the framework, an isolation scope: a set of data that a user belonging to that tenant can see and change, and that no user belonging to a different tenant can. The isolation is not enforced by careful coding in handlers; it is enforced by the only database object the framework hands you.
There is no ctx.rawDb. There is no ctx.unscopedDb. There is ctx.db, and
it is always tenant-scoped.
The single-handle invariant
Section titled “The single-handle invariant”Every write handler and every query handler receives ctx.db as its only way
to talk to the database. That object knows the current tenant ID — it was
constructed for this request, against this user’s ctx.tenantId, and it stays
in scope for as long as the handler runs. Every operation it performs carries
the tenant filter automatically:
| Operation | What runs against the database |
|---|---|
ctx.db.select().from(orders) | … WHERE tenant_id = $current |
ctx.db.insert(orders).values(data) | … (tenant_id, …) VALUES ($current, …) |
ctx.db.update(orders).set(data) | … WHERE tenant_id = $current AND id = … |
ctx.db.delete(orders) | … WHERE tenant_id = $current AND id = … |
A handler that forgets to add WHERE tenant_id = … is not a tenant leak,
because the handler cannot write that clause — ctx.db writes it. A handler
that forgets to set tenant_id on insert is not a tenant leak, because
ctx.db sets it. The narrow surface is the security model.
This is the same shape as schemas-as-data: the framework owns one well-defined surface, and there is no escape hatch for handler authors to misuse. Every correctness property compounds from there.
The tenant entity is also an aggregate
Section titled “The tenant entity is also an aggregate”Tenants are not a separate concept; they are an entity like any other.
tenants.id is the tenantId everywhere else. There is no parallel
mapping table, no namespace prefix dance, no parent-tenant magic. A
tenants:create write goes through the same pipeline as any other create
— the only difference is that it runs in the system scope and produces an
ID that is then used as tenantId for every other write that follows.
Inside a tenant entity definition there is no tenantId field — it would be
self-referential. Outside a tenant entity, every entity gets a tenantId
column injected at boot, with a foreign key to tenants.id. Removing the
tenant cascades the data; multi-tenancy as a feature does not need a separate
deletion strategy.
When you need to cross tenants
Section titled “When you need to cross tenants”Some features genuinely have to look across tenants — a system dashboard,
tenant management, billing reconciliation, the migration runner. They
declare r.systemScope():
defineFeature("system-dashboard", (r) => { r.systemScope(); r.access({ roles: ["Sysadmin"] });
r.queryHandler(tenantStats); r.queryHandler(globalUsageReport);});Inside a system-scoped feature, ctx.db does not carry a tenant filter.
You see all rows; you are responsible for what you do with them. Two things
keep the surface honest:
- Boot validation rejects a system-scoped feature without
r.access(...). An unrestricted system-scoped feature is a misconfiguration, not a decision; the framework refuses to start. - The role on a system-scoped feature must be a privileged role. Boot emits a warning if a system-scoped feature is exposed to a regular role, because that is almost always wrong. The warning is loud; ignoring it is a deliberate act.
Together those mean that crossing tenants is opt-in, declared per feature, visible in code review, and locked to admin roles. There is no “I just needed to peek across tenants once” path.
Anonymous access without a hack
Section titled “Anonymous access without a hack”Public pages — a marketing landing page, a public status page, a hosted incident report — need to read data without a logged-in user. Kumiko treats this as a first-class case rather than a workaround.
A handler can declare roles: ["anonymous"]. Anonymous calls do not bypass
tenant scoping; they have to bring a tenant identity from somewhere. The
anonymousAccess configuration on the app provides a tenantResolver —
typically a function that reads the request host or a custom header and
returns a tenant ID:
await runProdApp({ features: [statusFeature], anonymousAccess: { tenantResolver: async ({ db, request }) => { const host = request.headers.get("host") ?? ""; const subdomain = host.split(".")[0]; const tenant = await db.query("tenant", "by-slug", { slug: subdomain }); return tenant?.id ?? null; }, },});Three properties follow:
- The anonymous user has the role
"anonymous"and nothing else. Field access rules that restrict reads to"User"or"Admin"strip those fields out automatically. openToAll: truemeans any logged-in user — it does not mean anonymous. The role-list form is the single way to grant anonymous access, which means a regression that turns auth off cannot accidentally open a public endpoint to internals.- The tenant filter still applies. An anonymous request to the status page
for
acme.example.comreadsacme’s components and incidents; an anonymous request to the same handler fromwidgets.example.comcannot see them.
How a request gets its tenant
Section titled “How a request gets its tenant”The tenant ID does not appear by accident. The pipeline picks it up from one of three sources, in priority order:
- JWT claim. A logged-in user carries a
tenantIdin their token. The token is signed; a user cannot present someone else’s tenant ID and have the pipeline accept it. - Anonymous resolver. When the user is anonymous, the resolver
function decides. Typical inputs are the request host, a
X-Tenant-Slugheader, or a path-prefixed slug — pick the one that fits your routing. - Cross-feature call. When feature A’s handler calls
ctx.writeinto feature B’s handler, B inherits A’s tenant ID by default.writeAs(systemUser, …)exists for the rare case when B genuinely runs across tenants (typically to write platform-wide reference data).
Once set, ctx.tenantId is read-only for the rest of the request. There is
no “switch tenants mid-handler”. A handler that needs another tenant’s data
issues a separate cross-feature call with queryAs(systemUser, …), where
the elevation is visible in the call site.
Per-tenant configuration without per-tenant code
Section titled “Per-tenant configuration without per-tenant code”Configuration in Kumiko is multi-scoped: a setting can default to a
framework value, be overridden at app boot, be overridden by ops at the
system level, be overridden by a tenant admin per tenant, and (for UX
preferences) be overridden by individual users. The handler reads through
one accessor — ctx.config(key) — and the resolver walks the cascade.
The shape of the cascade matters less than the property it gives you:
tenant-specific behaviour does not require tenant-specific code. A
“max upload size” of 10 MB by default and 200 MB for the enterprise tenant
is a row in the tenant config table, not an if (tenant.plan === "enterprise")
in the handler. The handler stays uniform; the variability lives in
declarations.
When the difference is plan-driven — paid tenants get higher limits — the
config key carries a computed function that resolves to the current
plan’s value. The handler still calls ctx.config(key) and gets the right
number. The plan-shape stays in the subscription feature where it belongs;
the handler does not learn about pricing tiers.
What this gets you
Section titled “What this gets you”The single-handle invariant, the tenant-as-aggregate, the explicit system scope, and the configuration cascade combine into one practical property: adding a new tenant is a write, not a deployment. There is no per-tenant namespace, no per-tenant container, no per-tenant codebase fork. The framework runs one process, holds one connection pool, and serves N tenants behind it without any of them seeing each other.
The cost is the discipline at the framework boundary: ctx.db has to be
written carefully once, and r.systemScope() has to be enforced at boot.
Once both are in place, every feature you add inherits tenant isolation
without thinking about it.
See also
Section titled “See also”- Auth and permissions — how roles merge across global and per-tenant sources at login.
- Lifecycle and hooks — boot validation that catches a system-scoped feature missing its access gate.