Skip to content

Tenant Context & Scoping

Every authenticated request produces a TenantContext that is stored on locals.tenant:

type TenantContext = {
projectId: string | null;
organizationId?: string | null;
portalId?: string | null;
isOperator?: boolean;
};

The context is built from different sources depending on the request type:

  1. Primary: Hostname resolution (resolveHost() from Host header) → portal_domains → portals → projects
  2. Fallback: Lucia user attributes (tenantContextFromUser()) — uses buyers.project_id, buyers.organization_id, buyers.portal_id
  1. CRM session (validateAdminSession()) — reads admin_sessions.active_project_id
  2. Active project is re-validated on every request
  1. Cloudflare Access JWT → email → allowlist check
  2. Project ID from request header or URL path

All database queries that touch tenant-scoped tables must use one of these helpers from src/lib/db/scoped.ts:

withTenantFilter(ctx, projectColumn, existingWhere?)

Section titled “withTenantFilter(ctx, projectColumn, existingWhere?)”

Adds WHERE project_id = ctx.projectId to an existing where clause:

const rows = await db
.select()
.from(units)
.where(withTenantFilter(ctx, units.projectId, eq(units.status, 'available')));

Ensures inserted/updated values carry the active project ID:

await db.insert(units).values(withProjectScope(ctx, { title: 'Top 1', ... }));

Wraps DB work in a transaction that sets SET LOCAL app.project_id and SET LOCAL app.is_operator for RLS enforcement:

const [unit] = await withTenantSession(ctx, (db) => [
db.select().from(units).where(eq(units.id, id)),
]);

See Row-Level Security for details on the database-level isolation layer.

Note: CRM routes intentionally bypass RLS by passing isOperator: true — see RLS → Design Decisions → CRM routes bypass RLS by design. CRM isolation is application-layer only.

A no-restricted-imports ESLint rule prevents src/lib/admin/* and src/lib/operator/* from importing @/db directly — they must use withTenantSession.

Multi-tenant isolation is tested in tests/multi-tenant-isolation.test.ts which verifies that tenant A cannot read tenant B’s data.