Files
craftvia/src/server/db.ts
T
msolarczek a8fedc390f Merge lane/testphase in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	prisma/schema.prisma
#	scripts/test-e2e-tenant-isolation.ts
#	src/server/backup/topology.ts
#	src/server/db.ts
#	src/server/dsgvo/pii-fields.ts
2026-09-15 19:16:19 +02:00

459 lines
18 KiB
TypeScript

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:<pw>@<host>: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<string>([
"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('<table>')` 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<unknown>;
findFirstOrThrow: (a: unknown) => Promise<unknown>;
};
/** Roher Client bzw. Transaktions-Client als Quelle uneextendierter Delegates. */
type DelegateSource = Record<string, GuardDelegate>;
/**
* 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<unknown>,
): Promise<unknown> {
const a = args as Record<string, unknown>;
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<string, unknown>[];
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<string, unknown>) ?? {};
// 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<string, unknown> | 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<string, unknown>).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<Parameters<PrismaClient["$transaction"]>[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<string, (a: unknown) => Promise<unknown>>
>)[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<string, (a: unknown) => Promise<unknown>>
>)[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<typeof dbForTenant>;
/**
* 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<T>(
db: TenantDb,
tenantId: string,
fn: (tx: TenantDb) => Promise<T>,
options?: { timeout?: number; maxWait?: number },
): Promise<T> {
if (!RLS_ENFORCED) {
if (isTransactionClient(db)) return fn(db);
return db.$transaction((tx) => fn(tx as unknown as TenantDb), { ...TX_DEFAULTS, ...options }) as Promise<T>;
}
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";
}