- 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>
91 lines
4.0 KiB
TypeScript
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> };
|
|
};
|
|
}
|