Tenancy
Opt into gate.auth.tenant so requests resolve fx.tenant.id from claim, header, or subdomain.
Tenancy is an identity dimension under gate.auth.tenant, not a separate gate you attach with
.gate(...). When enabled, the runtime resolves a tenant for the request and exposes it as
fx.tenant.id. Your SQL / KV / files code (and optional RLS) use that id for isolation.
For developers shipping B2B or mixed B2C+B2B apps on okengine — enable the dimension, filter every
store access by fx.tenant.id.
The one rule
Enable gate.auth.tenant, keep membership checks on — then filter every store access by
fx.tenant.id. Do not invent a gate.auth.tenant trigger handle.
Smallest Example
Turn tenancy on
import { oke } from "okengine";
export const app = oke({
name: "shop",
env: "dev",
gate: {
auth: {
tenant: true, // claim source · header x-oke-tenant · required: false
},
},
});Or pass options:
tenant: {
required: true, // every user-plane request needs a tenant
source: "header", // "claim" | "header" | "subdomain" | "resolve"
header: "x-oke-tenant",
}Protect the route and scope data
import { on, flow, http } from "okengine";
import { eq } from "drizzle-orm";
import { member } from "@/core/gate";
import { db, invoices } from "@/schema";
export const list = on(
http.get().gate(member),
flow({
do: async (_, fx) => {
const tenantId = fx.tenant.id;
if (!tenantId) return fx.fail("Forbidden", { reason: "tenant_required" });
return await fx.store(db).select().from(invoices).where(eq(invoices.tenantId, tenantId));
},
}),
);Switch tenant (session)
// Session-only
const session = await fx.auth.switchTenant("ten_acme");
// New access token carries tid; subsequent requests resolve fx.tenant.idProgressive Patterns
From claim-based B2C+B2B to pure B2B, custom resolve, and Flow opt-out:
Default when tenant: true: source: "claim", required: false, header name x-oke-tenant
(unused until you switch source).
The signed session / key claim supplies tid. Membership is still checked unless
authoritative: true on a custom resolve.
Resolution Sources
Detailed section
Three tiers: signed claim (no membership query), client-supplied header/subdomain (membership
required), and custom resolve (membership unless authoritative).
Signed tid on the session / API key. Fast path — no membership query:
tenant: true;
// expands to source: "claim", required: false, header: "x-oke-tenant"After fx.auth.switchTenant(id), new access tokens carry tid for this source.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
required | boolean | false | Reject user-plane requests without a tenant |
source | "claim" | "header" | "subdomain" | "resolve" | "claim" | Where the tenant id comes from |
header | string | x-oke-tenant | Header name when source: "header" |
resolve | (ctx) => string | null | undefined | — | Custom resolver; membership still checked |
authoritative | boolean | false | Trust resolve without a membership query |
tenant: true expands to { required: false, source: "claim", header: "x-oke-tenant", authoritative: false }.
fx.auth tenant methods
Session-only. API keys get Forbidden with reason: "session_only":
| Method | Meaning |
|---|---|
listTenants() | Tenants the user belongs to |
switchTenant(id) | Mint a new session family with tid |
createTenant({ name, slug?, id? }) | Create + add caller as member |
deleteTenant(id) | Remove tenant |
addMember / removeMember | Membership |
listMembers(tenantId) | Members of a tenant |
upsertTenantRole({ tenantId, roleName, scopes }) | Role → scopes (unioned when scoped) |
import { on, flow, http } from "okengine";
import { z } from "zod";
import { member } from "@/core/gate";
export const switchTenant = on(
http
.post({
in: z.object({ tenantId: z.string() }),
})
.gate(member),
flow({
do: async ({ tenantId }, fx) => fx.auth.switchTenant(tenantId),
}),
);Store isolation
| Facet | Pattern |
|---|---|
| SQL | Filter / RLS by fx.tenant.id (and table tenant policies when declared) |
| KV | Optional {tenantId}: key prefix when the namespace is tenant-scoped |
| Files | Path / ACL under the active tenant |
RLS helper for a tenant column:
import { store, field } from "okengine";
export const invoices = store.schema.table(
"invoices",
{
id: field.text().primaryKey(),
tenantId: field.text(),
total: field.integer(),
},
[store.schema.policy.tenant("tenantId"), store.schema.rls()],
);Globally shared tables when tenancy is on need an explicit opt-out:
store.schema.table("plans", { id: field.text().primaryKey() }, [store.schema.unscoped()]);Consequence: Gate resolves identity; your Flow still owns the filter. A missing where tenant_id = … is a data leak, not a Gate bug.
Denial Mapping
| Situation | Code | error.data.reason |
|---|---|---|
required: true, authenticated, no tenant | Forbidden | tenant_required |
| Header / subdomain / resolve, not a member | Forbidden | not_member |
| Header / subdomain without session | Unauthorized | — |
| Tenant admin from API key | Forbidden | session_only |
Troubleshooting
Tenancy off, required: false with no claim/header, or membership failed. Enable
gate.auth.tenant, send the claim / header, and confirm the user is a member.
Tenant admin methods refuse API-key principals. Use a user session.
The Flow set tenantScoped: false, or the role was never upserted. Tenant-role union only applies
when the Flow is tenant-scoped (default when tenancy is on).
Pure B2B needs a tenant on every user-plane request — send tid (claim) or the configured header
before calling gated APIs.
The supplied tenant id is not in the user’s membership set. Add the member, or switch to source: "claim" after switchTenant so the signed tid is trusted.
Learn more
- Authentication —
gate.authandfx.auth - Authorization — scopes after tenant-role union
- RLS —
policy.tenantand the SQL stamp - Store — SQL / KV / files facets
- HTTP — gated routes that read
fx.tenant