feat(15-03): ModuleGrantsService — Freigaben setzen/entziehen mit Mandanten-Gegenprüfung
- 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
This commit is contained in:
@@ -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<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): 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<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),
|
||||
);
|
||||
|
||||
return modules.map((module: any) => ({
|
||||
module,
|
||||
viaGroups: groupNamesByModule.get(module.id) ?? [],
|
||||
direct: directModuleIds.has(module.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user