Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
/**
|
||||
* Central database access for the ISMS tool.
|
||||
*
|
||||
* Multi-tenant rule (see docs/SPEC.md §2): 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 `isms_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 `isms_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 isms_app (LOGIN, NOBYPASSRLS), z. B. " +
|
||||
"postgresql://isms_app:<pw>@<host>:5432/isms?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",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TaskParticipant",
|
||||
"Evidence",
|
||||
"Audit",
|
||||
"AuditEvidenceItem",
|
||||
"ControlDescription",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"OnboardingProgress",
|
||||
"ProjectFunctionAssignment",
|
||||
"WizardFact",
|
||||
"WizardScope",
|
||||
"PolicyPackageState",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"ProcessDependency",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"SupplierProfile",
|
||||
"ITServiceProfile",
|
||||
"SoftwareProfile",
|
||||
"ProjectProfile",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceControlResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"ControlAssessment",
|
||||
"ControlImplementation",
|
||||
// Incident-Management (IM-A)
|
||||
"Incident",
|
||||
"IncidentComment",
|
||||
"IncidentAsset",
|
||||
"IncidentProcess",
|
||||
"IncidentRisk",
|
||||
"IncidentControl",
|
||||
"IncidentMeasure",
|
||||
"IncidentEvidence",
|
||||
"IncidentAttachment",
|
||||
// IM-D — mandantengebundene Intake-Konfiguration (RLS). Die Review-Queue
|
||||
// (IncidentInboundReview) ist bewusst plattformweit → NICHT hier.
|
||||
"IncidentIntakeConfig",
|
||||
// WS4b: WebAuthnCredential ist jetzt identitäts-global (kein tenant_id) → NICHT hier.
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
// AP1: Framework-Zugehörigkeit je Mandant (RLS-Policy in der Migration).
|
||||
"TenantFramework",
|
||||
// AP3: Anwendbarkeitserklärung (SoA) je Mandant/Framework (RLS-Policy in der Migration).
|
||||
"SoaEntry",
|
||||
// AP4: Managementklauseln (Kennzahlen 9.1, Managementbewertung 9.3, CAPA 10.2).
|
||||
"Kpi",
|
||||
"KpiValue",
|
||||
"ManagementReview",
|
||||
"ManagementReviewDecision",
|
||||
"Nonconformity",
|
||||
"CorrectiveAction",
|
||||
// AP5: Dokumentenlenkung (Lesebestätigung + Versionshistorie).
|
||||
"PolicyAcknowledgement",
|
||||
"PolicyDocumentVersion",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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 `isms_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 / isms_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`): `isms_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.
|
||||
*/
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
Reference in New Issue
Block a user