Tenancy
Tenancy answers "which account, workspace, organization, or tenant is this work scoped to?" Keep it separate from authentication. A request can be authenticated and still lack a valid tenant for the resource it is trying to read or mutate.
The recommended shape is:
- Resolve a tenant scope from verified request state.
- Store it on
ctx.tenantas anActivityTenant. - Scope repository reads and writes by tenant ID.
- Re-check loaded resources in policies so direct lookups and background work cannot cross tenant boundaries.
Resolve tenant scope
Put tenant resolution in an app-owned helper instead of scattering
x-tenant-id reads across routes or use cases. Verified sources should come
first: session membership, API key principals, JWT or OIDC claims, host/path
mapping, or request metadata that has already been checked against app state.
import { createTenant } from "@beignet/core/ports";
import type { AuthSession } from "@/ports/auth";
export function resolveRequestTenant({
auth,
headers,
}: {
auth: AuthSession | null;
headers: Headers;
}) {
const tenantId =
tenantIdFromSession(auth) ??
tenantIdFromDemoHeader(headers) ??
"tenant_example";
return createTenant(tenantId);
}
function tenantIdFromSession(auth: AuthSession | null) {
const session = auth?.session;
return isRecord(session) ? nonEmptyString(session.tenantId) : undefined;
}
function tenantIdFromDemoHeader(headers: Headers) {
return nonEmptyString(headers.get("x-tenant-id"));
}
function nonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}Treat headers such as x-tenant-id as local-demo or development fallbacks
unless another trusted layer has verified that the caller belongs to that
tenant. Production apps should usually resolve tenant scope from verified
session membership, API key principal data, organization claims, subdomains, or
path segments checked against membership records.
Request context
The server context is the right default place to resolve tenant scope once per request:
import { createAnonymousActor, createUserActor } from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import { resolveRequestTenant } from "@/lib/tenant";
import type { AppContext } from "@/app-context";
export const appContext = defineServerContext<
AppContext,
AppContext["ports"]
>()({
gate: (ports) => ports.gate,
request: async ({ req, ports, requestId, trace }) => {
const auth = await ports.auth.getSession(req);
const tenant = resolveRequestTenant({ auth, headers: req.headers });
return {
actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
auth,
requestId,
...trace,
ports,
tenant,
};
},
});The exact resolver is app-owned because tenant models vary. Some apps are single-tenant, some use workspace memberships, and API-only apps may resolve tenant scope from an API key or OAuth client.
API keys and service actors
For API-key routes, resolve the tenant from the verified principal returned by the credential verifier. Do not trust a caller-supplied tenant header on those routes:
import { createServiceActor, createTenant } from "@beignet/core/ports";
import { createAuthHooks } from "@beignet/core/server";
import { z } from "zod";
import type { AppContext } from "@/app-context";
const apiKeyHeadersSchema = z.object({
authorization: z.string().startsWith("Bearer "),
});
export const apiKeyAuth = createAuthHooks<AppContext>()({
headers: apiKeyHeadersSchema,
resolve: async ({ ctx, headers }) => {
const apiKey = headers.authorization.replace(/^Bearer\s+/i, "");
const principal = await ctx.ports.apiKeys.verify({
apiKey,
});
if (!principal) return null;
return {
actor: createServiceActor(principal.id, {
metadata: { scopes: [...principal.scopes] },
}),
tenant: createTenant(principal.tenantId),
};
},
});This keeps the tenant, actor, and scopes tied to one verified credential. Route metadata and OpenAPI security can then describe the transport, while policies still enforce business access.
Use cases and policies
Use cases that touch tenant-owned data should require a tenant before loading records:
import { requireTenantId } from "@beignet/core/ports";
const record = await ctx.ports.records.findById({
recordId: input.recordId,
tenantId: requireTenantId(ctx),
});Repositories should filter by tenant ID so unrelated records are not loaded. Policies should still verify loaded subjects belong to the current tenant:
function sameTenant(ctx: AuthorizationContext, record: RecordSummary) {
if (ctx.tenant?.id === record.tenantId) return allow();
return deny({
reason: "Record belongs to another tenant.",
code: "TENANT_MISMATCH",
});
}Filtering prevents accidental disclosure. Policy checks make the rule explicit, testable, visible in devtools, and reusable from jobs, tasks, schedules, and scripts.
Read next
- Authentication for resolving users and service actors.
- Authorization for policy placement and tenant mismatch checks.
- Server for request context wiring.