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
This commit is contained in:
2026-09-09 15:07:09 +02:00
parent 7f08b27eea
commit abb6c8bea3
4 changed files with 228 additions and 50 deletions
+38 -19
View File
@@ -5,6 +5,7 @@ import {
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
@@ -47,8 +48,9 @@ export class ModuleGrantsService {
groupId?: string,
userId?: string,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
if (groupId) {
const group = await this.prisma.group.findFirst({
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group) {
@@ -56,7 +58,7 @@ export class ModuleGrantsService {
}
}
if (userId) {
const user = await this.prisma.user.findFirst({
const user = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
@@ -88,9 +90,20 @@ export class ModuleGrantsService {
);
}
// 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 activation = await this.prisma.tenantModuleActivation.findUnique({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: { tenantId_moduleId: { tenantId, moduleId } },
});
if (!activation?.isActive) {
@@ -102,7 +115,7 @@ export class ModuleGrantsService {
const target = groupId ? `group=${groupId}` : `user=${userId}`;
try {
const created = await this.prisma.moduleGrant.create({
const created = await tenantPrisma.moduleGrant.create({
data: {
tenantId,
moduleId,
@@ -116,7 +129,7 @@ export class ModuleGrantsService {
return created;
} catch (err: any) {
if (err?.code === 'P2002') {
const existing = await this.prisma.moduleGrant.findFirst({
const existing = await tenantPrisma.moduleGrant.findFirst({
where: {
tenantId,
moduleId,
@@ -148,7 +161,8 @@ export class ModuleGrantsService {
const { moduleId, groupId, userId } = data;
const target = groupId ? `group=${groupId}` : `user=${userId}`;
await this.prisma.moduleGrant.deleteMany({
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
await tenantPrisma.moduleGrant.deleteMany({
where: {
tenantId,
moduleId,
@@ -170,16 +184,17 @@ export class ModuleGrantsService {
* hinweg stabil.
*/
async getMatrix(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [activations, groups, groupGrants] = await Promise.all([
this.prisma.tenantModuleActivation.findMany({
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
this.prisma.group.findMany({
tenantPrisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
}),
this.prisma.moduleGrant.findMany({
tenantPrisma.moduleGrant.findMany({
where: { tenantId, groupId: { not: null } },
select: { moduleId: true, groupId: true },
}),
@@ -226,26 +241,30 @@ export class ModuleGrantsService {
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([
this.prisma.tenantModuleActivation.findMany({
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
this.prisma.moduleGrant.findMany({
tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } },
include: { group: true },
}),
this.prisma.moduleGrant.findMany({
tenantPrisma.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({
// 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 } } },
}),