Files
craftvia/src/server/action-guard.ts
T
msolarczekandClaude Opus 5 bf4456718e Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls)
- TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt
- moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services
- Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes,
  Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope
- docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD

Gate: tsc, lint, build, 22/22 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:49:21 +02:00

91 lines
4.0 KiB
TypeScript

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<string> };
};
}