import { AsyncLocalStorage } from "node:async_hooks"; import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; /** * Central database access for Craftvia. * * Multi-tenant rule (see docs/craftvia/SPEC-CRAFTVIA.md §5): no query on tenant-scoped models * without a tenant context. Application code MUST use `dbForTenant(tenantId)` * for all tenant-scoped models — the raw client is only for global data * (permissions, control catalogs) and platform administration. * * Postgres Row Level Security is added on top as a second line of defense * (Policy `tenant_isolation` je Tenant-Tabelle, gesetzt in den Migrationen). * * ── F-04: RLS scharfschalten (env-gesteuert) ───────────────────────────────── * Standardmäßig (`RLS_ENFORCED` != "true") bleibt alles wie bisher: die App * verbindet sich als Owner-Rolle (`DATABASE_URL`), RLS greift für den Owner * ohne FORCE nicht, die alleinige Isolation ist der Tenant-Guard unten. * * Ist `RLS_ENFORCED=true`, verbindet sich die App zusätzlich als eingeschränkte * Rolle `craftvia_app` über `RLS_DATABASE_URL` (NOBYPASSRLS). Für diese Rolle sind * die Policies unter `FORCE ROW LEVEL SECURITY` scharf. `dbForTenant` setzt dann * pro Operation den Mandantenkontext (`app.tenant_id`) TRANSAKTIONSLOKAL und * führt die Operation auf DERSELBEN Transaktions-Connection aus — nur so greift * der GUC-Kontext trotz Connection-Pooling. Der Tenant-Guard (F-02) bleibt als * Defense-in-Depth zusätzlich aktiv. * * Migrationen, Seeds und der mandantenübergreifende Login-Lookup laufen weiter * über die Owner-`prisma`-Instanz (`DATABASE_URL`) — die Owner-Rolle muss in * Prod BYPASSRLS/Superuser sein, sonst sieht der Login keine Nutzer. */ const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient; appBase?: PrismaClient; }; /** Owner-Client (DATABASE_URL) — Migrationen, Seed, Login-Lookup, Plattform-Admin. */ export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; /** * F-04: Ist die scharfe RLS aktiv? Zentral aus dem Env-Flag gelesen. * Default (Flag fehlt/!= "true") = AUS → heutiges Verhalten unverändert. */ export const RLS_ENFORCED = process.env.RLS_ENFORCED === "true"; /** * Basis-Client der eingeschränkten App-Rolle `craftvia_app` (nur bei aktivem Flag). * Uneextendiert — wird in `dbForTenant` gezielt für die transaktionslokale * Kontextsetzung genutzt, damit die Guard-Extension nicht rekursiv greift. * * Fail secure: `RLS_ENFORCED=true` ohne `RLS_DATABASE_URL` → harter Startabbruch, * damit die App nicht versehentlich doch als Owner (ohne RLS) läuft. */ let appBase: PrismaClient | undefined; if (RLS_ENFORCED) { const rlsUrl = process.env.RLS_DATABASE_URL; if (!rlsUrl) { throw new Error( "RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt. Setze eine Verbindung als " + "Rolle craftvia_app (LOGIN, NOBYPASSRLS), z. B. " + "postgresql://craftvia_app:@:5432/craftvia?schema=public — sonst würde die " + "App als Owner ohne scharfe RLS laufen (fail secure).", ); } appBase = globalForPrisma.appBase ?? new PrismaClient({ adapter: new PrismaPg({ connectionString: rlsUrl }), }); if (process.env.NODE_ENV !== "production") globalForPrisma.appBase = appBase; } /** Models that carry a tenantId column and must never be queried without one. */ const TENANT_MODELS = new Set([ "User", "Role", "AuditLog", // SEC1: MailLog trägt wie AuditLog eine nullable tenantId — Plattform-Zeilen // (scope=platform) werden bewusst über den rohen `prisma`-Client geschrieben, // der Guard deckt den Mandantenpfad ab. "MailLog", "NotificationPreference", // SEC2: ebenfalls nullable tenantId (Plattform-Admin-Tokens). Der Lookup beim // Einlösen läuft ohne Session über den rohen Client — der Eintrag hier schützt // davor, dass ein Token versehentlich ohne Mandantenfilter über dbForTenant // gelesen wird. "AuthToken", "TenantSettings", "TenantModule", // Craftvia domain (0002_craftvia_domain) "NumberSequence", "OrderType", "ChecklistTemplate", "Customer", "Contact", "Site", "Team", "TeamMember", "WorkOrder", "WorkOrderAssignee", "WorkOrderStatusChange", "ChecklistItem", "PhotoRequirement", "MaterialPlan", "MaterialUsage", "WorkSession", "TimeEntry", "ActivityNote", "Document", "Photo", "VoiceNote", "Report", "Signature", "ImportJob", "Notification", "SyncOperation", "AiGeneration", // L14 Abrechnungsübersicht "WorkOrderMilestone", "BillingRecord", // L16 Lotse-Chat für Monteure "LotseConversation", "LotseMessage", "LotseActionProposal", // L15 Testphase: Datenexport des Mandanten "TenantExport", // WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier. // Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts // (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('')` in der Migration // (docs/craftvia/MIGRATIONS.md). ]); /** * Meldet eine Mandanten-Isolationsverletzung als Sicherheitsereignis (F-16). * * Ein Cross-Tenant-Zugriffsversuch ist das schwerwiegendste denkbare Signal in * diesem Produkt und muss als strukturierter Audit-Eintrag (`action: "denied"`) * erfasst werden — nicht nur als anonymer Stacktrace im Container-Log. * * Import-Zyklus (F-16): `audit.ts` importiert statisch `prisma` aus DIESER Datei. * Ein statischer Gegenimport (`import { writeAuditLog } from "./audit"`) erzeugte * den Zyklus `db.ts → audit.ts → db.ts`. Wir lösen ihn per **Lazy-Import** * (`await import("./audit")`) genau an der Aufrufstelle: Zum Zeitpunkt des * Aufrufs (Laufzeit, nicht Modulauswertung) ist das `prisma`-Binding in `audit.ts` * längst aufgelöst. Das `prisma`-Modul selbst muss dafür nicht ausgelagert werden, * sodass KEIN bestehender `from "@/server/db"`-Import bricht. * * Best effort & fail-safe: Das Audit-Schreiben nutzt den ROHEN Owner-`prisma` * (NICHT `dbForTenant`) → keine Rekursion in die Guard-Extension. Fehler des * Audit-Schreibens werden hier geschluckt (nur geloggt) — die eigentliche * Isolations-Exception wirft der Aufrufer UNABHÄNGIG davon weiter. * * Achtung Kontext: Die Funktion läuft mitten in der laufenden DB-Operation * (im RLS-Pfad ggf. innerhalb der F-04-Transaktion des `craftvia_app`-Basisclients). * Der Audit-Insert läuft über den separaten Owner-`prisma` auf EIGENER Connection * und ist damit von der Tenant-Transaktion entkoppelt: Er verklemmt sie nicht und * bleibt bestehen, auch wenn die Tenant-Operation durch den anschließenden Throw * zurückgerollt wird — genau das gewünschte Verhalten für ein Sicherheitsereignis. */ async function reportIsolationViolation(details: { model: string; operation: string; expectedTenantId: string; reason: string; }) { console.error("[SECURITY] tenant-isolation-violation", JSON.stringify(details)); try { // Lazy-Import bricht den statischen Zyklus (siehe Doku oben). const { writeAuditLog } = await import("./audit"); await writeAuditLog({ // Der Versuch wird dem anfragenden (eigenen) Mandanten zugeschrieben — // ein actorId ist im Guard nicht verfügbar, wird also nicht erfunden. tenantId: details.expectedTenantId, action: "denied", entity: "tenant_isolation", entityId: details.model, after: { model: details.model, operation: details.operation, reason: details.reason, }, }); } catch (auditErr) { // Niemals die eigentliche Isolations-Exception verschlucken: hier nur loggen. console.error("[SECURITY] audit-write-failed (tenant-isolation-violation)", auditErr); } } /** Delegate-Form, wie sie der Guard für Direktaufrufe (ohne Rekursion) braucht. */ type GuardDelegate = { findUnique: (a: unknown) => Promise<{ tenantId?: string } | null>; findFirst: (a: unknown) => Promise; findFirstOrThrow: (a: unknown) => Promise; }; /** Roher Client bzw. Transaktions-Client als Quelle uneextendierter Delegates. */ type DelegateSource = Record; /** * F-02-Tenant-Guard, herausgelöst, damit er über zwei Ausführungspfade * (Owner ohne RLS / craftvia_app mit RLS-Transaktion) identisch wiederverwendet wird. * * `source` liefert die uneextendierten Modell-Delegates für die Direktaufrufe * (findUnique-Hybrid, Ownership-Vorprüfung) — bei RLS-off der Owner-`prisma`, * bei RLS-on der Transaktions-Client `tx`. `runFinal` führt die eigentliche * (ggf. transformierte) Operation aus — bei RLS-off `query(args)`, bei RLS-on * die Operation auf dem Transaktions-Delegate. So bleiben alle Direktaufrufe * garantiert auf derselben Connection wie die Kontextsetzung. */ async function applyTenantGuard( source: DelegateSource, tenantId: string, model: string, operation: string, args: unknown, runFinal: (args: unknown) => Promise, ): Promise { const a = args as Record; const delegate = source[model.charAt(0).toLowerCase() + model.slice(1)]; if ( operation === "findMany" || operation === "findFirst" || operation === "findFirstOrThrow" || operation === "count" || operation === "aggregate" || operation === "groupBy" || operation === "updateMany" || operation === "deleteMany" ) { a.where = { AND: [{ tenantId }, (a.where as object) ?? {}] }; } else if (operation === "create") { a.data = { ...(a.data as object), tenantId }; } else if (operation === "createMany") { const data = a.data as Record[]; a.data = data.map((d) => ({ ...d, tenantId })); } else if (operation === "findUnique" || operation === "findUniqueOrThrow") { // F-02: `findUnique` lässt sich nicht per `AND` um `tenantId` erweitern. // Hybrid-Guard: // (a) rein skalare where-Klausel (typisch `where: { id }`) → auf // `findFirst`/`findFirstOrThrow` mit tenantId-Vorfilter umschreiben: // Fremdzugriff wird verhindert, nicht nur erkannt. // (b) Compound-Unique-Wrapper (`tenantId_key: {...}`, `tenantId_control` // …) → `findUnique` bleibt, aber die Ownership-Prüfung wird // fail-closed (tenantId in die Projektion injizieren, hart abbrechen, // wenn das Ergebnis kein tenantId trägt). const whereObj = (a.where as Record) ?? {}; // Ein Compound-Unique-Wrapper trägt Objektwerte; skalare Unique-Filter // (id, slug …) sind Primitive bzw. Date. const scalarOnly = Object.values(whereObj).every( (v) => v === null || typeof v !== "object" || v instanceof Date, ); if (scalarOnly) { // (a) Fremdzugriff verhindern. const firstArgs = { ...a, where: { AND: [{ tenantId }, whereObj] } }; return operation === "findUniqueOrThrow" ? delegate.findFirstOrThrow(firstArgs) : delegate.findFirst(firstArgs); } // (b) Compound-Unique-Wrapper: fail-closed prüfen. const sel = a.select as Record | undefined; const injected = Boolean(sel) && !("tenantId" in (sel as object)); if (injected) a.select = { ...(sel as object), tenantId: true }; const result = await runFinal(args); if (result && typeof result === "object") { if (!("tenantId" in result)) { // Nach der Injektion darf das nicht mehr vorkommen — fail-closed // statt fail-open. await reportIsolationViolation({ model, operation, expectedTenantId: tenantId, reason: "result-without-tenantId", }); throw new Error( `Tenant isolation violation: ${model} belongs to another tenant`, ); } if ((result as { tenantId: string }).tenantId !== tenantId) { await reportIsolationViolation({ model, operation, expectedTenantId: tenantId, reason: "foreign-tenant", }); throw new Error( `Tenant isolation violation: ${model} belongs to another tenant`, ); } // Injiziertes tenantId vor der Rückgabe entfernen, damit sich der // Rückgabetyp für die Aufrufer nicht ändert. if (injected) delete (result as Record).tenantId; } return result; } else if ( operation === "update" || operation === "delete" || operation === "upsert" ) { // Mutations on unique keys: verify ownership BEFORE mutating. const existing = await delegate.findUnique({ where: a.where }); if (existing && existing.tenantId !== tenantId) { await reportIsolationViolation({ model, operation, expectedTenantId: tenantId, reason: "foreign-tenant", }); throw new Error( `Tenant isolation violation: ${model} belongs to another tenant`, ); } if (operation === "upsert") { a.create = { ...(a.create as object), tenantId }; } } return runFinal(args); } /** * Returns a Prisma client that transparently enforces the tenant scope: * reads are filtered by tenantId, creates get the tenantId injected. * * RLS-off (Default): Owner-`prisma` + Guard-Extension, `query(args)` als Ausführung. * RLS-on (`RLS_ENFORCED=true`): `craftvia_app`-Basisclient; jede Operation eines * Tenant-Modells läuft in einer eigenen Transaktion des Basisclients, in der * `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback * → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch. */ type TxClient = Parameters[0]>[0]; /** Active tenant transaction for the current async call chain (RLS path only). */ const tenantTx = new AsyncLocalStorage<{ tenantId: string; tx: TxClient }>(); export function dbForTenant(tenantId: string) { if (!tenantId) throw new Error("dbForTenant: tenantId is required"); const base = RLS_ENFORCED ? appBase! : prisma; return base.$extends({ query: { $allModels: { async $allOperations({ model, operation, args, query }) { // Globale Kataloge: kein Mandantenkontext, keine Transaktion. if (!TENANT_MODELS.has(model)) return query(args); if (!RLS_ENFORCED) { // Owner-Pfad: Guard transformiert, Ausführung via `query`. return applyTenantGuard( prisma as unknown as DelegateSource, tenantId, model, operation, args, (a) => query(a as typeof args), ); } // Inside tenantTransaction(): run on the shared transaction (atomic, context set once). const active = tenantTx.getStore(); if (active) { if (active.tenantId !== tenantId) { throw new Error("Tenant isolation violation: nested transaction for another tenant"); } const txDelegate = (active.tx as unknown as Record< string, Record Promise> >)[model.charAt(0).toLowerCase() + model.slice(1)]; return applyTenantGuard( active.tx as unknown as DelegateSource, tenantId, model, operation, args, (a) => txDelegate[operation](a), ); } // RLS-Pfad: alles in EINER Transaktion des Basisclients, damit // Kontextsetzung und Ausführung garantiert auf derselben Connection // liegen. `tx` ist uneextendiert → keine Rekursion in die Extension. return appBase!.$transaction(async (tx) => { // transaktionslokal (dritter Parameter true): gilt nur in dieser Tx, // wird beim Commit/Rollback verworfen. tenantId kommt gebunden als // Parameter (CUID aus der Session) → keine SQL-Injektion. await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`; const delegate = (tx as unknown as Record< string, Record Promise> >)[model.charAt(0).toLowerCase() + model.slice(1)]; return applyTenantGuard( tx as unknown as DelegateSource, tenantId, model, operation, args, (a) => delegate[operation](a), ); }, TX_DEFAULTS); }, }, }, }); } export type TenantDb = ReturnType; /** * Interactive transaction limits. Prisma defaults (maxWait 2 s, timeout 5 s) fail under parallel load * (pool exhausted while notifications/mails run inside a transaction) → wider but still bounded. */ const TX_DEFAULTS = { maxWait: 10_000, timeout: 20_000 } as const; /** * Run `fn` atomically for one tenant. ALWAYS use this (or `inTransaction` in services) * instead of `db.$transaction(...)` for multi-step writes — atomic in both modes: * - Owner mode: interactive transaction of the guarded client (the extension applies to tx). * - RLS mode: one craftvia_app transaction with app.tenant_id set once; every operation of the * guarded client inside `fn` (same async call chain) runs on that transaction. * Nested calls join the outer transaction. */ export async function tenantTransaction( db: TenantDb, tenantId: string, fn: (tx: TenantDb) => Promise, options?: { timeout?: number; maxWait?: number }, ): Promise { if (!RLS_ENFORCED) { if (isTransactionClient(db)) return fn(db); return db.$transaction((tx) => fn(tx as unknown as TenantDb), { ...TX_DEFAULTS, ...options }) as Promise; } const active = tenantTx.getStore(); if (active) { if (active.tenantId !== tenantId) throw new Error("Tenant isolation violation: nested transaction for another tenant"); return fn(db); } return appBase!.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`; return tenantTx.run({ tenantId, tx }, () => fn(db)); }, { ...TX_DEFAULTS, ...options }); } /** Interactive transaction clients have no $transaction method. */ function isTransactionClient(db: TenantDb): boolean { return typeof (db as unknown as { $transaction?: unknown }).$transaction !== "function"; }