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

920 lines
64 KiB
Markdown

# 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 `return`et — 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):**
5. `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.
7. `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):**
3. `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.
4. `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.
6. `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`.