diff --git a/apps/api/src/ldap/ldap.service.spec.ts b/apps/api/src/ldap/ldap.service.spec.ts index 630cf19..f8012bf 100644 --- a/apps/api/src/ldap/ldap.service.spec.ts +++ b/apps/api/src/ldap/ldap.service.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock ldapts so no real directory connection is attempted. The single shared // search mock is re-programmed per test. @@ -59,6 +59,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ import { Client } from 'ldapts'; import { LdapService } from './ldap.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; describe('LdapService.syncUsersForTenant — per-user exclude list', () => { let service: LdapService; @@ -2300,3 +2301,183 @@ describe('LdapService — AD group import (SC-1/SC-2, D-01/D-02)', () => { ]); }); }); + +/** + * Aufgabe 3 (260909-ipc, WINDOWS #20 Etappe 2, Befund F): der Identitaets- + * Mock von forTenant() weiter oben in dieser Datei bemerkt eine Umstellung + * von `this.prisma.X` auf `tenantPrisma.X` nicht, weil beide Seiten auf + * denselben Objekt zeigen. Dieser Block biegt forTenant() ausschliesslich + * fuer sich selbst auf ein ZWEITES, unterscheidbares Client-Objekt um: der + * ungebundene Ersatz (`unboundPrisma`, das an den Konstruktor uebergebene + * `this.prisma`) und der gebundene Ersatz (`boundPrisma`, das Ergebnis von + * forTenant()) sind zwei verschiedene Spione. Damit ist nachweisbar, WELCHER + * Client welchen Aufruf bekommt — mit dem Identitaets-Mock waere das nicht + * unterscheidbar. + */ +describe('LdapService — Bindungsnachweis mit unterscheidbaren Clients (260909-ipc, Befund F)', () => { + let service: LdapService; + let unboundPrisma: any; + let boundPrisma: any; + let userService: any; + + const cfg = { + id: 'cfg1', + tenantId: 't1', + serverUrl: 'ldap://example', + baseDn: 'dc=example,dc=com', + searchFilter: '(objectClass=person)', + groupFilterDns: [] as string[], + userExcludeList: [] as string[], + fieldMappings: [ + { ldapField: 'sAMAccountName', tesseraField: 'username' }, + { ldapField: 'mail', tesseraField: 'email' }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBind.mockResolvedValue(undefined); + mockUnbind.mockResolvedValue(undefined); + + // Der ungebundene Ersatz: genau das, was resolveEmailForWrite() ueber + // this.prisma.user.findUnique erreicht (Befund A, bleibt bewusst + // ungebunden). + unboundPrisma = { + user: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + // Der gebundene Ersatz: das Ergebnis von forTenant(this.prisma, tenantId) + // in jeder umgestellten Methode. + boundPrisma = { + user: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + group: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + }, + groupMembership: { + createMany: vi.fn().mockResolvedValue({ count: 0 }), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + ldapConfig: { update: vi.fn().mockResolvedValue({}) }, + }; + + (forTenant as any).mockImplementation(() => boundPrisma); + + userService = { create: vi.fn().mockResolvedValue({}) }; + service = new LdapService(unboundPrisma, userService, {} as any); + }); + + afterEach(() => { + // Andere describe-Bloecke dieser Datei verlassen sich auf die + // Identitaets-Grundform (forTenant gibt denselben Client zurueck) — die + // Umbiegung bleibt auf diesen Block beschraenkt. + (forTenant as any).mockImplementation((p: unknown) => p); + }); + + it('syncUsersForTenant: resolveEmailForWrite fragt den UNGEBUNDENEN Client, upsertMappedUser den GEBUNDENEN', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' }, + ], + }); + + const result = await service.syncUsersForTenant(cfg as any, 't1'); + + expect(result.created).toBe(1); + expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1'); + // Die Adressabfrage aus resolveEmailForWrite() landet auf dem + // UNGEBUNDENEN Client — niemals auf dem gebundenen (Befund A, T-IPC-04). + expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: 'alice@x' }, + }); + // Die Identitaetssuche aus upsertMappedUser() landet auf dem GEBUNDENEN + // Client. + expect(boundPrisma.user.findFirst).toHaveBeenCalled(); + }); + + it('syncUsersForTenant bindet die Deaktivierungs-Kandidatenliste und die lastSyncAt-Fortschreibung', async () => { + mockSearch.mockResolvedValue({ searchEntries: [] }); + + await service.syncUsersForTenant(cfg as any, 't1'); + + expect(boundPrisma.user.findMany).toHaveBeenCalled(); + expect(boundPrisma.ldapConfig.update).toHaveBeenCalledWith({ + where: { id: 'cfg1' }, + data: { lastSyncAt: expect.any(Date) }, + }); + }); + + it('listGroups bindet ueber forTenant() an den uebergebenen Mandanten', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=Sales,dc=example,dc=com', + cn: 'Sales', + objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'), + }, + ], + }); + + await service.listGroups(cfg as any, 't1'); + + expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1'); + expect(boundPrisma.group.findMany).toHaveBeenCalled(); + }); + + it('searchUsers bindet ueber forTenant() an den uebergebenen Mandanten', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=alice,dc=example,dc=com', sAMAccountName: 'alice', mail: 'alice@x' }, + ], + }); + + await service.searchUsers(cfg as any, 't1', 'a'); + + expect(forTenant).toHaveBeenCalledWith(unboundPrisma, 't1'); + expect(boundPrisma.user.findMany).toHaveBeenCalled(); + }); + + it('importUsersByDn bindet Dedup und Update, die Adress-Kollisionspruefung bleibt ungebunden', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { dn: 'cn=carol,dc=example,dc=com', sAMAccountName: 'carol', mail: 'carol@x' }, + ], + }); + + const result = await service.importUsersByDn(cfg as any, 't1', [ + 'cn=carol,dc=example,dc=com', + ]); + + expect(result.created).toBe(1); + expect(boundPrisma.user.findFirst).toHaveBeenCalled(); + expect(unboundPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: 'carol@x' }, + }); + }); + + it('importGroupsByDn bindet die Idempotenzpruefung und die Anlage auf DEMSELBEN gebundenen Client', async () => { + mockSearch.mockResolvedValue({ + searchEntries: [ + { + dn: 'cn=Sales,dc=example,dc=com', + cn: 'Sales', + objectGUID: Buffer.from('0123456789abcdef0123456789abcdef', 'hex'), + }, + ], + }); + + const result = await service.importGroupsByDn(cfg as any, 't1', [ + 'cn=Sales,dc=example,dc=com', + ]); + + expect(result.imported).toBe(1); + expect(boundPrisma.group.findFirst).toHaveBeenCalled(); + expect(boundPrisma.group.create).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 7aed337..6be1ed9 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -295,6 +295,10 @@ export class LdapService { const client = new Client( this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized), ); + // Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die + // "bereits importiert"-Markierung darf nur die Gruppen DIESES Mandanten + // sehen. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { await this.bind(client, config.bindDn, config.bindPassword); @@ -343,7 +347,7 @@ export class LdapService { .filter((h): h is string => !!h); let importedSet = new Set(); if (guidHexes.length > 0) { - const existing = await this.prisma.group.findMany({ + const existing = await tenantPrisma.group.findMany({ where: { tenantId, ldapObjectGuid: { in: guidHexes } }, select: { ldapObjectGuid: true }, }); @@ -412,6 +416,21 @@ export class LdapService { * NEVER handed from one account to another (T-Q3-01): a directory entry * could otherwise take over a real person's address and receive their * password-reset mail. + * + * BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund A, + * T-IPC-04): `email` und `username` sind in `prisma/schema.prisma` + * plattformweit eindeutig (`@unique`), nicht je Mandant. Wuerde diese + * Abfrage mit `forTenant()` an den eigenen Mandanten gebunden, saehe sie + * einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und + * der anschliessende Schreibvorgang liefe in die plattformweite + * Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten + * Kollision (WINDOWS #15/T-Q3-01) wuerde ein P2002-Abbruch des gesamten + * Sync-Laufs. Nach dem Scharfschalten (Etappe 4) liefert diese Abfrage + * fuer jeden Mandanten AUSSER dem der Adresse selbst 0 Zeilen und meldet + * damit IMMER "frei" — ein bekannter, hier bewusst offen gelassener Punkt. + * Die Loesung gehoert nach Etappe 3, vermutlich als vierte + * SECURITY-DEFINER-Funktion nach dem Muster des Anmeldewegs + * (siehe auth.service.ts). */ private async resolveEmailForWrite( desiredEmail: string, @@ -449,12 +468,16 @@ export class LdapService { status: 'created' | 'updated'; emailConflict?: LdapEmailConflict; }> { - const existingByDn = await this.prisma.user.findFirst({ + // Mandantengescopter Identitaets-/Schreibpfad (WINDOWS #20 Etappe 2, + // 260909-ipc). Nicht zu verwechseln mit resolveEmailForWrite() oben, die + // bewusst ungebunden bleibt. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const existingByDn = await tenantPrisma.user.findFirst({ where: { ldapDn: dn, tenantId }, }); const existingByUsername = existingByDn ? null - : await this.prisma.user.findFirst({ where: { username, tenantId } }); + : await tenantPrisma.user.findFirst({ where: { username, tenantId } }); const existing = existingByDn || existingByUsername; if (existing) { @@ -472,7 +495,7 @@ export class LdapService { } } - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: existing.id }, data: { ...(mappedData['displayName'] && { @@ -540,6 +563,10 @@ export class LdapService { const client = new Client( this.buildClientOptions(config.serverUrl, config.tlsRejectUnauthorized), ); + // Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die + // "bereits importiert"-Markierung darf nur die Konten DIESES Mandanten + // sehen. + const tenantPrisma = forTenant(this.prisma, tenantId) as any; const first = (v: unknown): string => Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : ''; @@ -576,7 +603,7 @@ export class LdapService { const usernames = entries .map((e) => e.username.toLowerCase()) .filter(Boolean); - const existing = await this.prisma.user.findMany({ + const existing = await tenantPrisma.user.findMany({ where: { tenantId, OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }], @@ -584,10 +611,12 @@ export class LdapService { select: { ldapDn: true, username: true }, }); const dnSet = new Set( - existing.map((u) => u.ldapDn).filter((d): d is string => !!d), + existing + .map((u: { ldapDn: string | null }) => u.ldapDn) + .filter((d: string | null): d is string => !!d), ); const usernameSet = new Set( - existing.map((u) => u.username.toLowerCase()), + existing.map((u: { username: string }) => u.username.toLowerCase()), ); return entries.map((e) => ({ @@ -632,6 +661,9 @@ export class LdapService { .map((u) => u.trim().toLowerCase()) .filter(Boolean), ); + // Mandantengescopter Dedup-/Schreibpfad (WINDOWS #20 Etappe 2, + // 260909-ipc). + const tenantPrisma = forTenant(this.prisma, tenantId) as any; try { await this.bind(client, config.bindDn, config.bindPassword); @@ -672,12 +704,12 @@ export class LdapService { // Dedup: if a user already exists (by ldapDn or username) skip it, // but link the ldapDn so a later group/OU sync matches it and never // duplicates. - const existing = await this.prisma.user.findFirst({ + const existing = await tenantPrisma.user.findFirst({ where: { tenantId, OR: [{ ldapDn: dn }, { username }] }, }); if (existing) { if (existing.ldapDn !== dn) { - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: existing.id }, data: { ldapDn: dn }, }); @@ -797,7 +829,7 @@ export class LdapService { // Idempotency: a second import of the same AD group is a skip, not // a duplicate row. - const existingByGuid = await this.prisma.group.findFirst({ + const existingByGuid = await tenantPrisma.group.findFirst({ where: { tenantId, ldapObjectGuid }, }); if (existingByGuid) { @@ -1000,7 +1032,7 @@ export class LdapService { } // 5. Deactivation per D-15: Deactivate users removed from LDAP - const localLdapUsers = await this.prisma.user.findMany({ + const localLdapUsers = await tenantPrisma.user.findMany({ where: { tenantId, ldapDn: { not: null }, @@ -1011,7 +1043,7 @@ export class LdapService { for (const localUser of localLdapUsers) { if (localUser.ldapDn && !syncedDns.includes(localUser.ldapDn)) { - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: localUser.id }, data: { isActive: false }, }); @@ -1047,7 +1079,7 @@ export class LdapService { ); // 6. Update lastSyncAt - await this.prisma.ldapConfig.update({ + await tenantPrisma.ldapConfig.update({ where: { id: config.id }, data: { lastSyncAt: new Date() }, }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 5c94192..1e5ccd5 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -63,26 +63,40 @@ Fundstellen gelöst werden: die Policy braucht für den Lesezugriff `tenantId IS NULL OR tenantId = current_tenant_id()`, während Schreibzugriffe weiterhin einen Mandanten verlangen. -## Übersicht je Bereich (Zeilentreffer, `this.prisma.*` ohne Specs) +## Übersicht je Bereich (Zeilentreffer je Bereich, ungebunden vs. gebunden) -Gemessen mit `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/ | grep -v spec | wc -l` -am 2026-09-09, **nach** den Änderungen aus Aufgabe 1/2 dieses Plans: +**Wichtig, seit 260909-ipc (Aufgabe 3):** die Spalte "Ungebunden" zählt NUR +noch `this.prisma.`-Rohtreffer — eine unveränderte Spaltenüberschrift +über einer veränderten Bedeutung wäre die nächste stille Falle, seit ein +Bereich (`ldap`) tatsächlich gebundene Zugriffe hat, die aus dieser Zählung +verschwinden. Die neue Spalte "Gebunden" zählt daneben die +`forTenant()`-gebundenen Rohtreffer (`tenantPrisma.`, Konvention +dieses Codes — siehe `rls-access-inventory.spec.ts` für die allgemeinere, +namensunabhängige Erkennung über die `const = forTenant(`-Zuweisungsform). +Beide Spalten sind Rohtreffer (mehrere Vorkommen desselben Modells in +derselben Datei zählen mehrfach), nicht (Datei, Modell)-Paare wie in der +Bestandsaufnahme unten. -| Bereich | Treffer | Hinweis | -|---|---|---| -| tenders | 62 | unverändert gegenüber measured_baseline | -| groups | 37 | unverändert | -| ldap | 21 | unverändert | -| dkv | 21 | unverändert | -| user | 17 | unverändert | -| module-registry | 17 | unverändert | -| dashboard | 13 | unverändert | -| auth | 8 | **war 13 in measured_baseline** — Aufgabe 2 hat 3 Lesezugriffe durch `auth_lookup_*()`-Funktionsaufrufe (`$queryRaw`, kein `this.prisma.`) ersetzt und 5 Schreibzugriffe auf `forTenant()`-gebundene Aufrufe (`tenantPrisma.*`, ebenfalls kein `this.prisma.`) umgestellt | -| calendar | 12 | unverändert | -| tenant | 8 | unverändert | -| favorites | 7 | unverändert | -| settings | 4 | unverändert | -| **Summe** | **227** | war 232 in measured_baseline, Delta = die 5 in Aufgabe 2 verschwundenen `auth`-Treffer minus ein bereits vorher fehlerhaft mitgezähltes Kommentarvorkommen in der neuen Kopfzeile von `validateUser()`, das bewusst umformuliert wurde, um einen Eigentreffer der Bestandsaufnahme-Prüfung zu vermeiden | +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): + +| Bereich | Ungebunden | Gebunden | Hinweis | +|---|---|---|---| +| tenders | 62 | 0 | unverändert | +| groups | 37 | 0 | unverändert | +| 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 | +| module-registry | 17 | 0 | unverändert | +| dashboard | 13 | 0 | unverändert | +| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand | +| calendar | 12 | 0 | unverändert | +| 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` | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 61 Paare) @@ -109,15 +123,27 @@ Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — muss aber *innerhalb* der Schleife je Mandant binden. Drei Dateien sind betroffen: -- **`ldap.service.ts`** (AD-Abgleich): iteriert nicht selbst über alle - Mandanten (der Sync läuft je Aufruf für einen übergebenen Mandanten), aber - innerhalb der Sync-Methoden bleiben Lesezugriffe auf `group`, `ldapConfig` - und `user` teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt bereits - bekannt ist — die 4 echten `forTenant()`-Aufrufstellen (Zeilen 762, 905, - 1179, 1342) decken nur einen Teil der Lese-/Schreibpfade ab. Das ist der im - Plankontext benannte Kern von WINDOWS #20: genau dieser Löschzweig - (~Zeile 1559) deutet Leere nach dem Scharfschalten als "Gruppe im - Verzeichnis verschwunden". +- **`ldap.service.ts`** (AD-Abgleich) — **Stand 260909-ipc, Aufgaben 2/3: + geschlossen.** Iteriert nicht selbst über alle Mandanten (der Sync läuft + je Aufruf für einen übergebenen Mandanten). Vor dieser Etappe blieben + Lesezugriffe auf `group`, `ldapConfig` und `user` innerhalb der + Sync-Methoden teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt + bereits bekannt war — nur 4 der inzwischen 11 `forTenant()`-Aufrufstellen + deckten die Lese-/Schreibpfade ab. Jetzt sind `group` und `ldapConfig` + vollständig gebunden; bei `user` bleibt ausschließlich + `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). - **`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 @@ -161,10 +187,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit | apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | ungebunden | Zielbenutzer eines Grants innerhalb des Mandanten. | | 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 | gemischt | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). | +| 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. | | apps/api/src/ldap/ldap.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `Group`. `syncGroupMembershipsForTenant` laeuft vollstaendig ueber `forTenant()` (Plan 16-03/16-05) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | -| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | ungebunden | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. | -| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. | +| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. | +| apps/api/src/ldap/ldap.service.ts | user | beides | gemischt | Mit Aufgabe 3 (260909-ipc) sind `upsertMappedUser` (Identitaetssuche und Aktualisierung), `searchUsers` (die "bereits importiert"-Markierung), `importUsersByDn` (Dedup und ldapDn-Nachtrag) und die Deaktivierungsschleife in `syncUsersForTenant` auf `forTenant()` umgestellt. `resolveEmailForWrite` bleibt ausdruecklich UNGEBUNDEN (Befund A, T-IPC-04): `email`/`username` sind plattformweit eindeutig, eine mandantengebundene Suche saehe einen fremden Halter nicht mehr und meldete faelschlich "frei" — die geloeste Klasse waere `muss-mandantengebunden` gewesen, bleibt wegen dieser einen bewusst uebergreifenden Abfrage `beides`. Der Loeschzweig um `syncBoundGroupsForTenant` (WINDOWS #20) ist bereits seit Etappe 1 gebunden und war nie Teil dieses Befunds. | | apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId`. | | apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | ungebunden | Modulfreigaben je Mandant. | | apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | ungebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. | @@ -204,7 +230,12 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit ## Was diese Etappe NICHT entscheidet - Ob Controller künftig über `req.tenantPrisma` statt eines erneuten - `forTenant()`-Aufrufs im Service gehen (offener Befund oben). + `forTenant()`-Aufrufs im Service gehen (offener Befund oben). Der Bereich + `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 + ü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 muss.