Aufgabe 1 der Etappe 2: erweitert das Wegwerf-Werkzeug rls-scratch-check.mjs um fuenf Messungen des forTenant()-Musters gegen die echte, aus der ausgelieferten Migration geschnittene LdapConfig/LdapFieldMapping-Policy unter einer Rolle ohne BYPASSRLS. Belegt insbesondere, dass ein ungebundener Zugriff nach dem Scharfschalten 0 Zeilen liefert, nicht alle -- die Fehlerrichtung dreht sich um. Die neue Kritikschrift docs/mandantentrennung-etappe2-fehlerrichtung.md haelt das schriftlich fest, mit Signaltabelle je Pfad und den vier Stellen, die Leere als Abwesenheit deuten. Kein Dienstcode angefasst. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
11 KiB
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:
-
syncGroupMembershipsForTenant,deleteManymitnotIn— gefährlich, zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließendedeleteMany({ 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 (tenantPrismaseit Etappe 1, Zeile 1179 vor dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals entfernt würde. -
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. -
Der Löschzweig in
syncBoundGroupsForTenant— die eigentliche Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr fürobjectGUID"), 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)undensureDefaultGroup(tenantId)liegen ingroups.service.tsund sind NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefertreassignDefaultBeforeDeletestillfalse(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)). -
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 —
resolveEmailForWritemuss übergreifend bleiben.emailundusernamesind inprisma/schema.prismaplattformweit 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 anreassignDefaultBeforeDelete/ensureDefaultGroupingroups.service.tsist es nicht. Das ist eine Reihenfolgebedingung für Etappe 4: der Bereichgroupsmuss umgestellt sein, bevor scharf geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung ohne Standardgruppe zurück.groupsist ohnehin als nächster Bereich der Etappe 2 vorgesehen. -
Die offene Architekturfrage
req.tenantPrisma.tenant.middleware.tsundtenant.guard.tssetzenreq.tenantPrisma = forTenant(...), aber kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT, ob Controller künftig darüber gehen sollten statt eines erneutenforTenant()-Aufrufs im Service — der Bereichldapbindet weiterhin dienst-intern, wie die vier Bestandsstellen inldap.service.tsund die drei inauth.service.tses vormachen. Die Frage bleibt für die übrigen Bereiche der Etappe 2 offen (siehedocs/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.