Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

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:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+430
View File
@@ -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>;