Files
certvia/scripts/check-module-guards.ts
T
msolarczekandClaude Opus 4.8 8e4040d1da Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)
Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines
Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung
für den sicheren Betrieb beim ersten echten Kunden.

Store & Migration
- Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret,
  Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin.
- AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform).
- Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen
  (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt.

Getrennter Login + MFA
- Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und
  eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId.
- TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR +
  Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes.
- Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen,
  Fehlversuche und Sperren (scope=platform).

Autorisierung / Trennung
- Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt
  Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin.
- Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die
  Plattform-Session statt des abgelösten Flags.
- provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform-
  Admin (admin@demo.example) im getrennten Store an.

Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes →
/admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf
/platform/login umgeleitet. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:55:12 +02:00

91 lines
3.5 KiB
TypeScript

/**
* 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-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind
// keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard.
"admin.ts": "EXEMPT",
"platform.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|Platform\w*|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.`
);