Files
tessera-ctl/apps/api/src/groups/module-grants.service.ts
T
schalli abb6c8bea3 feat(jts-03): module-grants.service.ts binden, beide Dokumente schliessen
Alle fuenf Methoden von ModuleGrantsService (assertTargetBelongsToTenant,
grant, revoke, getMatrix, getUserAccess) laufen jetzt ueber forTenant(); bei
den beiden Datenlieferungen teilen sich alle parallel abgesetzten
Teilabfragen denselben gebundenen Client. Die Mandanten-Gegenpruefung vor
jedem Erteilen bleibt ausdruecklich bestehen und bekommt einen Verweis auf
Befund F/T-JTS-03: die Regel auf ModuleGrant prueft nur die
Mandantenkennung der Zeile, nicht die referenzierte Gruppe. Der veraltete
Kommentar ueber der Mitgliedschaftsabfrage im Benutzer-Detail ("kein
forTenant hier") ist durch den neuen Stand ersetzt.

module-grants.service.spec.ts bekommt denselben Bindungsnachweis-Mock wie
groups.service.spec.ts (zwei unterscheidbare Clients ueber demselben
Speicher) und sechs neue Bindungsnachweise; alle 28 Bestandstests bleiben
gruen.

Beide Dokumente geschlossen: die Bereichsuebersicht fuer groups ist neu
gemessen (0 ungebunden, 31 gebunden — ein dokumentierter methodischer
Bodensatz, da die einfache Rohtrefferzaehlung die neun ueber `tx` gebundenen
Zugriffe innerhalb der drei Transaktionen nicht sieht), die
Klassen-Verteilung auf 62 Paare aktualisiert, und der als offen gefuehrte
Befund D aus dem ldap-Abschnitt der Fehlerrichtung ist mit Verweis auf
diesen Durchlauf als erledigt vermerkt (Nachtrag, nicht Neuschrieb). 743
Tests und die Typpruefung gruen; Schema, Migrationen und alle vier
Compose-Dateien unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 15:07:09 +02:00

308 lines
11 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* 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> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
if (groupId) {
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group) {
throw new NotFoundException(`Gruppe '${groupId}' nicht gefunden`);
}
}
if (userId) {
const user = await tenantPrisma.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',
);
}
// Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03,
// 260909-jts, Aufgabe 1): die ausgelieferte Regel auf ModuleGrant
// prueft ausschliesslich die Mandantenkennung der Zeile selbst
// ("tenantId" = current_tenant_id()), NICHT die referenzierte Gruppe.
// Eine Zeile mit korrekter eigener Mandantenkennung, die auf die
// Gruppe eines fremden Mandanten zeigt, verletzt diese Regel
// nachweislich nicht (gemessen gegen die echte Migration in Aufgabe 1).
// Diese Anwendungspruefung ist damit der einzige Schutz gegen diese
// Form der Rechteausweitung und darf nicht als "macht jetzt die
// Datenbank" entfallen.
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const activation = await tenantPrisma.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 tenantPrisma.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 tenantPrisma.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}`;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
await tenantPrisma.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 tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [activations, groups, groupGrants] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
tenantPrisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
}),
tenantPrisma.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 }.
*
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
* `group.internalName ?? group.name` — Nullish, nicht Oder, damit ein
* bereits auf null normalisierter Leerstring (Task 2) nicht versehentlich
* über eine Truthiness-Prüfung nachträglich anders interpretiert wird.
* Die Sortierung von `groups` läuft danach automatisch über den
* angezeigten Namen, weil sie auf demselben Feld (`name`) aufsetzt —
* anders als GroupsService.listForTenant(), das weiterhin über die
* Datenbankspalte `name` sortiert.
*/
async getUserAccess(tenantId: string, userId: string) {
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [activations, groupGrants, directGrants, memberships] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } },
include: { group: true },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
}),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
// es entsteht kein zweiter gebundener Client. Der `where`-Filter
// über die Beziehung zur Gruppe (`group: { tenantId }`) bleibt
// ZUSÄTZLICH stehen: GroupMembership trägt keine eigene tenantId-
// Spalte, und die ausgelieferte Regel auf dieser Tabelle bezieht
// ihre Sichtbarkeit ausschließlich über die Gruppenseite (gemessen
// in Aufgabe 1) — der Anwendungsfilter ist deshalb nicht redundant,
// sondern das zweite Netz.
tenantPrisma.groupMembership.findMany({
where: { userId, group: { tenantId } },
include: { group: { select: { id: true, name: true, internalName: 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.internalName ?? 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.internalName ?? 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),
})),
};
}
}