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. - Require a branded
TenantScopeat tenant-owned repository boundaries. - 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 tenant parsing 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,
}: {
auth: AuthSession | null;
}) {
const tenantId = tenantIdFromSession(auth) ?? "tenant_example";
return createTenant(tenantId);
}
function tenantIdFromSession(auth: AuthSession | null) {
const session = auth?.session;
return isRecord(session) ? nonEmptyString(session.tenantId) : undefined;
}
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 caller-supplied headers such as x-tenant-id as untrusted unless another
trusted layer has verified that the caller belongs to that tenant and stamped
the request. 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 });
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 { requireTenantScope, type TenantScope } from "@beignet/core/tenancy";
type RecordSummary = {
id: string;
tenantId: string;
};
export interface RecordRepository {
findById(id: string, scope: TenantScope): Promise<RecordSummary | null>;
}
const scope = requireTenantScope(ctx);
const record = await ctx.ports.records.findById(input.recordId, scope);Repository adapters unwrap the scope at the persistence boundary with
tenantScopeId(scope) and filter by the 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.
beignet doctor --strict includes a conservative tenant-scope baseline for
Drizzle repositories. It warns when generated tenant-scoped resources lose
their TenantScope repository boundary or tenantScopeId(scope) predicates,
when hand-authored repositories expose raw tenantId or workspaceId
app-facing methods, and when scoped adapters for either column convention no
longer derive their storage key from tenantScopeId(scope).
Provider-correlation lookups are separate from authorization. A webhook handler
may still look up a billing account by customerId after signature
verification; that lookup should not be treated as proof of tenant authority.
Once app code acts inside a tenant boundary, pass TenantScope through the
repository API and keep policy checks around loaded subjects. Doctor is a drift
detector, not a replacement for tenant-aware use-case and policy tests.
Read next
- Authentication for resolving users and service actors.
- Authorization for policy placement and tenant mismatch checks.
- Server for request context wiring.