Skip to content

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.

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:

OperationWhat 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.

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.

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.

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: true means 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.com reads acme’s components and incidents; an anonymous request to the same handler from widgets.example.com cannot see them.

The tenant ID does not appear by accident. The pipeline picks it up from one of three sources, in priority order:

  1. JWT claim. A logged-in user carries a tenantId in their token. The token is signed; a user cannot present someone else’s tenant ID and have the pipeline accept it.
  2. Anonymous resolver. When the user is anonymous, the resolver function decides. Typical inputs are the request host, a X-Tenant-Slug header, or a path-prefixed slug — pick the one that fits your routing.
  3. Cross-feature call. When feature A’s handler calls ctx.write into 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.

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.