Files
tessera-ctl/docs/mandantentrennung-etappe2-fehlerrichtung.md
T
schalli 761e5e2c36 feat(quick-260909-mir): dkv-Fehlerform messen und Kritikschrift erweitern
- rls-scratch-check.mjs: runDkvAreaChecks() misst die drei ausgelieferten
  Policies (DkvInvoiceHistory/DkvModuleConfig/DkvVehicleMaster) wortgleich
  aus der Migration, plus die Nebenlaeufigkeitsform von getHistory()
  (Promise.all ueber zwei gebundene Einzelabfragen); alle 9 neuen plus
  32 bestehende Pruefungen bestehen (41 gesamt)
- Belegt Befund H (kein P2002-Fall, Mandant ist Teil des zusammengesetzten
  Schluessels) und Befund I (gebundenes INSERT mit fremder tenantId wird
  ohne eigene WITH-CHECK-Klausel trotzdem abgewiesen) an der echten
  Datenbank statt am Policy-Text
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
  "Bereich dkv" mit der dritten Fehlerform der Etappe (ein Einzelobjekt
  wird null, wo null bereits "nicht eingerichtet" bedeutet), der
  Signaltabelle je umzustellendem Pfad, den sieben Stellen aus Befund K
  (zerstoerend/lautlos/irrefuehrend) und der ausgeschriebenen
  Planer-Entscheidung (Form c, mit Unsymmetrie zum ldap-Praezedenzfall)

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

64 KiB

Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um

Dieses Dokument gehört zusammen mit docs/mandantentrennung-zugriffsklassifikation.md zur Vorbereitung der Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält, welche Fundstelle umgestellt wird, hält dieses Dokument fest, woran man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten (Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich ldap zum Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc).

(a) Die Leitfrage

Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die Rolle tessera läuft mit BYPASSRLS, jede Abfrage sieht alle Zeilen aller Mandanten, unabhängig davon, ob sie an forTenant() gebunden ist oder nicht. Ein vergessener forTenant()-Aufruf blieb bisher unsichtbar, weil die Policy gar nicht griff.

Nach dem Scharfschalten (Etappe 4, wenn DATABASE_URL auf eine Rolle ohne BYPASSRLS zeigt) kehrt sich das um. Jede Abfrage, die nicht gebunden ist, sieht nicht mehr alle Zeilen, sondern keine — die Policy vergleicht gegen current_tenant_id(), und ohne vorheriges set_config ist dieser Wert NULL. NULL = "tenantId" ist in SQL nie wahr, auch wenn "tenantId" selbst nicht NULL ist. Der Fehlerfall ist damit nicht mehr "ein Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut so, als sei nichts zu tun."

Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim Scharfschalten entdeckt: Woran würde ich merken, dass eine umgestellte Abfrage jetzt zu WENIG liefert statt zu viel?

(b) Die Messung

Task 1 dieses Plans hat apps/api/scripts/rls-scratch-check.mjs um einen dritten Abschnitt erweitert, der exakt das im Bereich ldap verwendete Muster (Tabellen LdapConfig/LdapFieldMapping, Policies wortgleich aus der ausgelieferten Migration 20260618112133_rls_policies/migration.sql herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle ohne BYPASSRLS prüft. Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-09, gegen tessera-ctl-db-1, Adresse 172.19.0.2):

ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n)
fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"]
fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich
fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping"
Alle 13 Pruefungen bestanden.

Der Beleg, der diese Kritikschrift trägt, ist die Zeile ldapconfig-ungebunden-null-zeilen: der IDENTISCHE SELECT "tenantId" FROM "LdapConfig" ohne vorheriges set_config liefert 0 Zeilen, nicht etwa alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung "sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine beobachtete Tatsache.

(c) Signaltabelle je umgestelltem Pfad des Bereichs ldap

Pfad Verhalten bei zu wenig Ergebnis Konkretes Signal
LdapConfigService.getConfig/createConfig/updateConfig forTenant() liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration GET /ldap/config liefert null; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat
LdapConfigService.addFieldMapping/removeFieldMapping Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung POST/DELETE /ldap/config/mappings scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert
LdapService.listGroups/searchUsers (Markierung "bereits importiert") Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen ldapObjectGuid-Eindeutigkeit scheitern)
LdapService.upsertMappedUser/importUsersByDn (Identitätssuche über ldapDn/username) Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos Der Abgleich-Bericht zählt das Konto unter created statt updated — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts
LdapService.syncUsersForTenant (Deaktivierungs-Kandidatenliste) Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen Der Zähler deactivated bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel
LdapService.syncUsersForTenant (lastSyncAt-Fortschreibung) Das UPDATE trifft 0 Zeilen statt der eigenen Konfiguration Das Feld lastSyncAt der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb
LdapService.importGroupsByDn (Idempotenzprüfung über ldapObjectGuid) Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil group.create an der eigenen ldapObjectGuid-Unique-Bedingung ohnehin scheitert
LdapConfigScheduler (Planer, getAllActiveConfigs()) Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E

(d) Welcher Code deutet Leere als Abwesenheit

Diese vier Stellen sind die still gefährlichsten Orte des Bereichs ldap, weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr") interpretieren:

  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"]

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.

(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.

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.

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
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.

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

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

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

Verweis

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