Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { assertModuleEnabled } from "@/server/modules";
import { writeAuditLog } from "@/server/audit";
import { isTokenStillValid } from "@/server/sessions";
/**
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
* Reihenfolge: Session → Kontostatus/Rechte (autoritativ aus der DB) →
* Modul-Aktivierung (wirft bei Deaktivierung + Audit). 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")`).
*
* F-06: Kontostatus, Passwortzwang und effektive Rechte werden AUTORITATIV aus der
* Datenbank geprüft, NICHT aus dem JWT. Sonst blieben ein deaktiviertes Konto und
* ein entzogenes Recht bei schreibenden Aktionen bis zum Token-Ablauf wirksam,
* obwohl die UI/Layout-Guards (Lesepfade) bereits sperren. Die Layout-Guards
* (`(app)/layout.tsx`) decken die Seitenaufrufe ab, dieser Guard die Mutationen.
* Kostet eine zusätzliche Query pro Mutation — vertretbar, da Mutationen ohnehin
* mehrere Queries ausführen.
*
* 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();
const db = dbForTenant(session.user.tenantId);
// Mitgliedschaft (Status ACTIVE) + effektive Rechte des AKTIVEN Mandanten
// autoritativ aus der DB (F-06).
const account = await db.user.findFirst({
where: { id: session.user.id, status: "ACTIVE" },
select: {
userRoles: {
select: {
role: {
select: {
rolePermissions: { select: { permission: { select: { key: true } } } },
},
},
},
},
},
});
// Option C (WS4): Passwortzwang, Kill-Switch und globaler Sperrstatus gehören der
// GLOBALEN Identity — separat und autoritativ aus der DB.
const identity = session.user.identityId
? await prisma.identity.findUnique({
where: { id: session.user.identityId },
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
})
: null;
if (!account || !identity || identity.status !== "ACTIVE") {
// Deaktiviertes/entferntes Konto (Membership ODER Identity) versucht eine
// Mutation → als Sicherheitsereignis protokollieren (nicht nur ablehnen).
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "denied",
entity: "account_inactive",
entityId: session.user.id,
});
throw new Error("Konto ist nicht aktiv.");
}
// SEC2: entwertete Session (Kill-Switch an der Identity) darf keine Mutation mehr.
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
throw new Error("Sitzung ist nicht mehr gueltig. Bitte neu anmelden.");
}
if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich.");
if (permissions.length > 0) {
const effective = new Set(
account.userRoles.flatMap((ur) =>
ur.role.rolePermissions.map((rp) => rp.permission.key),
),
);
for (const permission of permissions) {
if (!effective.has(permission)) throw new ForbiddenError(permission);
}
}
await assertModuleEnabled(session, moduleKey);
return { session, db };
};
}