Files
schalli b34500b452 docs(quick-260909-ipc): Plan fuer Etappe 2, Bereich ldap
Drei Aufgaben: Fehlerrichtung schriftlich festhalten und an der
ausgelieferten Policy messen, dann ldap-config.service.ts und
ldap.service.ts auf forTenant() umstellen. Alle Fundstellen zur
Planungszeit einzeln aufgeschlagen statt gezaehlt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 13:43:06 +02:00

39 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260909-ipc 01 execute 1
true
WINDOWS-20
ETAPPE-2-LDAP
docs/mandantentrennung-etappe2-fehlerrichtung.md
apps/api/scripts/rls-scratch-check.mjs
apps/api/src/ldap/ldap-config.service.ts
apps/api/src/ldap/ldap-config.service.spec.ts
apps/api/src/ldap/ldap.controller.ts
apps/api/src/ldap/ldap.service.ts
apps/api/src/ldap/ldap.service.spec.ts
apps/api/src/prisma/rls-access-inventory.spec.ts
docs/mandantentrennung-zugriffsklassifikation.md
tokens raw_tokens tasks confidence
120000 90000 3 low
truths artifacts key_links
Jeder mandantengebundene Datenbankzugriff im Bereich ldap laeuft ueber forTenant(), mit dem am Aufrufort bereits bekannten Mandanten.
Die drei bewusst uebergreifenden Zugriffe (resolveEmailForWrite, getAllActiveConfigs, Start-Nachverschluesselung) bleiben ungebunden und tragen eine ausgeschriebene Begruendung im Quelltext.
Es existiert eine schriftliche Kritik, die je Pfad das konkrete Signal nennt, an dem ein zu-wenig-Ergebnis erkennbar waere, und die benennt, welcher Code Leere als Abwesenheit deutet.
Dass LdapFieldMapping seine Sichtbarkeit ueber den Join auf LdapConfig bezieht, ist unter einer Rolle ohne BYPASSRLS GEMESSEN, nicht behauptet.
Das Loeschen einer Feldzuordnung eines fremden Mandanten ueber ihre Kennung gelingt nicht mehr.
Klassifikationsdokument und maschinelle Absicherung zeigen den neuen Stand; ein gruener Testlauf mit veraltetem Dokument ist unmoeglich.
701+ Tests und die Typpruefung sind gruen; DATABASE_URL, Compose-Dateien, .env und prisma/schema.prisma sind unveraendert.
docs/mandantentrennung-etappe2-fehlerrichtung.md
apps/api/scripts/rls-scratch-check.mjs
apps/api/src/ldap/ldap-config.service.ts
apps/api/src/ldap/ldap.service.ts
apps/api/src/ldap/ldap.controller.ts
apps/api/src/prisma/rls-access-inventory.spec.ts
docs/mandantentrennung-zugriffsklassifikation.md
forTenant() <-> Policy tenant_isolation_policy auf LdapConfig (gemessen gegen die ausgelieferte Migration, nicht angenommen)
LdapFieldMapping <-> LdapConfig ueber die Join-Policy (Lesen UND Schreiben gemessen)
ldap.controller.ts req.tenantId <-> tenantId-Parameter der LdapConfigService-Methoden (schliesst die Fremdzugriffsluecke beim Loeschen)
Loeschzweig in ldap.service.ts <-> groups.service.ts (reassignDefaultBeforeDelete/ensureDefaultGroup) — NICHT Teil dieser Umstellung, dokumentierte Uebergabe an den Bereich groups
rls-access-inventory.spec.ts <-> Bestandsaufnahme-Tabelle inkl. neuer Stand-Spalte
Der Bereich `ldap` (21 klassifizierte Zugriffe in zwei Dateien) wird auf `forTenant()` umgestellt — als erster Bereich der Etappe 2, weil dort der gefaehrlichste Loeschzweig sitzt und die Wirkung am besten pruefbar ist.

Zweck: Die Mandantentrennung im Bereich ldap so weit fertigstellen, dass das Scharfschalten (Etappe 4) diesen Bereich nicht mehr beschaedigen kann — und die Umkehr der Fehlerrichtung ("sieht zu viel" wird zu "sieht nichts") vorher schriftlich und gemessen festhalten, statt sie zu entdecken, wenn sie eintritt.

Ergebnis: eine Kritikschrift mit Signaltabelle, fuenf neue Messungen im vorhandenen Wegwerf-Datenbank-Werkzeug, zwei umgestellte Dienste, eine geschlossene Fremdzugriffsluecke beim Loeschen von Feldzuordnungen, und ein Klassifikationsdokument, das seinen neuen Stand maschinell nachweist.

Aufgabe 1 fuehrt bewusst, obwohl sie keinen Nutzernutzen liefert: sie ist der duenne Durchstich durch die gesamte Kette (ausgelieferte Policy -> Rolle ohne BYPASSRLS -> forTenant-Muster -> echte LDAP-Tabellen) und beantwortet die vom Wiedereinstieg verlangte Frage mit einer Messung statt mit einer Behauptung. Erst danach wird Dienstcode angefasst.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @.planning/.continue-here.md @docs/mandantentrennung-zugriffsklassifikation.md @docs/mandantentrennung-datenbankrolle.md @apps/api/src/prisma/prisma-tenant.extension.ts @apps/api/src/prisma/rls-access-inventory.spec.ts @apps/api/scripts/rls-scratch-check.mjs @apps/api/src/ldap/ldap-config.service.ts @apps/api/src/ldap/ldap.service.ts @apps/api/src/ldap/ldap.controller.ts @CLAUDE.md

<planning_time_findings>

Alles Folgende wurde am 2026-09-09 zur Planungszeit am lebenden Baum gemessen, nicht aus Notizen uebernommen. Zahlen aus fremden Sitzungen sind Hinweise, keine Aenderungsvollmacht — die Fundstellen wurden einzeln aufgeschlagen.

Ausgangsstand (gemessen):

  • npm --prefix apps/api run test -> 53 Dateien, 701 Tests, gruen, 4,76 s.
  • npm --prefix apps/api run type-check -> Rueckgabewert 0.
  • pnpm --filter @tessera/api exec vitest run src/ldap -> 76 Tests gruen (9 in ldap-config.service.spec.ts, 67 in ldap.service.spec.ts).
  • TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs -> "Alle 8 Pruefungen bestanden." Das Fundament ist damit JETZT belegt, nicht laut Bericht. docker inspect tessera-ctl-db-1 liefert derzeit 172.19.0.2.

Die 21 Fundstellen, einzeln angesehen (nicht gezaehlt):

apps/api/src/ldap/ldap-config.service.ts — 9 Vorkommen von this.prisma.<Modell>:

Zeile Methode Modell Mandant am Aufrufort bekannt?
57 onApplicationBootstrap ldapConfig NEIN — laeuft beim Start ueber alle Mandanten
69 onApplicationBootstrap ldapConfig mittelbar (config.tenantId aus der Zeile)
125 getConfig ldapConfig ja (Parameter)
137 createConfig ldapConfig ja (Parameter)
177 updateConfig ldapConfig ja (Parameter)
215 addFieldMapping ldapFieldMapping NEIN — Signatur nimmt nur configId
230 removeFieldMapping ldapFieldMapping NEIN — Signatur nimmt nur mappingId
242 removeFieldMapping ldapFieldMapping NEIN — dito
252 getAllActiveConfigs ldapConfig NEIN — liest bewusst alle Mandanten fuer den Planer

apps/api/src/ldap/ldap.service.ts — 12 Vorkommen von this.prisma.<Modell> plus 9 bereits gebundene tenantPrisma.*-Stellen (4 forTenant()-Zuweisungen in Zeile 762, 905, 1179, 1342 — Zeilenangaben aus dem Klassifikationsdokument am lebenden Baum bestaetigt):

Zeile Methode Modell Umstellen?
346 listGroups group ja (Parameter tenantId)
420 resolveEmailForWrite user NEIN — siehe Befund A
452 upsertMappedUser user ja (Parameter)
457 upsertMappedUser user ja
475 upsertMappedUser user ja
579 searchUsers user ja (Parameter)
675 importUsersByDn user ja (Parameter)
680 importUsersByDn user ja
800 importGroupsByDn group ja — tenantPrisma liegt ab 762 bereits vor
1003 syncUsersForTenant user ja — tenantPrisma liegt ab 905 bereits vor
1014 syncUsersForTenant user ja
1050 syncUsersForTenant ldapConfig ja

9 + 12 = 21. Die dokumentierte Bereichszahl stimmt und traegt.

Befund A — resolveEmailForWrite darf NICHT gebunden werden. prisma/schema.prisma fuehrt email String? @unique und username String @unique plattformweit, nicht je Mandant. Die Kollisionspruefung aus T-Q3-01/WINDOWS #15 muss deshalb ueber Mandantengrenzen sehen: sonst meldet sie "Adresse frei", der Schreibvorgang laeuft in die Eindeutigkeitsverletzung der Datenbank, und der Sync bricht mit P2002 ab, statt die Kollision zu berichten. Nach dem Scharfschalten liefert diese ungebundene Abfrage null Zeilen und damit IMMER "frei" — ein bekannter, hier bewusst offen gelassener Punkt fuer Etappe 3 (dort gehoert er zum Systemkontext, moeglicherweise als vierte SECURITY-DEFINER-Funktion nach dem Muster des Anmeldewegs). Dieser Durchlauf loest ihn NICHT, weil er eine Migration braeuchte und Migrationen hier ausgeschlossen sind.

Befund B — zwei ldap-config-Zugriffe sind uebergreifend, nicht mandantengebunden. getAllActiveConfigs() (Zeile 252) liefert dem Planer ldap-sync.scheduler.ts bewusst die Konfigurationen ALLER Mandanten; die Schleife bindet danach je Mandant. Die Start-Nachverschluesselung (Zeile 57/69) laeuft, bevor irgendein Mandantenkontext existiert. Die heutige Klasse muss-mandantengebunden fuer das Paar (ldap-config.service.ts, ldapConfig) ist damit falsch — richtig ist beides. Das ist eine Korrektur des Dokuments, keine Verhaltensaenderung.

Befund C — Fremdzugriffsluecke beim Loeschen einer Feldzuordnung. DELETE /ldap/config/mappings/:id (ldap.controller.ts, removeFieldMapping) nimmt ausschliesslich die Kennung entgegen und reicht sie ungebunden an den Dienst weiter. Ein Administrator des Mandanten A kann damit heute die Feldzuordnung des Mandanten B loeschen, wenn er deren Kennung kennt. Die Rollenpruefung schuetzt nicht davor, sie prueft nur die Rolle. Die Umstellung schliesst das als Nebenwirkung — deshalb wird sie hier als eigener Sicherheitsbefund gefuehrt (T-IPC-01) und nicht beilaeufig miterledigt.

Befund D — der Loeschzweig ist bereits gebunden, seine Absicherung nicht. Der als gefaehrlichster Punkt benannte Loeschzweig (heute Zeile 1559) laeuft schon ueber tenantPrisma aus Zeile 1342, ebenso der Kandidaten-Lesezugriff in Zeile 1350. Die Loeschentscheidung faellt ausserdem am VERZEICHNIS ("kein Treffer fuer objectGUID"), nicht an der Datenbank; ein zu kleines Datenbankergebnis fuehrt dort zu WENIGER Loeschungen, nicht zu mehr. Gefaehrlich ist stattdessen die Uebergabe unmittelbar davor: this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id) und ensureDefaultGroup(tenantId) liegen in groups.service.ts und sind NICHT umgestellt. Nach dem Scharfschalten liefert reassignDefaultBeforeDelete still false (kein Ersatzkandidat sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird trotzdem geloescht — der Mandant bleibt still ohne Standardgruppe zurueck. Das ist eine Reihenfolgebedingung fuer Etappe 4 und ein Argument, groups als naechsten Bereich zu nehmen (so ist es ohnehin geplant). Dieser Durchlauf fasst groups.service.ts NICHT an.

Befund E — die stillste Stelle im Bereich ist der Planer. Liefert getAllActiveConfigs() nach dem Scharfschalten null Zeilen, stellt der LDAP-Abgleich fuer JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein. Eine Laufzeitwarnung bei "null aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer laeuft jede Minute, und auf einer Installation ohne LDAP ist null der Normalfall — die Warnung waere Dauerlaerm. Das Signal gehoert deshalb in die Kritikschrift (Aufgabe 1) und in die Vorabpruefung von Etappe 4, nicht in den Minutentakt.

Befund F — die Tests wuerden die Umstellung nicht bemerken. ldap.service.spec.ts ersetzt forTenant durch die Identitaet (vi.mock(... forTenant: vi.fn((p) => p))). Ein Umbau von this.prisma.X auf tenantPrisma.X laeuft dort also gruen durch, ohne irgendetwas zu beweisen. Aufgabe 3 braucht deshalb einen Test, der forTenant durch ein UNTERSCHEIDBARES zweites Objekt ersetzt — sonst ist "umgestellt" eine Behauptung. ldap-config.service.spec.ts mockt forTenant gar nicht und uebergibt ein blankes Objekt als Prisma-Ersatz; ohne den gleichen Mock bricht es beim ersten $extends-Aufruf.

Befund G — die maschinelle Absicherung wuerde an der Umstellung zerbrechen. rls-access-inventory.spec.ts sucht ausschliesslich this.prisma.<Modell>. Werden Fundstellen auf tenantPrisma.<Modell> umgestellt, verschwindet das Paar aus dem Quelltext, und der Test "jeder Eintrag hat eine tatsaechliche Fundstelle" schlaegt fehl — fuer ldap-config.service.ts/ldapFieldMapping, ldap.service.ts/group und ldap.service.ts/ldapConfig. Die Absicherung muss also erweitert werden, sonst zwingt sie dazu, den Nachweis aus dem Dokument zu LOESCHEN statt ihn fortzuschreiben.

Nicht betroffen: grep -rn "searchProvider\|tenderRssFeedSource" apps/api/src/ldap/ liefert 0 Treffer — WINDOWS #19 reicht nicht in diesen Bereich hinein.

Gewaehltes Muster (bewusst, nicht stillschweigend): Der Mandantenkontext wird weiterhin IM DIENST per forTenant(this.prisma, tenantId) erzeugt, wie an den vier Bestandsstellen in ldap.service.ts und den drei in auth.service.ts. Der offene Befund req.tenantPrisma (gesetzt in tenant.middleware.ts:44 und tenant.guard.ts:41, nirgends gelesen) wird dadurch AUSDRUECKLICH NICHT entschieden — dieser Durchlauf legt nur fest, was der Bereich ldap tut, und schreibt im Klassifikationsdokument fest, dass die Frage fuer die uebrigen zehn Bereiche offen bleibt. </planning_time_findings>

Aufgabe 1: Fehlerrichtung schriftlich festhalten und an der echten Policy messen Der Container `tessera-ctl-db-1` laeuft und ist erreichbar; die aktuelle Adresse per `docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}'` NEU ermitteln (zur Planungszeit 172.19.0.2 — eine Container-Adresse ist veraenderlich und darf nicht aus dem Plan abgeschrieben werden). docs/mandantentrennung-etappe2-fehlerrichtung.md, apps/api/scripts/rls-scratch-check.mjs Zuerst die Messung erweitern, dann die Kritik daraus schreiben — nicht umgekehrt.

TEIL 1, apps/api/scripts/rls-scratch-check.mjs: einen dritten Abschnitt runLdapAreaChecks(adminUrl, scratchRoleUrl, results) nach dem Vorbild des vorhandenen runAuthLookupChecks ergaenzen und in main() nach diesem aufrufen. Der Abschnitt legt in der Wegwerf-Datenbank schlanke Tabellen LdapConfig (id, tenantId, serverUrl) und LdapFieldMapping (id, ldapConfigId, ldapField, tesseraField) an — genau wie der auth-Abschnitt das fuer User tut —, aktiviert darauf ENABLE plus FORCE ROW LEVEL SECURITY, vergibt SELECT/INSERT/UPDATE/DELETE an die Wegwerf-Rolle und legt je eine Konfiguration fuer TENANT-A und TENANT-B samt je einer Feldzuordnung an.

Die beiden Policies werden NICHT im Werkzeug neu getippt, sondern aus der ausgelieferten Migration 20260618112133_rls_policies/migration.sql gelesen und daraus die beiden CREATE POLICY-Anweisungen fuer "LdapConfig" und "LdapFieldMapping" bis zum abschliessenden Semikolon herausgeschnitten (Vorbild: readAuthLookupMigrationSql). Findet die Extraktion eine der beiden nicht, meldet der Abschnitt eine FEHLGESCHLAGENE Pruefung ldap-policies-aus-migration-gefunden und bricht ab — das Werkzeug darf nicht still durchlaufen, wenn es nichts zu messen gefunden hat, sonst begeht es genau den Fehler, den dieser Plan beschreibt.

Gemessen werden unter der Rolle ohne BYPASSRLS, ueber das vorhandene forTenantQuery-Hilfsmittel, fuenf Verhaltensweisen mit diesen Kennungen: ldapconfig-gebunden-nur-eigene-zeile (forTenant(TENANT-A) sieht genau die Zeile von A und keine von B), ldapconfig-ungebunden-null-zeilen (derselbe SELECT ohne Bindung liefert 0 Zeilen — die Fehlerrichtung, an der echten Policy statt an der Hilfstabelle probe gemessen), fieldmapping-folgt-join-auf-ldapconfig (forTenant(TENANT-A) sieht genau die Feldzuordnung, die an A's Konfiguration haengt), fieldmapping-schreiben-eigene-konfiguration-erlaubt (gebundenes INSERT mit A's ldapConfigId gelingt) und fieldmapping-schreiben-fremde-konfiguration-abgelehnt (gebundenes INSERT unter TENANT-A mit B's ldapConfigId wird abgewiesen; die Abweisung ist das bestandene Ergebnis).

Das Werkzeug raeumt weiterhin ausschliesslich seine fest verdrahtete Wegwerf-Datenbank ab und bekommt keine steuerbaren Namen (T-EOR-07 bleibt gueltig).

TEIL 2, docs/mandantentrennung-etappe2-fehlerrichtung.md neu anlegen. Eine eigene Datei statt eines Abschnitts im Klassifikationsdokument, mit ausgeschriebener Begruendung im Kopf: das Klassifikationsdokument wird von rls-access-inventory.spec.ts mit einem Zeilenmuster geparst, das JEDE Tabellenzeile der Form Datei-Modell-Klasse einsammelt; eine Kritik mit eigenen Fundstellentabellen wuerde diesem Parser in die Quere kommen. Ausserdem ist die Kritik etappenbezogen, die Bestandsaufnahme dagegen laufend. Beide Dokumente verweisen aufeinander.

Inhalt der Kritikschrift, in ganzen Saetzen: (a) Die Leitfrage und warum sie gestellt wird — bis heute war der Fehlerfall "sieht zu viel", nach der Umstellung ist er "sieht nichts". (b) Die Messung aus Teil 1 mit den tatsaechlich beobachteten Zeilen als Beleg, dass ungebunden nach dem Scharfschalten null Zeilen bedeutet und nicht etwa alle. (c) Eine Signaltabelle je umgestelltem Pfad des Bereichs ldap mit den Spalten Pfad, Verhalten bei zu wenig Ergebnis, konkretes Signal. Mindestens diese Zeilen, jeweils mit dem Signal, an dem man es merkt: der Abgleich-Bericht mit seinen Zaehlern (created/updated/deactivated/groupMembershipsAdded/groupMembershipsRemoved/ groupsAdopted/groupsRenamed/groupsDeleted/defaultMarkerMoved), das Feld lastSyncAt der Konfiguration, die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern, und die Protokollzeile "Starting LDAP sync for tenant ..." des Planers. (d) Ein eigener, hervorgehobener Abschnitt "Welcher Code deutet Leere als Abwesenheit" mit genau diesen vier Stellen, jeweils mit Richtung der Gefahr: syncGroupMembershipsForTenant — die Benutzerausloesung liefert zu wenig, danach entfernt deleteMany mit notIn ALLE LDAP-Mitgliedschaften der Gruppe (gefaehrlich, zerstoerend, bereits gebunden); die Deaktivierungsschleife in syncUsersForTenant — liefert die Kandidatenliste zu wenig, wird zu WENIG deaktiviert (harmlose Richtung, festhalten); der Loeschzweig in syncBoundGroupsForTenant — die Entscheidung faellt am Verzeichnis, das zu kleine Datenbankergebnis fuehrt zu weniger Loeschungen, ABER die Uebergabe reassignDefaultBeforeDelete/ensureDefaultGroup liegt in groups.service.ts und ist nicht umgestellt: sie meldet dann still "kein Ersatzkandidat", der Standard-Marker wandert nicht mit, und der Mandant bleibt ohne Standardgruppe zurueck (Befund D); getAllActiveConfigs — null Zeilen heisst, der Abgleich stellt fuer alle Mandanten lautlos die Arbeit ein (Befund E), samt der Begruendung, warum dagegen KEINE Laufzeitwarnung eingebaut wird und das Signal stattdessen in die Vorabpruefung von Etappe 4 gehoert. (e) Ein Abschnitt "Was dieser Durchlauf bewusst nicht loest" mit Befund A (resolveEmailForWrite muss uebergreifend bleiben, weil email und username plattformweit eindeutig sind — sonst wird aus einer berichteten Kollision ein P2002-Abbruch; nach dem Scharfschalten liefert die Pruefung immer "frei", Loesung gehoert nach Etappe 3, vermutlich als vierte SECURITY-DEFINER-Funktion), Befund D als Reihenfolgebedingung fuer Etappe 4, und dem ausdruecklichen Hinweis, dass die Frage req.tenantPrisma fuer die uebrigen Bereiche offen bleibt.

Der Text wird auf Deutsch geschrieben und kommt ohne Umlaut-Sonderzeichen in Dateinamen aus; im Fliesstext sind Umlaute in Ordnung, die Datei liegt im Repository und nicht auf einer Webseite. set -o pipefail && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}'):5432/postgres" node apps/api/scripts/rls-scratch-check.mjs | tee "${TMPDIR:-/tmp}/rls-ldap-check.log" && grep -q "fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden" "${TMPDIR:-/tmp}/rls-ldap-check.log" && grep -q "ldapconfig-ungebunden-null-zeilen: bestanden" "${TMPDIR:-/tmp}/rls-ldap-check.log" && grep -q "fieldmapping-folgt-join-auf-ldapconfig: bestanden" "${TMPDIR:-/tmp}/rls-ldap-check.log" && test -f docs/mandantentrennung-etappe2-fehlerrichtung.md Das Werkzeug meldet alle Pruefungen bestanden (8 aus Etappe 1 plus die 5 neuen plus die Fundpruefung der Policies) und beendet sich mit 0. Die Kritikschrift existiert, nennt je Pfad ein konkretes Signal, listet die vier Stellen, die Leere als Abwesenheit deuten, und traegt die tatsaechlich gemessenen Werte ein — nicht erwartete. Kein Dienstcode wurde in dieser Aufgabe angefasst.

Aufgabe 2: ldap-config.service.ts binden, Fremdzugriff beim Loeschen schliessen, Absicherung erweitern apps/api/src/ldap/ldap-config.service.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/ldap/ldap-config.service.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md - `getConfig(tenantId)`, `createConfig(tenantId, dto)` und `updateConfig(tenantId, dto)` erzeugen `forTenant(this.prisma, tenantId)` und fuehren ihre Abfrage darauf aus; der Test prueft, dass `forTenant` mit genau diesem Mandanten aufgerufen wurde. - `addFieldMapping` nimmt den Mandanten entgegen und schreibt gebunden; ein Aufruf ohne Mandant ist typseitig unmoeglich. - `removeFieldMapping` nimmt den Mandanten entgegen, liest die Zuordnung gebunden und liefert null, wenn sie unter diesem Mandanten nicht sichtbar ist — die Steuerung macht daraus 404 statt einer Loeschung. - Der Schutz der Vorgabe-Zuordnungen (isDefault) bleibt unveraendert wirksam. - `getAllActiveConfigs()` und die Start-Nachverschluesselung bleiben ungebunden; ein Test haelt fest, dass fuer sie KEIN Mandantenkontext erzeugt wird. - Die neun Bestandstests der Datei (Verschluesselung at rest, Altbestand, Backfill) bleiben gruen. - Die Bestandsaufnahme-Pruefung erkennt gebundene Fundstellen und vergleicht sie gegen eine neue Stand-Spalte des Dokuments. Reihenfolge: erst die Absicherung erweitern, dann die Tests schreiben, dann umstellen.

SCHRITT 1, apps/api/src/prisma/rls-access-inventory.spec.ts erweitern (Befund G). Die Fundstellensuche bekommt neben this.prisma.<Modell> eine zweite Erkennung fuer gebundene Zugriffe: je Datei werden die Zuweisungen der Form const <Name> = forTenant( eingesammelt und danach die Vorkommen <Name>.<Modell> gesucht. Jede Fundstelle traegt fortan zusaetzlich, ob sie gebunden oder ungebunden ist. Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand: gebunden, ungebunden oder gemischt.

Die Bestandsaufnahme-Tabelle im Dokument bekommt eine vierte Spalte Stand zwischen Klasse und Begruendung; das vorhandene Zeilenmuster verankert nur die ersten drei Spalten und bleibt dadurch gueltig. Neue Pruefungen: jeder Eintrag traegt einen der drei Stand-Werte, und der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein. Die Fehlermeldung dieser Pruefung nennt je abweichendem Paar den gemessenen Wert, damit das Dokument aus der Messung gefuellt werden kann statt aus Vermutung. Die bestehende Pruefung "jeder Eintrag hat eine tatsaechliche Fundstelle" gilt kuenftig fuer gebundene wie ungebundene Fundstellen.

Die Erkennung hat eine bekannte Grenze: ein forTenant(...)-Ergebnis, das nicht an eine Konstante gebunden, sondern direkt weiterverwendet wird, sieht sie nicht. Eine eigene Pruefung haelt diese Grenze offen: jedes forTenant(-Vorkommen im Quelltext muss entweder der erkannten Zuweisungsform entsprechen oder in einer kurzen, begruendeten Ausnahmeliste stehen. In dieser Liste stehen zum Start genau tenant.middleware.ts und tenant.guard.ts mit dem Vermerk, dass sie den gebundenen Client auf dem Anfrageobjekt veroeffentlichen und dass genau dieser Weg die offene Architekturfrage ist.

SCHRITT 2, apps/api/src/ldap/ldap-config.service.spec.ts: den Identitaets-Mock fuer forTenant nach dem Vorbild aus ldap.service.spec.ts ergaenzen (ohne ihn bricht die Datei am blanken Prisma-Ersatz, Befund F), und die in <behavior> beschriebenen Erwartungen als Tests schreiben. Diese Tests laufen zunaechst rot.

SCHRITT 3, apps/api/src/ldap/ldap-config.service.ts umstellen. In getConfig, createConfig und updateConfig je einmal am Methodenkopf einen gebundenen Client erzeugen und die Abfrage darauf ausfuehren; die vorhandene Cast-Schreibweise der Bestandsstellen uebernehmen, damit die Typpruefung gruen bleibt. addFieldMapping bekommt den Mandanten als ersten Parameter, removeFieldMapping ebenso; beide binden Lesen und Schreiben. Das verschachtelte Anlegen der drei Vorgabe-Zuordnungen in createConfig bleibt eine einzige Prisma-Operation und laeuft damit in derselben Transaktion wie das Setzen des Kontexts — dass diese Schreibweise unter der Policy traegt, ist in Aufgabe 1 gemessen.

getAllActiveConfigs und onApplicationBootstrap bleiben unveraendert ungebunden. Beide bekommen darueber einen ausgeschriebenen Absatz, der sagt, warum sie uebergreifend lesen muessen, dass sie nach dem Scharfschalten null Zeilen sehen wuerden, was das jeweils bedeutet (Abgleich stellt lautlos die Arbeit ein; Nachverschluesselung wird stillschweigend zum Nichtstun) und dass die Loesung Etappe 3 gehoert. Die Formulierung dieser Absaetze beschreibt den Sachverhalt, ohne die Klassennamen der Bestandsaufnahme als isolierte Schlagworte zu setzen.

SCHRITT 4, apps/api/src/ldap/ldap.controller.ts: beide Aufrufstellen nachziehen. Bei addFieldMapping liegt der Mandant bereits als lokale Variable vor. Bei removeFieldMapping fehlt er ganz — die Methode bekommt das Anfrageobjekt, holt den Mandanten daraus, weist wie die uebrigen Routen der Datei bei fehlendem Mandanten ab und reicht ihn weiter (Befund C, T-IPC-01).

SCHRITT 5, docs/mandantentrennung-zugriffsklassifikation.md nachziehen: die Stand-Spalte in die Bestandsaufnahme-Tabelle einfuegen und ALLE Zeilen mit dem gemessenen Stand fuellen — dazu die erweiterte Pruefung laufen lassen und ihre Ausgabe als Quelle nehmen, nicht schaetzen. Die Zeile (ldap-config.service.ts, ldapConfig) wird von muss-mandantengebunden auf beides korrigiert, mit Begruendung nach Befund B; die Zeile (ldap-config.service.ts, ldapFieldMapping) behaelt ihre Klasse und wird gebunden. Die Verteilungstabelle wird entsprechend nachgerechnet. Im Abschnitt "Was diese Etappe NICHT entscheidet" wird festgehalten, dass der Bereich ldap den Dienst-internen Weg gewaehlt hat und die Frage req.tenantPrisma fuer die uebrigen Bereiche offen bleibt. Ein Verweis auf die Kritikschrift aus Aufgabe 1 kommt in den Kopf des Dokuments. npm --prefix apps/api run test -- src/ldap/ldap-config.service.spec.ts src/prisma/rls-access-inventory.spec.ts && npm --prefix apps/api run type-check && npm --prefix apps/api run test Die neun Bestandstests der Konfigurationsdatei sind weiterhin gruen, dazu die neuen Bindungstests. Die Bestandsaufnahme-Pruefung erkennt gebundene Fundstellen, prueft die Stand-Spalte gegen den Quelltext und ist gruen — mit aktualisiertem Dokument, nicht mit geloeschten Zeilen. Das Loeschen einer Feldzuordnung verlangt den Mandanten. Der gesamte Testlauf bleibt bei mindestens 701 Tests gruen, die Typpruefung liefert 0. Reine Dienst- und Testaenderung ohne Schema-, Migrations- oder Konfigurationsanteil; ein einzelner Commit laesst sich zuruecknehmen.

Aufgabe 3: ldap.service.ts binden, die uebergreifende Kollisionspruefung festnageln, Dokument schliessen apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md - `listGroups`, `upsertMappedUser`, `searchUsers` und `importUsersByDn` erzeugen je einen gebundenen Client und fuehren ihre Abfragen darauf aus; `forTenant` wird mit dem uebergebenen Mandanten aufgerufen. - `importGroupsByDn` und `syncUsersForTenant` nutzen fuer ihre bisher ungebundenen Abfragen den in derselben Methode bereits vorhandenen gebundenen Client; es entsteht kein zweiter. - `resolveEmailForWrite` fragt weiterhin ueber den UNGEBUNDENEN Client; ein Test mit zwei unterscheidbaren Clients weist nach, dass die Adressabfrage am ungebundenen und der Benutzer-Upsert am gebundenen Client landet. - Die Kollisionsmeldung aus WINDOWS #15/T-Q3-01 verhaelt sich unveraendert: eine von einem fremden Konto gehaltene Adresse wird zurueckgehalten und berichtet, nie uebertragen. - Alle 67 Bestandstests der Datei bleiben gruen, insbesondere die Reihenfolge 5a vor 5b und die Loeschsemantik. SCHRITT 1, Tests zuerst, `apps/api/src/ldap/ldap.service.spec.ts`. Der vorhandene Identitaets-Mock von `forTenant` kann eine Umstellung nicht bemerken (Befund F). Deshalb einen eigenen Testblock ergaenzen, der die Mock-Umsetzung fuer diesen Block auf ein ZWEITES, unterscheidbares Client-Objekt umbiegt: der ungebundene Ersatz und der gebundene Ersatz bekommen getrennte Spione. Damit werden nachgewiesen: die Adressabfrage aus `resolveEmailForWrite` landet am ungebundenen Client, die Benutzersuche und der Benutzer-Upsert am gebundenen, und `forTenant` wird je Methode mit dem uebergebenen Mandanten aufgerufen. Zusaetzlich je einen knappen Nachweis fuer `listGroups`, `searchUsers`, `importUsersByDn`, `importGroupsByDn` und `syncUsersForTenant`. Diese Tests laufen zunaechst rot.

SCHRITT 2, apps/api/src/ldap/ldap.service.ts umstellen. Die Fundstellen werden ueber ihren Inhalt aufgesucht, nicht ueber die Zeilennummern aus diesem Plan — die Datei verschiebt sich waehrend der eigenen Bearbeitung. Umzustellen sind genau elf Abfragen in sechs Methoden: in listGroups die Abfrage, die die bereits importierten Gruppen anhand ihrer Verzeichniskennung markiert; in upsertMappedUser die beiden Identitaetssuchen (ueber ldapDn und ueber den Benutzernamen) sowie die anschliessende Aktualisierung; in searchUsers die Abfrage, die die schon vorhandenen Konten markiert; in importUsersByDn die Dublettenpruefung und die Aktualisierung, die den ldapDn nachtraegt; in importGroupsByDn die Idempotenzpruefung ueber die Verzeichniskennung; in syncUsersForTenant die Kandidatenliste der Deaktivierung, die Deaktivierung selbst und das Fortschreiben des Zeitpunkts der letzten Ausfuehrung.

In importGroupsByDn und syncUsersForTenant existiert der gebundene Client bereits am Methodenkopf und wird schlicht mitbenutzt. In listGroups, upsertMappedUser, searchUsers und importUsersByDn wird er einmal am Methodenkopf erzeugt, in der Schreibweise der vier Bestandsstellen. Gebundene Clients werden NICHT zwischen Methoden weitergereicht: jede Methode bleibt fuer sich lesbar, und die Fundstellenerkennung aus Aufgabe 2 kann sie je Datei zuordnen. Die vorhandenen Filterbedingungen auf den Mandanten bleiben stehen — sie sind das erste Netz, die Policy das zweite.

Die zwoelfte Fundstelle, die Adressabfrage in resolveEmailForWrite, bleibt ausdruecklich ungebunden. Darueber kommt ein ausgeschriebener Absatz mit dem vollstaendigen Grund: die Spalten fuer Adresse und Benutzername sind im Schema plattformweit eindeutig, nicht je Mandant; eine auf den eigenen Mandanten eingeschraenkte Suche wuerde einen fremden Halter uebersehen, die Pruefung meldete "frei", und aus einer sauber berichteten Kollision wuerde ein Abbruch an der Eindeutigkeitsbedingung der Datenbank. Der Absatz haelt ausserdem fest, dass diese Abfrage nach dem Scharfschalten null Zeilen liefert und deshalb in Etappe 3 einen Systemkontext braucht — vermutlich nach dem Muster der Funktionen des Anmeldewegs.

SCHRITT 3, docs/mandantentrennung-zugriffsklassifikation.md schliessen. Die drei Zeilen zu ldap.service.ts bekommen ihren gemessenen Stand und eine Begruendung, die den Sonderfall der Adressabfrage benennt. Die Bereichsuebersicht wird NEU GEMESSEN, nicht fortgeschrieben: die dort dokumentierte Zaehlung fuer den Bereich ldap erneut ausfuehren, dazu die Zaehlung der gebundenen Fundstellen, beide Werte eintragen und die Summenzeile nachrechnen. Die Kopfzeile der Uebersicht wird so umformuliert, dass erkennbar ist, dass die Spalte kuenftig ungebundene Fundstellen zaehlt und die gebundenen daneben stehen — eine unveraenderte Ueberschrift ueber veraenderter Bedeutung waere die naechste stille Falle. Der Abschnitt zum Hintergrunddienst wird fuer ldap.service.ts auf den neuen Stand gebracht: was jetzt gebunden ist, was bewusst nicht, und dass die Uebergabe an den Bereich groups (Standardgruppe vor dem Loeschen) offen bleibt und vor Etappe 4 erledigt sein muss. npm --prefix apps/api run test -- src/ldap/ldap.service.spec.ts src/prisma/rls-access-inventory.spec.ts && npm --prefix apps/api run type-check && npm --prefix apps/api run test && GESPERRT=$(git status --porcelain -- apps/api/prisma/schema.prisma apps/api/prisma/migrations docker-compose.yml docker-compose.prod.yml .env) && test -z "$GESPERRT" Alle 67 Bestandstests der Datei plus die neuen Bindungsnachweise sind gruen; der Test mit zwei unterscheidbaren Clients belegt, dass die Adressabfrage ungebunden und der Rest gebunden laeuft. Die Bestandsaufnahme-Pruefung ist mit aktualisiertem Dokument gruen. Der gesamte Testlauf zeigt mindestens 701 Tests gruen, die Typpruefung liefert 0. Schema und Compose-Dateien sind unberuehrt. Dienst- und Testaenderung ohne Schema- oder Konfigurationsanteil.

<threat_model>

Vertrauensgrenzen

Grenze Beschreibung
Browser/Administrator -> API tenantId stammt aus dem Sitzungsnachweis, die Kennung der Feldzuordnung dagegen aus der URL — ungeprueft fremd
API -> PostgreSQL Heute Rolle tessera mit BYPASSRLS; die Policies wirken erst nach Etappe 4. Bis dahin ist die Bindung Vorsorge, keine Durchsetzung
Verzeichnis (AD) -> API Nur lesend, svc_tessera; Verzeichnisantworten steuern Loeschentscheidungen
Werkzeug -> PostgreSQL Das Wegwerf-Werkzeug spricht dieselbe Instanz an wie die Entwicklungsdatenbank

STRIDE-Register

Kennung Kategorie Bauteil Schwere Umgang Massnahme
T-IPC-01 Elevation of Privilege DELETE /ldap/config/mappings/:id in ldap.controller.ts high mitigate Die Route nimmt heute nur die Kennung; ein Administrator des Mandanten A kann die Feldzuordnung des Mandanten B loeschen. Aufgabe 2 fuehrt den Mandanten aus dem Sitzungsnachweis ein und bindet Lesen und Loeschen; eine fremde Kennung loest dann 404 aus.
T-IPC-02 Information Disclosure Lesen von LdapConfig/LdapFieldMapping high mitigate Alle mandantenbezogenen Lese- und Schreibpfade beider Dienste laufen ueber forTenant(); dass die Join-Policy fuer LdapFieldMapping traegt, wird in Aufgabe 1 unter einer Rolle ohne BYPASSRLS gemessen statt behauptet.
T-IPC-03 Denial of Service (selbst verursacht, zerstoerend) syncGroupMembershipsForTenant, Entfernen mit notIn high mitigate Eine zu kleine Benutzerausloesung entfernt saemtliche LDAP-Mitgliedschaften einer Gruppe. Der Pfad ist bereits gebunden; Aufgabe 1 haelt Richtung und Signal (groupMembershipsRemoved) schriftlich fest, Aufgabe 1 misst die Bindungswirkung an der echten Policy.
T-IPC-04 Tampering resolveEmailForWrite high mitigate Wuerde diese Abfrage mitgebunden, saehe sie einen fremden Halter nicht mehr, meldete "Adresse frei" und der Schreibvorgang liefe in die plattformweite Eindeutigkeitsbedingung. Aufgabe 3 laesst sie bewusst ungebunden, begruendet das am Ort und nagelt es mit einem Test fest, der zwei unterscheidbare Clients verwendet.
T-IPC-05 Denial of Service getAllActiveConfigs, Start-Nachverschluesselung medium transfer Nach Etappe 4 saehen beide null Zeilen: der Abgleich stellt lautlos die Arbeit ein, die Nachverschluesselung wird zum Nichtstun. Uebergabe an Etappe 3 (Systemkontext) mit Eintrag in Kritikschrift und Klassifikationsdokument; eine Laufzeitwarnung wurde erwogen und wegen Dauerlaerm im Minutentakt verworfen.
T-IPC-06 Repudiation Loeschzweig ohne Standardgruppen-Uebergabe medium transfer reassignDefaultBeforeDelete/ensureDefaultGroup liegen im nicht umgestellten Bereich groups und wuerden nach Etappe 4 still versagen. Als Reihenfolgebedingung fuer Etappe 4 dokumentiert; groups ist ohnehin der naechste Bereich.
T-IPC-07 Tampering Wegwerf-Werkzeug trifft die echte Datenbank high mitigate Der Name der Wegwerf-Datenbank bleibt im Werkzeug fest verdrahtet und nicht steuerbar; der neue Abschnitt legt seine Tabellen ausschliesslich dort an und raeumt mit dem vorhandenen Abbau ab (T-EOR-07 unveraendert gueltig).
T-IPC-08 Spoofing Policy-Text im Messwerkzeug medium mitigate Die gemessenen Policies werden aus der ausgelieferten Migrationsdatei gelesen, nicht im Werkzeug nachgetippt; findet die Extraktion nichts, meldet das Werkzeug eine fehlgeschlagene Pruefung statt still durchzulaufen.

Paketlegitimitaet: Dieser Durchlauf installiert kein Paket (npm/pip/cargo). Das Legitimitaetstor greift daher nicht; es wird kein Lieferketten-Eintrag erfunden.

Schema-Tor: prisma/schema.prisma wird nicht angefasst, es entsteht keine Migration. Das Schema-Tor greift nicht. Sollte sich bei der Ausfuehrung zeigen, dass eine Schemaaenderung unvermeidbar ist, ist das ein Abbruchgrund: melden statt machen. </threat_model>

1. `npm --prefix apps/api run test` -> mindestens 701 Tests gruen (Ausgangsstand am 2026-09-09 gemessen: 53 Dateien, 701 Tests). 2. `npm --prefix apps/api run type-check` -> Rueckgabewert 0. 3. Das Wegwerf-Werkzeug meldet alle Pruefungen bestanden, einschliesslich der fuenf neuen aus dem Bereich ldap. 4. `git diff --stat` zeigt keine Aenderung an `apps/api/prisma/schema.prisma`, an `apps/api/prisma/migrations/`, an `.env` oder an einer Compose-Datei. 5. `rls-access-inventory.spec.ts` ist gruen, obwohl Fundstellen von ungebunden auf gebunden gewechselt sind — die Absicherung ist mitgewachsen, nicht ausgehoehlt.

<success_criteria>

  • Der Bereich ldap ist umgestellt: elf Abfragen in ldap.service.ts und fuenf Methoden in ldap-config.service.ts laufen gebunden; drei Zugriffe bleiben mit ausgeschriebener Begruendung uebergreifend.
  • Die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ist geschlossen.
  • Die Frage "Woran wuerde ich merken, dass eine umgestellte Abfrage zu wenig liefert?" ist schriftlich beantwortet, je Pfad mit einem konkreten Signal, und die Aussage stuetzt sich auf eine Messung an der ausgelieferten Policy.
  • Klassifikationsdokument und maschinelle Absicherung zeigen denselben, gemessenen Stand.
  • Der Schalter ist unveraendert AUS; Schema, Migrationen, Compose-Dateien und .env sind unberuehrt; am Verzeichnis wurde nichts geaendert. </success_criteria>
Erzeuge `.planning/quick/260909-ipc-mandantentrennung-etappe-2-bereich-ldap-/260909-ipc-SUMMARY.md`, wenn alle drei Aufgaben abgeschlossen sind. Der Bericht haelt fest: die tatsaechlich gemessenen Zahlen (Testanzahl, Fundstellen je Stand, Ergebnis des Wegwerf-Werkzeugs), die drei bewusst uebergreifend gebliebenen Zugriffe mit Begruendung, und die drei an spaetere Etappen uebergebenen Punkte (Adresskollision, Planer-Stille, Standardgruppen-Uebergabe an den Bereich groups).