Files
certvia/src/server/db.ts
T
msolarczekandClaude Opus 4.8 bd56a478fc GAP WP3.0: Generisches, editierbares Register-Datenmodell + Editor
Verwaltete Register (GAP-Report §5.2) sind nicht mehr nur benannte Dokumente,
sondern editierbare Register mit Pflichtspalten und Cross-Links.

- Schema: ManagedRegister (code, title, columns JSON, reviewCycle, responsible,
  supplierLink/assetLink) + RegisterRow (values JSON, supplierRef, assetRef).
  Migration + RLS; in TENANT_MODELS.
- import-managed.ts: 7 generische Register (REG-PROJECTS, -SENS-ROLES, -AUDIT-PLAN,
  -EXT-SERVICES, -SW-WHITELIST, -CRIT-SERVICES, -NET) mit definierten Pflichtspalten
  + Cross-Link-Flags; legt Register-Dokument (Bibliothek/Link) UND ManagedRegister-
  Definition idempotent an (RegisterRow bleibt bei Re-Import erhalten).
- actions/register.ts: addRegisterRow/updateRegisterRow/deleteRegisterRow
  (Modul policies, policy:write; Werte gegen die Pflichtspalten, optional Lieferant/Asset).
- components/generic-register.tsx: editierbare Zeilen-Tabelle + Cross-Link-Selects
  (Lieferant = Asset SUPPLIER/IT_SERVICE, Asset = alle); in /policies/[code] eingebunden.

Browser-verifiziert: REG-EXT-SERVICES rendert 7 Spalten + Lieferant/Asset-Selects;
Eintrag anlegen persistiert alle Werte. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:07:08 +02:00

147 lines
4.5 KiB
TypeScript

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
* (prisma/rls.sql, applied via migration).
*/
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
/** Models that carry a tenantId column and must never be queried without one. */
const TENANT_MODELS = new Set<string>([
"User",
"Role",
"AuditLog",
"Task",
"TaskComment",
"ManagedRegister",
"RegisterRow",
"TenantSettings",
"TenantModule",
"Asset",
"AssetRelation",
"Process",
"ProcessAsset",
"BiaEntry",
"Risk",
"RiskAsset",
"Measure",
"RiskMeasure",
"SupplierProfile",
"ITServiceProfile",
"SupplierAssessment",
"Contract",
"Nda",
"SupplierEvidence",
"ServiceControlResponsibility",
"Subcontractor",
"ManagementDecision",
"MaturityAssessment",
"PolicyDocument",
"PolicyRequirement",
"PolicyVariable",
"PolicyBaselineParam",
"PolicyEvidence",
"CryptoEntry",
"ClassificationClass",
"HandlingAspect",
"HandlingRule",
"RiskMatrixClass",
"RiskEwLevel",
"RiskDamageDimension",
"HandbookTopic",
]);
/**
* Returns a Prisma client that transparently enforces the tenant scope:
* reads are filtered by tenantId, creates get the tenantId injected.
*/
export function dbForTenant(tenantId: string) {
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!TENANT_MODELS.has(model)) return query(args);
const a = args as Record<string, unknown>;
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") {
// Unique lookups cannot be widened with AND — verify ownership on the result.
const result = await query(args);
if (
result &&
typeof result === "object" &&
"tenantId" in result &&
(result as { tenantId: string }).tenantId !== tenantId
) {
throw new Error(
`Tenant isolation violation: ${model} belongs to another tenant`
);
}
return result;
} else if (
operation === "update" ||
operation === "delete" ||
operation === "upsert"
) {
// Mutations on unique keys: verify ownership BEFORE mutating.
const delegate = (
prisma as unknown as Record<
string,
{ findUnique: (a: unknown) => Promise<{ tenantId?: string } | null> }
>
)[model.charAt(0).toLowerCase() + model.slice(1)];
const existing = await delegate.findUnique({ where: a.where });
if (existing && existing.tenantId !== tenantId) {
throw new Error(
`Tenant isolation violation: ${model} belongs to another tenant`
);
}
if (operation === "upsert") {
a.create = { ...(a.create as object), tenantId };
}
}
return query(args);
},
},
},
});
}
export type TenantDb = ReturnType<typeof dbForTenant>;