import { BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für * einzelne Benutzer anlegen und entziehen, plus die Datenlieferung für die * Freigabe-Matrix (D-15) und das Benutzer-Detail (D-16). * * Liest/schreibt dieselben ModuleGrant-Zeilen, die * ModuleAccessService.getAccessibleModuleIds (15-01) für die Leseseite * konsumiert — eine Schreib- und eine Leseseite auf einem Datensatz. * * D-23: jede erfolgreiche Mutation schreibt ausschließlich eine Logzeile * über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine * Ansicht im Admin-UI. * * D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet * keine Methode, die eine solche setzen könnte. */ @Injectable() export class ModuleGrantsService { private readonly logger = new Logger(ModuleGrantsService.name); constructor(private readonly prisma: PrismaService) {} /** * Prüft, dass die referenzierte Gruppe bzw. der referenzierte Benutzer * zum Mandanten aus dem JWT gehört, und wirft andernfalls * NotFoundException. * * tenantId stammt vertrauenswürdig aus dem Token — groupId/userId kommen * dagegen aus dem Request-Body eines Admin-Clients. Ohne diese * Gegenprüfung könnte ein Admin eines Mandanten einen Grant auf eine * Gruppe oder einen Benutzer eines anderen Mandanten legen und darüber * Zugriff verschaffen (T-15-01). Im Bestandscode gibt es dafür kein * Vorbild — die bisherigen Ownership-Prüfungen (z. B. * DashboardService.removeWidget) betreffen nur direktes Eigentum, nicht * eine zweite Mandantengrenze über eine Relation. */ private async assertTargetBelongsToTenant( tenantId: string, groupId?: string, userId?: string, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId) as any; if (groupId) { const group = await tenantPrisma.group.findFirst({ where: { id: groupId, tenantId }, }); if (!group) { throw new NotFoundException(`Gruppe '${groupId}' nicht gefunden`); } } if (userId) { const user = await tenantPrisma.user.findFirst({ where: { id: userId, tenantId }, }); if (!user) { throw new NotFoundException(`Benutzer '${userId}' nicht gefunden`); } } } /** * Legt einen Grant für eine Gruppe ODER einen einzelnen Benutzer an (nie * beides, nie keines — D-04). Prüfreihenfolge: Entweder-oder der beiden * Referenzen (BadRequestException mit Klartext, damit das Admin-UI nicht * den rohen Postgres-Constraint-Namen sieht), dann die Mandanten- * Gegenprüfung, dann die aktive TenantModuleActivation des Mandanten für * die moduleId (ein Grant auf ein nicht aktiviertes Modul wäre * wirkungslos, D-02), dann create. Ein P2002 aus dem partiellen * Unique-Index (zwei parallele Klicks auf dieselbe Matrix-Zelle) wird als * Erfolg behandelt und liefert den bestehenden Datensatz zurück statt * eines HTTP 500. */ async grant( tenantId: string, data: { moduleId: string; groupId?: string; userId?: string }, ) { const { moduleId, groupId, userId } = data; if ((groupId && userId) || (!groupId && !userId)) { throw new BadRequestException( 'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines', ); } // Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03, // 260909-jts/260910-jab): bis Migration // 20260910120000_rls_widen_membership_grant_and_platform_read pruefte // die Regel auf ModuleGrant ausschliesslich die Mandantenkennung der // Zeile selbst ("tenantId" = current_tenant_id()), // NICHT die referenzierte Gruppe oder den referenzierten Benutzer — eine // Zeile mit korrekter eigener Mandantenkennung, die auf die Gruppe/den // Benutzer eines fremden Mandanten zeigt, verletzte diese Regel // nachweislich nicht (gemessen in Aufgabe 1 von 260909-jts). Die // Datenbank zieht diese Grenze inzwischen ebenfalls (260910-jab, Aufgabe // 1) — diese zweite Ziehung wirkt aber erst NACH dem Scharfschalten // (#18, der Schalter ist weiterhin aus). Diese Anwendungspruefung bleibt // deshalb bis dahin der EINZIGE und danach der ERSTE Schutz gegen diese // Form der Rechteausweitung und darf nicht als "macht jetzt die // Datenbank" entfallen. await this.assertTargetBelongsToTenant(tenantId, groupId, userId); const tenantPrisma = forTenant(this.prisma, tenantId) as any; const activation = await tenantPrisma.tenantModuleActivation.findUnique({ where: { tenantId_moduleId: { tenantId, moduleId } }, }); if (!activation?.isActive) { throw new BadRequestException( `Modul '${moduleId}' ist für diesen Mandanten nicht aktiviert`, ); } const target = groupId ? `group=${groupId}` : `user=${userId}`; try { const created = await tenantPrisma.moduleGrant.create({ data: { tenantId, moduleId, groupId: groupId ?? null, userId: userId ?? null, }, }); this.logger.log( `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`, ); return created; } catch (err: any) { if (err?.code === 'P2002') { const existing = await tenantPrisma.moduleGrant.findFirst({ where: { tenantId, moduleId, groupId: groupId ?? null, userId: userId ?? null, }, }); if (existing) { this.logger.log( `Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`, ); return existing; } } throw err; } } /** * Entzieht einen Grant. deleteMany statt delete: folgenlos, wenn nichts * passt, kein vorheriger Lookup nötig. tenantId im where ist gleichzeitig * der IDOR-Schutz (T-15-02) — ein Ziel eines fremden Mandanten trifft * null Zeilen. */ async revoke( tenantId: string, data: { moduleId: string; groupId?: string; userId?: string }, ) { const { moduleId, groupId, userId } = data; const target = groupId ? `group=${groupId}` : `user=${userId}`; const tenantPrisma = forTenant(this.prisma, tenantId) as any; await tenantPrisma.moduleGrant.deleteMany({ where: { tenantId, moduleId, ...(groupId ? { groupId } : {}), ...(userId ? { userId } : {}), }, }); this.logger.log( `Grant entzogen: tenant=${tenantId} module=${moduleId} ${target}`, ); } /** * Datenlieferung für die Freigabe-Matrix (D-15): die aktiven Module, die * Gruppen und die Gruppen-Grants des Mandanten in einer Antwort. Module * sind nach category und dann name sortiert, Gruppen nach name — die * explizite Sortierung hält Spalten-/Zeilenreihenfolge über Aufrufe * hinweg stabil. */ async getMatrix(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; const [activations, groups, groupGrants] = await Promise.all([ tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, include: { module: true }, }), tenantPrisma.group.findMany({ where: { tenantId }, orderBy: { name: 'asc' }, }), tenantPrisma.moduleGrant.findMany({ where: { tenantId, groupId: { not: null } }, select: { moduleId: true, groupId: true }, }), ]); const modules = activations .map((a: any) => a.module) .sort( (a: any, b: any) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name), ); return { modules, groups, grants: groupGrants.map((g: any) => ({ moduleId: g.moduleId as string, groupId: g.groupId as string, })), }; } /** * Datenlieferung für das Benutzer-Detail (D-16): zwei unabhängige * Antworten in einem Objekt. * * `groups` stammt aus GroupMembership und ist bewusst unabhängig von * ModuleGrant — eine Mitgliedschaft ohne jede Modul-Freigabe bleibt * dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere * Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein * Direkt-Grant) und behält dafür je Eintrag exakt die Form * { module, viaGroups, direct }. * * Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide * Projektionsstellen (viaGroups-Namen, groups[].name) liefern * `group.internalName ?? group.name` — Nullish, nicht Oder, damit ein * bereits auf null normalisierter Leerstring (Task 2) nicht versehentlich * über eine Truthiness-Prüfung nachträglich anders interpretiert wird. * Die Sortierung von `groups` läuft danach automatisch über den * angezeigten Namen, weil sie auf demselben Feld (`name`) aufsetzt — * anders als GroupsService.listForTenant(), das weiterhin über die * Datenbankspalte `name` sortiert. */ async getUserAccess(tenantId: string, userId: string) { await this.assertTargetBelongsToTenant(tenantId, undefined, userId); const tenantPrisma = forTenant(this.prisma, tenantId) as any; const [activations, groupGrants, directGrants, memberships] = await Promise.all([ tenantPrisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, include: { module: true }, }), tenantPrisma.moduleGrant.findMany({ where: { tenantId, group: { memberships: { some: { userId } } } }, include: { group: true }, }), tenantPrisma.moduleGrant.findMany({ where: { tenantId, userId }, select: { moduleId: true }, }), // Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird // über denselben tenantPrisma wie die drei Abfragen oben gesetzt — // es entsteht kein zweiter gebundener Client. Der `where`-Filter // über die Beziehung zur Gruppe (`group: { tenantId }`) bleibt // ZUSÄTZLICH stehen: GroupMembership trägt keine eigene tenantId- // Spalte, und die ausgelieferte Regel auf dieser Tabelle bezieht // ihre Sichtbarkeit ausschließlich über die Gruppenseite (gemessen // in Aufgabe 1) — der Anwendungsfilter ist deshalb nicht redundant, // sondern das zweite Netz. tenantPrisma.groupMembership.findMany({ where: { userId, group: { tenantId } }, include: { group: { select: { id: true, name: true, internalName: true } } }, }), ]); const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string)); const groupNamesByModule = new Map(); for (const g of groupGrants as any[]) { if (!g.group) continue; const names = groupNamesByModule.get(g.moduleId) ?? []; names.push(g.group.internalName ?? g.group.name); groupNamesByModule.set(g.moduleId, names); } const modules = activations .map((a: any) => a.module) .sort( (a: any, b: any) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name), ); const groups = (memberships as any[]) .filter((m) => m.group) .map((m) => ({ id: m.group.id as string, name: (m.group.internalName ?? m.group.name) as string, source: m.source as string, })) .sort((a, b) => a.name.localeCompare(b.name)); return { groups, modules: modules.map((module: any) => ({ module, viaGroups: groupNamesByModule.get(module.id) ?? [], direct: directModuleIds.has(module.id), })), }; } }