Files
tessera-ctl/.planning/quick/260909-ipc-mandantentrennung-etappe-2-bereich-ldap-/260909-ipc-PLAN.md
T
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

558 lines
39 KiB
Markdown

---
phase: quick-260909-ipc
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [WINDOWS-20, ETAPPE-2-LDAP]
files_modified:
- 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
estimate:
tokens: 120000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "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."
artifacts:
- 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
key_links:
- "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"
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Fehlerrichtung schriftlich festhalten und an der echten Policy messen</name>
<precondition>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).</precondition>
<files>docs/mandantentrennung-etappe2-fehlerrichtung.md, apps/api/scripts/rls-scratch-check.mjs</files>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: ldap-config.service.ts binden, Fremdzugriff beim Loeschen schliessen, Absicherung erweitern</name>
<files>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</files>
<behavior>
- `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.
</behavior>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>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.</done>
<reversibility rating="reversible">Reine Dienst- und Testaenderung ohne Schema-, Migrations- oder Konfigurationsanteil; ein einzelner Commit laesst sich zuruecknehmen.</reversibility>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: ldap.service.ts binden, die uebergreifende Kollisionspruefung festnageln, Dokument schliessen</name>
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<behavior>
- `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.
</behavior>
<action>
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.
</action>
<verify>
<automated>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"</automated>
</verify>
<done>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.</done>
<reversibility rating="reversible">Dienst- und Testaenderung ohne Schema- oder Konfigurationsanteil.</reversibility>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
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).
</output>