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
459 lines
18 KiB
TypeScript
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";
|
|
}
|