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
This commit is contained in:
2026-08-05 09:33:51 +02:00
parent b6d4e4acb6
commit ecadf69e14
2 changed files with 42 additions and 12 deletions
@@ -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)
+34 -7
View File
@@ -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) => ({
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),
}));
})),
};
}
}