Files
tessera-ctl/docs/mandantentrennung-etappe2-fehlerrichtung.md
T
schalli 388690fdf0 docs(quick-260911-mkj): Abschnitte abgeleitet, WINDOWS #27 geschlossen, Restmenge als eigener Eintrag
- Klassifikationsdokument: Klassen-Verteilung (35/21/14/2, 72 Paare),
  Uebersichtsabsatz (zweite methodische Luecke, geschlossen) und
  Hintergrunddienst-Nachtrag (ldap/getAllActiveConfigs reicht auch in
  LdapFieldMapping hinein) aus Tabelle/Greps abgeleitet, nicht abgeschrieben
- Kritikschrift: Nachtrag (260911-mkj) unter (n4) im Bereich tenant, Vermerk
  im Etappe-2-Abschluss dass #27 geschlossen ist
- WINDOWS.md: #27 fixed (Nachweis: vierte Erkennungsform, Proben,
  Zwischenmessung 7/3/1); neuer Eintrag #33 fuer die Empfaenger ausserhalb
  der vier Erkennungsformen (tenders.seed.ts, backfill-tender-source.ts)
- Spec: WINDOWS #TBD-MKJ-Platzhalter durch #33 ersetzt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 16:49:58 +02:00

233 KiB
Raw Blame History

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.

    Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN. Der Bereich groups ist umgestellt — reassignDefaultBeforeDelete und ensureDefaultGroup laufen seit Aufgabe 2 dieses Plans vollständig über forTenant() bzw. withTenantTransaction() (siehe Abschnitt "Bereich groups" unten und docs/mandantentrennung-zugriffsklassifikation.md, Zeile groups.service.ts/group, Stand gebunden). Die Reihenfolgebedingung für Etappe 4 ist damit erfüllt. Der Befund oben bleibt unverändert stehen — er beschreibt korrekt den Zustand zum Zeitpunkt der ldap-Umstellung.

  • 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").

Bereich groups

Dieser Abschnitt erweitert die Kritikschrift um den Bereich groups (Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches Modul sehen darf.

(g1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen vierten Abschnitt (runGroupsAreaChecks) und eine eigene Transaktionsmessung (runTransactionShapeMeasurement) erweitert, beide gegen die Wegwerf-Datenbank unter der Rolle ohne BYPASSRLS, mit den vier Policies für Group, GroupMembership, ModuleGrant (aus 20260804130918_groups_rls_policies) und TenantModuleActivation (aus 20260909140000_rls_remaining_tenant_tables) WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-09, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E)
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F)
tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]

NACHTRAG (260910-jab): die beiden fett hervorgehobenen Zeilen oben (groupmembership-schreiben-fremder-benutzer-nicht-verhindert und modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt) sind ein Messprotokoll vom 2026-09-09 und bleiben UNVERÄNDERT stehen — sie belegen, dass die beiden Löcher existierten. Seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read sind beide Aussagen ÜBERHOLT: die Prüfungen sind umgekehrt (groupmembership-schreiben- fremder-benutzer-abgelehnt, modulegrant-fremde-gruppe-abgelehnt), das INSERT wird jetzt jeweils ABGEWIESEN statt zu gelingen. Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die aktuell beobachtete Ausgabe.

Die Belegzeile, die diesen Abschnitt trägt, ist group-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "Group" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa die 2 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.

Die Transaktionsmessung — namentliches Ergebnis. Drei Formen wurden gegen einen Client beobachtet, der die Erweiterungsform aus prisma-tenant.extension.ts wortgleich nachbaut, jeweils mit pg_backend_pid() und current_tenant_id() in jeder Teilabfrage plus einem echten Lesezugriff auf "Group":

[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}}
[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}}
[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}}
mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]

Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: step1 lief auf Verbindung 276749, step2 auf Verbindung 276750 — zwei verschiedene physische Verbindungen, obwohl beide Schritte denselben Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem Kopfkommentar von prisma-tenant.extension.ts: jede Modell-Operation eines $transaction-Arrays auf dem gebundenen Client dispatcht durch $allOperations und bekommt dadurch ihre EIGENE Ein-Element-Transaktion — mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand inkonsistent.

Form (ii) und Form (iii) bestanden beide die im Plan festgelegte Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche, sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber durch die Tragweite dieses Bereichs geboten) beide Formen unter echter Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN gemeinsamen Client, alternierend TENANT-A/TENANT-B.

Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank und war damit nicht nachvollziehbar — eine Zahl, die eine Entscheidung trug, ohne dass jemand sie hätte nachprüfen können. Genau das Anti-Muster, das dieses Projekt sich selbst verboten hat. Sie ist deshalb als runConcurrencyProbe in apps/api/scripts/rls-scratch-check.mjs nachgereicht worden und läuft seither bei jedem Werkzeuglauf mit. Als Verletzung zählt beides: ein Aufruf, der einen fremden oder gar keinen Mandantenkontext sieht, und ein Aufruf, der abbricht.

Gemessen wird, nicht behauptet:

  • Form (ii) bricht unter dieser Last ab, mit Fehlern der Familie PrismaClientKnownRequestError: Transaction API error (Code P2028). Ursache: jede tx.$queryRaw-Anweisung innerhalb der interaktiven Transaktion auf dem gebundenen Client löst selbst wieder eine VERSCHACHTELTE Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil $allOperations bei jedem Aufruf erneut feuert) — die äußere interaktive Transaktion UND jede innere Verschachtelung belegen gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last reicht der Pool nicht mehr aus.
  • Form (iii) besteht dieselbe Belastung ohne Verletzung — sie belegt pro Aufruf genau eine Verbindung, ohne Verschachtelung.

Die konkreten Zahlen eines einzelnen Laufs stehen bewusst NICHT in diesem Dokument, sondern fallen bei jeder Ausführung neu an; der Werkzeuglauf vom 2026-09-09 ergab 24 Verletzungen von 40 für Form (ii) und 0 von 40 für Form (iii). Geprüft wird nur die Eigenschaft, auf die sich der Code stützt — Form (iii) ohne Verletzung. Das Verhalten von Form (ii) läuft daneben als ausgedruckte Beobachtung mit und ist bewusst KEINE Bedingung für einen grünen Lauf: ab welcher Last sie bricht, hängt an Verbindungsvorrat und Maschine.

Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2: obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht, ist sie unter echter Nebenläufigkeit strukturell fragil und ein Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die Startreparatur ruft ensureDefaultGroup für mehrere Mandanten auf). Form (iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen Hilfsmittels withTenantTransaction in prisma-tenant.extension.ts.

(g2) Signaltabelle je umzustellendem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal, Ort
GroupsService.listForTenant Liefert 0 Gruppen statt der tatsächlich vorhandenen Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt)
GroupsService.getImpact Liefert {memberCount:0, grantCount:0} statt der tatsächlichen Zahlen Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage
GroupsService.listMembers Liefert 0 Mitglieder statt der tatsächlich vorhandenen Die Mitgliederliste im Gruppen-Detail ist leer
ModuleGrantsService.getMatrix Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe
ModuleGrantsService.getUserAccess Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine"
LdapService.syncBoundGroupsForTenant (Übergabe an reassignDefaultBeforeDelete/ensureDefaultGroup) Der Zähler defaultMarkerMoved im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06)
GroupsService.addUserToDefaultGroup Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln)

(g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups

Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung beider Dateien:

  • GroupsService.reassignDefaultBeforeDelete — zerstörend und still. Zwei getrennte Stellen liefern false: die Gruppe selbst ist nicht sichtbar (findFirst auf Group liefert 0 Zeilen), oder es ist kein Ersatzkandidat sichtbar (beide findFirst-Fallbacks liefern 0 Zeilen). Der Aufrufer im Verzeichnis-Sync (syncBoundGroupsForTenant) löscht die Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist ein Hauptzweck dieses Durchlaufs.
  • GroupsService.ensureDefaultGroup — die einzige Stelle des Bereichs, an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist UMGEKEHRT gepolt: null gelesene Gruppen (group.count liefert 0 statt der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern "alles neu aufbauen". Bliebe der Zähler ungebunden, während der Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex Group_one_default_per_tenant fängt einen Teil der Fälle ab (P2002 beim group.create, abgefangen und in null übersetzt) — aber nur, wenn der Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab. Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden, nie einzeln (T-JTS-05).
  • GroupsService.getImpact — die Zahlen des Löschdialogs. Zwei Zählungen (groupMembership.count, moduleGrant.count) ohne Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator entscheidet auf dieser Grundlage über eine kaskadierende Löschung und bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle Gruppe angezeigt.
  • GroupsService.addUserToDefaultGroup — stilles Zurückkehren ohne sichtbare Standardgruppe (group.findFirst liefert 0 Zeilen statt der einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust.

Gegenrichtung, ebenfalls festgehalten: ModuleGrantsService.grant (die Aktivierungsprüfung: kein aktives TenantModuleActivation wirft BadRequestException, statt still zu erteilen) und ModuleGrantsService.assertTargetBelongsToTenant (die Mandanten-Gegenprüfung: kein Treffer wirft NotFoundException) werfen bei Leere LAUT und sind damit die harmlosen Stellen des Bereichs.

Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend geschlossen: apps/web/src/app/(portal)/admin/modules/grants/page.tsx schaltet die Freigabe-Matrix je Zelle einzeln (ein POST bzw. DELETE pro Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied zum deleteMany-mit-notIn des ldap-Bereichs.

(g4) Was dieser Durchlauf bewusst nicht löst

  • Die offene Architekturfrage req.tenantPrisma. Auch der Bereich groups entscheidet sie nicht — er bindet dienst-intern, wie ldap und auth.service.ts es vormachen.
  • Die Policies auf GroupMembership und ModuleGrant prüfen jeweils nur eine Seite. Gemessen in (g1): GroupMembership prüft ausschließlich die Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die Policy NICHT. ModuleGrant prüft ausschließlich die Mandantenkennung der Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in addUserToDefaultGroup, assertTargetBelongsToTenant) bleiben deshalb der primäre Schutz gegen diese beiden Formen der Rechteausweitung und werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt. NACHTRAG (260910-jab): ÜBERHOLT — seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read prüft GroupMembership BEIDE Seiten (T-JTS-02 geschlossen) und ModuleGrant zusätzlich beide möglichen Ziele (T-JTS-03 geschlossen, beide Zweige des Entweder-oder D-04). Die Anwendungsprüfungen bleiben trotzdem bestehen — sie sind bis zum Scharfschalten (#18) der einzige tatsächlich wirksame Schutz, siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten.

(g5) Fortschreibung des ldap-Abschnitts

Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt tatsächlich vorliegt.

Bereich tenders

Dieser Abschnitt erweitert die Kritikschrift um den Bereich tenders (Quick-Task 260909-laa) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt beantwortet sie erneut, aber für einen Bereich, der überwiegend NICHT umgestellt wird: von 23 Paaren sind fünf umzustellen, zehn bleiben bewusst der plattformweite Ausschreibungskatalog (D-03), zwei sind bewusste Fan-outs, und sechs zerfallen in eine übergreifende Hälfte (Etappe 3) und eine mandantengebundene Hälfte (hier). Dieser Bereich trägt außerdem eine Fehlerform, die ldap und groups nicht hatten: zwei Benachrichtigungswege, die bei zu kleinem Leseergebnis nicht falsch handeln, sondern GAR NICHT.

(t1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen fünften Abschnitt (runTendersAreaChecks) erweitert, mit den fünf Policies für TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch und TenderTriage (alle aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables) WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-09, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

tendersavedsearch-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
tendersavedsearch-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenderSavedSearch" liefert 0 Zeile(n)
tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar: bestanden — forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen
tenderemailconfig-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendertriage-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendernotificationpref-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: false, forTenant(TENANT-B) sieht sie: false — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen: ERROR: new row violates row-level security policy for table "TenderRssFeedSource" — createPlatform darf deshalb nicht gebunden werden
tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit: bestanden — gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: Code 23505, Unique constraint failed — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler
Alle 32 Pruefungen bestanden.

NACHTRAG (260910-jab): die fett hervorgehobene Zeile oben (tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar) ist ein Messprotokoll vom 2026-09-09 und bleibt UNVERÄNDERT stehen — sie belegt, dass WINDOWS #19 existierte. Seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read ist diese Aussage ÜBERHOLT: die Prüfung ist umgekehrt (tenderrssfeed-plattformzeile-gebunden-sichtbar), die plattformweite Zeile ist jetzt unter BEIDEN Mandantenkontexten SICHTBAR. Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die aktuell beobachtete Ausgabe.

Die Belegzeile, die diesen Abschnitt trägt, ist tendersavedsearch-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "TenderSavedSearch" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa die 3 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.

Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger Bereich brauchte:

  • tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar — das GELINGEN (beide Nutzer von TENANT-A sind sichtbar) IST das bestandene Ergebnis. Alle fünf Policies dieses Bereichs lauten schlicht "tenantId" = current_tenant_id(), ohne Benutzerdimension — zwei Nutzer DESSELBEN Mandanten sind füreinander vollständig sichtbar. Die anwendungsseitige userId-Filterung, die alle fünf umzustellenden Dienste bereits führen, bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
  • tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar — die plattformweite RSS-Zeile (userId/tenantId beide NULL, wie der geseedete service.bund.de-Feed) ist unter TENANT-A UND TENANT-B gebunden unsichtbar, weil NULL = current_tenant_id() in SQL nie wahr ist. Das ist WINDOWS #19, hier nicht als ferne Sorge, sondern als der Grund, warum listForUser, createPlatform und remove in tender-rss-feed.service.ts NICHT gebunden werden — eine Bindung würde die plattformweite Quelle für JEDEN Mandanten verschwinden lassen. tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt bestätigt die Kehrseite: ein gebundenes INSERT mit tenantId = NULL wird von der ausgelieferten Policy abgewiesen — createPlatform würde in genau diese Abweisung laufen, würde man es binden. NACHTRAG (260910-jab): ÜBERHOLT für listForUser — seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read bindet dieser Pfad (die neue Leseregel schließt Zeilen ohne Mandant ausdrücklich ein). createPlatform/remove bleiben unverändert ungebunden, aus dem jetzt ausdrücklichen Grund einer eigenen Schreibregel je Befehl (WINDOWS #24).

Eine dritte Messung trägt die Fehlerbehandlung von Aufgabe 2: tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit zeigt, dass ein gebundenes INSERT auf ein (userId, tenderId)-Paar, dessen Zeile existiert, aber einem anderen Mandanten gehört und deshalb unsichtbar ist, an der Eindeutigkeitsbedingung scheitert (Postgres prüft Unique-Indizes gegen die physischen Zeilen, unabhängig von der RLS-Sichtbarkeit) — NICHT an einer Policy-Abweisung. Aus einem stillen Überschreiben wird dadurch ein harter Fehler, der in Aufgabe 2 als verständliche deutsche Meldung herauskommen muss, nicht als roher 500er.

TEIL 2 dieser Aufgabe hat zusätzlich nachgemessen, dass dieser Bereich keine mandantengebundene Transaktion enthält (Befund A): grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec liefert genau EINEN Treffer, tender-fingerprint-backfill.service.ts:89, die Array-Form auf der plattformweiten Tabelle Tender (D-03), außerhalb jeder Mandantenbindung. Der im Kopf von prisma-tenant.extension.ts verlangte erneute Test vor jedem neuen forTenant()-Fall mit eigener Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer Fall an, withTenantTransaction() wird hier nicht gebraucht und auch nicht eingeführt.

(t2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal
TenderSavedSearchService.list Liefert 0 gespeicherte Suchprofile statt der tatsächlich vorhandenen Die Suchprofil-Leiste im Ausschreibungs-Radar ist leer für einen Nutzer, der tatsächlich Profile gespeichert hat
TenderSavedSearchService.create/update/remove Die Besitzprüfung (findUnique gebunden) liefert 0 Zeilen statt der eigenen Zeile PATCH/DELETE /saved-searches/:searchId scheitert mit 404, obwohl das Profil existiert; POST legt scheinbar erfolgreich ein neues Profil an (unkritisch, betrifft nur den Lesepfad danach)
TenderTriageService.listForUser Die Markierungsabfrage liefert 0 Treffer statt der tatsächlich vorhandenen Die Trefferliste zeigt für bereits gelesene/favorisierte Ausschreibungen keine Markierung mehr — ein bereits gelesener Treffer erscheint wieder als ungelesen
TenderTriageService.favoriteIds Liefert 0 favorisierte tenderIds statt der tatsächlich vorhandenen Der Merklisten-Filter (favOnly) zeigt eine leere Liste für einen Nutzer, der tatsächlich Favoriten hat
TenderNotificationPrefService.getForUser Sonderfall: kein Treffer bedeutet hier nicht "leer", sondern der Vorgabewert daily (D-01) Ein Nutzer, der off gewählt hat, sieht in der Oberfläche wieder daily — ein zu kleines Leseergebnis setzt die Einstellung stillschweigend auf täglich zurück, statt sie leer zu lassen
TenderEmailConfigService.getConfigForApi Der gebundene findUnique liefert 0 Zeilen statt der eigenen Konfiguration GET /email-config liefert null; die Oberfläche zeigt "kein Postfach verbunden" für einen Nutzer, der tatsächlich eines hat
TenderEmailConfigService.testConnection Der gebundene Rückgriff auf gespeicherte Zugangsdaten liefert 0 Zeilen statt der eigenen Ein Verbindungstest mit leer gelassenem Formular (Rückgriff auf gespeicherte Zugangsdaten) schlägt fehl, obwohl gespeicherte Zugangsdaten existieren
TenderRssFeedSourceService.listForUser/createPlatform/remove (bewusst UNGEBUNDEN, WINDOWS #19) Betrifft nicht diese drei Pfade selbst — sie binden nicht und liefern deshalb weiterhin die plattformweite Zeile korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3), nicht in dieser Umstellung Würde man sie binden: leere Feed-Liste bzw. 404 beim Entfernen einer plattformweiten Quelle — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten. NACHTRAG (260910-jab): ÜBERHOLT für listForUser — dieser Pfad ist seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read GEBUNDEN (WINDOWS #19 geschlossen, Befund F: ungebunden hätte die Reparatur ihn sonst still auf nur die plattformweiten Zeilen reduziert). createPlatform/remove bleiben unverändert ungebunden (WINDOWS #24).
TenderRssFeedSourceService.createForUser (gebunden) Der Zähler (count, gebunden) liefert 0 statt der tatsächlichen Anzahl eigener Feeds Ein Nutzer, der bereits am Limit von 20 eigenen Feeds ist, könnte scheinbar unbegrenzt neue anlegen (harmlose Richtung: die Kappung aus T-17-10 wirkt nicht mehr, kein Datenverlust)

(t3) Welcher Code Leere als Abwesenheit deutet

Sichtbare Formen (Anzeige bleibt leer oder fällt auf einen Vorgabewert zurück, jemand merkt es beim nächsten Blick auf die Oberfläche): siehe Tabelle (t2) oben — Suchprofilliste, Triage-Markierung, Favoriten-Filter, der daily-Sonderfall der Benachrichtigungseinstellung, und die Postfach-Anzeige.

Lautlose Formen — die zusätzliche Fehlerform dieses Bereichs. Fünf Stellen in den beiden Hintergrunddiensten deuten ein zu kleines Leseergebnis nicht als Fehler, sondern als "nichts zu tun", und protokollieren dabei NICHTS:

  1. tender-digest.scheduler.ts, if (!candidates.length) return; — liefert die gebundene Kandidatenabfrage innerhalb eines Profildurchlaufs zu wenig, bricht der GESAMTE Digest für diesen Lauf ab, für ALLE Mandanten, ohne Protokolleintrag.
  2. tender-digest.scheduler.ts, if (!matches.length) continue; — liefert die gebundene Treffer-Abfrage für einen Kandidaten zu wenig, bekommt dieser eine Nutzer keine Post, der Lauf macht mit dem nächsten Kandidaten weiter, ohne Protokolleintrag.
  3. tender-digest.scheduler.ts, if (!user || !user.email) continue; — liefert die gebundene Benutzer-Abfrage zu wenig, bekommt dieser Nutzer keine Post, ohne Protokolleintrag.
  4. tender-matching.service.ts, if (!fresh.length) continue; — liefert die gebundene Abfrage der noch nicht benachrichtigten Treffer innerhalb eines Profildurchlaufs zu wenig, bekommt dieser Nutzer keinen Sofort-Alarm, ohne Protokolleintrag.
  5. tender-matching.service.ts, if (!user || !user.email) continue; — dieselbe Form wie Stelle 3, für den Sofort-Alarm-Pfad.

Keine dieser fünf Stellen protokolliert etwas — eine ausbleibende Warnung erzeugt keine Fehlermeldung, keinen Protokolleintrag und keine Beschwerde, außer der stillen Abwesenheit einer E-Mail, die niemand erwartet, weil niemand wusste, dass sie hätte kommen sollen.

Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen behauptet: weil notifiedAt auf TenderMatch nur nach ERFOLGREICHEM Versand gestempelt wird (D-06), bleiben die betroffenen Zeilen auf notifiedAt IS NULL stehen und werden beim nächsten Lauf erneut versucht. Es geht also nichts verloren — es kommt nur nichts an, solange die Ursache (z. B. ein nach dem Scharfschalten ungebunden gebliebener Lesepfad) fortbesteht. Daraus ergibt sich das einzige nachprüfbare Signal dieser Fehlerform: eine wachsende Zahl von TenderMatch-Zeilen mit notifiedAt IS NULL bei gleichzeitig fehlendem Versandprotokoll. Dieses Signal gehört in die Vorabprüfung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf — Aufgabe 3 hält es hier fest, löst es aber nicht.

Eine Laufzeitwarnung an den fünf Stellen wurde erwogen und VERWORFEN, aus demselben Grund wie bei getAllActiveConfigs im ldap-Durchlauf: beide Hintergrunddienste laufen regelmäßig, und "kein passender Kandidat"/"kein Konto mit Adresse" ist ein regulärer Zustand, kein Fehlerfall — eine Warnung wäre Dauerlärm und verlöre ihr Signal.

(t4) Was dieser Durchlauf bewusst nicht löst

  • WINDOWS #19 — nullbares tenantId bei TenderRssFeedSource. Gemessen in (t1): eine plattformweite Zeile ist unter JEDEM Mandantenkontext unsichtbar, ein gebundenes Einfügen ohne Mandant wird abgewiesen. listForUser, createPlatform und remove bleiben deshalb bewusst UNGEBUNDEN, mit einem Codekommentar, der die Grenze benennt. Die Policy-Semantik selbst (eine tenantId IS NULL OR tenantId = current_tenant_id()-Lesevariante) gehört zu Etappe 3 und wird hier nicht angefasst. NACHTRAG (260910-jab): ÜBERHOLT — WINDOWS #19 ist geschlossen. Migration 20260910120000_rls_widen_membership_grant_and_platform_read ersetzt die einfache Regel durch vier nach Befehl getrennte Regeln (tenant_platform_read_policy schließt Zeilen ohne Mandant beim Lesen ausdrücklich ein, die drei Schreibregeln verlangen weiterhin einen Mandanten). listForUser ist seither GEBUNDEN (Befund F: ungebunden hätte die Reparatur ihn sonst still auf nur die plattformweiten Zeilen reduziert); createPlatform/remove bleiben bewusst ungebunden — beide Pfade lassen sich unter der Anwendungsrolle grundsätzlich nicht anlegen/entfernen (WINDOWS #24, eigener offener Punkt, verschwindet nicht mit #19). Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten.

  • Die übergreifenden Hälften der beiden Hintergrunddienste. Aufgabe 3 bindet nur die Je-Treffer-Hälften von tender-digest.scheduler.ts und tender-matching.service.ts; die Kandidatenabfrage (tenderMatch.findMany/tenderSavedSearch.findMany) bleibt bewusst über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe kommentiert — siehe docs/mandantentrennung-zugriffsklassifikation.md, Abschnitt "Der Hintergrunddienst als Falle".

    Nachtrag (260909-laa, Aufgabe 3): Je-Treffer-Hälften GESCHLOSSEN, übergreifende Hälften ausdrücklich an Etappe 3 übergeben. Innerhalb der Kandidatenschleife von tender-digest.scheduler.ts (tenderNotificationPref.findUnique, tenderMatch.findMany/updateMany, user.findUnique) und der Profilschleife von tender-matching.service.ts (tenderMatch.upsert in der Treffer-Anlage, sowie im nachgelagerten Instant-Dispatch tenderMatch.findMany/updateMany und user.findUnique) laufen jetzt alle Zugriffe über forTenant(), gebunden an den Mandanten der jeweiligen Kandidaten-/Profilzeile — je EIN gebundener Client pro Zeile, nicht neu je Modellzugriff. Belegt durch Bindungstests je Methode UND einen Falsifizierungsnachweis (ein probeweiser Rückbau der user.findUnique-Bindung im Instant-Dispatch von tender-matching.service.ts machte genau den erwarteten Test rot, danach zurückgenommen — siehe 260909-laa-SUMMARY.md). Die übergreifenden Kandidaten-/Profilabfragen selbst (tenderMatch.findMany({distinct:['userId']}) bzw. tenderSavedSearch.findMany()) bleiben UNVERÄNDERT ungebunden und tragen im Code einen Kommentar, der sie als Etappe-3-Übergabe benennt — die Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht verwischt. Der Sonderfall, dass ein Nutzer Treffer unter zwei verschiedenen Mandanten haben könnte (der Digest wählt das denormalisierte tenantId der Kandidatenzeile über distinct, was bei einem Mandantenwechsel veraltet sein kann), ist NICHT gelöst, sondern im Code und hier benannt — Gegenstand von Etappe 3.

  • Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich settings. tender-mail.service.ts holt die SMTP-Angaben über SettingsService.getDecryptedSmtpConfig(tenantId); settings.service.ts ist noch vollständig unbound (4 Rohtreffer, siehe Übersichtstabelle in docs/mandantentrennung-zugriffsklassifikation.md). Nach dem Scharfschalten fände diese ungebundene Abfrage keine SMTP-Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf (dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für Etappe 4, genau wie Befund D des ldap-Durchlaufs es für groups war — hier festgehalten, nicht gelöst.

    Nachtrag (260911-gwh): getDecryptedSmtpConfig(tenantId) läuft seit Aufgabe 2 dieses Laufs über forTenant() (GENAU EIN Klient tenantPrisma je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe docs/mandantentrennung-zugriffsklassifikation.md, Bestandsaufnahme-Zeile settings.service.ts/smtpConfig, und den Hintergrunddienst-Abschnitt dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr führen.

  • Die offene Architekturfrage req.tenantPrisma. Auch der Bereich tenders entscheidet sie nicht — er bindet dienst-intern, wie ldap und groups es vormachen.

  • Befund G — ein Administrator eines beliebigen Mandanten kann eine plattformweite RSS-Quelle entfernen. TenderRssFeedSourceService.remove ist bereits heute ein einziger bedingter deleteMany mit der Besitzbedingung in der Datenbank — das ist das gewünschte Muster, keine Wiederholung des ldap-Fundes (Auflösung über die Kennung allein). Die eine Beobachtung, die trotzdem festgehalten gehört: ein Administrator EINES beliebigen Mandanten kann über diesen Pfad eine plattformweite Quelle entfernen, die ALLE Mandanten speist — eine Produkt-/ Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser Aufgabe.

(t5) Was dieser Durchlauf bewusst nicht anfasst

Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03, tender-dedup.service.ts, tender-fingerprint-backfill.service.ts, tender-ingestion.service.ts, tender-matching.service.ts/tender, tender-scheduler.service.ts, tenders.controller.ts, tenders.module.ts) und der beiden bewussten Fan-out-Adapter (adapters/email-alert.adapter.ts, adapters/rss.adapter.ts) sind geprüft und deliberat ungebunden — nicht übersehen. Jede der zwölf trägt ihre Begründung im eigenen Dateikopf bzw. in D-03.

Befund I gehört ausdrücklich hierher: tender-ingestion.service.spec.ts enthält eine Schutzprüfung, die den QUELLTEXT von tender-ingestion.service.ts liest und gegen ein Vorkommen des Bezeichners forTenant prüft ("never calls forTenant()"). In dieser einen Datei darf deshalb auch kein ERKLÄRENDER Kommentar diesen Bezeichner nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier nur festgehalten, damit niemand sie beim Nachziehen der Begründungen "freundlich kommentiert" und den Lauf rot macht.

Bereich dkv

Dieser Abschnitt erweitert die Kritikschrift um den Bereich dkv (Quick-Task 260909-mir) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt beantwortet sie erneut, für einen Bereich mit einer dritten, eigenen Fehlerform: nicht "eine Liste ist leer" (wie bei ldap/groups) und nicht "ein Benachrichtigungsweg handelt gar nicht" (wie bei tenders), sondern "ein einzelnes Objekt wird null, und null hat an dieser Stelle bereits eine gültige, harmlose Bedeutung". Ein eingerichtetes Modul sieht danach aus wie ein nie eingerichtetes.

(d1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen sechsten Abschnitt (runDkvAreaChecks) erweitert, mit den drei Policies für DkvInvoiceHistory, DkvModuleConfig und DkvVehicleMaster (alle aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables) WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-09, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

dkvmoduleconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
dkvmoduleconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DkvModuleConfig" liefert 0 Zeile(n)
dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile: bestanden — ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert 0 Zeile(n), obwohl 2 existieren — die Form, die der Planer-Startpfad heute benutzt: aus einer beliebigen-aber-vorhandenen Zeile wird KEINE Zeile, und der aufrufende Code liest das als "dieses Modul ist nicht eingerichtet"
dkvinvoicehistory-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
dkvvehiclemaster-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ERROR: new row violates row-level security policy for table "DkvVehicleMaster"
dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — Folge fuer Aufgabe 3 (Befund G): die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt
dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision: bestanden — gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' gelingt — der Mandant ist Teil des zusammengesetzten Schluessels, keine Kollision auf einer unsichtbaren fremden Zeile, keine P2002-Uebersetzung noetig
dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext: bestanden — TENANT-A: pid=286680, t="TENANT-A", rows=1; TENANT-B: pid=286679, t="TENANT-B", rows=1 — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext
Alle 41 Pruefungen bestanden.

Die Belegzeile, die diesen Abschnitt der Kritikschrift trägt, ist dkvmoduleconfig-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "DkvModuleConfig" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa die 2 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.

Daneben trägt dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile diesen Abschnitt zusätzlich, weil dieser Bereich an der entscheidenden Stelle kein Mengenergebnis liest, sondern ein Einzelobjekt: dieselbe Tabelle, dasselbe fehlende set_config, aber diesmal ein SELECT ... LIMIT 1 ohne jede Bedingung — exakt die Form, die loadConfig() ohne Mandant (der Planer-Startpfad) heute über findFirst() benutzt. Auch diese Abfrage liefert 0 Zeilen, obwohl 2 existieren. Der Unterschied zur ersten Belegzeile ist nicht die Zahl (beide sind 0), sondern die Lesart: ein leeres findMany-Ergebnis ist im aufrufenden Code sichtbar leer, ein leeres findFirst-Ergebnis wird zu null, und null hat in loadConfig()/onModuleInit() bereits eine gültige, harmlose Bedeutung ("kein aktives Modul konfiguriert") — siehe (d3).

TEIL 2 hat zusätzlich die Nebenläufigkeitsform gemessen, auf die sich getHistory() stützt (Befund C): zwei über Promise.all gleichzeitig gestartete gebundene Einzelabfragen über DENSELBEN Klienten, hier für zwei verschiedene Mandanten nachgebaut (dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext). Jede Abfrage sah den Kontext, unter dem sie gestartet wurde (TENANT-A/ TENANT-B), und jede lieferte die richtige Zeilenzahl (je 1) — keine Verletzung, kein Abbruch.

TEIL 3 hat nachgemessen, dass dieser Bereich keine mandantengebundene Transaktion enthält (Befund C): grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec liefert null Treffer (Rückgabewert 1, keine Ausgabe). Der im Kopf von prisma-tenant.extension.ts verlangte erneute Test vor jedem neuen forTenant()-Fall mit eigener Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer Fall an, withTenantTransaction() wird hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt. Die beiden mehrschrittigen Stellen des Bereichs — der Ersetzen-Modus des Fahrzeug-Imports (deleteMany gefolgt von createMany) und die Zugangsdaten-Erhaltung in saveConfig (lesen, entschlüsseln, neu verschlüsseln, schreiben) — bleiben deshalb so unatomar wie heute; sie in eine Transaktion zu heben wäre eine Verhaltensänderung jenseits dieses Auftrags, siehe (d5).

Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger Bereich in dieser Form brauchte:

  • dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt (Befund I) — dieselbe Frage wie bei TenderRssFeedSource in tenders, hier mit demselben Ergebnis: die ausgelieferten Policies dieses Bereichs tragen keine eigene WITH CHECK-Klausel, also verwendet PostgreSQL denselben USING-Ausdruck auch für neu geschriebene Zeilen — ein gebundenes INSERT unter TENANT-A mit tenantId = TENANT-B wird abgewiesen. Was PostgreSQL daraus für ein INSERT ableitet, ist eine Eigenschaft der Datenbank, keine des Policy-Textes, deshalb gemessen und nicht aus dem Text geschlossen.
  • dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision (Befund H) — der Gegenbefund zu tenders-Befund F: weil DkvVehicleMaster die zusammengesetzte Eindeutigkeit @@unique([tenantId, kennzeichen]) trägt, gibt es hier KEINE Kollision auf einer unsichtbaren fremden Zeile. Ein gebundenes INSERT unter TENANT-A auf ein Kennzeichen, das unter TENANT-B bereits existiert, GELINGT — das bestandene Ergebnis ist das Gelingen, nicht die Abweisung. Aufgabe 2/3 bauen deshalb keine P2002-Übersetzung für diesen Bereich; anders als bei TenderTriage in tenders ist hier keine gebaut, weil keine gebraucht wird — nachgemessen statt unterstellt.

(d2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal
DkvService.getConfigForApi Der gebundene erste Lesezugriff (loadConfig(tenantId)) liefert null statt der eigenen Konfiguration GET /dkv/config liefert 404 DKV module not yet configured; die Oberfläche zeigt das Einrichtungsformular für ein Modul, das tatsächlich eingerichtet ist
DkvService.getConfigForApi, der zweite (rohe) Lesezugriff auf die Zugangsdaten Läuft dieser gebundene findUnique leer (während der erste — sicher ausgewählte — noch träfe), bleibt raw null, der try-Block liefert hasPassword=false Die Oberfläche meldet "kein Passwort hinterlegt" für ein Modul mit tatsächlich hinterlegtem Passwort — ohne Fehlermeldung, siehe (d3) Stelle 4
DkvService.saveConfig, die Zugangsdaten-Erhaltung Der gebundene erhaltende Lesezugriff liefert null statt der bestehenden Zeile, der try/catch schluckt das Ein gespeichertes Passwort wird mit dem LEEREN Wert neu verschlüsselt — die zerstoerende Stelle, siehe (d3) Stelle 5 und T-MIR-07
DkvService.testConnection, der Rückgriff auf gespeicherte Zugangsdaten Der gebundene Lesezugriff liefert null statt der bestehenden Zeile Der Verbindungstest schlägt mit einem Anmeldefehler des Postfachs fehl — die Meldung zeigt auf das Postfach, nicht auf die Datenbank
DkvService._runPipeline, der Konfigurations-Lesezugriff Der gebundene findUnique liefert null statt der bestehenden Konfiguration _runPipeline protokolliert no config for tenant ... als Warnung und returnet — die Rechnungsverarbeitung stellt für diesen Mandanten die Arbeit ein, ohne Fehlermeldung
DkvService.listVehicles/createVehicle Der gebundene Zugriff liefert 0 Zeilen statt der tatsächlich vorhandenen bzw. schreibt nicht Die Fahrzeugliste ist leer für einen Mandanten mit tatsächlich vorhandenen Fahrzeugen
DkvService.updateVehicle/deleteVehicle, die Besitzprüfung Der gebundene findFirst liefert null statt der eigenen Zeile PUT/DELETE /dkv/vehicles/:id scheitert mit der vorhandenen NotFoundException, obwohl das Fahrzeug existiert — siehe (d2)-Zeile zum neuen Riegel unten für die spiegelbildliche Fehlerform
DkvService.importVehiclesCsv (Ersetzen-Modus) Der gebundene deleteMany löscht 0 Zeilen statt der tatsächlich vorhandenen (harmlos: dann bleiben Alt-Fahrzeuge stehen, createMany legt zusätzlich an) Nach einem Ersetzen-Import bestehen alte UND neue Fahrzeugzeilen nebeneinander — kein Datenverlust, aber ein Zustand, der als "Ersetzen" nicht mehr stimmt
DkvService.getHistory Beide gebundenen Parallelabfragen liefern 0 Zeilen bzw. Zählung 0 statt der tatsächlich vorhandenen Die Rechnungshistorie-Tabelle ist leer für einen Mandanten mit tatsächlich vorhandener Historie
DkvService._buildExportRows, der gebündelte Lesezugriff auf die Fahrzeugstammdaten Der gebundene findMany liefert 0 Zeilen statt der tatsächlich vorhandenen Eine vollständige Ausfuhrdatei OHNE einen einzigen Fahrer entsteht — kein Fehler, keine Warnung, eine Datei, die plausibel aussieht und falsch ist, siehe (d3) Stelle 7
DkvService.getExportFile, neuer Riegel (Aufgabe 3, Befund E) Der gebundene Lesezugriff auf DkvInvoiceHistory.exportFilename liefert keinen Treffer, obwohl die Datei existiert und das Namensmuster besteht GET /dkv/exports/:filename liefert 404, obwohl die Datei auf der Platte liegt — die Absicht der Umstellung: Fehlen und Fremdbesitz kollabieren bewusst zur selben Antwort
loadConfig(tenantId) ohne Mandant (bewusst ungebunden, Planer-Startpfad) Betrifft nicht die Bindung selbst — die Methode bindet niemals. Nach dem Scharfschalten liefert dieselbe Abfrage null statt einer beliebigen Zeile onModuleInit() protokolliert DKV scheduler: no active config found — cron job not registered und richtet für JEDEN Mandanten nichts ein — siehe (d4)

(d3) Welcher Code Leere als Abwesenheit deutet

Die Form, die diesen Bereich von ldap, groups und tenders unterscheidet: nicht "eine Liste ist leer" und nicht "ein Benachrichtigungsweg handelt gar nicht", sondern "ein einzelnes Objekt wird null, und null hat an dieser Stelle bereits eine gültige, harmlose Bedeutung". Ein eingerichtetes Modul sieht danach aus wie ein nie eingerichtetes: ein leeres Formular, eine unauffällige Protokollzeile, kein Alarm.

Zerstörend (eine Stelle, der gefährlichste Punkt des Bereichs):

  1. DkvService.saveConfig, die Erhaltung der nicht ausgefüllten Zugangsdaten — liest die bestehende Zeile, um Benutzername oder Passwort zu übernehmen, wenn das Formularfeld leer gelassen wurde. Läuft dieser Lesezugriff nach dem Scharfschalten leer (weil ungebunden oder unter falschem Kontext gebunden), wird das Feld mit dem LEEREN Wert neu verschlüsselt: aus einem gespeicherten Passwort wird ein leeres. Die Stelle liegt hinter einem try/catch, das ausdrücklich sagt, dass es Fehler ignoriert und mit dem Übergebenen überschreibt — T-MIR-07 im Bedrohungsregister dieses Plans. Aufgabe 2 bindet Lese- UND Schreibzugriff dieser Methode gemeinsam an denselben Mandanten, sodass ein leerer Lesezugriff nicht mit einem erfolgreichen Schreibzugriff unter einem anderen Kontext kombiniert werden kann.

Lautlos (drei Stellen, die Rechnungsverarbeitung bekommt eigenen Raum):

  1. DkvSchedulerService.onModuleInit(), gefolgt von config?.isActive && config.tenantId — null heißt "kein aktives Modul konfiguriert", der Planer richtet nichts ein und protokolliert das als Normalfall (DKV scheduler: no active config found — cron job not registered). Kein Fehler, keine Warnung, keine sichtbare Änderung — siehe (d4) für die volle Begründung, warum dieser Pfad bewusst ungebunden bleibt.
  2. DkvService._runPipeline, if (!config) { warn; return; } — null heißt "dieser Mandant hat DKV nicht eingerichtet". Die Rechnungsverarbeitung stellt die Arbeit ein: Rechnungen laufen im Postfach weiter auf, es entsteht keine Historienzeile, keine Ausfuhrdatei, kein Versand — und keine Fehlermeldung. Anders als beim Planer-Startpfad ist dieser Lesezugriff in Aufgabe 2 vollständig gebunden; die Stelle bleibt hier festgehalten, weil sie die Folge einer still verschwundenen Konfiguration ist, nicht weil sie ungebunden bliebe.
  3. DkvService._buildExportRows, der gebündelte Lesezugriff auf die Fahrzeugstammdaten — ein fehlender Treffer je Kennzeichen ist nach D-13 bereits ein GÜLTIGER Zustand (unbekanntes Kennzeichen, leeres Fahrerfeld). Läuft der Lesezugriff selbst ganz leer (0 Fahrzeuge statt der tatsächlich vorhandenen), entsteht eine vollständige Ausfuhrdatei OHNE einen einzigen Fahrer — kein Fehler, keine Warnung, eine Datei, die plausibel aussieht und falsch ist. Diese Stelle ist der Grund, warum es nicht genügt, nur die Anzeigepfade zu binden.

Irreführend (drei Stellen, die auf die falsche Ursache zeigen oder eine falsche Vergangenheit nahelegen):

  1. DkvService.getConfigForApi, if (!safe) return null — die Oberfläche zeigt daraufhin ein leeres Einrichtungsformular. Ein Administrator sieht "noch nicht eingerichtet" für ein Modul, das eingerichtet IST — und würde beim Neu-Ausfüllen die vorhandenen Zugangsdaten überschreiben. Diese Stelle bleibt bewusst unter "irreführend" und nicht unter "zerstörend": die Zerstörung selbst passiert erst in saveConfig (Stelle 5), falls der Administrator tatsächlich neu ausfüllt und speichert — hier liegt nur die irreführende Voraussetzung dafür.
  2. DkvService.getConfigForApi, der try/catch um die Entschlüsselung — fängt heute Entschlüsselungsfehler ab und liefert einen leeren Benutzernamen. Nach dem Scharfschalten fällt der Lesezugriff selbst leer aus, raw ist null, und der Zweig läuft ohne Fehler durch: hasPassword bleibt false. Die Oberfläche meldet "kein Passwort hinterlegt" für ein hinterlegtes Passwort.
  3. DkvService.testConnection, der Rückgriff auf das gespeicherte Passwort — läuft leer, der Test schlägt mit einem Anmeldefehler des Postfachs fehl. Harmlos in der Richtung, aber irreführend: die Meldung zeigt auf das Postfach, nicht auf die Datenbank.

Gegenrichtung, ebenfalls nachgesehen statt geschlossen behauptet: die laut werfenden Stellen sind updateVehicle/deleteVehicle (NotFoundException bei Leere), importVehiclesCsv (wirft bei leerem CSV) und getExportFile (wirft bei fehlender Datei, jetzt zusätzlich bei fehlendem gebundenen Historientreffer, Aufgabe 3). Diese Stellen sind harmlos, weil ein zu kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem Scharfschalten neu entsteht.

(d4) Was dieser Durchlauf bewusst nicht löst

Der Planer-Startpfad — die eigentliche Aufgabe dieses Plans, ausgeschrieben statt still getroffen. DkvSchedulerService.onModuleInit() ruft DkvService.loadConfig() ohne Mandant auf und übernimmt config.tenantId als den einen Mandanten, den der eine benannte Cron-Auftrag dkv-inbox-poll fortan bedient (this.dkvService.loadConfig() → findFirst() ganz ohne Bedingung). Zwei Zustände, beide gehören benannt, sonst liest sich die Markierung wie eine Entwarnung:

  • Heute ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren Mandanten bedient sie einen BELIEBIGEN und die übrigen NIE. Die Prüfung config?.isActive && config.tenantId verschärft das — ist ausgerechnet die gezogene beliebige Zeile inaktiv, registriert der Planer gar nichts, obwohl ein zweiter Mandant aktiv wäre.
  • Nach dem Scharfschalten verstummt sie zusätzlich: dieselbe Abfrage liefert null, der Planer protokolliert DKV scheduler: no active config found — cron job not registered und richtet für JEDEN Mandanten nichts ein — eine Zeile, die auf einer frischen Installation der Normalfall ist und deshalb niemanden alarmiert.

Von den drei im Auftrag genannten Formen wurde geprüft:

  • (a) An einen konkret aufgelösten Mandanten binden — nicht möglich. onModuleInit() hat keine Anfrage, keinen Sitzungsnachweis und keinen Konfigurationswert, aus dem ein Mandant käme. Einen einzuführen wäre eine neue Einstellung, also eine Funktionsänderung.
  • (b) Umbau auf einmal-abfragen-viele-bedienen — abgelehnt, mit Begründung. Das ist genau die Mehrmandanten-Planung, die 07-04 zurückgestellt hat: alle aktiven Konfigurationen lesen, je Mandant einen Auftrag führen, deren Lebenszyklus bei jeder Konfigurationsänderung nachziehen (heute verwaltet setInterval GENAU EINEN Auftrag unter einem festen Namen), und entscheiden, was bei unterschiedlichen Intervallen je Mandant gilt. Das ist eine Funktion, kein Bindungsumbau.
  • (c) Als benannte Altlast weiterführen, mit Markierung — GEWÄHLT. Der unmittelbare Präzedenzfall ist getAllActiveConfigs im Bereich ldap (260909-ipc, Befund B): ein bewusst übergreifender Planer-Lesezugriff, der ungebunden bleibt, einen eigenen Kopfkommentar trägt, und dessen Verstummen nach dem Scharfschalten an die Vorabprüfung von Etappe 4 übergeben wird.

Die Unsymmetrie, die dieser Präzedenzfall NICHT deckt: getAllActiveConfigs ist HEUTE korrekt und verstummt erst später. Der DKV-Planer ist HEUTE bereits falsch — er bedient bei mehreren Mandanten einen beliebigen und die übrigen nie — und verstummt zusätzlich später. Die Markierung in Aufgabe 2 sagt beides, sonst läse sie sich wie eine Entwarnung. Die gewählte Form hat drei Teile, alle umgesetzt: die übergreifende Abfrage ist eine EIGENE, benannte Methode (kein Zweig hinter einem optionalen Parameter), sie und der Planer tragen einen Kopfkommentar, der beide Zustände benennt, und die Altlast steht als offener Eintrag im Broken-Windows-Register (siehe SUMMARY dieses Plans für die genaue Eintragskennung). Das Signal für das Verstummen gehört in die Vorabprüfung von Etappe 4 (apps/api/scripts/rls-preflight.mjs), NICHT in diesen Durchlauf.

Die gemeinsame Ablage der Ausfuhrdateien samt Verdrängung über Mandantengrenzen (Befund F). DkvExportService.writeAndPrune behält die letzten zehn Dateien des GEMEINSAMEN Verzeichnisses user-files/. Verarbeitet ein Mandant zehn Rechnungen, verdrängt er damit die Dateien aller anderen; deren Historienzeilen nennen dann einen Dateinamen, der nicht mehr existiert. Das ist keine Bindungsfrage — es ist die Ablagestruktur, und sie zu ändern (Unterverzeichnisse je Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag. Siehe auch (d5) und T-MIR-08.

Die Übergaben in die noch nicht umgestellten Bereiche. dkv-mail.service.ts hängt an SettingsService.getDecryptedSmtpConfig (settings.service.ts ist noch vollständig unbound, siehe docs/mandantentrennung-zugriffsklassifikation.md) — dieselbe Reihenfolgebedingung, die der tenders-Durchlauf für dieselbe Abhängigkeit als Befund K festhielt. dkv.seed.ts hängt an module-registry, ebenfalls noch unbound. Nach dem Scharfschalten fände die ungebundene SMTP-Abfrage keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung für Etappe 4, hier festgehalten, nicht gelöst.

Nachtrag (260911-gwh): der settings-Teil dieser Übergabe ist erfüllt — getDecryptedSmtpConfig(tenantId) läuft seit Aufgabe 2 dieses Laufs über forTenant(), siehe die Bestandsaufnahme-Zeile settings.service.ts/smtpConfig in docs/mandantentrennung-zugriffsklassifikation.md und (s4)(b). Erneut gemessen: dkv.seed.ts ruft weiterhin ModuleRegistryService.seedModule() (module-registry.service.ts:206, this.prisma.module.upsert) — UNGEBUNDEN, aber bewusst und unverändert seit 260910-exd, weil Module der plattformweite Modulkatalog ohne tenantId- Spalte ist (Befund E, keine-mandantengebundene-tabelle); "gebunden seit 260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan abgeschrieben.

Die offene Architekturfrage req.tenantPrisma. Auch der Bereich dkv entscheidet sie nicht — er bindet dienst-intern, wie ldap, groups und tenders es vormachen.

(d5) Was dieser Durchlauf bewusst nicht anfasst

  • Die beiden mehrschrittigen Stellen bleiben unatomar (Befund C, TEIL 3). Der Ersetzen-Modus des Fahrzeug-Imports (deleteMany gefolgt von createMany) und die Zugangsdaten-Erhaltung in saveConfig (lesen, entschlüsseln, neu verschlüsseln, schreiben) werden NICHT in eine Transaktion gehoben — geprüft und bewusst gelassen, nicht übersehen. Der im Kopf von prisma-tenant.extension.ts verlangte erneute Test ist mit TEIL 3 dieser Aufgabe beantwortet: kein neuer Transaktionsfall, withTenantTransaction() bleibt für diesen Bereich ungenutzt.
  • Die Ablagestruktur der Ausfuhrdateien bleibt unverändert (Befund F). Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).

Bereich user

Dieser Abschnitt erweitert die Kritikschrift um den Bereich user (Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt beantwortet sie erneut, für den Bereich, in dem das plattformweite Eindeutigkeitsproblem tatsächlich wohnt: username und email sind im Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN (Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF (Nachschlagen auf dem plattformweit eindeutigen Schlüssel username). Ein Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen Vorhabens.

(u1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen siebten Abschnitt (runUserAreaChecks) erweitert. Die Tabelle "User" wird dabei NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs, samt eingeschaltetem und erzwungenem Zeilenschutz, beiden Eindeutigkeitsbedingungen (username, email) und zwei Testzeilen in zwei Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der ausgelieferten Migration 20260618112133_rls_policies geschnittene Fassung (beide sind nach Normalisierung von Leerraum und abschließendem Semikolon wortgleich — keine Ersetzung nötig), legt eine Tabelle "Tenant" ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-10, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen
user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n)
user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an
user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"
user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette
user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt
user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n)
tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false
user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"]
Alle 53 Pruefungen bestanden.

Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette sind:

  • Die Belegzeile zur Unsichtbarkeit, user-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "User" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa die 4 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die ungebundene Suche nach einem GÜLTIGEN Benutzernamen (user-ungebundene-suche-nach-benutzername-liefert-keine-zeile) — die Form, die die Erstanlage-Prüfung beim Start heute benutzt.
  • Die Zeile zum falschen „frei", user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen bob (gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist.
  • Die Zeile zum harten Eindeutigkeitsfehler, user-eindeutigkeit-greift-trotz-unsichtbarkeit: unmittelbar danach ein gebundenes INSERT unter TENANT-A mit genau diesem, angeblich freien Benutzernamen bob. Die Ablehnung trägt SQLSTATE 23505 (Eindeutigkeitsverletzung) — gelesen aus err.meta.code, nicht aus err.code (das bei einem fehlgeschlagenen $executeRaw immer den generischen Prisma-Code P2010 trägt, empirisch gegen tessera-ctl-db-1 geprüft, siehe Kopfkommentar von sqlStateOf() im Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser Abschnitt in zwei anderen Zeilen ebenfalls misst (user-gebundenes-einfuegen-fremder-mandant-abgelehnt, user-ungebundenes-einfuegen-abgelehnt). Diese Unterscheidung ist der Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette, die zweite wäre eine ganz andere Geschichte.

Zusätzlich gemessen, statt behauptet: tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar hält sowohl das ungebundene SELECT (2 von 2 Zeilen sichtbar) als auch den Systemkatalog (pg_class.relrowsecurity für "Tenant" = false) gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser Abschnitt selbst keinen Zeilenschutz eingeschaltet hat. user-fan-out-je-mandant-gebunden-liefert-alle-zeilen bildet die in Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten ungebunden lesen, je Mandant EIN gebundener SELECT, Ergebnisse vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit BYPASSRLS) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide Mengen sind identisch: ["alice","bob","carol","dave"].

TEIL 2, Beleg statt Behauptung für Befund B — keine Transaktion in diesem Bereich:

$ grep -rn '\$transaction(' apps/api/src/user --include=*.ts | grep -v spec
$ echo $?
1

Null Treffer, Rückgabewert 1. Der im Kopf von prisma-tenant.extension.ts verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer Transaktionsfall, withTenantTransaction() wird hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt.

TEIL 3, Beleg statt Behauptung für Befund D — die Aufrufermessung für findByUsername:

$ grep -rn "findByUsername" apps/api/src packages
apps/api/src/user/user.service.ts:21:  async findByUsername(username: string) {

Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1 (260909-eor) hat den Anmeldeweg auf die drei schmalen SECURITY-DEFINER-Funktionen umgezogen; auth.service.ts sucht seither über auth_lookup_user_by_username und nicht mehr über diese Methode. Die Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden (Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in Aufgabe 2 richtiggestellt.

(u2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal, Ort
UserService.findById Der gebundene findUnique liefert null statt des eigenen Benutzers GET /users/:id liefert 404 User not found, obwohl der Benutzer existiert
UserService.create Betrifft nicht das Lesen — ein gebundenes INSERT mit fremder Mandantenkennung wird von der Policy abgewiesen (user-gebundenes-einfuegen-fremder-mandant-abgelehnt) POST /users scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil tenantId aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt
UserService.update Der gebundene update über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen), nicht laut PATCH /users/:id würfe ohne die vorgeschaltete Prüfung in user.controller.ts keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht
UserService.deactivate/delete Dieselbe stille Form wie update DELETE /users/:id bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler
Neue Methode: Plattform-Administratorsicht, Liste Der Schleifentreiber (tenant.findMany, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen Die Benutzerliste des SUPER_ADMIN ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — user-fan-out-je-mandant-gebunden-liefert-alle-zeilen misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs
Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht GET /users/:id, PATCH /users/:id, DELETE /users/:id liefern für den SUPER_ADMIN 404, obwohl der Benutzer existiert
user.controller.ts, findAll (ADMIN-Zweig) Der gebundene findMany liefert 0 Benutzer statt der tatsächlich vorhandenen Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus
Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen GET /users/me/avatar liefert die vorhandene 404 No avatar set, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3)
UserService.findByUsername (bewusst UNGEBUNDEN) Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D)
AdminSeedService, Erstanlage-Prüfung (bewusst UNGEBUNDEN) Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs
AdminSeedService, beide Zugriffe auf tenant (bewusst UNGEBUNDEN) Betrifft nicht diese Pfade selbst — Tenant trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar misst die tragende Eigenschaft

(u3) Welcher Code Leere als Abwesenheit deutet

Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L, namentlich benannt und nach ihrer Wirkung sortiert:

Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen Vorhaben):

  1. AdminSeedService.seedAdmin(), die Erstanlage-Prüfung — die vollständige, im Auftrag beschriebene Kette in Reinform: eine unsichtbare Zeile wird als Abwesenheit gelesen (user.findUnique({ where: { username } }) liefert nach dem Scharfschalten null, nicht weil der Administrator fehlt, sondern weil ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar ist — user-ungebundene-suche-nach-benutzername-liefert-keine-zeile), die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt 3 entfällt), und das Anlegen scheitert hart an der plattformweiten Eindeutigkeit von username (user-eindeutigkeit-greift-trotz-unsichtbarkeit, SQLSTATE 23505, KEINE Zeilenschutz-Ablehnung). Weil seedAdmin() bewusst NICHT gekapselt ist — der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren Zeile eine Startsperre: die Anwendung startet nach dem Scharfschalten nicht mehr, für jede bestehende Installation mit gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern) wird damit ungewollt zu einer Sperre für den Normalfall.

Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im Bereich):

  1. user.controller.ts, findAll im ADMIN-Zweig — eine leere Liste heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen kollidiert auf username bzw. email. Die Oberfläche zeigt dabei nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen Installation der Normalzustand.
  2. user.controller.ts, findAll im SUPER_ADMIN-Zweig — dieselbe Leere, eine Ebene höher: die Plattformverwaltung sieht eine Installation ohne jeden Benutzer und würde, bliebe die neue übergreifende Methode ungebunden statt als gebundene Schleife gebaut, ebenfalls zum Neuanlegen verleiten.
  3. Eine gebundene Suche nach Benutzername oder Adresse (Befund D, user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile) — meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird zur Kollisionserzeugung; genau deshalb bleibt findByUsername ungebunden und übersetzt UserService.create/update die Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in eine verständliche Meldung (Aufgabe 2).

Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand dieses Durchlaufs):

  1. ldap.service.ts, upsertMappedUser — bereits gebunden, seit 260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht anzufassen: die Identitätssuche entscheidet über Anlegen-oder- Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die Kette nicht erst nach dem Scharfschalten existiert — die Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist also bereits heute unsichtbar. Die Übersetzung der Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt in user.service.ts (Aufgabe 2), der in diesem Plan ohnehin angefasst wird — dort und nicht in ldap.

Harmlos (eine Stelle):

  1. Die Selbstbedienungswege für Bild und Akzentfarbe (Befund G) — ein leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten.

Was ein GEBUNDENER Nachschlageweg auf username mit dem Anmeldeweg machen würde, und warum die Frage hier gegenstandslos ist: der Anmeldeweg läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen und nicht mehr über UserService.findByUsername — belegt durch die Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst), nicht behauptet. Würde findByUsername dennoch gebunden, säße das Problem nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe Grund, aus dem resolveEmailForWrite im Bereich ldap ungebunden bleibt.

Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift gehörend: ein gebundenes Ändern oder Löschen über die Kennung allein trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung, sondern schlicht mit null betroffenen Zeilen (user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen, user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen) — die vorgeschalteten Besitz- und Rollenprüfungen in user.controller.ts bleiben deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt. Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen (user-gebundenes-einfuegen-fremder-mandant-abgelehnt, SQLSTATE 42501). Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus Alarm.

(u4) Was dieser Durchlauf bewusst nicht löst

Die plattformweite Eindeutigkeit von username und email selbst — die Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie festgehalten, nicht entschieden, und ausdrücklich nicht durch eine Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2).

Die Übergabe in den noch nicht umgestellten Bereich auth: auth.service.ts ist aus demselben Grund gemischt (Befund E) und NICHT Gegenstand dieses Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat drei bereits über forTenant() gebundene Schreibzugriffe (Anmeldezeitstempel, Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf user, die zu getMe, changePassword und adminResetPassword gehören. Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über $queryRaw auf die drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst auth.service.ts an KEINER Stelle an.

Die offene Architekturfrage req.tenantPrisma — auch der Bereich user entscheidet sie nicht. Er bindet dienst-intern, wie ldap, groups, tenders und dkv es vormachen.

(u5) Was dieser Durchlauf bewusst NICHT anfasst

  • auth.service.ts an keiner Stelle (siehe (u4)).
  • Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht — der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus TEIL 3 oben.
  • Schema und Migrationen nicht.
  • Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder (user-files/avatars/) nicht — anders als bei den Ausfuhrdateien des Bereichs dkv (Befund F dort) ist die Dateibenennung hier {userId}.{ext} und damit bereits kollisionsfrei über Mandanten hinweg; es ist ohnehin keine Bindungsfrage.

Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind — nicht übersehen.

Nachtrag (260910-das, Aufgabe 3). Wie in den vorherigen Durchläufen wird der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3 tatsächlich umgesetzt haben.

Tatsächlich umgesetzte Pfade gegen die angekündigten gehalten: alle in (u2) genannten Pfade sind wie beschrieben umgestellt. UserService.findById, create, update, deactivate, delete laufen über forTenant(); create/update übersetzen die plattformweite Eindeutigkeitsverletzung in eine deutsche Konfliktmeldung, die weder Halter noch Mandant nennt. AdminSeedService.seedAdmin() bindet die Erstanlage des Administrators an den unmittelbar zuvor angelegten Mandanten und entschärft die Startsperre (P2002 wird wie „Administrator existiert bereits" behandelt, jeder andere Fehler bricht weiterhin ab). UserController bindet alle sieben eigenen Zugriffe (ADMIN-Zweig der Benutzerliste, alle fünf Selbstbedienungswege) und löst die drei Wege über die Kennung rollenabhängig auf. findByUsername bleibt wie angekündigt ungebunden, mit richtiggestelltem Kopfkommentar.

Die geschlossene Lücke im Selbstlösch-Riegel (Befund H): der Vergleich user.id === currentUser.sub griff nie, weil der Sitzungsnachweis kein Feld sub trägt (JwtStrategy.validate() liefert exakt { id, username, role, tenantId }). Der Nachweis kommt aus der Reihenfolge selbst, nicht aus einer Behauptung: user.controller.spec.ts, Test 6, wurde zuerst gegen die alte Fassung ausgeführt — die Zeile if (user.id === currentUser.sub) wurde probeweise wiederhergestellt, der Testlauf zeigte den erwarteten roten Test („promise resolved … instead of rejecting"), danach wurde auf currentUser.id repariert und derselbe Testlauf grün. Die Wirkung ist eine Verhaltensänderung: ein Administrator kann sein eigenes Konto seither nicht mehr löschen — das ist die ursprüngliche, im Code bereits formulierte Absicht, nicht neu erfunden.

Die gewählte Form der Plattform-Administratorsicht, samt Beleg aus der Messung: wie in (u3)/Befund F angekündigt, laufen UserService.findAllForPlatformAdmin() und findByIdForPlatformAdmin() als Schleife über alle Mandanten (this.prisma.tenant.findMany, ungebunden, weil Tenant keinen Zeilenschutz trägt) mit je EINEM gebundenen Lesezugriff im Rumpf — dieselbe Form wie AdminSeedService.ensureDefaultGroupsForAllTenants(). Der Beleg, dass diese Form die heutige Sicht erhält statt sie zu mindern, kommt aus Aufgabe 1: user-fan-out-je-mandant-gebunden-liefert-alle-zeilen maß die Vereinigung der je-Mandant gebundenen SELECTs gegen eine über die Wartungsrolle (mit BYPASSRLS) gemessene Gesamtmenge — beide Mengen waren identisch (["alice","bob","carol","dave"]). In user.service.spec.ts bestätigen Test 6 und Test 7 dasselbe am Code: je Mandant genau EIN Protokolleintrag, die Sortierung nach Benutzername bleibt über die zusammengeführten Teilmengen hinweg korrekt, und eine Kennungsauflösung für einen Benutzer eines fremden Mandanten gelingt nachweislich über einen gebundenen Lesezugriff.

Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und zurückgenommen: in Aufgabe 2 wurde UserService.findById probeweise auf den ungebundenen Klienten zurückgebaut — genau user.service.spec.ts, Test 4, wurde rot, mit der Meldung „erwarteter gebundener Aufruf user.findUnique(tenant=t1) fehlt im Protokoll: []"; der Rückbau wurde zurückgenommen, derselbe Testlauf danach wieder grün. In derselben Aufgabe wurde zusätzlich AdminSeedService.seedAdmin()s gebundener tenantPrisma.user.create-Aufruf probeweise auf den ungebundenen Basisclient zurückgebaut — sechs Tests wurden rot (u. a. Test 9–12), alle mit der Meldung „tenantPrisma.user.create is not a function", weil der ungebundene Basisclient in der Testattrappe keine create-Methode auf user trägt; der Rückbau wurde zurückgenommen, alle zehn Tests danach wieder grün. In Aufgabe 3 wurde UserController.uploadAvatars gebundener Schreibzugriff probeweise auf den ungebundenen Basisclient zurückgebaut — genau user.controller.spec.ts, Test 7, wurde rot, mit der Meldung „Cannot read properties of undefined (reading 'update')"; der Rückbau wurde zurückgenommen, derselbe Testlauf danach wieder grün.

Bereich module-registry

Dieser Abschnitt erweitert die Kritikschrift um den Bereich module-registry (Quick-Task 260910-exd) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt beantwortet sie für den Bereich, der bei JEDER Modulanfrage entscheidet, wer was benutzen darf: die Aktivierungsstufe (TenantModuleActivation) und die Freigabestufe (ModuleGrant). Bleibt hier eine Abfrage ungebunden, sieht das nach dem Scharfschalten nicht wie ein Fehler aus, sondern wie ein Rechteentzug — die sichtbarste Ausprägung der umgekehrten Fehlerrichtung im gesamten Vorhaben und zugleich die am wenigsten meldungswahrscheinliche.

(m1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen achten Abschnitt (runModuleRegistryAreaChecks) erweitert. Er setzt auf den vier Tabellen auf, die runGroupsAreaChecks bereits mit Policies WORTGLEICH aus den ausgelieferten Migrationen anlegt (Group, GroupMembership, ModuleGrant, TenantModuleActivation) und legt selbst nur hinzu: die Tabelle "Module" OHNE Zeilenschutz (die zu messende Eigenschaft selbst), den Eindeutigkeitsindex auf ("tenantId","moduleId") für "TenantModuleActivation" (wortgleich aus 20260619103242_add_module_registry geschnitten), zwei Direkt-Freigabezeilen und die Mitgliedschaft (group-b, user-a), die runGroupsAreaChecks unter gebundenem Kontext bewusst nicht anlegen konnte. Die Zeile grant-foreign-group aus dem Abschnitt groups wird wiederverwendet. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-10, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

modulegrant-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "ModuleGrant" liefert 0 Zeile(n), tatsaechlich vorhanden sind 5
tenantmoduleactivation-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenantModuleActivation" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen: bestanden — forTenant(TENANT-A) liefert 1 aktive Aktivierung(en): ["TENANT-A"]
direktfreigabe-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) 1 Zeile(n): ["grant-direct-a"]
gruppenpfad-gebunden-folgt-der-gruppenregel: bestanden — forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a 1 Zeile(n): ["grant-a"]
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ["grant-a"]), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"] — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau
module-tabelle-traegt-keinen-zeilenschutz: bestanden — ungebundenes SELECT auf "Module" liefert 2 Zeile(n): ["mod-1","mod-2"]; pg_class.relrowsecurity fuer "Module" = false
katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge: bestanden — forTenant(TENANT-A) liefert ["mod-1","mod-2"], ungebunden liefert ["mod-1","mod-2"] — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)
gebundener-join-auf-den-katalog-liefert-den-modulnamen: bestanden — forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: [{"moduleId":"mod-1","name":"Modul Eins"}]
aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "TenantModuleActivation")
aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision: bestanden — gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)
freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung: bestanden — aus 20260804130130_add_groups_and_module_grants extrahiert: beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" ("tenantId","moduleId","groupId") und ("tenantId","moduleId","userId") fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle
Alle 66 Pruefungen bestanden.

NACHTRAG (260910-jab): die fett hervorgehobene Zeile oben (gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus) ist ein Messprotokoll vom 2026-09-10 und bleibt UNVERÄNDERT stehen. Ihre Behauptung "die Regel auf ModuleGrant lässt diese Zeile durch (T-JTS-03)" ist seit Migration 20260910120000_rls_widen_membership_grant_and_platform_read ÜBERHOLT: die Regel weist ein gebundenes Einfügen von grant-foreign-group jetzt nachweislich ab (modulegrant-fremde-gruppe-abgelehnt); die Zeile existiert in aktuellen Läufen nur noch, weil modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich sie über die Wartungsrolle (BYPASSRLS) anlegt. Der Meldetext dieser Prüfung ist im Quelltext des Werkzeugs entsprechend richtiggestellt.

Die Belegzeile, die diesen Abschnitt trägt, ist modulegrant-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "ModuleGrant" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa die 5 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen. tenantmoduleactivation-ungebunden-null-zeilen misst dieselbe Unsichtbarkeit für die Tabelle, die der Rollen-Kurzschluss für ADMIN/SUPER_ADMIN als EINZIGE liest: auch hier fällt der Verwaltungszugriff nach dem Scharfschalten aus.

TEIL 2, Beleg statt Behauptung für Befund B — keine Transaktion in diesem Bereich:

$ grep -rn '\$transaction(' apps/api/src/module-registry --include=*.ts | grep -v spec
$ echo $?
1

Null Treffer, Rückgabewert 1. Der im Kopf von prisma-tenant.extension.ts verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer Transaktionsfall, withTenantTransaction() wird hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt.

TEIL 3, Beleg statt Behauptung für Befund G — die Aufrufermessung für isModuleActive und findActiveForTenant:

$ grep -rn "isModuleActive" apps/api/src apps/web/src packages
apps/api/src/module-registry/module-registry.service.ts:133:  async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {

$ grep -rn "findActiveForTenant" apps/api/src apps/web/src packages
apps/api/src/module-registry/module-registry.service.ts:35:  async findActiveForTenant(tenantId: string) {
apps/api/src/module-registry/module-registry.controller.ts:51:   * uses, not just tenant-wide activation. findActiveForTenant on

isModuleActive hat genau EINEN Treffer, die Definition selbst — ihr Kopfkommentar behauptet "Used by ModuleGuard to gate access to module-specific endpoints"; der Wächter ruft sie nachweislich nie auf (module.guard.ts hält keinen eigenen Datenbankzugriff, siehe Befund D). findActiveForTenant hat zwei Treffer, die Definition und eine Prosa-Erwähnung im Kopfkommentar des Controllers ("stays unchanged for Plan 15-03's marketplace catalog") — auch sie hat keinen echten Aufrufer, der Marktplatz-Katalog wird nachweislich von getCatalogFlags bedient. Beide Kommentare werden in Aufgabe 3 richtiggestellt, mit Bezug auf diese Messung.

(m2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal, Ort
ModuleAccessService.getAccessibleModuleIds, Rollen-Kurzschluss (ADMIN/SUPER_ADMIN) Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module Sidebar und Marktplatz zeigen für JEDEN Administrator des Mandanten keine Module mehr; jede Modulroute antwortet mit 403
ModuleAccessService.getAccessibleModuleIds, Direktweg (USER) Der gebundene Freigabe-Lesezugriff über userId liefert 0 Zeilen statt der eigenen Direkt-Freigaben Der Benutzer verliert genau die Module, die ihm direkt zugewiesen waren — ununterscheidbar von einem echten Entzug
ModuleAccessService.getAccessibleModuleIds, Gruppenweg (USER) Der gebundene, verschachtelte Freigabe-Lesezugriff über die Gruppenmitgliedschaft liefert 0 Zeilen statt der Gruppen-Freigaben Der Benutzer verliert alle über Gruppen geerbten Module, während seine Direkt-Freigaben unberührt bleiben — ein teilweiser, schwer zu erklärender Verlust
ModuleAccessService.getAccessibleModuleIds, Schnittmengenabfrage Die gebundene Aktivierungsabfrage über moduleId: { in: grantedIds } liefert 0 Zeilen, obwohl Freigaben vorliegen Ein Benutzer mit vorhandenen Freigaben sieht trotzdem kein Modul — von der leeren Vorgabemenge (kein Freigabe) nicht zu unterscheiden
ModuleAccessService.findAccessibleModules Der Rückgabepunkt bei leerer accessibleIds-Menge liefert [], ohne den Katalog überhaupt anzufragen GET /modules/active liefert eine leere Liste; die Sidebar ist leer
ModuleAccessService.getCatalogFlags Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module Der Marktplatz zeigt für JEDES Modul isActiveForTenant: false UND hasAccess: false — eine ANDERE Unwahrheit als der Sperrhinweis: "nicht aktiviert" statt "nicht freigegeben"
ModuleRegistryService.findActiveForTenant (heute ohne Aufrufer) Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen Betrifft heute keinen erreichbaren Pfad — die Falle liegt in einer KÜNFTIGEN Verdrahtung, siehe TEIL 3
ModuleRegistryService.activateForTenant Der gebundene Schreibzugriff schlägt fehl bzw. die Existenzprüfung des Katalogs (ungebunden) liefert null POST /modules/:id/activate liefert 404 Module with id '...' not found, obwohl das Modul existiert
ModuleRegistryService.deactivateForTenant Der gebundene Lesezugriff auf die Aktivierung liefert null statt der vorhandenen Zeile POST /modules/:id/deactivate liefert 404 Module '...' is not activated for this tenant — LAUT, siehe (m3)
ModuleRegistryService.isModuleActive (heute ohne Aufrufer) Der gebundene Aktivierungs-Lesezugriff liefert null, die Methode gibt false zurück Betrifft heute keinen erreichbaren Pfad — die stille Falle für eine KÜNFTIGE Verdrahtung, siehe (m3)
ModuleRegistryService.findAll/findBySlug/seedModule (bewusst UNGEBUNDEN, Modulkatalog) Betrifft nicht diese Pfade selbst — sie binden nicht und liefern deshalb weiterhin korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3, sobald Module eine Regel bekommt) Würde man sie binden: der gesamte Modulkatalog verschwände für JEDEN Mandanten — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten
ModuleAccessService, Katalogzugriffe in findAccessibleModules/getCatalogFlags (bewusst UNGEBUNDEN) Dieselbe Grenze wie oben, hier für die beiden Katalog-Lesezugriffe des Nachbardienstes Dieselbe Folge: eine künftige Bindung würde den Katalog mandantenweit unsichtbar machen

(m3) Welcher Code Leere als Abwesenheit deutet

Nach Wirkung sortiert:

  1. ModuleAccessService.getAccessibleModuleIds, grantedIds.length === 0 → return new Set() — der Rückgabepunkt bei leerer Freigabemenge. Der Vorgabezustand ist geschlossen (D-04/T-EXD-04); eine zu leer gebliebene Freigabeabfrage sieht identisch aus wie ein Benutzer ohne jede Freigabe.
  2. ModuleAccessService.getAccessibleModuleIds, Rollen-Kurzschluss — if (role === 'ADMIN' || role === 'SUPER_ADMIN') liest ausschließlich tenantModuleActivation; eine leere Aktivierungsliste liefert ein leeres Set, ohne die Freigabestufe überhaupt zu befragen. Betrifft damit JEDEN Administrator des Mandanten gleichzeitig.
  3. ModuleAccessService.findAccessibleModules, accessibleIds.size === 0 → return [] — der Rückgabepunkt bei leerer Modulliste, bedient GET /modules/active und damit die Sidebar.
  4. ModuleAccessService.getCatalogFlags — eine leere Aktivierungsliste lässt die zurückgegebene Map leer; der Controller (module-registry.controller.ts, findCatalog) mappt einen fehlenden Eintrag auf BEIDE Flags false. Das erzeugt eine ANDERE Unwahrheit als der Sperrhinweis der Freigabestufe: "nicht aktiviert" statt "nicht freigegeben" — der Marktplatz zeigt dem Benutzer den falschen Grund für die Sperre.
  5. ModuleGuard.canActivate, die 403-Stelle — if (!accessibleModuleIds.has(module.id)) throw new ForbiddenException(...). Dieselbe Meldung für "wirklich keine Freigabe" und "die Aufösung hat nichts gefunden" — siehe die Leitfrage dieses Abschnitts unten.
  6. ModuleRegistryService.isModuleActive, Vorgabewert false — heute ohne Aufrufer (TEIL 3), aber die stille Falle für morgen: return activation?.isActive === true liefert bei null (leerer, gebundener Lesezugriff) exakt dasselbe false wie eine echte, mandantenweite Deaktivierung. Wird dieser Pfad morgen verdrahtet, ist er von Fall an nicht mehr vom Rest dieses Abschnitts zu unterscheiden.

Gegenrichtung, damit dieser Abschnitt nicht nur aus Alarm besteht: ModuleRegistryService.deactivateForTenant wirft bei leerer Aktivierungsabfrage (if (!activation) throw new NotFoundException(...)) LAUT — die Ausnahme meldet sich sofort und verständlich, statt ein stilles false zu liefern. Dieselbe laute Richtung gilt für activateForTenant bei unbekannter moduleId.

Die Frage, die dieser Bereich vor allen anderen beantworten muss: welches Signal unterscheidet "wirklich keine Freigabe" von "die Abfrage hat nichts gefunden"? Die Antwort lautet keines — schlicht, ohne Beschönigung. Drei Stellen sehen heute identisch aus, ob der Benutzer tatsächlich keine Freigabe hat oder ob eine gebundene Abfrage nach dem Scharfschalten leer lief: dieselbe ForbiddenException-Meldung im Wächter ("Module '...' is not accessible for this user"), dieselbe leere Modulliste mit Status 200 (GET /modules/active), kein einziger Protokolleintrag. Der Zusatz, der diesen Bereich von allen vorherigen unterscheidet: der Betroffene hat eine fertige, FALSCHE Erklärung zur Hand ("mein Administrator hat mir das entzogen") und meldet deshalb keinen Fehler — anders als etwa bei LdapConfigScheduler, wo niemand eine plausible Alltagserklärung für ausbleibende Synchronisation hat.

Die eine Asymmetrie, die sich zu einem Signal machen LIESSE, wird hier benannt und an Etappe 4 übergeben, nicht gelöst: nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und nicht selektiv — JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, während die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten. Die Vorabprüfung von Etappe 4 (rls-preflight.mjs) kann genau das feststellen: aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den oben genannten Stellen wird ERWOGEN und VERWORFEN, mit derselben Begründung wie bei getAllActiveConfigs im Bereich ldap und den fünf Stellen im Bereich tenders: eine leere Menge ist für einen Benutzer ohne Freigabe und auf einer frischen Installation der Normalzustand, eine Warnung wäre Dauerlärm und verlöre ihr Signal.

(m4) Was dieser Durchlauf bewusst nicht löst

  • T-JTS-02/T-JTS-03 bleiben aufgezeichnet und ungefixt. Gemessen in Aufgabe 1 (Prüfungen 6/7): die Regel auf ModuleGrant prüft nur die Mandantenkennung der Zeile selbst, nicht die referenzierte Gruppe; die Regel auf GroupMembership prüft nur die Gruppenseite. Die Bindung fügt auf der LESESEITE eine zweite Verteidigung hinzu (der Drei-Tabellen-Weg schließt die fremde Gruppe gebunden aus), aber erst nach Etappe 4 — die Schreibseiten-Gegenprüfung in module-grants.service.ts (assertTargetBelongsToTenant) bleibt deshalb der heutige Schutz und wird durch diesen Durchlauf NICHT ersetzt. NACHTRAG (260910-jab): ÜBERHOLT — BEIDE Befunde sind geschlossen. Migration 20260910120000_rls_widen_membership_grant_and_platform_read lässt die Regel auf GroupMembership jetzt beide Seiten prüfen (T-JTS-02) und die Regel auf ModuleGrant zusätzlich beide möglichen Ziele (T-JTS-03, beide Zweige des Entweder-oder D-04). Die Schreibseiten-Gegenprüfung in module-grants.service.ts (assertTargetBelongsToTenant) bleibt TROTZDEM bestehen — sie ist bis zum Scharfschalten (#18, Schalter weiterhin aus) der einzige tatsächlich wirksame Schutz und wird NICHT ersetzt. Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten.
  • Die Regel für den Modulkatalog gehört zu Etappe 3. Gemessen in Aufgabe 1 (Prüfung 8/9, Befund E): "Module" trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal. Katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt — dann verschwände der gesamte Katalog für jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
  • Die offene Architekturfrage req.tenantPrisma — auch dieser Bereich entscheidet sie nicht. Er bindet dienst-intern, wie ldap, groups, tenders, dkv und user es vormachen.

(m5) Was dieser Durchlauf bewusst NICHT anfasst

  • module.guard.ts — geprüft (Befund D: hält keinen eigenen Datenbankzugriff, seine Richtigkeit ist vollständig eine Funktion dessen, was ModuleAccessService zurückgibt) und bewusst gelassen, keine Bindung nötig.
  • module-registry.controller.ts — geprüft (hält ebenfalls keinen eigenen Datenbankzugriff) und bewusst gelassen.
  • Das Frontend — geprüft und bewusst gelassen, keine Datei dieses Plans.
  • Schema und Migrationen — geprüft und bewusst gelassen, keine Schemaänderung in dieser Etappe.

Nachtrag (260910-exd, Aufgabe 3). Wie in den vorherigen Durchläufen wird der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3 tatsächlich umgesetzt haben.

Tatsächlich umgesetzte Pfade gegen die in (m2) angekündigten gehalten: alle in (m2) genannten Pfade sind wie beschrieben umgestellt. ModuleAccessService.getAccessibleModuleIds bindet Kurzschlusszweig, Direktweg, Gruppenweg und Schnittmenge über EINEN Klienten je Aufruf; getCatalogFlags bindet ihren eigenen Aktivierungs-Lesezugriff, die geschachtelte getAccessibleModuleIds-Auflösung erzeugt ihren eigenen Klienten; findAccessibleModules erreicht den Katalog weiterhin über den ungebundenen Klienten. ModuleRegistryService.findActiveForTenant, activateForTenant und isModuleActive binden ihren jeweiligen Aktivierungszugriff; deactivateForTenant bindet beide Aktivierungszugriffe (Lesen, Schreiben) über EINEN Klienten. Alle sechs Katalogzugriffe in module-registry.service.ts (findAll, findBySlug, die beiden Katalog-Existenzprüfungen, die Katalogsuche in isModuleActive, seedModule) und der eine Katalogzugriff in module-access.service.ts (findAccessibleModules) blieben wie angekündigt ungebunden, mit Kommentaren, die Messung und Bedingung trennen. Beide falschen Kopfkommentare aus Befund G sind behandelt: isModuleActives Kommentar ist richtiggestellt (mit Bezug auf die Aufrufermessung aus TEIL 3); der irreführende Satz im Kopfkommentar von module-registry.controller.ts ("findActiveForTenant on ModuleRegistryService stays unchanged for Plan 15-03's marketplace catalog") wurde NICHT mitgeändert — der Controller ist nicht Teil dieses Plans. Die Feststellung, dass diese Aussage falsch ist (der Marktplatz-Katalog wird nachweislich von getCatalogFlags bedient, nicht von findActiveForTenant), steht stattdessen hier in (m5).

Deviation (Rule 1/3): eine cross-area Testabhängigkeit brach durch die Umstellung. tender-scheduler.service.spec.ts instanziiert ModuleRegistryService unmocked gegen einen hand-gerollten Fake ohne $extends (derselbe Zweck wie in ldap.service.spec.ts: der Aktivierungs- Aufruf soll echt sein, nicht ein Stand-in). Nach der Umstellung von activateForTenant auf forTenant() scheiterte dieser Test mit prisma.$extends is not a function. Behoben mit derselben Konvention wie ldap.service.spec.ts — forTenant in dieser einen Datei über vi.mock auf eine Identitätsfunktion gelegt (forTenant: vi.fn((p) => p)), weil die Datei RLS-Bindungsmechanik nicht testet, nur das Poll-once-fan-out-many- Verhalten des Schedulers. Kein anderer Aufrufer von new ModuleRegistryService(...) existiert im Quelltext (geprüft).

Deviation (Rule 3): die Klassifikationsdokument-Stände für module-access.service.ts wurden bereits in Aufgabe 2 nachgezogen, nicht erst in dieser Aufgabe — rls-access-inventory.spec.ts ist Teil der von Aufgabe 2 verlangten vollständigen Testsuite und wäre sonst am Ende von Aufgabe 2 bereits rot gewesen. Diese Abweichung von der Aufgabenaufteilung (die Klassifikationsdatei war für Aufgabe 3 vorgesehen) ist auf das Notwendige beschränkt: nur die Stand-Spalte der beiden betroffenen Zeilen, keine Begründung, keine Zahlen. Die vollständige Nachziehung (fünf Bestandsaufnahme-Zeilen inklusive module-registry.service.ts, Übersichtszeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst- Abschnitt) erfolgte wie geplant in dieser Aufgabe.

Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und zurückgenommen: in Aufgabe 2 wurde die Gruppenweg-Bindung der Freigabe-Auflösung probeweise zurückgebaut (tenantPrisma.moduleGrant → this.prisma.moduleGrant im Gruppenweg von getAccessibleModuleIds) — genau module-access.service.spec.ts, Test "ModuleAccessService — Bindung an forTenant() (260910-exd) > USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung", wurde rot, mit der Meldung expected 1 to be 2; der Rückbau wurde zurückgenommen, derselbe Testlauf danach wieder grün (23/23). In Aufgabe 3 wurde der Schreibzugriff des Deaktivierens probeweise zurückgebaut (tenantPrisma.tenantModuleActivation.update → this.prisma.tenantModuleActivation.update in deactivateForTenant) — genau module-registry.service.spec.ts, Test "ModuleRegistryService.deactivateForTenant > bindet beide Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen Klienten", wurde rot, mit der Meldung "erwarteter gebundener Aufruf tenantModuleActivation.update(tenant=t1) fehlt im Protokoll: [{"tenantId":"t1","model":"tenantModuleActivation","method":"findUnique"}]: expected false to be true"; der Rückbau wurde zurückgenommen, derselbe Testlauf danach wieder grün (16/16).

Die offene WINDOWS-Aufzeichnung. Eintrag #23 (deviation) hält fest, dass es kein Signal gibt, das "wirklich keine Freigabe" von "die Abfrage hat nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und .planning/WINDOWS.md.

Nachtrag (260910-krx): der oben in Befund E festgehaltene Befund zur Reihenfolge — der Bereich dashboard erbt die Bindung der Modul-Zugriffsauflösung, ohne dass eine Datei unter apps/api/src/module-registry dafür angefasst werden muss — ist mit Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: dashboard.service.ts ruft ModuleAccessService.getAccessibleModuleIds für den Widget-Modulfilter in getWidgets unverändert auf, diese Auflösung bindet seit 260910-exd bereits über forTenant(), und der Filter ist damit gebunden. Nachgeprüft mit grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/ module-access.service.ts (ein Treffer) und mit einem Wachhund-Testfall in dashboard.service.spec.ts, der den Modulkatalog aus dem Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx NICHT ein zweites Mal gebunden — keine Datei unter apps/api/src/module-registry ist Teil dieses Plans.

Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19

Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab, auf ausdrückliche Anweisung des Nutzers): die drei Regelfixe, ursprünglich nach Etappe 2 vorgesehen, wurden vorgezogen. Folge: die fünf noch offenen Bereiche der Etappe 2 messen ab jetzt gegen die NEUEN Regeln, mehrere bereits abgeschlossene Bereiche (groups, tenders, module-registry oben) haben gegen die ALTEN gemessen — diese Messungen stehen aufgezeichnet und sind mit Nachträgen an den betroffenen Stellen versehen, nicht umgeschrieben.

(r1) Tatsächlich beobachtete Ausgabe und Regelliste der lebenden Datenbank

Lauf vom 2026-09-10 gegen tessera-ctl-db-1, Adresse 172.19.0.2 (nur die Zeilen der drei betroffenen Bereiche sowie der beiden aufsetzenden module-registry-Prüfungen; die vollständige Ausgabe umfasst 74 Prüfungen):

groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-schreiben-fremder-benutzer-abgelehnt: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — der Ausschluss aus der vorigen Pruefung kommt damit nachweislich von der Regel, nicht vom Aufbau
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
modulegrant-fremde-gruppe-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId (group-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
modulegrant-fremder-benutzer-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder userId (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — legt zugleich die Zeile 'grant-foreign-group' an, auf der zwei Pruefungen des Bereichs module-registry aufsetzen (Befund C)
tenderrssfeed-plattformzeile-gebunden-sichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: true, forTenant(TENANT-B) sieht sie: true
tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar: bestanden — forTenant(TENANT-A) liefert ["rss-a","rss-platform"] — eigene Zeile (rss-a) sichtbar: true, fremde Zeile (rss-b, TENANT-B) sichtbar: false
tenderrssfeed-ungebunden-nur-die-plattformzeile: bestanden — ungebundener SELECT auf "TenderRssFeedSource" liefert 1 Zeile(n): ["rss-platform"]
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen (tenant_insert_policy)
tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt: bestanden — gebundenes UPDATE auf die plattformweite Zeile traf keine Zeile (tenant_update_policy filtert sie heraus) — url unveraendert
tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt: bestanden — gebundenes DELETE auf die plattformweite Zeile traf 0 Zeilen (tenant_delete_policy filtert sie heraus)
searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar: bestanden — forTenant(TENANT-A) sieht die mandantenlose Zeile: false, forTenant(TENANT-B) sieht sie: false — bewusst UNVERAENDERT (widerlegte Praemisse)
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT — die Zeile wurde ueber die Wartungsrolle angelegt
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"]
Alle 74 Pruefungen bestanden.

Regelliste aus pg_policies der lebenden Datenbank (Systemkatalog, nicht die Migrationsdatei):

GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN (SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN (SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))
ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN (SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN (SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))
SearchProvider#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_delete_policy#DELETE#("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_insert_policy#INSERT##WITH CHECK ("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_platform_read_policy#SELECT#(("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL))
TenderRssFeedSource#tenant_update_policy#UPDATE#USING ("tenantId" = current_tenant_id())#WITH CHECK ("tenantId" = current_tenant_id())

(r2) Signaltabelle — beide Fehlerrichtungen je Regel

Regel Zu streng (Falsch-Negativ, DoS) Zu locker (Falsch-Positiv, Rechteausweitung)
GroupMembership Wäre die neue Regel zu streng, würde eine tatsächlich mandanteneigene Mitgliedschaft unsichtbar — Signal: groupmembership-folgt-join-auf-group würde fehlschlagen (liefert heute korrekt 1 Zeile) groupmembership-schreiben-fremder-benutzer-abgelehnt — Signal wäre ein GELUNGENES Einfügen einer fremden Benutzerkennung; heute abgewiesen
ModuleGrant Eine eigene, korrekt referenzierende Freigabe würde unsichtbar — Signal: modulegrant-gebunden-nur-eigene-zeile würde fehlschlagen (heute 1 Zeile) modulegrant-fremde-gruppe-abgelehnt/modulegrant-fremder-benutzer-abgelehnt — Signal wäre ein GELUNGENES Einfügen mit fremder Gruppen- bzw. Benutzerkennung; heute beide abgewiesen
TenderRssFeedSource (Lese-/Schreibsplit) Zu streng bedeutet: die plattformweite Zeile bleibt trotz Bindung unsichtbar — Signal: tenderrssfeed-plattformzeile-gebunden-sichtbar würde fehlschlagen (heute sichtbar unter beiden Mandanten) ODER die eigene Zeile verschwindet — Signal: tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar würde fehlschlagen Zu locker bedeutet: ein Mandant kann die plattformweite Zeile ändern/entfernen — Signal: tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt/tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt würden fehlschlagen (heute beide: 0 betroffene Zeilen)

(r3) Stellen, an denen Leere weiterhin als Abwesenheit gedeutet wird

Namentliche Liste, wie in den Abschnitten (d)/(g3)/(t3)/(u3)/(m3) oben je Bereich geführt — hier bereichsübergreifend für die drei betroffenen Tabellen, inklusive der NEUEN Stelle aus Befund F:

  • TenderRssFeedSourceService.listForUser (NEU seit 260910-jab, Aufgabe 2) — vor dieser Reparatur lieferte der ungebundene Pfad nach dem Scharfschalten NICHTS (eine schreiende Leere, die auffällt). Nach der Reparatur würde er UNGEBUNDEN nur die plattformweiten Zeilen liefern — eine kurze, glaubhafte Teilantwort, die NICHT auffällt (der Nutzer sieht plattformweite Feeds und hat keinen Anlass zu melden, dass seine eigenen fehlen). Deshalb gebunden — die einzige Stelle, die diese Aufgabe still falsch gemacht hätte, wenn sie nicht mitgebunden worden wäre.
  • Alle bereits in (t3) genannten fünf lautlosen Stellen in den beiden Tender-Hintergrunddiensten bleiben unverändert lautlos — von dieser Regeländerung nicht betroffen.
  • GroupMembership/ModuleGrant selbst tragen keine "Leere als Abwesenheit gedeutet"-Stelle im hier verstandenen Sinn (eine ABGEWIESENE Schreibung ist ein harter Fehler, keine stille Leere) — die relevante Fehlerrichtung ist hier die Rechteausweitung (r2), nicht die stille Leere.

(r4) Was dieser Durchlauf bewusst nicht löst

  • Der Verwaltungsweg für plattformweite Zeilen fehlt. Unter der Anwendungsrolle lässt sich eine plattformweite TenderRssFeedSource-Zeile weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel einen Mandanten verlangt. createPlatform/remove bleiben deshalb bewusst ungebunden. Als eigener offener Ledger-Eintrag festgehalten (WINDOWS #24), damit dieser Rest nicht mit #19 verschwindet — ein Verwaltungsweg (z. B. eine eigene Systemrolle oder ein expliziter Admin-Bypass-Pfad) ist Gegenstand von Etappe 4.
  • Die fehlende Benutzerdimension der Ausschreibungsregeln. Selbst gemessen statt übernommen (grep -rn "current_setting\|set_config" apps/api/src apps/api/prisma/migrations, 260910-jab): es existiert GENAU EINE Sitzungsvariable, app.current_tenant (prisma-tenant.extension.ts, 20260618112133_rls_policies). Es gibt KEINE zweite Sitzungsvariable für den Benutzer — Tabellen wie TenderSavedSearch (siehe tendersavedsearch-fremder-nutzer-desselben- mandanten-sichtbar oben) haben deshalb strukturell keine Möglichkeit, eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die anwendungsseitige userId-Filterung bleibt der einzige Schutz.
  • Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS #22) — unverändert, nicht Gegenstand dieses Plans.

(r5) Was dieser Durchlauf bewusst nicht anfasst

  • SearchProvider — die Regel bleibt wörtlich unverändert ("tenantId" = current_tenant_id()), mit gemessener Begründung (widerlegte Prämisse, siehe docs/mandantentrennung-zugriffsklassifikation.md).
  • Die Regeln auf Group und TenantModuleActivation — beide unverändert, nicht Teil der drei benannten Löcher.
  • Der Schalter (DATABASE_URL, Rolle tessera) — bleibt aus. Die drei Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen.

Bereich dashboard

Dieser Abschnitt erweitert die Kritikschrift um den Bereich dashboard (Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe (w3).

(w1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen neunten Abschnitt (runDashboardAreaChecks) erweitert, unmittelbar nach runModuleRegistryAreaChecks und vor runTransactionShapeMeasurement aufgerufen. Er legt die beiden Wegwerf-Tabellen DashboardLayout und WidgetInstance selbst neu an und benutzt die von runSearchProviderAreaChecks bereits angelegte Tabelle SearchProvider WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit extractPolicySql() WORTGLEICH aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables geschnitten, gemessen am Regelstand NACH der Migration 20260910120000_rls_widen_membership_grant_and_platform_read (die drei Regeln dieses Bereichs sind von jener Migration unverändert gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2, nur die dreizehn neuen Zeilen dieses Abschnitts sowie die abschließende Summenzeile):

dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"]
dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten
dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"]
widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs
dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout"
widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance")
widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten
searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"]
searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)
searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider")
Alle 87 Pruefungen bestanden.

Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans namentlich vorgezeichnet — die dreizehnte (widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar) wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für DashboardLayout und SearchProvider einen entsprechenden Testfall vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf 87 (74 + 13).

Die tragende Belegzeile ist dashboardlayout-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "DashboardLayout" ohne vorheriges set_config liefert 0 Zeilen, nicht die 4 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen. widgetinstance-ungebunden- null-zeilen misst dieselbe Unsichtbarkeit für die Widget-Tabelle.

Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine Vermutung. Ein gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE 42501 ("new row violates row-level security policy (USING expression) for table "DashboardLayout""), nicht mit einem Eindeutigkeitsfehler (23505) und nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma Client gemessen (nicht nur an rohem SQL), weil saveLayout in Wahrheit prisma.dashboardLayout.upsert() aufruft, nicht $executeRaw: gegen eine eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz (id, userId, tenantId, layouts, createdAt, updatedAt) und einer eigenen Wegwerf-Rolle ohne BYPASSRLS liefert derselbe Konfliktfall über tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create }) einen PrismaClientUnknownRequestError — nicht den bekannten PrismaClientKnownRequestError mit .code === 'P2002', den der Bereich tenders für seinen Eindeutigkeitsfall abfängt. .code und .meta sind bei diesem Fehlertyp undefined; die einzige verlässliche Information steht im rohen .message-Text, der den PostgreSQL-Fehler eingebettet enthält (code: "42501", message: "new row violates row-level security policy (USING expression) for table \"DashboardLayout\""). Das ist die zentrale Abweichung von der Annahme, das tenders-P2002-Muster ließe sich wörtlich übernehmen — es lässt sich nicht, weil dieser Fehler eine andere Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb Prisma.PrismaClientUnknownRequestError ab (Prüfung auf die Fehlerklasse, nicht auf .code) und übersetzt ihn in eine verständliche deutsche Meldung — siehe (w4) für die Grenze dieser Behandlung.

Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F, WINDOWS #19). Nachgeprüft mit derselben Anweisung wie zur Planungszeit, diesmal gegen den aktuellen Quelltext (2026-09-11): grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json (ohne node_modules, dist/, .next/). Ergebnis unverändert: der einzige Schreibweg ist dashboard.service.ts:241 (addSearchProvider → create) mit tenantId: string als PFLICHTPARAMETER der aufrufenden Methode; keine Seed-Datei (apps/api/prisma/ enthält ausschließlich migrations und schema.prisma), kein Skript, kein weiterer Schreibweg. Reichweite der Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über einen dynamisch gebildeten Modellnamen und deckt keine manuelle Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich datenbankseitig verteidigt: searchprovider-gebundenes-einfuegen-ohne- mandant-abgelehnt weist ein gebundenes Einfügen mit tenantId = NULL laut mit SQLSTATE 42501 ab, weil die Regel ohne eigene WITH CHECK- Klausel ihre USING-Klausel dafür wiederverwendet.

(w2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal, Ort
DashboardService.getLayout Der gebundene Lesezugriff liefert null statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" Der Rückgabepunkt liefert die Vorgabeanordnung { lg: [], md: [], sm: [], xs: [], xxs: [] }, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette
DashboardService.saveLayout Der gebundene Schreibzugriff trifft im update-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel userId — Konfliktmessung, siehe (w1) PUT /dashboard/layout liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze
DashboardService.getWidgets, Widget-Lesezugriff Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler
DashboardService.addWidget Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung POST /dashboard/widgets liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (ForbiddenException bereits im Controller)
DashboardService.updateWidgetConfig/removeWidget, Besitzprüfung Die gebundene findUnique-Abfrage liefert null statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" NotFoundException — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4)
DashboardService.getSearchProviders Der gebundene Lesezugriff auf custom liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J
DashboardService.addSearchProvider Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter —
DashboardService.removeSearchProvider, Besitzprüfung Wie bei Widgets: null statt der eigenen Zeile NotFoundException — dieselbe Meldung wie beim echten Besitzverstoß

(w3) Welcher Code Leere als Abwesenheit deutet

Backend, eine Stelle: DashboardService.getLayout — if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };. Kein Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe Deutung wie bei getWidgets (leere Liste) und getSearchProviders (nur die drei Konstanten).

Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der beiden Dateien etwas:

  1. apps/web/src/lib/stores/dashboard-store.ts, loadDashboard: setzt layouts und widgets genau auf das, was api.fetchLayout() und api.fetchWidgets() liefern. Der catch-Zweig (error: 'Failed to load dashboard') feuert nur bei einem Netzwerk- oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen Fehlerzustand.
  2. apps/web/src/lib/stores/dashboard-store.ts, setEditMode: if (prev && !mode && get().isDirty) { get().saveLayout(); } — der Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch zurückgeschrieben, ohne dass jemand auf "Speichern" klickt.
  3. apps/web/src/components/dashboard/widgets/search-widget.tsx: die Rückfallprüfung if (!cancelled && data.length > 0) greift NIE, weil getSearchProviders die drei Vorgaben immer voranstellt — data ist nie leer, selbst wenn custom (die eigenen Suchmaschinen) nach dem Scharfschalten leer geblieben wäre. Der .catch()-Rückfallzweig auf DEFAULT_PROVIDERS feuert deshalb ebenfalls nie in diesem Fall.

Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I): leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu auf, addWidget legt echte neue WidgetInstance-Zeilen an (keine Eindeutigkeitsbedingung über (userId, widgetType), Dubletten häufen sich also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer "Speichern" geklickt hat → die layouts-Spalte der ursprünglichen Zeile ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb keinen Fehler — derselbe Mechanismus, der auch in module-registry (m3) und ldap beschrieben ist, hier aber mit einer zusätzlichen, aktiven Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem der beiden anderen Bereiche gibt.

Das Verhalten der Suchleiste (Befund J), präzise statt "still" formuliert: verschwinden die eigenen Suchmaschinen des Nutzers aus custom, bleibt die Auswahlliste (<select>) dennoch gefüllt — mit den drei Vorgaben. Sichtbar wird das nur, wenn der Nutzer VORHER eine eigene Suchmaschine ausgewählt hatte: React setzt value={selectedProviderId} auf dem <select>, aber selectedProviderId referenziert nach dem Verschwinden keinen vorhandenen <option>-Wert mehr — der Browser zeigt in diesem Fall (kein <option> mit passendem value) den ERSTEN Eintrag der Liste an, also "Google", OHNE dass der interne React-State selectedProviderId sich ändert oder irgendeine Meldung erscheint. Klickt der Nutzer danach Suchen, greift handleSearch: providers.find((p) => p.id === selectedProviderId) ?? providers[0] — der find schlägt fehl (die eigene Suchmaschine ist nicht mehr in providers), der Rückfall auf providers[0] (Google) greift, und eine Anfrage, die für ein internes Werkzeug gedacht war, geht an eine externe Suchmaschine. Für einen Nutzer, der die Anzeige "Google" im Dropdown nicht bewusst als Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.

(w4) Was dieser Durchlauf bewusst nicht löst

  • Die plattformweite Eindeutigkeit von DashboardLayout.userId (Befund K). userId trägt @unique ohne Mandantenanteil — strukturell dieselbe Kette wie WINDOWS #22 im Bereich user: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler. Anders als bei #22 ist der Fehler hier aber GEMESSEN statt angenommen (siehe (w1), Prüfung 5) und wird in Aufgabe 2 BEDINGT auf das gemessene Ergebnis behandelt: eine verständliche deutsche Meldung statt eines rohen 500ers, indem Prisma.PrismaClientUnknownRequestError abgefangen wird — NICHT .code === 'P2002' wie im Bereich tenders, weil der gemessene Fehler eine andere Prisma-Fehlerklasse ist. Die ehrliche Reparatur wäre eine Schemaänderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung für Etappe 3 vorgemerkt, wie #22 es für denselben Fall bereits tut.
  • Die fehlende Unterscheidbarkeit von "noch keine Anordnung gespeichert" und "Anordnung nicht sichtbar". Beide liefern identisch die Vorgabeanordnung, Status 200, keinen Protokolleintrag. Die konkrete Vorabprüfung für Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile für einen bekannten Benutzer, aber der gebundene Lesezugriff für dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall (keine Zeile vorhanden) vom Trennungsfehler (Zeile vorhanden, aber unsichtbar).
  • Eine Laufzeitwarnung an getLayout/getWidgets/getSearchProviders wurde erwogen und VERWORFEN, mit derselben Begründung wie bei getAllActiveConfigs im Bereich ldap: eine leere Anordnung, eine leere Widget-Liste oder keine eigene Suchmaschine sind auf einer frischen Installation oder für einen neuen Benutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie gebraucht wird. Das gilt hier NOCH ausgeprägter als in ldap, weil praktisch jeder neue Benutzer diesen Zustand beim ersten Login durchläuft.
  • Der offene Ledger-Eintrag zur beweisvernichtenden Schleife (angelegt in Aufgabe 3, siehe .planning/WINDOWS.md) ist an dieselbe Bedingung gebunden wie #18 — er wird erst nach dem Scharfschalten beobachtbar.

(w5) Was dieser Durchlauf bewusst nicht anfasst

  • Das Frontend — geprüft (Befund I/J, (w3) oben) und bewusst gelassen, keine Datei dieses Plans. dashboard-store.ts und search-widget.tsx werden NICHT geändert.
  • Der Bereich favorites. FavoriteLink hängt per Fremdschlüssel an WidgetInstance (apps/api/prisma/schema.prisma, favoriteLinks FavoriteLink[] auf WidgetInstance), ist aber ein eigener Bereich mit eigener Umstellung (apps/api/src/favorites/ favorites.service.ts, ungebunden, sieben Rohtreffer laut Klassifikationsdokument) — nicht Teil dieses Plans.
  • Der Bereich module-registry samt der geerbten Entlastung aus Befund E: dashboard.service.ts ruft ModuleAccessService.getAccessibleModuleIds für den Widget-Modulfilter auf, und diese Auflösung bindet bereits seit 260910-exd (const tenantPrisma = forTenant(this.prisma, tenantId) in module-access.service.ts). Der Filter ist damit gebunden, OHNE dass dieser Plan eine Datei unter apps/api/src/module-registry anfasst — nachgeprüft mit grep -n "const tenantPrisma = forTenant" apps/api/src/ module-registry/module-access.service.ts, ein Treffer. Der eine Katalogzugriff in dashboard.service.ts (getWidgets, this.prisma. module) bleibt bewusst ungebunden — siehe die Begründung in der Klassifikationstabelle, die Messung und Bedingung trennt.
  • Die Ungenauigkeit in apps/api/src/groups/groups.service.ts (Befund L), zur Ausführungszeit gegengelesen und WÖRTLICH bestätigt, NICHT behoben. Der Kopfkommentar um Zeile 24 begründet die dortige Join-Filterung mit "nach dem Ownership-Check-Muster aus DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu: removeWidget filtert NICHT zusätzlich in der Datenbankabfrage, sondern vergleicht NACH dem Laden im JavaScript (widget.userId !== userId), und dieser Vergleich läuft über die BENUTZER-, nicht die Mandantenkennung. Die Datei apps/api/src/groups/groups.service.ts wird von diesem Plan NICHT angefasst — diese Feststellung steht hier als Richtigstellung, nicht als Änderung an der fremden Datei.
  • Schema und Migrationen — geprüft und bewusst gelassen, keine Schemaänderung in dieser Etappe.

Bereich calendar

Dieser Abschnitt erweitert die Kritikschrift um den Bereich calendar (Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern — die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers. Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer Kalender — und das Frontend verstärkt das, siehe (k3).

(k1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen elften Abschnitt (runCalendarAreaChecks) erweitert, unmittelbar nach runDashboardAreaChecks und vor runTransactionShapeMeasurement aufgerufen. Er legt die Wegwerf-Tabelle CalendarSource selbst neu an, mit SÄMTLICHEN 17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus Prüfung 5b im Bereich dashboard: der generierte Client wählt standardmäßig JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit der Regel extractPolicySql() WORTGLEICH aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables geschnitten, gemessen am Regelstand NACH der Migration 20260910120000_rls_widen_membership_grant_and_platform_read. Diese zweite Migration wird zur LAUFZEIT geprüft (Prüfung calendarsource-regelstand-eindeutig), nicht nur zur Planungszeit behauptet: extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource') liefert null — jene Migration trägt KEINE eigene Regel für CalendarSource, der Stand aus 20260909140000 ist weiterhin der ausgelieferte, aktuelle Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die Messfalle aus 260910-jab.

Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client (nicht nur an rohem SQL), weil calendar.service.ts in Wahrheit this.prisma.calendarSource.findMany/update/create() aufruft, nicht $executeRaw — dieselbe dashboard-Lehre: Prüfung 8 (calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients) vergleicht die zur Laufzeit aus schema.prisma gelesenen 17 Feldnamen des Modells CalendarSource mit den tatsächlichen Spalten der Wegwerf-Tabelle über information_schema.columns — sie steht VOR den Client-Prüfungen 9-12 und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden Client-Messungen sonst wertlos wären.

Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2, nur die dreizehn neuen Zeilen dieses Abschnitts sowie die abschließende Summenzeile):

calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
Alle 101 Pruefungen bestanden.

Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung des Plans namentlich vorgezeichnet — die dreizehnte (calendarsource-regelstand-eindeutig) wurde ergänzt, weil sie die Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa festzuhalten, derselbe Grund, aus dem der Bereich dashboard eine dreizehnte Prüfung ergänzt hatte.

Die tragende Belegzeile ist calendarsource-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "CalendarSource" ohne vorheriges set_config liefert 0 Zeilen, nicht die 3 tatsächlich vorhandenen — an der echten, ausgelieferten Policy gemessen.

Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen — und es ist eine ANDERE Fehlerklasse als im Bereich dashboard. Ein gebundenes bound.calendarSource.update({ where: { id: 'source-b1' }, ... }) unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine PrismaClientKnownRequestError mit .code === 'P2025' ("Record to update not found") — NICHT die PrismaClientUnknownRequestError, die dashboardLayout.upsert() im Bereich dashboard bei genau diesem Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des Zugriffs: dashboardLayout.upsert() löst ein INSERT ... ON CONFLICT aus, das die RLS-USING-Klausel beim INSERT-Zweig verletzt (SQLSTATE 42501, von Prisma als unbekannter Fehler durchgereicht); ein einfaches calendarSource.update({ where: { id } }) ohne Konfliktbehandlung sieht unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe Verhalten wie ein UPDATE über eine nicht existierende Kennung, das Prisma grundsätzlich als P2025 meldet. Das bedeutet für Aufgabe 2: keine der drei Besitzprüfungsschreibpfade (updateSource/deleteSource/testConnection) braucht eine neue Fehlerübersetzung — P2025 wäre ohnehin nicht der Pfad, über den ein Nutzer diese Zeile erreicht (die vorgeschaltete findUnique-Besitzprüfung fängt den Fall vorher über NotFoundException ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife (Befund K, siehe (k2)).

(k2) Signaltabelle je umgestelltem Pfad

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal, Ort Frontend lässt Signal durch?
CalendarService.getSources Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen GET /calendar/sources liefert [], Status 200, kein Fehler Nein — calendar-widget.tsx zeigt emptyNoSources ("keine Quelle eingerichtet"), calendar-settings-panel.tsx zeigt sourceEmpty; beide ununterscheidbar vom echten Erstbenutzer-Zustand
CalendarService.addSource Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter Schlägt ohne Mandant im Controller bereits mit ForbiddenException fehl —
CalendarService.updateSource, Besitzprüfung Die gebundene findUnique-Abfrage liefert null statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" NotFoundException('Calendar source not found') — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 calendar-settings-panel.tsx fängt Schreibfehler bei handleVisibilityToggle (catch { revert }) bzw. beim Formular (saveError/editSaveError) — sichtbar als Fehlermeldung, NICHT als leerer Zustand
CalendarService.deleteSource, Besitzprüfung Wie bei updateSource: null statt der eigenen Zeile NotFoundException — dieselbe Meldung wie beim echten Besitzverstoß Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt
CalendarService.testConnection, Besitzprüfung Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) NotFoundException bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen PrismaClientKnownRequestError (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe tenantPrisma-Klient beide Schritte trägt testResults-State zeigt 'error' — sichtbar, nicht verschluckt
CalendarService.fetchAndCacheEvents Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — if (sources.length === 0) return []; kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: PrismaClientKnownRequestError (P2025) — strukturell ausgeschlossen durch EINEN tenantPrisma-Klient je Aufruf GET /calendar/events liefert [], Status 200, kein Fehler, keine Protokollzeile Nein — calendar-widget.tsx zeigt bei leerem events (aber hasSources === true) emptyNoEvents ("keine Termine"); ein LAUTER Fehler von fetchEvents() ODER fetchSources() landet im selben catch und setzt ebenfalls events = [] — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht
CalendarService.refreshCacheInBackground Ruft fetchAndCacheEvents mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch .catch((error) => this.logger.warn(...)): selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet

(k3) Welcher Code Leere als Abwesenheit deutet

Backend, zwei Stellen (Befund I): CalendarService.getSources liefert bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall in dieser Etappe. CalendarService.fetchAndCacheEvents, if (sources.length === 0) return [];: kehrt VOR dem Cache-Eintrag zurück, ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten, sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter Cache-Treffer, der fünf Minuten stehen bleibt. Die drei Besitzprüfungspfade (updateSource, deleteSource, testConnection) sind dagegen LAUT: ein zu kleines Nachschlagen wirft NotFoundException.

Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei Dateien etwas:

  1. apps/web/src/components/dashboard/widgets/calendar-widget.tsx, Zeile 37: if (sources.length === 0) setzt hasSources = false, gerendert als emptyNoSources ("keine Quelle eingerichtet"). Zeile 50: catch { // Silent fail — show empty state } fängt jeden Fehler von fetchSources() ODER fetchEvents() — bei einem Fehler bleibt hasSources jedoch beim vorherigen Wert (nicht false) und events wird auf [] gesetzt, wodurch die Render-Logik in den Zweig events.length === 0 fällt und emptyNoEvents ("keine Termine") zeigt — NICHT emptyNoSources. Ein LAUTER Fehler (403, 500, Netzwerkfehler) auf GET /calendar/sources oder GET /calendar/events ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade keine Termine" nicht zu unterscheiden — beide zeigen emptyNoEvents.
  2. apps/web/src/components/settings/calendar-settings-panel.tsx, Zeile 49: .catch(() => { // Silent fail — show empty state }) — hier bleibt sources beim initialen [], unabhängig davon, ob fetchSources() eine echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127: sources.length === 0 zeigt sourceEmpty. Auf dieser Seite sind "keine Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine schärfere Form derselben Verschluckung als im Widget.
  3. apps/web/src/components/settings/calendar-source-form.tsx, Zeile 149: if (password) payload.password = password; — nicht Leere, sondern Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe (k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt WEGGELASSEN, nicht als leere Zeichenkette übertragen.

Die Folgekette, abgegrenzt gegen dashboard (Befund J): leerer Kalender oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle NEU an (addSource, gebunden, gelingt) → er tippt sein Exchange- oder CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei dashboard wird dabei NICHTS überschrieben (CalendarSource hat keinen automatischen Rückschreibpfad wie setEditMode im Dashboard) — die Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein System ein, das er für kaputt hält, und sobald die Ursache behoben ist (Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für denselben Server vor — Dubletten bei Quellen UND bei Terminen.

Die Fehlerverschluckung als eigener Punkt: selbst ein LAUTER Backend-Fehler auf GET /calendar/sources oder GET /calendar/events (403, 500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.

(k4) Was dieser Durchlauf bewusst nicht löst

  • (a) Das Urteil zum Cache-Schlüssel, vierteilig belegt, jedes Glied einzeln nachgesehen: eventCache (Zeile 109 alt) wird mit ${userId}:${from}:${to} beschlüsselt. userId kommt aus calendar.controller.ts extractContext (req.user?.id); das ist laut apps/api/src/auth/strategies/jwt.strategy.ts validate (Zeile 27-34) wörtlich id: payload.sub; payload.sub ist laut apps/api/src/auth/auth.service.ts (Zeilen 143 und 332, beide sub: user.id) die Datenbankkennung User.id; die trägt laut apps/api/prisma/schema.prisma model User @id @default(uuid()). Urteil: der Schlüssel trägt eine plattformweit eindeutige UUID, kein Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen eindeutig PRO MANDANT statt plattformweit) betrifft User.username und User.email, nicht User.id, und berührt den Schlüssel deshalb NICHT. Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als Kommentar unmittelbar über der eventCache-Zuweisung in calendar.service.ts (Aufgabe 2).
  • (b) Der Befund zur Zugangsdaten-Erhaltung (Befund E), an vier Stellen zur Ausführungszeit nachgelesen: calendar.service.ts updateSource besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu zu verschlüsseln — die dkv-Form (lesen, entschlüsseln, neu verschlüsseln) existiert hier nicht. Stattdessen: if (dto.password !== undefined) entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf → encryptedPassword bleibt im update-Aufruf gänzlich unerwähnt und damit in der Datenbank unverändert; Feld LEER ('') → wird explizit auf null gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite (calendar-source-form.tsx, Zeile 149) wird ein leer gelassenes Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die beiden lesenden Stellen (testConnection, fetchAndCacheEvents) entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
  • (c) Die fehlende Benutzerdimension der Regel (Befund G), gemessen in (k1) (calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar): die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel aufnimmt (CalendarSource steht dort ausdrücklich in der Liste). Bis dahin bleiben der userId-Filter in getSources/fetchAndCacheEvents und die drei Besitzprüfungen der EINZIGE Schutz.
  • (d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D): updateSource, deleteSource und testConnection werfen bei fremdem Besitz ForbiddenException('Not your calendar source') (403), bei unbekannter Kennung NotFoundException('Calendar source not found') (404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar). Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
  • (e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle nicht sichtbar". Beide liefern identisch eine leere Liste, Status 200, keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für Etappe 4 (rls-preflight.mjs): physisch vorhandene CalendarSource-Zeilen je Mandant über die Wartungsrolle zählen und mit der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an getSources/fetchAndCacheEvents wurde erwogen und VERWORFEN, mit derselben Begründung wie bei getAllActiveConfigs im Bereich ldap und bei getLayout/getWidgets/getSearchProviders im Bereich dashboard: keine Quelle ist auf einer frischen Installation oder für einen neuen Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie gebraucht wird.

(k5) Was dieser Durchlauf bewusst nicht anfasst

  • Das Frontend — geprüft (Befund I/J, (k3) oben) und bewusst gelassen, keine Datei dieses Plans. calendar-widget.tsx, calendar-settings-panel.tsx und calendar-source-form.tsx werden NICHT geändert.
  • Die drei Provider (ics.provider.ts, caldav.provider.ts, exchange.provider.ts) — reden mit echten Servern, werden in Aufgabe 2 NICHT ausgeübt, nur als Attrappen (fetchEvents/testConnection als vi.fn) verwendet.
  • testConnectionFromConfig — kein Datenbankzugriff, kein Mandant (prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
  • Der Bereich favorites — eigener Bereich mit eigener Umstellung, nicht Teil dieses Plans.
  • Die Antwortsemantik 403/404 — siehe (k4)(d), bewusst nicht geändert.
  • Der Fremdkommentar in apps/api/src/ldap/ldap-config.service.ts (Zeile 24, nennt CalendarSource als Vorbild der Verschlüsselung) — zutreffend (CalendarCryptoService, siehe Dezision 07-01 in STATE.md), nicht zu ändern.
  • Schema und Migrationen — geprüft und bewusst gelassen, keine Schemaänderung in dieser Etappe.

Bereich tenant

Dieser Abschnitt erweitert die Kritikschrift um den Bereich tenant (Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen, sondern die Mandantentabelle SELBST — Tenant hat per Definition keine tenantId-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund, den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle User hinein (Aufgabe 3).

(n1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen zwoelften Abschnitt (runTenantAreaChecks) erweitert. Sechs der neun neuen Pruefungen (4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT (prisma.tenant.findMany/findUnique/delete, bound.tenant.findMany, bound.user.count) statt ueber Roh-SQL — bewusst, weil der Relationszaehler (include: { _count: { select: { users } } }), den findAll/findOne tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client"). Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten migration.sql-Dateien und prueft auf CREATE POLICY ... ON "Tenant" sowie ALTER TABLE "Tenant" — ausdruecklich EINSCHLIESSLICH 20260910120000_rls_widen_membership_grant_and_platform_read, die "Tenant" nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": []
tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"]
tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"]
tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"]
tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2}
tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0
tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab
tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false
tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}]
Alle 110 Pruefungen bestanden.

Die Belegzeile, die diesen Abschnitt traegt, ist tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten (Pruefung 5): dieselbe Abfrage, die findAll heute stellt, liefert UNGEBUNDEN fuer JEDEN Mandanten userCount: 0, waehrend die Wartungsrolle fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die Zahl, die admin/tenants/page.tsx nach dem Scharfschalten anzeigen wuerde. Der Fremdschluessel User_tenantId_fkey (Pruefung 7, wortgleich aus Zeile 105 von 20260618112124_auth_multi_tenancy geschnitten) faengt das daraus folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code P2003), nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09.

(n2) Signaltabelle je Pfad

Pfad Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) Konkretes Signal Frontend laesst es durch?
TenantController.findAll Der Relationszaehler (include: { _count: { select: { users } } }) laeuft ungebunden auf dem generierten Client (Pruefung 5): userCount ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (Tenant ohne Regel) Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste Ja — admin/tenants/page.tsx zeigt tenant.userCount ungeprueft an
TenantController.findOne Dieselbe Form wie findAll, fuer eine einzelne Kennung userCount: 0 fuer den betrachteten Mandanten Ja — dieselbe Anzeige (falls einzeln abgefragt)
TenantController.create Kein Datenbankzugriff des Controllers selbst — delegiert an TenantService.create Keins (Controller-Ebene) —
TenantController.update Kein Datenbankzugriff des Controllers selbst — delegiert an TenantService.update, nach ungebundenem findById (Tenant ohne Regel, unveraendert) Keins —
TenantController.remove Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: _count.users ist 0 (Pruefung 7), der Riegel T-02-09 passiert, tenant.delete laeuft — und trifft den Fremdschluessel User_tenantId_fkey (ON DELETE RESTRICT): SQLSTATE 23503, Prisma-Code P2003, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" Teilweise — handleDelete in admin/tenants/page.tsx prueft res.ok, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3))
TenantService.findAll Ungebunden, Tenant ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) Keins (totes Codeglied) —
TenantService.findById Ungebunden, Tenant ohne Regel — unveraendert korrekt, wird von TenantController.update als Existenzpruefung benutzt Keins —
TenantService.create Ungebunden, Tenant ohne Regel; ruft danach groupsService.ensureDefaultGroup(tenant.id) auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber forTenant()/withTenantTransaction(), gebunden an den soeben angelegten Mandanten Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert —
TenantService.update Ungebunden, Tenant ohne Regel — unveraendert korrekt Keins —
TenantGuard Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) Keins —

(n3) Welcher Code eine falsche Zahl als Wahrheit deutet

Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt.

Backend: TenantController.findAll/findOne liefern userCount: 0 ohne jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl, die schlicht falsch ist. TenantController.remove laesst den Riegel T-02-09 passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400, siehe (n1)/(n2)).

Frontend, namentlich mit Stelle:

  • apps/web/src/app/(portal)/admin/tenants/page.tsx zeigt tenant.userCount ungeprueft in der Tabellenzeile an (Zeile 209: {tenant.userCount}). fetchTenants (Zeilen 44-55) prueft zwar res.ok, aber bei nicht-OK passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste bleibt leer oder veraltet stehen (catch { // silently fail }). handleDelete (Zeilen 122-133) prueft ebenfalls res.ok, aber bei nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der Bestaetigungsdialog (deleteConfirm) bleibt offen, fetchTenants() wird nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein Knopf, der nicht reagiert, nicht wie ein Fehler.
  • apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx faengt jede nicht-OK-Antwort in eine LEERE Liste (.then((res) => (res.ok ? res.json() : []))) und jeden Netzwerkfehler in ein stilles Nichts (.catch(() => {})) — der SUPER_ADMIN sieht im Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine".

Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen — Zeilennummern koennen sich verschieben.

(n4) Was dieser Durchlauf bewusst nicht löst

(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft. Gemessen (Befund B/C der Planung, in Aufgabe 1 wiederholt): apps/api/src/tenant/tenant.guard.ts und apps/api/src/tenant/tenant.middleware.ts setzen req.tenantPrisma = forTenant(this.prisma, tenantId), aber eine Volltextsuche ueber apps/api/src (grep -rn '\.tenantPrisma') findet ausserhalb dieser beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder apps/api/src noch apps/api/src/main.ts enthalten ein MiddlewareConsumer, ein configure( oder einen .apply(...).forRoutes(...)-Aufruf auf TenantMiddleware — app.module.ts implementiert kein NestModule. ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch req.tenantId; tenant.middleware.ts ist GELOESCHT (eine nie aufgerufene Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor diesem binden ausnahmslos dienst-intern, ein Klient je Methode (forTenant(this.prisma, tenantId) in der jeweiligen Service-Methode) — die Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz, den es nicht gibt.

(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G). rls-access-inventory.spec.ts sammelt (Datei, Modell)-Paare ausschliesslich ueber this.prisma.<Modell> und <gebundener Client>.<Modell> — eine Relationseinbindung (include:, Relationszaehler _count) in eine ZWEITE Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1 erneut vermessen:

Alle _count-Stellen ausserhalb von tenant/ (grep -rn "_count" apps/api/src --include=*.ts | grep -v spec): groups.service.ts:66 (auf bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und tenders.controller.ts:405 (groupBy auf der plattformweiten, ungeschuetzten Tender, kein Relationszugriff in eine zweite Tabelle — harmlos).

Alle include:-Stellen (grep -rn "include:" apps/api/src --include=*.ts | grep -v spec, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt — aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht, eingebundene Tabelle geschuetzt oder nicht:

Datei Aeusserer Aufruf Aeussere Tabelle geschuetzt Eingebundene Tabelle geschuetzt Urteil
tenant.controller.ts (3 Stellen: findAll/findOne/remove, vor Aufgabe 3) ungebunden Nein (Tenant) JA (User) GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben
ldap-config.service.ts:309 (getAllActiveConfigs) ungebunden (bewusst uebergreifend) JA (LdapConfig) eingebunden: tenant, fieldMappings harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues
tenders.controller.ts:612 (sources) ungebunden Nein (Tender, D-03 plattformweit) Nein (TenderSource, ebenfalls plattformweit) harmlos — beide Seiten plattformweit
übrige 14 Stellen (groups.service.ts, module-grants.service.ts, dashboard.service.ts, dkv.service.ts, tender-*.service.ts, user.service.ts) ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen — — harmlos, einzeln nachgesehen

Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3. ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit fand keine zweite — faende eine spaetere Wiederholung eine zweite Auspraegung, waere DANN ein gsd-tools windows append-Eintrag anzulegen, mit Verweis auf diesen Absatz.

(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke. User_tenantId_fkey faengt auch INAKTIVE Benutzer, waehrend der Riegel T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses Auftrags.

(d) TenantService.findAll ohne Aufrufer (Befund L) — bleibt totes Codeglied, nicht entfernt (Scope).

(e) Der unerreichbare null-Zweig des Guards — SUPER_ADMIN ohne tenantId und ohne x-tenant-id-Header ist mit dem heutigen Sitzungsnachweis unerreichbar (User.tenantId ist String, nicht nullbar), bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten getestet, nicht umgebaut.

(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft. Ein SUPER_ADMIN kann per x-tenant-id eine erfundene Kennung schicken (D-10 wie entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck.

(g) Die Mehrkosten des Fan-outs. Nach Aufgabe 3 kostet findAll eine gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger Mandantenzahl belanglos, dieselbe Form wie UserService.findAllForPlatformAdmin.

(h) Die Etappe-4-Vorabpruefung. Die Benutzerzahl je Mandant ueber die Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie im Bereich user — kein eigener Eintrag noetig.

Nachtrag (260911-mkj): WINDOWS #27 — die in (b) beschriebene Erkennungsluecke der Bestandsaufnahme — ist geschlossen. Eine vierte Erkennungsform in rls-access-inventory.spec.ts (analyzeSource, SCHEMA_RELATIONS) loest Relationsfelder ueber schema.prisma auf ihr Zielmodell auf und traegt das Zielmodell als eigene Fundstelle ein — der Nachweis steht gegen KONTROLLIERTE Proben im Spec, nicht gegen die drei realen Stellen aus (b): die einzige gefaehrliche Auspraegung (tenant.controller.ts, ungebundener aeusserer Aufruf auf ungeschuetzter Tabelle mit Einbindung in eine geschuetzte Tabelle) traegt die Form seit (n3)/Aufgabe 3 (dem Fan-out-Ersatz) nicht mehr — deshalb Probestrings, die dieselbe Form nachbilden (ungebunden und gebunden). Gemessen: SIEBEN neue (Datei, Modell)-Paare, DREI fortgeschriebene Staende, davon ldap-config.service.ts/ldapFieldMapping als einziger Klassenwechsel (muss-mandantengebunden -> beides, siehe (b) oben: getAllActiveConfigs reicht auch in LdapFieldMapping hinein, nicht nur in Tenant). Verbleibende Grenze, LAUT gehalten statt still: ein Empfaenger ausserhalb aller vier Erkennungsformen bleibt fuer JEDE Form unsichtbar — gemessen tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService) und tenders/backfill-tender-source.ts (eigenstaendiges Skript, eigener new PrismaClient()), beide als eigener Ledger-Eintrag gefuehrt (nicht stillschweigend mit #27 mitgeschlossen).

(n5) Was dieser Durchlauf bewusst nicht anfasst

  • Der direkte Prisma-Zugriff im Controller (Muster wie user.controller.ts) — bleibt, Wartbarkeitsvermerk, keine Verschiebung in den Dienst.
  • Die redundante @UseGuards(RolesGuard)-Klassenregistrierung neben der globalen APP_GUARD-Registrierung von RolesGuard.
  • Das Frontend — in (n3) beschrieben, nicht geaendert.
  • tenant.service.ts — unveraendert.
  • Die veraltete Tabellenliste im Abschnitt ## Mandantentrennung von docs/anleitung-entwicklung.md ("aktuell nur auf User, PasswordResetToken, ..." — seit 20260909140000 sind es 23 Tabellen); Aufgabe 3 aendert in jener Datei NUR die Absaetze zu Guard und Middleware, diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit festgehalten.
  • Die historische Nennung der Middleware in docs/mandantentrennung-datenbankrolle.md:124 — beschreibt den Stand VOR Etappe 1 korrekt, nicht zu aendern.
  • Schema und Migrationen — geprueft und bewusst gelassen, Tenant bekommt KEINE Regel.

Bereich auth

Dieser Abschnitt erweitert die Kritikschrift um den Bereich auth (Quick-Task 260911-fh9) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Anders als jeder Bereich davor traegt dieser Bereich die Grenze, an der die gesamte Mandantentrennung haengt: der Anmeldeweg (Benutzer suchen, BEVOR der Mandant bekannt ist) wurde bereits in Etappe 1 (260909-eor) ueber drei enge SECURITY-DEFINER-Funktionen geloest und bleibt in diesem Durchlauf unangetastet; gegenstand dieses Durchlaufs sind ausschliesslich die drei Methoden NACH der Anmeldung (getMe, changePassword, adminResetPassword), die in Etappe 1 bewusst liegen gelassen wurden.

(h1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen dreizehnten Abschnitt (runAuthAreaChecks) erweitert — ausdruecklich GETRENNT vom bestehenden runAuthLookupChecks: jener misst den Anmeldeweg VOR bekanntem Mandanten (die drei Funktionen ueber $queryRaw), dieser die drei Methoden NACH der Anmeldung (gebundener Modellzugriff ueber den GENERIERTEN Client). Der neue Abschnitt setzt auf der von runAuthLookupChecks bereits angelegten Wegwerf-Tabelle "User" auf (eingeschalteter und erzwungener Zeilenschutz, wortgleiche Policy, zwei Zeilen in zwei Mandanten; seit runTenantAreaChecks zusaetzlich mit Fremdschluessel auf "Tenant") und ruestet sie um die fuenf Spalten nach, die der generierte Client zusaetzlich braucht (createdAt, updatedAt, lastLoginAt, avatarPath, accentColor — Befund G): dafuer liest ein neuer Helfer readSchemaModelScalarFieldNames('User') die skalaren Felder aus schema.prisma und filtert Relationsfelder ueber ihren Typ heraus (tenant, passwordResetTokens, groupMemberships, moduleGrants fallen weg, role bleibt — Role ist ein enum, kein model). Diese Spaltenpruefung steht VOR den Client-Pruefungen und bricht den Abschnitt ab, wenn sie durchfaellt (Lehre aus Pruefung 8 im Bereich calendar).

Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, 15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]
auth-anmeldefunktionen-security-definer-unveraendert: bestanden — 3 Funktion(en) unter 'auth_lookup_%' gefunden: ["auth_lookup_reset_token","auth_lookup_user_by_email","auth_lookup_user_by_username"]; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: [{"proname":"auth_lookup_reset_token","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_email","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_username","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true}]
auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz: bestanden — auth_lookup_user_by_username('alice') liefert 1 Zeile(n) mit 9 Schluessel(n): ["displayName","id","isActive","ldapDn","mustChangePassword","passwordHash","role","tenantId","username"] — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch
auth-getme-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert null — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert
auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer: bestanden — gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): {"id":"user-a","username":"alice","displayName":null,"role":"USER","tenantId":"TENANT-A","mustChangePassword":false,"passwordHash":"hash-a","ldapDn":null,"avatarPath":null,"accentColor":null}
auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): null — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01
auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut: bestanden — ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — die Wartungsrolle liest danach weiterhin passwordHash="hash-a"
auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt: bestanden — gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash="hash-a-neu", updatedAt="2026-09-11T09:42:42.165Z" — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt
auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut: bestanden — gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash="hash-a-neu"
auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf: bestanden — Fan-out ueber 2 Mandant(en): 'user-a' gefunden unter "TENANT-A", tenantId der Zeile="TENANT-A" — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist
Alle 120 Pruefungen bestanden.

Die tragende Belegzeile ist auth-getme-generierter-client-ungebunden-liefert-null: der IDENTISCHE findUnique, den getMe heute stellt, liefert UNGEBUNDEN null, waehrend die Wartungsrolle die Zeile sieht — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert. Die pg_proc-Messung (auth-anmeldefunktionen-security-definer-unveraendert) und der feste Spaltensatz (auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz) belegen zusammen, dass die drei Anmeldefunktionen unangetastet sind und die Spaltenerweiterung von "User" nichts Zusaetzliches preisgibt: alle drei Funktionen sind weiterhin SECURITY DEFINER, STABLE, mit festem Suchpfad search_path=public, pg_temp und LIMIT 1, und auth_lookup_user_by_username liefert nach der Spaltenerweiterung weiterhin genau neun Spalten — weder avatarPath noch accentColor noch email sind darunter.

Sechs der zehn Pruefungen laufen ueber den GENERIERTEN CLIENT (prisma.user.findUnique/update, bound.user.findUnique/update), nicht ueber Roh-SQL — bewusst, weil findUnique ohne select (die Form von changePassword/adminResetPassword) und das select von getMe Client- Formen sind: Roh-SQL sieht sie strukturell nicht (Fehler 7 des Vorhabens).

(h2) Signaltabelle je Pfad

Pfad @Public() Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) Konkretes Signal Frontend laesst es durch?
validateUser (POST /auth/login) ja Sucht ueber auth_lookup_user_by_username (Funktion, unveraendert) — findet den Benutzer weiterhin, dann gebundener lastLoginAt-Schreibzugriff Anmeldung funktioniert unveraendert —
requestPasswordReset (POST /auth/request-reset) ja Sucht ueber auth_lookup_user_by_email (Funktion, unveraendert), dann gebundene Token-Anlage Unveraendert, immer 200 (T-02-12) —
resetPassword (POST /auth/reset-password) ja Sucht ueber auth_lookup_reset_token (Funktion, unveraendert), dann gebundenes Kennwort-Update und Token-Markierung Unveraendert —
login/logout ja/nein Kein Datenbankzugriff Keins —
getMe (GET /auth/me) nein Vor diesem Plan: ungebundener findUnique liefert null (auth-getme-generierter-client-ungebunden-liefert-null) statt der eigenen Zeile 200 mit leerem Rumpf statt der Profildaten Ja — verschluckt, siehe (h3)
changePassword (POST /auth/change-password) nein Vor diesem Plan: ungebundener findUnique liefert null UnauthorizedException('User not found or has no local password'), 401 Ja — als networkError, siehe (h3)
adminResetPassword, ADMIN-Zweig (POST /auth/admin-reset-password/:userId) nein Vor diesem Plan: ungebundener findUnique sieht Ziele JEDES Mandanten — Rechteausweitung ueber die Mandantengrenze (T-FH9-01), kein Frontend-Aufrufer BadRequestException('User not found') nur, wenn die Kennung gar nicht existiert; ein fremdmandantiges Ziel wuerde heute GEFUNDEN Kein UI-Aufrufer (gemessen, Befund D)
adminResetPassword, SUPER_ADMIN-Zweig nein Dieselbe Ausweitung; zusaetzlich keine Rollenpruefung des Ziels (T-FH9-04) Dieselbe Meldung Kein UI-Aufrufer

Nach der Bindung (Aufgabe 2) loest der SUPER_ADMIN-Zweig den Mandanten des Ziels ueber den gebundenen Fan-out UserService.findByIdForPlatformAdmin auf — denselben Praezedenzfall, den user.controller.ts (resolveTargetUser, 260910-das) fuer seine eigene Rollenverzweigung benutzt; der ADMIN-Zweig bindet dagegen an currentUser.tenantId aus dem Sitzungsnachweis.

(h3) Welcher Code Leere als Abwesenheit deutet

Die getMe-Kette, alle vier Glieder namentlich: getMe liefert null (die Belegzeile auth-getme-generierter-client-ungebunden-liefert-null) → AuthController.me gibt null zurueck, ohne zu werfen → NestJS' ExpressAdapter.reply sendet bei isNil(body) einen LEEREN Rumpf mit Status 200 (apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js, Methode reply) → fetchCurrentUser (apps/web/src/lib/auth-actions.ts) laeuft mit response.json() auf den leeren Rumpf, faengt im catch und liefert null → apps/web/src/components/layout/header.tsx (if (u) setUser(...)) und apps/web/src/components/settings/account-settings-form.tsx (if (u) ...) tun bei null NICHTS. Folge: die Portalhuelle rendert OHNE angemeldeten Benutzer — kein Name, kein Avatar, isAdmin falsch, Admin- Navigation weg. Der Auftrag vermutete hier "laut"; die Lesung ergibt verschluckt: fetchCurrentUser liefert fuer "nicht angemeldet" und "Zeile unsichtbar" denselben Wert null — das ist NICHT die Familie eines lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26. Die Seite apps/web/src/app/(portal)/change-password/page.tsx haengt an demselben Aufruf; ForcePasswordChangeInterceptor laesst /auth/me und /auth/change-password ausdruecklich durch, weil der erzwungene Kennwortwechsel getMe braucht.

changePassword: der gebundene findUnique liefert nach der Bindung null fuer ein fremdmandantiges Ziel (kommt hier praktisch nicht vor — der angemeldete Benutzer sucht sich selbst) → UnauthorizedException('User not found or has no local password'), 401 → auth-actions.ts uebersetzt NUR die woertliche Meldung Current password is incorrect in wrongCurrentPassword, jede andere 401-Meldung in networkError — der Nutzer saehe eine Netzwerkfehler-Meldung fuer einen Trennungsfehler. Der Auftrag vermutete "falsches altes Kennwort"; gemessen ist es networkError.

adminResetPassword: BadRequestException('User not found'), 400, kein UI-Aufrufer (Befund D) — fuer einen API-Aufrufer bedeutet das "diesen Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das NACH der Bindung dieses Plans die RICHTIGE Antwort: sie macht keine Aussage ueber die Existenz oder den Mandanten eines fremden Halters (dieselbe Form wie T-DAS-08).

(h4) Was dieser Durchlauf bewusst nicht löst

(a) Etappe 3. Die Etappe-3-Entscheidung (1) macht username (und email) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag: auth_lookup_user_by_username(p_username) (stuetzt sich heute auf username @unique plattformweit; braucht kuenftig (p_tenant_id, p_username) — die Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht weiter), gegebenenfalls auth_lookup_user_by_email, local.strategy.ts (kennt nur username/password, braucht eine Mandantenangabe VOR der Suche), die @unique-Indizes auf User, UserService.findByUsername, resolveEmailForWrite (ldap), die P2002-Uebersetzung in UserService.create/ update (WINDOWS #22). Dieser Plan ist dafuer NEUTRAL: die Bindung der drei Methoden haengt am Claim tenantId (das es unabhaengig davon gibt, WIE der Anmeldeweg den Mandanten ermittelt) und an User.id (plattformweite UUID, Kette aus 260911-cwh: Schema → auth.service.ts sub: user.id → JwtStrategy.validate → @CurrentUser().id) — nicht an username/email. Unterlassen, damit Etappe 3 nicht schwerer wird: Selbstbedienung ueber req.tenantId binden; den Mandanten irgendwo nach der Anmeldung aus username/email ableiten; die Funktionen um Spalten erweitern.

(b) Die Rechteausweitung ADMIN → SUPER_ADMIN im Schwesterweg. apps/api/src/user/user.controller.ts, update (Zeilen 172-208) prueft mit T-02-08 nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht, ob das ZIEL diese Rolle bereits HAT — password/isActive im UpdateUserDto gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch. remove (Zeilen 220-244) prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Erneut gelesen (260911-fh9, Aufgabe 1): beide Luecken bestehen unveraendert — grep -n "Role.SUPER_ADMIN" apps/api/src/user/user.controller.ts findet nur die eine Stelle in update (dto.role === Role.SUPER_ADMIN), keine im user-Objekt selbst. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten — derselbe Fall wie T-FH9-04 in diesem Plan, nur am Schwesterweg. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist user.role === Role.SUPER_ADMIN und der Aufrufer nicht SUPER_ADMIN, ForbiddenException) — die Vorlage steht seit dieser Aufgabe in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger-Eintrag statt Reparatur (Aufgabe 3, T-FH9-05).

(c) Das Frontend, das null verschluckt. Nicht angefasst (siehe (h3)); Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26.

(d) Die fehlende Existenzpruefung des x-tenant-id-Werts. Bekannt aus (n4)(f) des Abschnitts ## Bereich tenant; hier nur genannt, weil sie die Entscheidung gegen req.tenantId fuer Selbstbedienung stuetzt — ein SUPER_ADMIN kann per x-tenant-id eine erfundene Kennung schicken, sie bindet an einen leeren Kontext, kein Leck. Nicht dieser Auftrag.

(e) Die Etappe-4-Vorabpruefung. Fuer einen bekannten Benutzer die Zeile ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten — dieselbe Form wie bei den Bereichen user und tenant.

(f) changePassword hat zwei verschiedene 401-Meldungen. User not found or has no local password (kein lokales Kennwort, z. B. LDAP-Konto) vs. Current password is incorrect (falsches Kennwort) — bestehendes Verhalten, betrifft nur die eigene Zeile des angemeldeten Benutzers, unveraendert in diesem Plan.

(h5) Was dieser Durchlauf bewusst nicht anfasst

  • Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 (auth_lookup_user_by_username, auth_lookup_user_by_email, auth_lookup_reset_token) und ihre Migration 20260909160000_auth_lookup_functions — belegt durch git diff --name-only 6236b30 -- apps/api/prisma (leer) und die pg_proc-Messung aus (h1).
  • Die drei $queryRaw-Aufrufe in validateUser, requestPasswordReset, resetPassword — bleiben auf dem ungebundenen Klienten this.prisma.
  • local.strategy.ts und jwt.strategy.ts — nur gelesen.
  • apps/api/src/auth/dto/admin-reset-password.dto.ts — nur gelesen, kein Mandantenfeld.
  • docs/mandantentrennung-datenbankrolle.md, Abschnitt 3 — beschreibt die Anordnung des Anmeldewegs korrekt, keine Aenderung noetig.
  • docs/anleitung-entwicklung.md — nennt keine der drei Methoden.
  • apps/api/src/user/user.controller.ts — nur gelesen (siehe (h4)(b)).
  • Das Frontend (header.tsx, account-settings-form.tsx, auth-actions.ts, change-password/page.tsx) — nur beschrieben, nicht geaendert.
  • Schema und Migrationen.
  • Die Kopfzeile in auth.service.spec.ts (Zeile 4-6), die auf ein Muster in ldap.service.spec.ts verweist — wird in Aufgabe 2 ersetzt, hier nur als Befund F genannt.

Bereich favorites

Dieser Abschnitt erweitert die Kritikschrift um den Bereich favorites (Quick-Task 260911-gwh, zusammen mit settings der LETZTE Lauf von Etappe 2) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt FavoriteLink über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen Tabelle (WidgetInstance), deren Zeilenschutz-Regel der Fremdschlüssel auf Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal ausdrücklich mitgebaut statt nur benannt.

(f1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen weiteren Abschnitt (runFavoritesAreaChecks) erweitert, mit der Policy für FavoriteLink (aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables) WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
Alle 137 Pruefungen bestanden.

Die tragende Belegzeile ist favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: der IDENTISCHE findMany, den list heute stellt, liefert UNGEBUNDEN [], während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem favorites-widget.tsx Noch keine Favoriten. macht (siehe (f3)).

Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet (Befund C, WINDOWS #27): die Wegwerf-Tabelle "FavoriteLink" trägt FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE auf die von runDashboardAreaChecks bereits angelegte Tabelle; vorher wurde über die Wartungsrolle gemessen, welche WidgetInstance-Zeilen tatsächlich stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.

Das Ergebnis von Prüfung 7 (favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei) ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes create unter TENANT-A mit widgetId eines TENANT-B-Widgets GELINGT — der Fremdschlüssel prüft an der Zeilenschutz-Regel von WidgetInstance VORBEI (dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row Security). Derselbe Aufruf mit einer wirklich fehlenden widgetId scheitert dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen (T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen Besitzriegel in create, der beide Fälle auf dieselbe Antwort (Widget not found) abbildet.

(f2) Signaltabelle je umzustellendem Pfad

Pfad Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) Konkretes Signal Frontend lässt es durch?
list (GET /favorites?widgetId=) Ungebundener findMany liefert [] statt der eigenen Zeilen 200 [] Ja — fetchFavorites (favorites-api.ts:22-29) gibt [] durch, siehe (f3)
create (POST /favorites) Ungebundenes create schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob widgetId existiert und dem Aufrufer gehört NotFoundException('Widget not found'), 404, wenn das Widget nicht dem Aufrufer gehört Ja — createFavorite (favorites-api.ts:33-46) wirft Failed to create favorite bei !res.ok
update (PATCH /favorites/:id) Ungebundener findUnique liefert null statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) NotFoundException('FavoriteLink not found'), 404 Ja — updateFavorite wirft Failed to update favorite
remove (DELETE /favorites/:id) Dieselbe Form wie update NotFoundException('FavoriteLink not found'), 404 Ja — deleteFavorite wirft Failed to delete favorite
getIconBytes (GET /favorites/:id/icon) Dieselbe Vorprüfung wie update/remove NotFoundException('FavoriteLink not found'), 404 Ja — <img>-Ladefehler, vom Widget nicht gesondert behandelt

(f3) Welcher Code Leere als Abwesenheit deutet

Die list-Kette, alle Glieder namentlich: list liefert [] (die Belegzeile favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste) → GET /favorites?widgetId= antwortet 200 [] → fetchFavorites (favorites-api.ts:22-29) prüft nur !res.ok (bei 200 erfüllt das nicht) und gibt [] durch → favorites-widget.tsx:212-213 zeigt t('favorites.empty') = Noch keine Favoriten. (de.json, Zeile 219). "Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend derselbe Wert [] — das ist NICHT die Familie eines lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard, calendar, auth).

update/remove/getIconBytes deuten Leere dagegen LAUT: die Vorprüfung (findUnique → null oder fremder userId) wirft NotFoundException('FavoriteLink not found'), 404 — favorites-api.ts übersetzt das in Failed to update/delete favorite, das Widget setzt t('favorites.error') im catch. Diese Richtung ist harmlos, weil ein zu kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem Scharfschalten neu entsteht.

(f4) Was dieser Durchlauf bewusst nicht löst

  • (a) Die fehlende Benutzerdimension der Regel. Prüfung 4 (favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen) zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes findMany auf die widgetId eines Kollegen DESSELBEN Mandanten liefert dessen Zeile ebenfalls — die Regel auf FavoriteLink kennt keine Benutzerdimension (dieselbe Lehre wie bei CalendarSource, DashboardLayout, WidgetInstance). Die anwendungsseitige userId-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten.
  • (b) Das Frontend. Die list-Kette aus (f3) wird nicht geändert; Ledger-Eintrag in Aufgabe 3.
  • (c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht der auth-Präzedenzfall. favorites.controller.ts extractContext liest req.tenantId ?? req.user?.tenantId — WORTGLEICH mit dashboard.controller.ts, unter dessen Bindung WidgetInstance liegt. FavoriteLink hängt über widgetId an WidgetInstance; würde favorites stattdessen an das Claim binden (wie auth für Selbstbedienung), während dashboard an der Guard-Kennung bleibt, lägen Widget und Link unter einem x-tenant-id-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten. favorites-api.ts sendet die Kopfzeile heute nicht (grep -rn "x-tenant-id" apps/web/src: nur die vier Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am heutigen Aufrufer.
  • (d) Die Etappe-4-Vorabprüfung. Für einen bekannten Nutzer/Widget die Favoritenzahl über die Wartungsrolle und über den gebundenen findMany daneben halten — dieselbe Form wie bei dashboard/calendar.

(f5) Was dieser Durchlauf bewusst nicht anfasst

  • Der Icon-Proxy (icon-discovery.service.ts, Befund G) — erreicht die Datenbank NICHT und nimmt KEINE Client-URL: getIconBytes holt nur die GESPEICHERTE iconUrl einer Zeile, die der Aufrufer besitzt (T-QFIP-01); discoverFavoriteIconUrl nimmt die Nutzer-URL nur für den SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in der Testdatei eine Attrappe.
  • Die DTOs (create-favorite.dto.ts, update-favorite.dto.ts) — nur gelesen, kein Mandantenfeld.
  • dashboard.controller.ts — nur gelesen (Präzedenzfall für (f4)(c)).
  • Das Frontend (favorites-widget.tsx, favorites-api.ts) — nur beschrieben, nicht geändert.
  • Schema und Migrationen.

Bereich settings

Dieser Abschnitt erweitert die Kritikschrift um den Bereich settings (Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung. Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung Befund K aus den Läufen tenders und dkv: getDecryptedSmtpConfig(tenantId) ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich trägt der Startpfad des Mailmoduls den SECHSTEN Fall der Hintergrunddienst-Falle — anders als bei dkv (WINDOWS #21) verdeckt hier eine Rückfallkette das Verstummen mit einem falschen Transport statt schlichter Leere.

(s1) Die Messung

Aufgabe 1 hat apps/api/scripts/rls-scratch-check.mjs um einen weiteren Abschnitt (runSettingsAreaChecks) erweitert, mit der Policy für SmtpConfig (aus der ausgelieferten Migration 20260909140000_rls_remaining_tenant_tables) WORTGLEICH extrahiert. Die Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex SmtpConfig_tenantId_key WORTGLEICH aus 20260629130000_add_missing_tables — ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
Alle 137 Pruefungen bestanden.

Die tragenden Belegzeilen sind drei: smtpconfig-startpfad-generierter-client-ungebunden-liefert-null für den Startpfad, smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null für Befund K, und smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen — PrismaClientUnknownRequestError, dieselbe Fehlerklasse, die 260910-krx für DashboardLayout gemessen hat (nicht PrismaClientKnownRequestError mit P2002, die Form der Bereiche tenders/user): die Regel weist den Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die zugrundeliegende PostgreSQL-Meldung (SQLSTATE 42501, "new row violates row-level security policy") ist in der Belegausgabe wörtlich enthalten.

(s2) Signaltabelle je Pfad

Pfad Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) Signal Frontend
getSmtpConfig (GET /settings/smtp) Ungebundener findUnique liefert null statt der eigenen Zeile Controller gibt null → NestJS sendet 200 mit LEEREM Rumpf Ja — verschluckt, siehe (s3)
saveSmtpConfig (PUT /settings/smtp) Ungebundenes upsert scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist PrismaClientUnknownRequestError, 500 Nein — kein spezifischer Übersetzungspfad im Controller, roher 500
getDecryptedSmtpConfig — Aufrufer tender-mail.service.ts (resolveTransport) Ungebundener findUnique liefert null logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)'), KEIN Wurf, Digest übersprungen — (Hintergrunddienst, kein Frontend-Pfad)
getDecryptedSmtpConfig — Aufrufer dkv-mail.service.ts (sendExportEmail) Dieselbe Form throw new Error('No SMTP configuration found for tenant ...') — (Hintergrunddienst)
testSmtpConfig (POST /settings/smtp/test) Rückgriff auf getDecryptedSmtpConfig liefert null, password/username bleiben undefined (falls DTO leer) Verbindungstest scheitert am Postfach, { success: false } — irreführend, zeigt auf das Postfach statt auf die Datenbank Ja — Testergebnis wird angezeigt
Startpfad loadAnySmtpConfigForStartupTransport() (mail.module.ts, beim Start) Ungebundenes findFirst() liefert null statt einer beliebigen Zeile; die Rückfallkette von mail.module.ts greift (Priorität 2-4, zuletzt localhost:1025) — EIGENE Spalte, siehe (s4)(a) Kein Fehler, ein FALSCHER, aber vorhandener Transport; MailService fängt jeden Transportfehler (T-02-12), Controller antwortet 200 Ja — doppelt verdeckt

(s3) Welcher Code Leere als Abwesenheit deutet

Die getSmtpConfig-Kette, alle Glieder namentlich: getSmtpConfig liefert null (Belegzeile smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null zeigt dieselbe Form für den Versandpfad) → settings.controller.ts gibt null zurück, ohne zu werfen → NestJS' ExpressAdapter.reply sendet bei isNil(body) einen LEEREN Rumpf mit Status 200 (dieselbe Adapter-Kette wie in (h3), 260911-fh9) → fetchSmtp (settings-api.ts:51-57) prüft nur res.status === 404 (nicht erfüllt) und !res.ok (bei 200 nicht erfüllt), dann res.json() auf den leeren Rumpf → wirft → smtp-settings-form.tsx:76-78 .catch(() => { /* Silent fail */ }) → leeres Formular: "SMTP nicht eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand — dieselbe Familie wie WINDOWS #23/#25/#26/#28.

Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft saveSmtpConfig als upsert({ where: { tenantId } }): unter der UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein INSERT und scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key — PrismaClientUnknownRequestError (Prüfung 8, gemessen statt vorweggenommen; dieselbe Fehlerklasse wie die dashboard-Lehre aus 260910-krx, NICHT P2002).

Die beiden Versandpfade reagieren unterschiedlich auf null (getDecryptedSmtpConfig): tender-mail.service.ts protokolliert eine Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf), dkv-mail.service.ts wirft einen Fehler, der die aufrufende Pipeline abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern sich hier nicht.

(s4) Was dieser Durchlauf bewusst nicht löst

(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle, ausgeschrieben statt still getroffen. mail.module.ts's MailerModule.forRootAsync({ useFactory: async ... }) ruft SettingsService.loadAnySmtpConfigForStartupTransport() (vormals getStartupSmtpConfig()) BEIM START, vor jedem Anfragekontext. Zwei Zustände, beide gehören benannt:

  • Heute ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
  • Nach dem Scharfschalten liefert findFirst() null → mail.module.ts fällt auf Priorität 2 (MAIL_*), 3 (TESSERA_SMTP_*), zuletzt 4 (localhost:1025, Mailhog) zurück → MailService fängt jeden Transportfehler (mail.service.ts:69-81, T-02-12) und der Controller antwortet 200. Das Verstummen ist damit DOPPELT verdeckt: erst durch die Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann durch das absichtliche Verschlucken im Versand.

Drei geprüfte Formen: (a) An einen konkret aufgelösten Mandanten binden — nicht möglich, useFactory hat beim Start keinen Anfragekontext. (b) Umbau auf Transport je Versand — abgelehnt als Funktion für DIESEN Lauf, mit Grund: die Vorlage steht bereits in DkvMailService/ TenderMailService (Transport je Versand aus getDecryptedSmtpConfig(tenantId)), MailService müsste dafür nur den Mandanten entgegennehmen, den requestPasswordReset aus der Funktionszeile bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser Auftrag (siehe <hard_constraints>). (c) Als benannte Altlast weiterführen, mit Markierung — GEWÄHLT: Methode umbenannt (loadAnySmtpConfigForStartupTransport(), dkv-Präzedenzfall — ein Name, den niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen, Modulkommentar in mail.module.ts, eigener Ledger-Eintrag.

Die Unsymmetrie zu BEIDEN Präzedenzfällen: getAllActiveConfigs (ldap) ist heute korrekt und verstummt erst später; loadAnyActiveConfigForScheduler (dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später, ABER mit einer Protokollzeile ("no active config found — cron job not registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.

Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21. Andere Datei (mail.module.ts/settings.service.ts statt dkv-scheduler.service.ts), andere Reparatur (Transport je Versand statt Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.

(b) Befund K ist erfüllt. Die Reihenfolgebedingung aus (t4) Befund K und (d4) Übergaben-Absatz — getDecryptedSmtpConfig(tenantId) müsse gebunden sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten tenantPrisma. Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in diesem Dokument sowie den Hintergrunddienst-Abschnitt in docs/mandantentrennung-zugriffsklassifikation.md). Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr führen.

(c) Das Frontend. Die getSmtpConfig-Kette aus (s3) wird nicht geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe 200-leerer-Rumpf-Kette wie auth).

(d) Die Mandantenquelle req.tenantId im Controller. Bewusst NICHT auf das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus auth): settings.controller.ts ist eine ADMIN-Konfigurationsseite, für die req.tenantId (per x-tenant-id für SUPER_ADMIN umschaltbar) die RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb unverändert und steht nicht in der Erlaubnisliste.

(e) Die Etappe-4-Vorabprüfung. Für einen bekannten Mandanten die SmtpConfig-Zeile über die Wartungsrolle lesen und den gebundenen findUnique daneben halten — dieselbe Form wie bei dashboard/calendar.

(s5) Was dieser Durchlauf bewusst nicht anfasst

  • settings.controller.ts — nur gelesen (siehe (s4)(d)).
  • apps/api/src/settings/dto/smtp-config.dto.ts — nur gelesen, kein Mandantenfeld.
  • tender-mail.service.ts (resolveTransport) — nur gelesen, ruft weiterhin getDecryptedSmtpConfig(tenantId) unverändert auf.
  • dkv-mail.service.ts (sendExportEmail) — dieselbe Form.
  • mail.service.ts — nur gelesen; mail.module.ts wird für die Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur angekündigt.
  • Das Frontend (smtp-settings-form.tsx, settings-api.ts) — nur beschrieben, nicht geändert.
  • Schema und Migrationen.
  • nodemailer — kein echter Transport in irgendeinem Test dieses Laufs (lokal gibt es keinen mailhog); in der neuen Testdatei per vi.mock('nodemailer') ersetzt.

Etappe 2 — Abschluss

Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig: jede klassifizierte Fundstelle in apps/api/src ist entweder gebunden oder mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet (Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.

Läufe. Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt aus .planning/STATE.md, Reihenfolge): ldap (260909-ipc), groups (260909-jts), tenders (260909-laa), dkv (260909-mir), user (260910-das), module-registry (260910-exd), dashboard (260910-krx), das Regelschluss-Plan tenders/SearchProvider (260910-jab), tenant (260911-e2s), calendar (260911-cwh), auth (260911-fh9), favorites/ settings (260911-gwh, dieser Lauf).

Summenzeile der Übersichtstabelle, vorher/nachher. Zum Kopf des Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59 Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs — DERIVIERT aus der Summenzeile in docs/mandantentrennung-zugriffsklassifikation.md, nicht abgeschrieben: 68 ungebundene, 178 gebundene Rohtreffer (Summe 246 — mehr als 227, weil Aufgabe 2 dieses Laufs mit dem Widget-Besitzriegel einen zusätzlichen gebundenen Rohtreffer einführt, der zur Planungszeit noch nicht feststand). Jeder der 68 verbleibenden ungebundenen Rohtreffer ist einer der in diesem Dokument (Abschnitte "## Bereich ...") oder im Klassifikationsdokument namentlich benannten, bewusst ungebundenen Fälle — siehe die Liste unten.

Klassen-Verteilung. Siehe docs/mandantentrennung-zugriffsklassifikation.md, Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3): 65 Paare (33 muss-mandantengebunden, 17 keine-mandantengebundene-tabelle, 13 beides, 2 bewusst-uebergreifend) — ein neues Paar (favorites.service.ts/widgetInstance) gegenüber den 64 Paaren vor diesem Lauf, plus zwei Paare, die nur ihre Stand-Spalte ändern (favorites.service.ts/favoriteLink, settings.service.ts/smtpConfig).

Bewusst ungebundene Reste je Bereich, mit Grund:

  • tenders: der D-03-Katalog (platform-global, Tender/TenderSource/ TenderSourcePollConfig), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe), createPlatform/ remove in tender-rss-feed.service.ts (WINDOWS #24).
  • ldap: getAllActiveConfigs/onApplicationBootstrap (Befund B), resolveEmailForWrite (plattformweit eindeutiger Schlüssel, T-IPC-04).
  • dkv: der Planer-Startpfad loadAnyActiveConfigForScheduler() (WINDOWS #21).
  • user: findByUsername (plattformweit eindeutiger Schlüssel), die Erstanlage-Prüfung und beide tenant-Zugriffe in admin-seed.service.ts, der Schleifentreiber tenant.findMany der Plattform-Administratorsicht.
  • module-registry/dashboard: der Modulkatalog (Module, keine Regel heute — Befund E).
  • auth: die drei Anmeldefunktionen (validateUser, requestPasswordReset, resetPassword) über die SECURITY-DEFINER-Wege.
  • tenant: die Mandantentabelle selbst (Tenant, keine eigene tenantId-Spalte, keine Regel in irgendeiner Migration).
  • settings: der sechste Fall der Hintergrunddienst-Falle, loadAnySmtpConfigForStartupTransport() (dieser Lauf, siehe (s4)(a)).

Offene Ledger-Einträge dieser Etappe (Nummern siehe .planning/WINDOWS.md, Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22 (plattformweite Eindeutigkeit von username/email), #23 (module-registry, unterscheidbares Signal fehlt), #24 (plattformweite RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende Fehlerrichtung), #26 (calendar, verschluckte Leere), #27 (Relationszugriffe für die Bestandsaufnahme unsichtbar, seit 260911-mkj geschlossen), #28 (auth, verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad des Mailmoduls, verschluckte Leere favorites, verschluckte Leere settings — Nummern siehe Aufgabe 3 dieses Plans).

Prüfungen und Tests. Werkzeug: siehe die Zeile Alle N Prüfungen bestanden. des letzten Laufs von rls-scratch-check.mjs in dieser Aufgabe. Tests: siehe die letzte Testausgabe von npm --prefix apps/api run test in dieser Aufgabe.

Was für Etappe 3 bleibt:

  • Der Anmeldeweg unter je Mandant eindeutigen Namen (username/email, Etappe-3-Entscheidung (1)) — siehe (h4)(a).
  • Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe (k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
  • Die Modulkatalog-Regel für Module (Befund E, module-registry) — sobald eine Regel eingeführt wird, müssen die heute bewusst ungebundenen Katalogzugriffe nachgezogen werden.
  • Die Kennzeichnung der bewusst-uebergreifend-Stellen (Systemkontext, siehe "Die drei Klassen" im Klassifikationsdokument).
  • Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines Nutzers mit Treffern unter zwei verschiedenen Mandanten).

Was Etappe 4 (rls-preflight.mjs) VOR dem Scharfschalten prüfen muss — die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die jetzt erfüllte Befund-K-Bedingung, die entfällt):

  • ldap: das Verstummen von getAllActiveConfigs (Befund B).
  • dkv: das Verstummen des Planer-Startpfads (WINDOWS #21).
  • tenders: wachsende Zahl von TenderMatch-Zeilen mit notifiedAt IS NULL ohne Versandprotokoll (t3).
  • module-registry: aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen bekannten Administrator eine leere Menge (WINDOWS #23).
  • dashboard: eine physisch vorhandene DashboardLayout-Zeile für einen bekannten Benutzer, aber der gebundene Lesezugriff liefert null (WINDOWS #25).
  • calendar: physisch vorhandene CalendarSource-Zeilen je Mandant über die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen (k4)(e).
  • auth: einen bekannten Benutzer über die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten (WINDOWS #28).
  • favorites: für einen bekannten Nutzer/Widget die Favoritenzahl über die Wartungsrolle und über den gebundenen findMany daneben halten (f4)(d).
  • settings: für einen bekannten Mandanten die SmtpConfig-Zeile über die Wartungsrolle lesen und den gebundenen findUnique daneben halten (s4)(e).
  • Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES Signal, NICHT an #21 angeschlossen.

Verweis

Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang bereits vollzogen hat (Stand-Spalte gebunden/ungebunden/gemischt), steht in docs/mandantentrennung-zugriffsklassifikation.md.