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:

  1. Resolve a tenant scope from verified request state.
  2. Store it on ctx.tenant as an ActivityTenant.
  3. Require a branded TenantScope at tenant-owned repository boundaries.
  4. 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.