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>
147 lines
4.5 KiB
TypeScript
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>;
|