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("customers")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit * den benötigten Rechten auf, z. B. `const { session, db } = await guard("customer:write")`. * Mehrere Rechte werden alle geprüft (z. B. `guard("work_order:write", "work_order:assign")`). * * 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."); 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); // `permissions` = DB-authoritative effective set; domain services derive their // scope decisions from it (src/server/services/context.ts#ctxFromGuard). return { session, db, permissions: effective as ReadonlySet }; }; }