Skip to content

Multi-tenancy

One Kumiko process and database can serve many tenants. The framework gives handlers only a tenant-scoped ctx.db, which applies the current tenant to reads, inserts, updates, and deletes. Raw or cross-tenant access requires a declared escape hatch or a system-scoped feature and leaves an audit signal.

Cross-tenant reads are an explicit capability for trusted administrative handlers and require an access rule. Public handlers still need a resolver for the tenant they are serving. Boot validation rejects cross-tenant access that does not also declare the required protection.

every visitor -> [ one door, already knows which customer you belong to ] -> only that customer's data

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.

Ordinary handlers receive ctx.db, and it is tenant-scoped. A handler that needs raw SQL calls ctx.db.unsafeRaw() only when its registration declares an escape hatch. Each use is reported with the declared reason. A system-scoped feature uses ctx.systemDb and must acknowledge cross-tenant work explicitly.

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.user.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.selectMany(orders, where)Adds the current tenant to the filter
ctx.db.fetchOne(orders, where)Reads only the current tenant or system reference rows
ctx.db.insertOne(orders, data)Writes the current tenant ID
ctx.db.updateMany(orders, changes, where)Adds the current tenant to the update filter
ctx.db.deleteMany(orders, where)Adds the current tenant to the delete filter

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 tenant:write:tenant:create 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.

The tenant feature is system-scoped because tenant records define isolation boundaries and operators must manage them across tenants. Managed entity tables, including the tenant read model, carry a tenantId column. Other domain rows use that value as their isolation key. Tenant destruction is coordinated by the tenant-lifecycle and retention features because files, external providers, user rights, and subject keys need more than a database cascade.

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.queryHandler({
name: "tenant-stats",
access: { roles: ["SystemAdmin"] },
// …
});
});

Inside a system-scoped feature, tenant-filtered ctx.db is unavailable. Cross-tenant reads go through ctx.systemDb (fail-closed helpers). Access control is declared on each handler (access: { roles: […] }), not via a feature-level r.access(…). Crossing tenants is opt-in (r.systemScope()), visible in code review, and should stay locked to privileged roles on every exposed handler.

A handler, hook or job that has to leave the tenant-scoped surface declares why on its registration:

export const shareByTokenQuery = defineQueryHandler({
name: "share-by-token",
schema: z.object({ token: z.string().min(1) }),
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
escapeHatch: {
grants: ["unsafeRaw"],
reason: SHARE_BY_TOKEN_REASON,
},
handler: async (query, ctx) => {
const hash = await hashToken(query.payload.token);
const row = await fetchOne<ShareLinkRow>(ctx.db.unsafeRaw(), table, {
tokenHash: hash,
});
if (!row) {
throw new NotFoundError("share-link");
}
const now = getTemporal().Now.instant();
if (row.revokedAt != null || row.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
throw new NotFoundError("share-link");
}
return {
label: row.label,
payload: row.payload,
};
},
});

escapeHatch takes a reason and an optional grants list. Each grant unlocks one capability:

GrantUnlocks
unsafeRawctx.db.unsafeRaw(), a runner without the tenant filter
globalWritesctx.db.global() inserts, updates and deletes
systemIdentityctx.dispatch.queryAs / ctx.dispatch.writeAs with the SYSTEM user

Without grants the declaration unlocks all three. Name only what the handler needs. Boot rejects an empty reason and an empty or unknown grant list. ctx.db.unsafeRaw() throws AccessDeniedError when no escape hatch is declared. Each use is reported as security:escape-hatch-used with the declared reason: to the audit sink when the app has one, otherwise as a warning log line. Identical reports within a short window are collapsed into one. ctx.systemDb.unsafeRaw(reason) in system-scoped code still takes the reason as an argument.

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 access: { roles: ["anonymous"] }. Anonymous calls do not bypass tenant scoping; they have to bring a tenant identity from somewhere. The auth-foundation feature accepts registered tenant resolver and existence providers. A resolver commonly reads the request host and returns a tenant ID:

const routingFeature = createSubdomainTenantRoutingFeature({
lookupBySubdomain,
existsById,
trust: "authoritative",
});

The tested provider shape is in the auth-foundation providers recipe.

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: { reason } 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 provider. 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.dispatch.write into feature B’s handler, B inherits A’s tenant ID by default. An identity switch through ctx.dispatch.writeAs or ctx.dispatch.queryAs requires a system-scoped caller or a declared escape hatch.

Once set, ctx.user.tenantId is read-only for the rest of the request. There is no “switch tenants mid-handler”. Cross-tenant operator work belongs in a system-scoped handler with an explicit access gate and acknowledgment reason.

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(handle), 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(handle) 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.