ElementsGate

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

src/app.ts
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

src/flows/invoices/list.ts
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.id

Progressive 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

OptionTypeDefaultMeaning
requiredbooleanfalseReject user-plane requests without a tenant
source"claim" | "header" | "subdomain" | "resolve""claim"Where the tenant id comes from
headerstringx-oke-tenantHeader name when source: "header"
resolve(ctx) => string | null | undefinedCustom resolver; membership still checked
authoritativebooleanfalseTrust 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":

MethodMeaning
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 / removeMemberMembership
listMembers(tenantId)Members of a tenant
upsertTenantRole({ tenantId, roleName, scopes })Role → scopes (unioned when scoped)
src/flows/tenants/switch.ts
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

FacetPattern
SQLFilter / RLS by fx.tenant.id (and table tenant policies when declared)
KVOptional {tenantId}: key prefix when the namespace is tenant-scoped
FilesPath / 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

SituationCodeerror.data.reason
required: true, authenticated, no tenantForbiddentenant_required
Header / subdomain / resolve, not a memberForbiddennot_member
Header / subdomain without sessionUnauthorized
Tenant admin from API keyForbiddensession_only

Troubleshooting

Learn more

  • Authenticationgate.auth and fx.auth
  • Authorization — scopes after tenant-role union
  • RLSpolicy.tenant and the SQL stamp
  • Store — SQL / KV / files facets
  • HTTP — gated routes that read fx.tenant

Next

On this page