From 5e256db01d37928055aad54765eee3a8f55c9ff6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 18:41:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(15-03):=20ModuleGrantsService=20=E2=80=94?= =?UTF-8?q?=20Freigaben=20setzen/entziehen=20mit=20Mandanten-Gegenpr=C3=BC?= =?UTF-8?q?fung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert - grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02), P2002 als Erfolg (Doppelklick-Schutz) - getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und Benutzer-Detail, jeweils sortiert und mandantengescoped - 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency --- .../src/groups/dto/create-module-grant.dto.ts | 24 + .../src/groups/module-grants.service.spec.ts | 457 ++++++++++++++++++ apps/api/src/groups/module-grants.service.ts | 251 ++++++++++ 3 files changed, 732 insertions(+) create mode 100644 apps/api/src/groups/dto/create-module-grant.dto.ts create mode 100644 apps/api/src/groups/module-grants.service.spec.ts create mode 100644 apps/api/src/groups/module-grants.service.ts diff --git a/apps/api/src/groups/dto/create-module-grant.dto.ts b/apps/api/src/groups/dto/create-module-grant.dto.ts new file mode 100644 index 0000000..de5945d --- /dev/null +++ b/apps/api/src/groups/dto/create-module-grant.dto.ts @@ -0,0 +1,24 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +/** + * DTO für Grant-Erstellung und -Entzug (PERM-03). + * + * Die Entweder-oder-Regel (genau eine von groupId/userId, D-04) wird im + * Service geprüft, nicht hier — sie setzt zwei Felder zueinander in + * Beziehung, das DTO deckt nur die Feldtypen ab. Dieselbe Form bedient + * sowohl POST /module-grants (anlegen) als auch DELETE /module-grants + * (entziehen, Ziel im Body statt Pfadparameter). + */ +export class CreateModuleGrantDto { + @IsString() + @IsNotEmpty() + moduleId!: string; + + @IsString() + @IsOptional() + groupId?: string; + + @IsString() + @IsOptional() + userId?: string; +} diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts new file mode 100644 index 0000000..f46dae5 --- /dev/null +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -0,0 +1,457 @@ +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModuleGrantsService } from './module-grants.service'; + +/** + * ModuleGrantsService.spec — Beweis für PERM-03 (D-15/D-16), die + * Entweder-oder-Regel (D-04) und die Mandanten-Gegenprüfung vor jedem + * Grant-Insert (T-15-01). Hand-rolled In-Memory-Prisma-Fake im Stil von + * groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB, + * P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode + * simuliert. + */ + +function makeFakePrisma() { + const groups = new Map(); + const users = new Map(); + const memberships = new Map>(); // groupId -> Set + const activations = new Map(); // key: tenantId::moduleId + const grants = new Map(); + let grantCounter = 0; + + function throwUnique(): never { + const err: any = new Error('Unique constraint failed'); + err.code = 'P2002'; + throw err; + } + + function findGrant( + tenantId: string, + moduleId: string, + groupId?: string | null, + userId?: string | null, + ) { + return Array.from(grants.values()).find( + (g) => + g.tenantId === tenantId && + g.moduleId === moduleId && + (g.groupId ?? null) === (groupId ?? null) && + (g.userId ?? null) === (userId ?? null), + ); + } + + return { + __seedGroup(group: { id: string; tenantId: string; name: string }) { + groups.set(group.id, group); + }, + __seedUser(user: { id: string; tenantId: string }) { + users.set(user.id, user); + }, + __seedMembership(groupId: string, userId: string) { + const set = memberships.get(groupId) ?? new Set(); + set.add(userId); + memberships.set(groupId, set); + }, + __seedActivation(a: { + tenantId: string; + moduleId: string; + isActive: boolean; + module: { id: string; category: string; name: string }; + }) { + activations.set(`${a.tenantId}::${a.moduleId}`, a); + }, + __grantCount() { + return grants.size; + }, + group: { + findFirst: async ({ where }: any) => { + return ( + Array.from(groups.values()).find( + (g) => g.id === where.id && g.tenantId === where.tenantId, + ) ?? null + ); + }, + findMany: async ({ where }: any) => { + return Array.from(groups.values()) + .filter((g) => g.tenantId === where.tenantId) + .sort((a, b) => a.name.localeCompare(b.name)); + }, + }, + user: { + findFirst: async ({ where }: any) => { + return ( + Array.from(users.values()).find( + (u) => u.id === where.id && u.tenantId === where.tenantId, + ) ?? null + ); + }, + }, + tenantModuleActivation: { + findUnique: async ({ where }: any) => { + const { tenantId, moduleId } = where.tenantId_moduleId; + return activations.get(`${tenantId}::${moduleId}`) ?? null; + }, + findMany: async ({ where }: any) => { + return Array.from(activations.values()).filter( + (a) => a.tenantId === where.tenantId && a.isActive === where.isActive, + ); + }, + }, + moduleGrant: { + create: async ({ data }: any) => { + if (findGrant(data.tenantId, data.moduleId, data.groupId, data.userId)) { + throwUnique(); + } + grantCounter += 1; + const record = { id: `grant-${grantCounter}`, createdAt: new Date(), ...data }; + grants.set(record.id, record); + return record; + }, + findFirst: async ({ where }: any) => { + return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null; + }, + findMany: async ({ where }: any) => { + let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId); + + if (where.moduleId !== undefined) { + rows = rows.filter((g) => g.moduleId === where.moduleId); + } + if (where.groupId?.not === null) { + rows = rows.filter((g) => g.groupId !== null && g.groupId !== undefined); + } + if (where.group) { + const userId = where.group.memberships.some.userId; + rows = rows + .filter((g) => g.groupId && memberships.get(g.groupId)?.has(userId)) + .map((g) => ({ ...g, group: groups.get(g.groupId) })); + } else if (where.userId !== undefined) { + rows = rows.filter((g) => g.userId === where.userId); + } + return rows; + }, + deleteMany: async ({ where }: any) => { + let count = 0; + for (const [id, g] of grants.entries()) { + if ( + g.tenantId === where.tenantId && + g.moduleId === where.moduleId && + (where.groupId === undefined || g.groupId === where.groupId) && + (where.userId === undefined || g.userId === where.userId) + ) { + grants.delete(id); + count += 1; + } + } + return { count }; + }, + }, + }; +} + +function seedBase(prisma: ReturnType) { + prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' }); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-1', + isActive: true, + module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, + }); +} + +describe('ModuleGrantsService.grant', () => { + it('legt einen Gruppen-Grant an und gibt ihn zurück', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(result.moduleId).toBe('mod-1'); + expect(result.groupId).toBe('g1'); + expect(result.userId ?? null).toBeNull(); + }); + + it('legt einen Direkt-Grant an und gibt ihn zurück', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' }); + + expect(result.moduleId).toBe('mod-1'); + expect(result.userId).toBe('u1'); + expect(result.groupId ?? null).toBeNull(); + }); + + it('wirft BadRequestException, wenn groupId UND userId gesetzt sind', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + await expect( + service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', userId: 'u1' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__grantCount()).toBe(0); + }); + + it('wirft BadRequestException, wenn weder groupId noch userId gesetzt sind', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + await expect(service.grant('t1', { moduleId: 'mod-1' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(prisma.__grantCount()).toBe(0); + }); + + it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedGroup({ id: 'g-foreign', tenantId: 't2', name: 'Fremde Gruppe' }); + const service = new ModuleGrantsService(prisma as any); + + await expect( + service.grant('t1', { moduleId: 'mod-1', groupId: 'g-foreign' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.__grantCount()).toBe(0); + }); + + it('wirft NotFoundException für eine userId aus einem anderen Mandanten und legt nichts an', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); + const service = new ModuleGrantsService(prisma as any); + + await expect( + service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.__grantCount()).toBe(0); + }); + + it('wirft BadRequestException, wenn keine aktive TenantModuleActivation für das Modul existiert', async () => { + const prisma = makeFakePrisma(); + prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Gruppe A' }); + // keine Activation geseedet + const service = new ModuleGrantsService(prisma as any); + + await expect( + service.grant('t1', { moduleId: 'mod-unaktiviert', groupId: 'g1' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.__grantCount()).toBe(0); + }); + + it('idempotency: ein zweiter Grant auf dieselbe Kombination legt keinen zweiten Datensatz an und wirft nicht', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(second.id).toBe(first.id); + expect(prisma.__grantCount()).toBe(1); + }); + + it('concurrency: zwei parallele Grant-Erstellungen für dieselbe Kombination führen zu genau einer Zeile, keine der beiden wirft', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + const [first, second] = await Promise.all([ + service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }), + service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }), + ]); + + expect(first.groupId).toBe('g1'); + expect(second.groupId).toBe('g1'); + expect(prisma.__grantCount()).toBe(1); + }); +}); + +describe('ModuleGrantsService.revoke', () => { + it('entfernt einen bestehenden Grant', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(prisma.__grantCount()).toBe(0); + }); + + it('idempotency: ein zweites Entziehen eines bereits entzogenen Grants ist folgenlos und wirft nicht', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + await expect( + service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }), + ).resolves.not.toThrow(); + expect(prisma.__grantCount()).toBe(0); + }); + + it('entfernt nichts, wenn die groupId aus einem anderen Mandanten stammt', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + await service.revoke('t2', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(prisma.__grantCount()).toBe(1); + }); +}); + +describe('ModuleGrantsService.getMatrix', () => { + it('liefert modules, groups und grants; Module nach category+name, Gruppen nach name sortiert', async () => { + const prisma = makeFakePrisma(); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-b', + isActive: true, + module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' }, + }); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-a', + isActive: true, + module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' }, + }); + prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Zeta' }); + prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Alpha' }); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-a', groupId: 'g1' }); + + const matrix = await service.getMatrix('t1'); + + expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']); + expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']); + expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]); + }); + + it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => { + const prisma = makeFakePrisma(); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-1', + isActive: true, + module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, + }); + const service = new ModuleGrantsService(prisma as any); + + const matrix = await service.getMatrix('t1'); + + expect(matrix.groups).toEqual([]); + expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-1']); + }); + + it('ordering: die Matrix-Antwort liefert dieselbe Reihenfolge über wiederholte Aufrufe', async () => { + const prisma = makeFakePrisma(); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-b', + isActive: true, + module: { id: 'mod-b', category: 'zzz', name: 'B-Modul' }, + }); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-a', + isActive: true, + module: { id: 'mod-a', category: 'aaa', name: 'A-Modul' }, + }); + const service = new ModuleGrantsService(prisma as any); + + const first = await service.getMatrix('t1'); + const second = await service.getMatrix('t1'); + + expect(first.modules.map((m: any) => m.id)).toEqual(second.modules.map((m: any) => m.id)); + }); +}); + +describe('ModuleGrantsService.getUserAccess', () => { + it('liefert je aktivem Modul die geerbten Gruppen und den Direkt-Grant-Status', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedMembership('g1', 'u1'); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + const result = await service.getUserAccess('t1', 'u1'); + + expect(result).toEqual([ + { + module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, + viaGroups: ['Gruppe A'], + direct: false, + }, + ]); + }); + + it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedMembership('g1', 'u1'); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + await service.grant('t1', { moduleId: 'mod-1', userId: 'u1' }); + + const result = await service.getUserAccess('t1', 'u1'); + + expect(result[0].viaGroups).toEqual(['Gruppe A']); + expect(result[0].direct).toBe(true); + }); + + it('wirft NotFoundException für eine userId aus einem anderen Mandanten', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); + const service = new ModuleGrantsService(prisma as any); + + await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); + +describe('ModuleGrantsService — Logging (D-23)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('grant schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const service = new ModuleGrantsService(prisma as any); + + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(logSpy).toHaveBeenCalled(); + const message = logSpy.mock.calls[0][0] as string; + expect(message).toContain('t1'); + expect(message).toContain('mod-1'); + expect(message).toContain('g1'); + }); + + it('revoke schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + logSpy.mockClear(); + + await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expect(logSpy).toHaveBeenCalled(); + const message = logSpy.mock.calls[0][0] as string; + expect(message).toContain('t1'); + expect(message).toContain('mod-1'); + expect(message).toContain('g1'); + }); +}); diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts new file mode 100644 index 0000000..31b5530 --- /dev/null +++ b/apps/api/src/groups/module-grants.service.ts @@ -0,0 +1,251 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * 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 { + if (groupId) { + const group = await this.prisma.group.findFirst({ + where: { id: groupId, tenantId }, + }); + if (!group) { + throw new NotFoundException(`Gruppe '${groupId}' nicht gefunden`); + } + } + if (userId) { + const user = await this.prisma.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', + ); + } + + await this.assertTargetBelongsToTenant(tenantId, groupId, userId); + + const activation = await this.prisma.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 this.prisma.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 this.prisma.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}`; + + await this.prisma.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 [activations, groups, groupGrants] = await Promise.all([ + this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true }, + include: { module: true }, + }), + this.prisma.group.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + }), + this.prisma.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): je aktivem Modul die + * Namen der Gruppen, über die der Benutzer das Modul erbt, und ein + * Kennzeichen für einen bestehenden Direkt-Grant. Ohne diese Anzeige ist + * im Benutzer-Detail nicht erkennbar, warum jemand Zugriff hat. + */ + async getUserAccess(tenantId: string, userId: string) { + await this.assertTargetBelongsToTenant(tenantId, undefined, userId); + + const [activations, groupGrants, directGrants] = await Promise.all([ + this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true }, + include: { module: true }, + }), + this.prisma.moduleGrant.findMany({ + where: { tenantId, group: { memberships: { some: { userId } } } }, + include: { group: true }, + }), + this.prisma.moduleGrant.findMany({ + where: { tenantId, userId }, + select: { moduleId: 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.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), + ); + + return modules.map((module: any) => ({ + module, + viaGroups: groupNamesByModule.get(module.id) ?? [], + direct: directModuleIds.has(module.id), + })); + } +}