Files
tessera-ctl/apps/api/src/groups/module-grants.service.ts
T
schalli ecadf69e14 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
2026-08-05 09:33:51 +02:00

279 lines
9.4 KiB
TypeScript

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<void> {
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): 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, memberships] = 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 },
}),
// 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));
const groupNamesByModule = new Map<string, string[]>();
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),
);
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),
})),
};
}
}