diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts index e6afad2..aa4a59b 100644 --- a/apps/api/src/groups/module-grants.service.spec.ts +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -9,7 +9,18 @@ import { ModuleGrantsService } from './module-grants.service'; * groups.service.spec.ts / module-access.service.spec.ts — keine Live-DB, * P2002 wird exakt wie ein echter Postgres-Client über den Fehlercode * simuliert. + * + * Bindung an forTenant() (260909-jts, Aufgabe 3, Befund C uebertragen von + * groups.service.spec.ts): derselbe Mock wie dort — der gebundene Client + * ist ein ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN + * Speicher, das protokolliert, welche Aufrufe ueber ihn liefen. Ein reiner + * Identitaets-Mock (`forTenant: vi.fn((p) => p)`) koennte einen + * vergessenen Bindungsaufruf nicht von einem ungebundenen Aufruf + * unterscheiden. */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), +})); function makeFakePrisma() { const groups = new Map(); @@ -17,6 +28,7 @@ function makeFakePrisma() { const memberships = new Map>(); // groupId -> Set const membershipSources = new Map(); // `${groupId}::${userId}` -> source const activations = new Map(); // key: tenantId::moduleId + const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const grants = new Map(); let grantCounter = 0; @@ -41,7 +53,7 @@ function makeFakePrisma() { ); } - return { + const fake: any = { __seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) { groups.set(group.id, { internalName: null, ...group }); }, @@ -166,7 +178,46 @@ function makeFakePrisma() { return rows; }, }, + // --- Bindungsnachweis (260909-jts, Befund C uebertragen) --------------- + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + const bound: any = { __isBoundClient: true, __tenantId: tenantId }; + for (const modelName of BOUND_MODEL_NAMES) { + const model = fake[modelName]; + const wrapped: any = {}; + for (const method of Object.keys(model)) { + wrapped[method] = async (...args: any[]) => { + boundCallLog.push({ tenantId, model: modelName, method }); + return model[method](...args); + }; + } + bound[modelName] = wrapped; + } + return bound; + }, }; + + return fake; +} + +/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das + * Herkunfts-Tenant protokollierenden Wrapper versieht. */ +const BOUND_MODEL_NAMES = ['group', 'user', 'tenantModuleActivation', 'moduleGrant', 'groupMembership']; + +/** + * Bindungsnachweis: mindestens ein Aufruf von `..` + * lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen + * Fake). Ein vergessener `forTenant()`-Aufruf hinterlaesst hier KEINEN + * Eintrag und laesst den Test fehlschlagen. + */ +function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: any) => c.tenantId === tenantId && c.model === model && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); } function seedBase(prisma: ReturnType) { @@ -595,3 +646,82 @@ describe('ModuleGrantsService — Logging (D-23)', () => { expect(message).toContain('g1'); }); }); + +// --- Bindung an forTenant() (260909-jts, Aufgabe 3) ------------------------- + +describe('ModuleGrantsService — Bindung an forTenant() (260909-jts)', () => { + it('grant() bindet die Mandanten-Gegenpruefung, die Aktivierungspruefung und moduleGrant.create an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expectBoundCall(prisma, 't1', 'group', 'findFirst'); + expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findUnique'); + expectBoundCall(prisma, 't1', 'moduleGrant', 'create'); + }); + + it('grant() bindet auch die Mandanten-Gegenpruefung fuer eine userId und bleibt wirksam gegen einen fremden Benutzer (T-15-01)', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); + const service = new ModuleGrantsService(prisma as any); + + await expect( + service.grant('t1', { moduleId: 'mod-1', userId: 'u-foreign' }), + ).rejects.toBeInstanceOf(NotFoundException); + + expectBoundCall(prisma, 't1', 'user', 'findFirst'); + }); + + it('revoke() bindet moduleGrant.deleteMany an den uebergebenen Mandanten', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + await service.revoke('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + expectBoundCall(prisma, 't1', 'moduleGrant', 'deleteMany'); + }); + + it('getMatrix() bindet alle drei parallelen Teilabfragen (tenantModuleActivation, group, moduleGrant) an DENSELBEN gebundenen Mandanten', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + const service = new ModuleGrantsService(prisma as any); + + await service.getMatrix('t1'); + + expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); + expectBoundCall(prisma, 't1', 'group', 'findMany'); + expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany'); + }); + + it('getUserAccess() bindet alle vier parallelen Teilabfragen (tenantModuleActivation, moduleGrant x2, groupMembership) an DENSELBEN gebundenen Mandanten', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedMembership('g1', 'u1'); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + await service.getUserAccess('t1', 'u1'); + + expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); + expectBoundCall(prisma, 't1', 'moduleGrant', 'findMany'); + expectBoundCall(prisma, 't1', 'groupMembership', 'findMany'); + }); + + it('getUserAccess() bindet weiterhin die Mandanten-Gegenpruefung — sie wird durch die Bindung NICHT ersetzt', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedUser({ id: 'u-foreign', tenantId: 't2' }); + const service = new ModuleGrantsService(prisma as any); + + await expect(service.getUserAccess('t1', 'u-foreign')).rejects.toBeInstanceOf( + NotFoundException, + ); + + expectBoundCall(prisma, 't1', 'user', 'findFirst'); + }); +}); diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index c76fac9..a8be223 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -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 { + 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 } } }, }), diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index 377a1fa..f3689af 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -146,6 +146,15 @@ interpretieren: ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der Etappe 2 vorgesehen. + **Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN.** Der Bereich `groups` + ist umgestellt — `reassignDefaultBeforeDelete` und `ensureDefaultGroup` + laufen seit Aufgabe 2 dieses Plans vollständig über `forTenant()` bzw. + `withTenantTransaction()` (siehe Abschnitt "Bereich groups" unten und + `docs/mandantentrennung-zugriffsklassifikation.md`, Zeile + `groups.service.ts`/`group`, Stand `gebunden`). Die Reihenfolgebedingung + für Etappe 4 ist damit erfüllt. Der Befund oben bleibt unverändert stehen + — er beschreibt korrekt den Zustand zum Zeitpunkt der ldap-Umstellung. + - **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts` und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT, diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 185d103..0128dc2 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -80,12 +80,24 @@ Bestandsaufnahme unten. Gemessen mit `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/ | grep -v spec | wc -l` bzw. `grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/ | grep -v spec | wc -l` -am 2026-09-09, **nach** den Änderungen aus Aufgabe 2/3 dieses Plans (260909-ipc): +am 2026-09-09, **nach** den Änderungen aus Aufgabe 2/3 dieses Plans (260909-jts): + +**Methodische Lücke, seit 260909-jts sichtbar:** die zweite Zählung sucht +ausschließlich den Namen `tenantPrisma` — die Konvention, die `ldap` und +(bis auf die drei Transaktionen) auch `groups` verwenden. Die drei +`withTenantTransaction()`-Aufrufe in `groups.service.ts` binden zusätzliche +neun Modellzugriffe über den Namen `tx` (den Transaktionsparameter), die +diese einfache Rohtrefferzählung strukturell NICHT sieht — anders als die +maschinelle, namensunabhängige Erkennung in `rls-access-inventory.spec.ts` +(Befund B), die auch diese Form erfasst. Die Zahl 31 unten ist deshalb der +Bodensatz, nicht die vollständige Zahl gebundener Zugriffe in `groups`; die +Bestandsaufnahme unten (Spalte "Stand", je (Datei, Modell)-Paar) ist die +autoritative Quelle. | Bereich | Ungebunden | Gebunden | Hinweis | |---|---|---|---| | tenders | 62 | 0 | unverändert | -| groups | 37 | 0 | unverändert | +| groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) | | ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) | | dkv | 21 | 0 | unverändert | | user | 17 | 0 | unverändert | @@ -96,26 +108,29 @@ am 2026-09-09, **nach** den Änderungen aus Aufgabe 2/3 dieses Plans (260909-ipc | tenant | 8 | 0 | unverändert | | favorites | 7 | 0 | unverändert | | settings | 4 | 0 | unverändert | -| **Summe** | **210** | **31** | Ungebunden: war 227 vor dieser Etappe (260909-eor-Stand), Delta = die 17 in Aufgabe 2/3 umgestellten `ldap`-Rohtreffer. Gebunden: war 5 (nur `auth`), jetzt zusätzlich 26 in `ldap` | +| **Summe** | **173** | **62** | Ungebunden: war 210 vor dieser Etappe (260909-ipc-Stand), Delta = die 37 in Aufgabe 2/3 (260909-jts) umgestellten `groups`-Rohtreffer. Gebunden: war 31, jetzt zusätzlich 31 in `groups` (Bodensatz — siehe Methodenhinweis oben) | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 62 Paare) -Stand 260909-ipc (Aufgabe 2): 59 Paare aus der urspruenglichen Zaehlung plus -zwei bisher unentdeckte, weil bereits gebundene Fundstellen -(`auth.service.ts`/`passwordResetToken`, `ldap.service.ts`/`groupMembership`), -die erst die um gebundene Zugriffe erweiterte Erkennung (Befund G) sichtbar -macht — sie waren nie Teil der 227 `this.prisma.*`-Rohtrefferzahl, weil sie -schon vor diesem Plan über `forTenant()` liefen. Dazu die Korrektur von -(`ldap-config.service.ts`, `ldapConfig`) von `muss-mandantengebunden` auf -`beides` (Befund B). +Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf +(260909-ipc) plus ein bisher vollstaendig unsichtbares Paar +(`groups.service.ts`/`tenantModuleActivation`), das erst die um +Transaktionsparameter erweiterte Erkennung (Befund B, Aufgabe 2 dieses +Plans) sichtbar macht — der einzige Zugriff auf dieses Modell in dieser +Datei lief bis dahin ausschliesslich ueber den Rueckgabeparameter der +interaktiven Transaktion in `ensureDefaultGroup` und war weder ueber +`this.prisma.` noch ueber `.` erfassbar. +Die Zahl ist der Ausgabe der Pruefung in +`apps/api/src/prisma/rls-access-inventory.spec.ts` entnommen, nicht +geschaetzt. | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 32 | +| muss-mandantengebunden | 33 | | keine-mandantengebundene-tabelle | 16 | | beides | 10 | | bewusst-uebergreifend | 3 | -| **Summe** | **61** | +| **Summe** | **62** | ## Der Hintergrunddienst als Falle — drei `beides`-Fälle @@ -134,16 +149,17 @@ betroffen: `resolveEmailForWrite` bewusst ungebunden (Befund A, T-IPC-04 — siehe Bestandsaufnahme unten). Der als gefährlichster Punkt benannte Löschzweig (`syncBoundGroupsForTenant`, WINDOWS #20) war bereits seit - Etappe 1 gebunden. Offen bleibt eine Reihenfolgebedingung für Etappe 4, - NICHT Teil dieser Umstellung: die Übergabe unmittelbar vor der Löschung — - `this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und - `ensureDefaultGroup(tenantId)` — liegt in `groups.service.ts` und ist - nicht gebunden. Nach dem Scharfschalten würde `reassignDefaultBeforeDelete` - still `false` melden (kein Ersatzkandidat sichtbar), der Standard-Marker - wandert nicht mit, und der Mandant bliebe nach einer Gruppenlöschung ohne - Standardgruppe zurück — der Bereich `groups` muss deshalb vor Etappe 4 - umgestellt sein (siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, - Abschnitt (e), Befund D). + Etappe 1 gebunden. **Die seinerzeit offen geführte Reihenfolgebedingung + für Etappe 4 — die Übergabe unmittelbar vor der Löschung + (`this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und + `ensureDefaultGroup(tenantId)` in `groups.service.ts`) — ist mit + 260909-jts (Aufgabe 2) GESCHLOSSEN:** beide Methoden laufen seither über + `forTenant()`/`withTenantTransaction()` (siehe Bestandsaufnahme unten, + `groups.service.ts`/`group`, Stand `gebunden`). Nachtrag, nicht + Neuschrieb: der ursprüngliche Befund bleibt oben lesbar, weil er den + Zustand zum Zeitpunkt der ldap-Umstellung korrekt beschreibt und für + spätere Etappen als Beleg dient, siehe auch + `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt (e), Befund D. - **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest `tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im @@ -181,11 +197,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden, einschliesslich des Zugriffs innerhalb des Standardgruppen-Aufbaus (Befund B). | | apps/api/src/groups/groups.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat. Bis 260909-jts vollstaendig unsichtbar (Befund B, 260909-jts-PLAN.md): der einzige Zugriff dieser Datei lief innerhalb der interaktiven Transaktion von `ensureDefaultGroup` ueber den Rueckgabeparameter (`tx.tenantModuleActivation.findMany`) — weder `this.prisma.` noch `.` sahen das, weil `tx` weder `this.prisma` noch aus einer `forTenant(`-Zuweisung stammte. Die um Transaktionsparameter erweiterte Erkennung aus Aufgabe 2 macht dieses Paar erstmals sichtbar; die Stelle ist seit derselben Aufgabe gebunden (ueber `withTenantTransaction()`). | | apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb eines Mandanten. Seit 260909-jts gebunden; `addUserToDefaultGroup` prueft seither zusaetzlich, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf `GroupMembership` prueft nachweislich nur die Gruppenseite. | -| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | ungebunden | Wie groups.service.ts. | -| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | ungebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. | -| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. | -| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. | -| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | ungebunden | Zielbenutzer eines Grants innerhalb des Mandanten. | +| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | gebunden | Wie groups.service.ts. Seit 260909-jts (Aufgabe 3) gebunden — `assertTargetBelongsToTenant` erzeugt seinen eigenen Kontext, `getMatrix` teilt sich einen Kontext mit den beiden anderen parallelen Teilabfragen. | +| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. Seit 260909-jts gebunden; der `where`-Filter über die Beziehung zur Gruppe bleibt zusätzlich stehen, weil die Regel auf dieser Tabelle nachweislich nur die Gruppenseite prüft (Aufgabe 1). | +| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung vor jedem Erteilen (`assertTargetBelongsToTenant`) bleibt zusätzlich bestehen, weil die Regel auf dieser Tabelle nur die Mandantenkennung der Zeile prüft, nicht die referenzierte Gruppe (Befund F, T-JTS-03, Aufgabe 1). | +| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden. | +| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | gebunden | Zielbenutzer eines Grants innerhalb des Mandanten. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung bleibt bestehen. | | apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | gemischt | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` bleiben bewusst uebergreifend: beide laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md). Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. | | apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten jetzt als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. | | apps/api/src/ldap/ldap.service.ts | group | beides | gebunden | AD-Abgleich: `listGroups` (die "bereits importiert"-Markierung) und `importGroupsByDn` (die Idempotenzpruefung ueber `ldapObjectGuid`) sind mit Aufgabe 3 (260909-ipc) auf `forTenant()` umgestellt — zusammen mit den bereits vorher gebundenen Stellen (Anlage, Mitgliedschafts- und Gruppenabgleich) ist damit jeder `group`-Zugriff dieser Datei gebunden. Die Klasse bleibt `beides`, weil ein zukuenftiger uebergreifender Lesezugriff (z. B. ein neuer Planer-Pfad) hier ebenso legitim waere wie bei `ldapConfig` unten — nicht, weil heute noch ein ungebundener Zugriff bestuende. | @@ -235,7 +251,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit `ldap` (260909-ipc) hat sich für den Dienst-internen Weg entschieden — `forTenant(this.prisma, tenantId)` wird in jeder umgestellten Methode neu erzeugt, wie es die vier Bestandsstellen in `ldap.service.ts` und die drei - in `auth.service.ts` bereits vormachten. Die Frage bleibt für alle + in `auth.service.ts` bereits vormachten. Der Bereich `groups` (260909-jts) + hat sich für denselben dienst-internen Weg entschieden — jede Methode in + `groups.service.ts` und `module-grants.service.ts` erzeugt ihren eigenen + `forTenant()`- bzw. `withTenantTransaction()`-Aufruf, gebundene Clients + werden nicht zwischen Methoden weitergereicht. Die Frage bleibt für alle übrigen Bereiche der Etappe 2 offen. - Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource` am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein