From ecadf69e149ca06d0992ee2b921cc35cd21a3510 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 5 Aug 2026 09:33:51 +0200 Subject: [PATCH] feat(260805-d0r): getUserAccess returns groups from GroupMembership (D-16) - New groupMembership.findMany query, tenant-scoped via group.tenantId (GroupMembership has no own tenantId column) - Response shape changes from an array to { groups, modules }; modules entries stay field-identical to before - Group without any module grant now stays visible, closing the reproduced defect --- .../src/groups/module-grants.controller.ts | 7 ++- apps/api/src/groups/module-grants.service.ts | 47 +++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/api/src/groups/module-grants.controller.ts b/apps/api/src/groups/module-grants.controller.ts index a12eb01..07c1b9f 100644 --- a/apps/api/src/groups/module-grants.controller.ts +++ b/apps/api/src/groups/module-grants.controller.ts @@ -52,8 +52,11 @@ export class ModuleGrantsController { /** * GET /module-grants/users/:userId - * Je aktivem Modul: über welche Gruppen der Benutzer erbt, und ob ein - * Direkt-Grant besteht (D-16). + * Liefert { groups, modules } (D-16). `groups` sind die tatsächlichen + * Gruppenmitgliedschaften des Benutzers (aus GroupMembership, unabhängig + * von Modul-Freigaben). `modules` ist je aktivem Modul, über welche + * Gruppen der Benutzer das Modul erbt, und ob zusätzlich ein + * Direkt-Grant besteht. */ @Get('users/:userId') @UseGuards(RolesGuard) diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index 31b5530..91983fd 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -203,15 +203,20 @@ export class ModuleGrantsService { } /** - * 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. + * 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 }. */ async getUserAccess(tenantId: string, userId: string) { await this.assertTargetBelongsToTenant(tenantId, undefined, userId); - const [activations, groupGrants, directGrants] = await Promise.all([ + const [activations, groupGrants, directGrants, memberships] = await Promise.all([ this.prisma.tenantModuleActivation.findMany({ where: { tenantId, isActive: true }, include: { module: true }, @@ -224,6 +229,16 @@ export class ModuleGrantsService { where: { tenantId, userId }, select: { moduleId: true }, }), + // Kein forTenant hier — dieselbe Begründung wie bei den drei + // Abfragen oben: die Datenbankrolle umgeht RLS ohnehin (siehe + // Migration 20260804130918_groups_rls_policies), der `where`-Filter + // ist wie im Rest dieser Methode und in GroupsService der primäre + // Schutz. GroupMembership trägt keine eigene tenantId-Spalte, daher + // läuft der Mandantenfilter über die Relation `group: { tenantId }`. + this.prisma.groupMembership.findMany({ + where: { userId, group: { tenantId } }, + include: { group: { select: { id: true, name: true } } }, + }), ]); const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string)); @@ -242,10 +257,22 @@ export class ModuleGrantsService { 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), - })); + const groups = (memberships as any[]) + .filter((m) => m.group) + .map((m) => ({ + id: m.group.id as string, + name: 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), + })), + }; } }