API-seitige Modul-Durchsetzung vervollständigt (§3.4, Phase-1-Härtung Paket 1)

Der requireModule-Guard war bislang nur exemplarisch in den Policy-Actions
gesetzt. Jetzt läuft jede mutierende Server-Action eines gegateten Moduls über
einen zentralen, modul- und rechteprüfenden Guard — ein für den Mandanten
deaktiviertes Modul weist damit auch Writes serverseitig ab (nicht nur Reads
über die Route-Layouts).

- action-guard.ts: moduleGuard("<key>") erzeugt je Modul einen guard(...perms),
  der Session → Modul-Aktivierung → RBAC prüft und {session, db} zurückgibt.
- modules.ts: assertModuleEnabled(session, key) wirft (statt Redirect) bei
  Deaktivierung und protokolliert die Verweigerung (Audit-Aktion "denied").
- audit.ts: Audit-Aktion "denied" + optionaler scope-Parameter.
- Alle gegateten Action-Dateien umgestellt: assets→assets, processes→bia,
  risks→risk, measures→measures, suppliers/services→suppliers, policies→policies.
  Freigabe/Ablehnung der Richtlinien laufen jetzt ebenfalls über den Modul-Guard.
- Vollständigkeitscheck scripts/check-module-guards.ts (Registry Action→Modul):
  schlägt fehl bei nicht zugeordneter Datei, fehlendem moduleGuard oder einer
  Action ohne guard(...). Als prebuild verdrahtet ⇒ Build failt bei vergessenem
  Endpoint. admin/tenant-settings sind als EXEMPT (eigene Auth) markiert.

Verifiziert: tsc sauber, lint sauber, Guard-Check grün (9 Dateien), Negativ-Probe
(ungemappte Action-Datei) failt den Check wie erwartet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:27:13 +02:00
co-authored by Claude Opus 4.8
parent c963ffadea
commit 8348764c99
12 changed files with 233 additions and 161 deletions
+25
View File
@@ -0,0 +1,25 @@
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission, type Permission } from "@/server/rbac";
import { assertModuleEnabled } from "@/server/modules";
/**
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
* Reihenfolge: Session → Modul-Aktivierung (wirft bei Deaktivierung + Audit) → RBAC.
* Gibt Session und den mandantengebundenen Prisma-Client zurück.
*
* `moduleGuard("assets")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit
* den benötigten Rechten auf, z. B. `const { session, db } = await guard("asset:write")`.
* Mehrere Rechte werden alle geprüft (z. B. `guard("risk:write", "measure:write")`).
*
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
* dass jede Action eines gegateten Moduls über einen so erzeugten `guard(...)` läuft.
*/
export function moduleGuard(moduleKey: string) {
return async function guard(...permissions: Permission[]) {
const session = await requireSession();
await assertModuleEnabled(session, moduleKey);
for (const permission of permissions) requirePermission(session, permission);
return { session, db: dbForTenant(session.user.tenantId) };
};
}
+8 -18
View File
@@ -3,11 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const assetSchema = z.object({
@@ -48,9 +48,7 @@ function parseAssetForm(formData: FormData) {
}
export async function createAsset(formData: FormData) {
const session = await requireSession();
requirePermission(session, "asset:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("asset:write");
const data = parseAssetForm(formData);
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
@@ -71,9 +69,7 @@ export async function createAsset(formData: FormData) {
}
export async function updateAsset(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "asset:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
@@ -95,9 +91,7 @@ export async function updateAsset(assetId: string, formData: FormData) {
}
export async function deleteAsset(assetId: string) {
const session = await requireSession();
requirePermission(session, "asset:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
@@ -117,9 +111,7 @@ export async function deleteAsset(assetId: string) {
}
export async function addAssetRelation(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "asset:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("asset:write");
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
if (relatedAssetId === assetId) return;
@@ -153,9 +145,7 @@ export async function addAssetRelation(assetId: string, formData: FormData) {
}
export async function removeAssetRelation(assetId: string, relationId: string) {
const session = await requireSession();
requirePermission(session, "asset:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("asset:write");
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
if (!before) return;
+11 -25
View File
@@ -3,10 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("measures");
import { recomputeResidualRisk } from "@/server/risk-calc";
const measureSchema = z.object({
@@ -54,9 +55,7 @@ async function nextRefNo(tenantId: string) {
}
export async function createMeasure(formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("measure:write");
const data = parseMeasureForm(formData);
const measure = await db.measure.create({
@@ -81,9 +80,7 @@ export async function createMeasure(formData: FormData) {
}
export async function updateMeasure(measureId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before) throw new Error("Maßnahme nicht gefunden");
@@ -107,9 +104,7 @@ export async function updateMeasure(measureId: string, formData: FormData) {
/** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */
export async function updateMeasureStatus(measureId: string, status: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("measure:write");
const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status);
const before = await db.measure.findUnique({ where: { id: measureId } });
@@ -131,9 +126,7 @@ export async function updateMeasureStatus(measureId: string, status: string) {
}
export async function deleteMeasure(measureId: string) {
const session = await requireSession();
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({
where: { id: measureId },
@@ -162,9 +155,7 @@ export async function deleteMeasure(measureId: string) {
/** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */
export async function linkMeasureToRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const measureId = z.string().min(1).parse(formData.get("measureId"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
@@ -201,10 +192,7 @@ export async function linkMeasureToRisk(riskId: string, formData: FormData) {
/** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */
export async function createMeasureForRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
requirePermission(session, "measure:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write", "measure:write");
const riskCount = await db.risk.count({ where: { id: riskId } });
if (riskCount !== 1) throw new Error("Risiko nicht gefunden");
@@ -245,9 +233,7 @@ export async function createMeasureForRisk(riskId: string, formData: FormData) {
}
export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } });
if (!before) return;
+8 -15
View File
@@ -3,20 +3,17 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { requireModule } from "@/server/modules";
import { moduleGuard } from "@/server/action-guard";
import { type Permission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
async function guard() {
const session = await requireSession();
await requireModule("policies"); // API-Ebene: deaktiviertes Modul sperrt auch Actions
requirePermission(session, "policy:write");
return { session, db: dbForTenant(session.user.tenantId) };
const guardModule = moduleGuard("policies");
/** Modul-Gating „policies" + RBAC. Default-Recht policy:write; Freigabe nutzt policy:approve. */
async function guard(permission: Permission = "policy:write") {
return guardModule(permission);
}
/* ── Krypto-Register (VA-07) ── */
@@ -244,9 +241,7 @@ export async function submitForApproval(code: string) {
}
export async function approvePolicy(code: string) {
const session = await requireSession();
requirePermission(session, "policy:approve");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("policy:approve");
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
if (doc.status !== "IN_FREIGABE") throw new Error("Dokument ist nicht in Freigabe");
@@ -261,9 +256,7 @@ export async function approvePolicy(code: string) {
}
export async function rejectPolicy(code: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "policy:approve");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("policy:approve");
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const reason = str(formData.get("reason"));
+10 -24
View File
@@ -3,11 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("bia");
const processSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
@@ -30,9 +30,7 @@ function parseProcessForm(formData: FormData) {
}
export async function createProcess(formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const data = parseProcessForm(formData);
// tenantId explizit für die Typen, der Guard injiziert ohnehin
@@ -53,9 +51,7 @@ export async function createProcess(formData: FormData) {
}
export async function updateProcess(processId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId } });
if (!before) throw new Error("Prozess nicht gefunden");
@@ -77,9 +73,7 @@ export async function updateProcess(processId: string, formData: FormData) {
}
export async function deleteProcess(processId: string) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId } });
if (!before) throw new Error("Prozess nicht gefunden");
@@ -99,9 +93,7 @@ export async function deleteProcess(processId: string) {
}
export async function assignAsset(processId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role"));
@@ -128,9 +120,7 @@ export async function assignAsset(processId: string, formData: FormData) {
}
export async function unassignAsset(processId: string, processAssetId: string) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
if (!before) return;
@@ -152,9 +142,7 @@ const hours = z
.transform((v) => (v === "" ? null : v));
export async function saveBia(processId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const processCount = await db.process.count({ where: { id: processId } });
if (processCount !== 1) throw new Error("Prozess nicht gefunden");
@@ -197,9 +185,7 @@ export async function saveBia(processId: string, formData: FormData) {
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
*/
export async function saveProcessAll(processId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({
where: { id: processId },
+9 -18
View File
@@ -3,10 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("risk");
import { recomputeResidualRisk } from "@/server/risk-calc";
const scale = z.coerce.number().int().min(1).max(5);
@@ -53,9 +54,7 @@ function parseRiskForm(formData: FormData) {
}
export async function createRisk(formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const data = parseRiskForm(formData);
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
@@ -97,9 +96,7 @@ export async function createRisk(formData: FormData) {
}
export async function updateRisk(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
@@ -123,9 +120,7 @@ export async function updateRisk(riskId: string, formData: FormData) {
}
export async function deleteRisk(riskId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
@@ -145,9 +140,7 @@ export async function deleteRisk(riskId: string) {
}
export async function addRiskAsset(riskId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const assetCount = await db.asset.count({ where: { id: assetId } });
@@ -173,9 +166,7 @@ export async function addRiskAsset(riskId: string, formData: FormData) {
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
const session = await requireSession();
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("risk:write");
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
if (!before) return;
+10 -21
View File
@@ -3,10 +3,11 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("suppliers");
import { withQuery } from "@/lib/supplier";
const level = z.coerce.number().int().min(1).max(4);
@@ -45,9 +46,7 @@ function parse(formData: FormData) {
}
export async function createService(formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
@@ -63,9 +62,7 @@ export async function createService(formData: FormData) {
}
export async function updateService(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
if (!before) throw new Error("Service nicht gefunden");
const { asset, profile } = parse(formData);
@@ -79,9 +76,7 @@ export async function updateService(assetId: string, formData: FormData) {
}
export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Service nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
@@ -92,9 +87,7 @@ export async function deleteService(assetId: string, returnTo: string = "/suppli
}
export async function addRaci(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
await db.serviceControlResponsibility.create({
data: {
@@ -114,9 +107,7 @@ export async function addRaci(assetId: string, formData: FormData) {
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
export async function cycleRaci(assetId: string, raciId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
if (!row) return;
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
@@ -127,9 +118,7 @@ export async function cycleRaci(assetId: string, raciId: string) {
}
export async function deleteRaci(assetId: string, raciId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
+23 -39
View File
@@ -3,12 +3,13 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { prisma, type TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const supplierSchema = z.object({
@@ -67,9 +68,7 @@ function parse(formData: FormData) {
}
export async function createSupplier(formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
@@ -100,9 +99,7 @@ export async function createSupplier(formData: FormData) {
}
export async function updateSupplier(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
if (!before) throw new Error("Lieferant nicht gefunden");
const { asset, profile } = parse(formData);
@@ -116,9 +113,7 @@ export async function updateSupplier(assetId: string, formData: FormData) {
}
export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
@@ -128,17 +123,16 @@ export async function deleteSupplier(assetId: string, returnTo: string = "/suppl
redirect(returnTo);
}
async function ensure(session: Awaited<ReturnType<typeof requireSession>>, assetId: string) {
const db = dbForTenant(session.user.tenantId);
/** Stellt sicher, dass das Asset zum Mandanten gehört (Guard hat Modul/Recht bereits geprüft). */
async function ensure(db: TenantDb, assetId: string) {
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
export async function addContract(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
@@ -160,9 +154,8 @@ export async function addContract(assetId: string, formData: FormData) {
}
export async function addNda(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.nda.create({
data: {
assetId,
@@ -181,9 +174,8 @@ export async function addNda(assetId: string, formData: FormData) {
}
export async function addEvidence(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierEvidence.create({
data: {
assetId,
@@ -201,9 +193,8 @@ export async function addEvidence(assetId: string, formData: FormData) {
}
export async function addAssessment(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierAssessment.create({
data: {
assetId,
@@ -221,9 +212,8 @@ export async function addAssessment(assetId: string, formData: FormData) {
}
export async function addDecision(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.managementDecision.create({
data: {
assetId,
@@ -241,10 +231,7 @@ export async function addDecision(assetId: string, formData: FormData) {
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
export async function createGateRisk(assetId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write", "risk:write");
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Lieferant nicht gefunden");
@@ -274,9 +261,8 @@ export async function createGateRisk(assetId: string) {
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
const justification = (formData.get("justification") as string)?.trim() || null;
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
@@ -296,9 +282,7 @@ export async function deleteChild(
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
id: string
) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { session, db } = await guard("supplier:write");
const map = {
contract: db.contract,
nda: db.nda,
+3 -1
View File
@@ -8,7 +8,8 @@ import { prisma } from "./db";
export async function writeAuditLog(entry: {
tenantId: string;
actorId?: string;
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import";
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
scope?: "tenant" | "platform";
entity: string;
entityId?: string;
before?: unknown;
@@ -17,6 +18,7 @@ export async function writeAuditLog(entry: {
await prisma.auditLog.create({
data: {
tenantId: entry.tenantId,
scope: entry.scope ?? "tenant",
actorId: entry.actorId,
action: entry.action,
entity: entry.entity,
+35
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
/**
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
@@ -16,3 +18,36 @@ export async function requireModule(moduleKey: string) {
if (row && !row.enabled) redirect("/dashboard?module=disabled");
return session;
}
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
export class ModuleDisabledError extends Error {
constructor(public readonly moduleKey: string) {
super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
this.name = "ModuleDisabledError";
}
}
/**
* Action-Ebene der Modul-Durchsetzung (§3.4): prüft für eine bereits ermittelte
* Session, ob das Modul aktiv ist. Anders als `requireModule` (Redirect, für
* Layouts/Reads) **wirft** diese Variante bei Deaktivierung — der Layout-Guard
* greift bei Server-Actions erst nach der Mutation, daher hier vor dem Schreiben.
* Ein deaktiviertes Modul wird zusätzlich als Zugriffsverweigerung protokolliert.
* Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
*/
export async function assertModuleEnabled(session: Session, moduleKey: string) {
const tenantId = session.user.tenantId;
const row = await dbForTenant(tenantId).tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
});
if (row && !row.enabled) {
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "denied",
entity: "module",
entityId: moduleKey,
});
throw new ModuleDisabledError(moduleKey);
}
}