Tenant Context & Scoping
The Tenant Context Object
Section titled “The Tenant Context Object”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;};Context Sources
Section titled “Context Sources”The context is built from different sources depending on the request type:
Buyer Portal
Section titled “Buyer Portal”- Primary: Hostname resolution (
resolveHost()fromHostheader) →portal_domains → portals → projects - Fallback: Lucia user attributes (
tenantContextFromUser()) — usesbuyers.project_id,buyers.organization_id,buyers.portal_id
- CRM session (
validateAdminSession()) — readsadmin_sessions.active_project_id - Active project is re-validated on every request
Operator
Section titled “Operator”- Cloudflare Access JWT → email → allowlist check
- Project ID from request header or URL path
Tenant Scoping at the Application Layer
Section titled “Tenant Scoping at the Application Layer”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')));withProjectScope(ctx, values)
Section titled “withProjectScope(ctx, values)”Ensures inserted/updated values carry the active project ID:
await db.insert(units).values(withProjectScope(ctx, { title: 'Top 1', ... }));withTenantSession(ctx, buildFn)
Section titled “withTenantSession(ctx, buildFn)”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)),]);RLS Safety Net
Section titled “RLS Safety Net”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.
ESLint Enforcement
Section titled “ESLint Enforcement”A no-restricted-imports ESLint rule prevents src/lib/admin/* and src/lib/operator/* from importing @/db directly — they must use withTenantSession.
Testing
Section titled “Testing”Multi-tenant isolation is tested in tests/multi-tenant-isolation.test.ts which verifies that tenant A cannot read tenant B’s data.