# Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um Dieses Dokument gehört zusammen mit `docs/mandantentrennung-zugriffsklassifikation.md` zur Vorbereitung der Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält, **welche** Fundstelle umgestellt wird, hält dieses Dokument fest, **woran** man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten (Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich `ldap` zum Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc). ## (a) Die Leitfrage Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die Rolle `tessera` läuft mit `BYPASSRLS`, jede Abfrage sieht alle Zeilen aller Mandanten, unabhängig davon, ob sie an `forTenant()` gebunden ist oder nicht. Ein vergessener `forTenant()`-Aufruf blieb bisher unsichtbar, weil die Policy gar nicht griff. Nach dem Scharfschalten (Etappe 4, wenn `DATABASE_URL` auf eine Rolle ohne `BYPASSRLS` zeigt) kehrt sich das um. Jede Abfrage, die **nicht** gebunden ist, sieht nicht mehr alle Zeilen, sondern **keine** — die Policy vergleicht gegen `current_tenant_id()`, und ohne vorheriges `set_config` ist dieser Wert `NULL`. `NULL = "tenantId"` ist in SQL nie wahr, auch wenn `"tenantId"` selbst nicht `NULL` ist. Der Fehlerfall ist damit nicht mehr "ein Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut so, als sei nichts zu tun." Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim Scharfschalten entdeckt: **Woran würde ich merken, dass eine umgestellte Abfrage jetzt zu WENIG liefert statt zu viel?** ## (b) Die Messung Task 1 dieses Plans hat `apps/api/scripts/rls-scratch-check.mjs` um einen dritten Abschnitt erweitert, der exakt das im Bereich `ldap` verwendete Muster (Tabellen `LdapConfig`/`LdapFieldMapping`, Policies wortgleich aus der ausgelieferten Migration `20260618112133_rls_policies/migration.sql` herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle **ohne** `BYPASSRLS` prüft. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`): ``` ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"] ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n) fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"] fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping" Alle 13 Pruefungen bestanden. ``` Der Beleg, der diese Kritikschrift trägt, ist die Zeile `ldapconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM "LdapConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung "sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine beobachtete Tatsache. ## (c) Signaltabelle je umgestelltem Pfad des Bereichs `ldap` | Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal | |---|---|---| | `LdapConfigService.getConfig/createConfig/updateConfig` | `forTenant()` liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration | `GET /ldap/config` liefert `null`; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat | | `LdapConfigService.addFieldMapping/removeFieldMapping` | Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung | `POST`/`DELETE /ldap/config/mappings` scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert | | `LdapService.listGroups`/`searchUsers` (Markierung "bereits importiert") | Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen | Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen `ldapObjectGuid`-Eindeutigkeit scheitern) | | `LdapService.upsertMappedUser`/`importUsersByDn` (Identitätssuche über `ldapDn`/`username`) | Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos | Der Abgleich-Bericht zählt das Konto unter `created` statt `updated` — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts | | `LdapService.syncUsersForTenant` (Deaktivierungs-Kandidatenliste) | Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen | Der Zähler `deactivated` bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel | | `LdapService.syncUsersForTenant` (`lastSyncAt`-Fortschreibung) | Das `UPDATE` trifft 0 Zeilen statt der eigenen Konfiguration | Das Feld `lastSyncAt` der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb | | `LdapService.importGroupsByDn` (Idempotenzprüfung über `ldapObjectGuid`) | Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe | Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil `group.create` an der eigenen `ldapObjectGuid`-Unique-Bedingung ohnehin scheitert | | `LdapConfigScheduler` (Planer, `getAllActiveConfigs()`) | Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten | Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E | ## (d) Welcher Code deutet Leere als Abwesenheit Diese vier Stellen sind die still gefährlichsten Orte des Bereichs `ldap`, weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr") interpretieren: 1. **`syncGroupMembershipsForTenant`, `deleteMany` mit `notIn`** — gefährlich, zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließende `deleteMany({ where: { NOT: { userId: { in: [...] } } } })`-Aufruf ALLE LDAP-Mitgliedschaften der Gruppe, weil die Vergleichsliste leer ist und jede vorhandene Mitgliedschaft als "nicht mehr in der Liste" gilt. Dieser Pfad ist bereits gebunden (`tenantPrisma` seit Etappe 1, Zeile 1179 vor dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals entfernt würde. 2. **Die Deaktivierungsschleife in `syncUsersForTenant`** — harmlose Richtung, trotzdem festgehalten. Liefert die Kandidatenliste (`localLdapUsers`) zu wenig, wird zu WENIG deaktiviert, nie zu viel: ein Konto, das eigentlich deaktiviert werden müsste, bleibt aktiv. Das ist unerwünscht, aber nicht destruktiv — es verliert keine Daten und sperrt niemanden fälschlich aus. 3. **Der Löschzweig in `syncBoundGroupsForTenant`** — die eigentliche Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr für `objectGUID`"), nicht an der Datenbank; ein zu kleines Datenbankergebnis führt hier zu WENIGER Löschungen, nicht zu mehr. Gefährlich ist stattdessen die Übergabe unmittelbar davor: `this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und `ensureDefaultGroup(tenantId)` liegen in `groups.service.ts` und sind NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefert `reassignDefaultBeforeDelete` still `false` (kein Ersatzkandidat sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird trotzdem gelöscht — der Mandant bleibt ohne Standardgruppe zurück (Befund D, siehe (e)). 4. **`getAllActiveConfigs`** — die stillste Stelle im gesamten Bereich. Liefert diese Abfrage nach dem Scharfschalten 0 Zeilen (sie ist bewusst übergreifend und bleibt ungebunden, siehe (e)), stellt der LDAP-Abgleich für JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne sichtbare Änderung die Arbeit ein (Befund E). Eine Laufzeitwarnung bei "0 aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer läuft jede Minute, und auf einer frischen Installation ohne LDAP ist 0 der Normalfall — eine Warnung wäre Dauerlärm, der nach kurzer Zeit ignoriert wird und sein Signal verliert. Das Signal gehört deshalb hierhin und in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), nicht in den Minutentakt des Planers. ## (e) Was dieser Durchlauf bewusst nicht löst - **Befund A — `resolveEmailForWrite` muss übergreifend bleiben.** `email` und `username` sind in `prisma/schema.prisma` plattformweit eindeutig (`@unique`), nicht je Mandant. Würde diese Abfrage mitgebunden, sähe sie einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und der anschließende Schreibvorgang liefe in die plattformweite Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten Kollision (WINDOWS #15/T-Q3-01) würde ein P2002-Abbruch des gesamten Sync-Laufs. Nach dem Scharfschalten liefert diese ungebundene Abfrage IMMER "frei" (0 Zeilen unter jedem Mandantenkontext außer dem der Adresse selbst) — ein bekannter, hier bewusst offen gelassener Punkt. Die Lösung gehört nach Etappe 3, vermutlich als vierte SECURITY-DEFINER-Funktion nach dem Muster der drei Funktionen des Anmeldewegs (`auth_lookup_user_by_username`, `auth_lookup_reset_token`, plus die dritte aus Etappe 1). - **Befund D — die Standardgruppen-Übergabe an den Bereich `groups`.** Wie in (d.3) beschrieben, ist der Löschzweig selbst bereits gebunden, aber die Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup` in `groups.service.ts` ist es nicht. Das ist eine Reihenfolgebedingung für Etappe 4: der Bereich `groups` muss umgestellt sein, bevor scharf geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der Etappe 2 vorgesehen. - **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts` und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT, ob Controller künftig darüber gehen sollten statt eines erneuten `forTenant()`-Aufrufs im Service — der Bereich `ldap` bindet weiterhin dienst-intern, wie die vier Bestandsstellen in `ldap.service.ts` und die drei in `auth.service.ts` es vormachen. Die Frage bleibt für die übrigen Bereiche der Etappe 2 offen (siehe `docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese Etappe NICHT entscheidet"). ## Verweis Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`), steht in `docs/mandantentrennung-zugriffsklassifikation.md`.