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:
@@ -4,6 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"check:guards": "tsx scripts/check-module-guards.ts",
|
||||
"prebuild": "tsx scripts/check-module-guards.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Vollständigkeitscheck der serverseitigen Modul-Durchsetzung (§3.4, Phase-1-Härtung).
|
||||
*
|
||||
* Jede mutierende Server-Action eines gegateten Moduls MUSS über einen
|
||||
* `moduleGuard("<key>")`-Guard laufen (siehe src/server/action-guard.ts), damit ein
|
||||
* für den Mandanten deaktiviertes Modul auch Writes serverseitig abweist.
|
||||
*
|
||||
* Dieses Script erzwingt das statisch: Es kennt die Zuordnung Action-Datei → Modul
|
||||
* und schlägt fehl (Exit 1 → Build/Test rot), sobald
|
||||
* - eine neue Action-Datei nicht zugeordnet ist ("vergessener Endpoint"),
|
||||
* - eine gegatete Datei den erwarteten moduleGuard nicht verwendet, oder
|
||||
* - eine exportierte Action nicht über `await guard(...)` läuft.
|
||||
*
|
||||
* Neue Action-Datei anlegen ⇒ hier eintragen (Modul-Key oder "EXEMPT").
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MODULE_KEYS } from "../src/lib/modules";
|
||||
|
||||
const ACTIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "server", "actions");
|
||||
|
||||
/** Zuordnung Action-Datei → Modul-Key. "EXEMPT" = kein gegatetes Fachmodul (eigene Auth). */
|
||||
const ACTION_MODULE: Record<string, string> = {
|
||||
"assets.ts": "assets",
|
||||
"processes.ts": "bia",
|
||||
"risks.ts": "risk",
|
||||
"measures.ts": "measures",
|
||||
"suppliers.ts": "suppliers",
|
||||
"services.ts": "suppliers",
|
||||
"policies.ts": "policies",
|
||||
// Plattform-Admin (isPlatformAdmin) und Kunden-Einstellungen (tenant:manage) sind
|
||||
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
|
||||
"admin.ts": "EXEMPT",
|
||||
"tenant-settings.ts": "EXEMPT",
|
||||
};
|
||||
|
||||
const errors: string[] = [];
|
||||
const files = readdirSync(ACTIONS_DIR).filter((f) => f.endsWith(".ts"));
|
||||
|
||||
for (const file of files) {
|
||||
const mapped = ACTION_MODULE[file];
|
||||
if (!mapped) {
|
||||
errors.push(
|
||||
`Nicht zugeordnete Action-Datei: ${file} — in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const src = readFileSync(join(ACTIONS_DIR, file), "utf8");
|
||||
|
||||
if (mapped === "EXEMPT") {
|
||||
if (!/require(Session|PlatformAdmin|Permission)/.test(src)) {
|
||||
errors.push(`${file}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MODULE_KEYS.includes(mapped)) {
|
||||
errors.push(`${file}: unbekannter Modul-Key "${mapped}" (nicht in src/lib/modules.ts).`);
|
||||
}
|
||||
if (!src.includes(`moduleGuard("${mapped}")`)) {
|
||||
errors.push(`${file}: erwartet moduleGuard("${mapped}") — Modul-Gating fehlt oder falscher Key.`);
|
||||
}
|
||||
|
||||
// Jede exportierte Server-Action muss über await guard(...) laufen.
|
||||
const exportRe = /export async function (\w+)\s*\(/g;
|
||||
const positions: { name: string; index: number }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = exportRe.exec(src))) positions.push({ name: m[1], index: m.index });
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const start = positions[i].index;
|
||||
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
|
||||
if (!/await guard\(/.test(src.slice(start, end))) {
|
||||
errors.push(
|
||||
`${file}: Action "${positions[i].name}" läuft nicht über await guard(...) — Modul-/Rechte-Guard fehlt.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error("✗ Modul-Guard-Vollständigkeitscheck fehlgeschlagen:");
|
||||
for (const e of errors) console.error(" - " + e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ Modul-Guard-Vollständigkeitscheck: ${files.length} Action-Dateien geprüft — alle mutierenden Actions sind modul- und rechtegegated.`
|
||||
);
|
||||
@@ -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) };
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user