Files
tessera-ctl/docs/mandantentrennung-etappe2-fehlerrichtung.md
T
schalli 12409322f5 docs(quick-260911-gwh): Etappe 2 auf Endstand bringen -- sechster Fall, Befund K erfuellt, Ledger, Anleitung
- .planning/WINDOWS.md: drei neue offene Eintraege (#30 Startpfad des
  Mailmoduls, #31 verschluckte Leere favorites, #32 verschluckte Leere
  settings); Platzhalter WINDOWS #TBD-GWH in settings.service.ts und
  mail.module.ts durch #30 ersetzt
- docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeilen
  favorites (0/8, war 7/0) und settings (1/3, war 4/0) neu gemessen;
  Summenzeile 68/178 (Endstand Etappe 2); Bestandsaufnahme (favoriteLink
  gebunden, smtpConfig gemischt, neue Zeile widgetInstance/gebunden);
  Klassen-Verteilung 65 Paare (33/17/13/2); Hintergrunddienst-Abschnitt mit
  sechstem Fall (mail.module.ts, WINDOWS #30) und erfuellter
  Befund-K-Bedingung; neuer Punkt in "Was diese Etappe NICHT entscheidet"
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtraege unter Befund
  K in (t4) und im Uebergaben-Absatz von (d4) -- Reihenfolgebedingung
  erfuellt; ## Etappe 2 -- Abschluss mit den derivierten Endzahlen
- docs/anleitung-entwicklung.md: 23 RLS-Tabellen statt sieben, FavoriteLink
  nicht mehr als Tabelle ohne Regel, tenantPrisma statt manuellem
  tenantId-Filter als gelebter Stil
- rls-access-inventory.spec.ts wieder gruen (11/11), volle Suite 994/994,
  Werkzeug 137/137; zwei Dokument-Falsifizierungen durchgefuehrt und
  zurueckgenommen (siehe SUMMARY)

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

3132 lines
231 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um
Dieses Dokument gehört zusammen mit
`docs/mandantentrennung-zugriffsklassifikation.md` zur Vorbereitung der
Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält,
**welche** Fundstelle umgestellt wird, hält dieses Dokument fest, **woran**
man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten
(Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend
nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich `ldap` zum
Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc).
## (a) Die Leitfrage
Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die
Rolle `tessera` läuft mit `BYPASSRLS`, jede Abfrage sieht alle Zeilen aller
Mandanten, unabhängig davon, ob sie an `forTenant()` gebunden ist oder nicht.
Ein vergessener `forTenant()`-Aufruf blieb bisher unsichtbar, weil die Policy
gar nicht griff.
Nach dem Scharfschalten (Etappe 4, wenn `DATABASE_URL` auf eine Rolle ohne
`BYPASSRLS` zeigt) kehrt sich das um. Jede Abfrage, die **nicht** gebunden
ist, sieht nicht mehr alle Zeilen, sondern **keine** — die Policy vergleicht
gegen `current_tenant_id()`, und ohne vorheriges `set_config` ist dieser Wert
`NULL`. `NULL = "tenantId"` ist in SQL nie wahr, auch wenn `"tenantId"`
selbst nicht `NULL` ist. Der Fehlerfall ist damit nicht mehr "ein
Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der
Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut
so, als sei nichts zu tun."
Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim
Scharfschalten entdeckt: **Woran würde ich merken, dass eine umgestellte
Abfrage jetzt zu WENIG liefert statt zu viel?**
## (b) Die Messung
Task 1 dieses Plans hat `apps/api/scripts/rls-scratch-check.mjs` um einen
dritten Abschnitt erweitert, der exakt das im Bereich `ldap` verwendete
Muster (Tabellen `LdapConfig`/`LdapFieldMapping`, Policies wortgleich aus der
ausgelieferten Migration `20260618112133_rls_policies/migration.sql`
herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle **ohne**
`BYPASSRLS` prüft. Tatsächlich beobachtete Ausgabe dieses Laufs
(2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n)
fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"]
fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich
fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping"
Alle 13 Pruefungen bestanden.
```
Der Beleg, der diese Kritikschrift trägt, ist die Zeile
`ldapconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
"LdapConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen,
nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung
"sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine
beobachtete Tatsache.
## (c) Signaltabelle je umgestelltem Pfad des Bereichs `ldap`
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|---|---|---|
| `LdapConfigService.getConfig/createConfig/updateConfig` | `forTenant()` liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration | `GET /ldap/config` liefert `null`; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat |
| `LdapConfigService.addFieldMapping/removeFieldMapping` | Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung | `POST`/`DELETE /ldap/config/mappings` scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert |
| `LdapService.listGroups`/`searchUsers` (Markierung "bereits importiert") | Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen | Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen `ldapObjectGuid`-Eindeutigkeit scheitern) |
| `LdapService.upsertMappedUser`/`importUsersByDn` (Identitätssuche über `ldapDn`/`username`) | Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos | Der Abgleich-Bericht zählt das Konto unter `created` statt `updated` — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts |
| `LdapService.syncUsersForTenant` (Deaktivierungs-Kandidatenliste) | Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen | Der Zähler `deactivated` bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel |
| `LdapService.syncUsersForTenant` (`lastSyncAt`-Fortschreibung) | Das `UPDATE` trifft 0 Zeilen statt der eigenen Konfiguration | Das Feld `lastSyncAt` der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb |
| `LdapService.importGroupsByDn` (Idempotenzprüfung über `ldapObjectGuid`) | Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe | Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil `group.create` an der eigenen `ldapObjectGuid`-Unique-Bedingung ohnehin scheitert |
| `LdapConfigScheduler` (Planer, `getAllActiveConfigs()`) | Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten | Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E |
## (d) Welcher Code deutet Leere als Abwesenheit
Diese vier Stellen sind die still gefährlichsten Orte des Bereichs `ldap`,
weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als
gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr")
interpretieren:
1. **`syncGroupMembershipsForTenant`, `deleteMany` mit `notIn`** — gefährlich,
zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach
dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließende
`deleteMany({ where: { NOT: { userId: { in: [...] } } } })`-Aufruf ALLE
LDAP-Mitgliedschaften der Gruppe, weil die Vergleichsliste leer ist und
jede vorhandene Mitgliedschaft als "nicht mehr in der Liste" gilt. Dieser
Pfad ist bereits gebunden (`tenantPrisma` seit Etappe 1, Zeile 1179 vor
dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals
entfernt würde.
2. **Die Deaktivierungsschleife in `syncUsersForTenant`** — harmlose
Richtung, trotzdem festgehalten. Liefert die Kandidatenliste
(`localLdapUsers`) zu wenig, wird zu WENIG deaktiviert, nie zu viel: ein
Konto, das eigentlich deaktiviert werden müsste, bleibt aktiv. Das ist
unerwünscht, aber nicht destruktiv — es verliert keine Daten und sperrt
niemanden fälschlich aus.
3. **Der Löschzweig in `syncBoundGroupsForTenant`** — die eigentliche
Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr für
`objectGUID`"), nicht an der Datenbank; ein zu kleines Datenbankergebnis
führt hier zu WENIGER Löschungen, nicht zu mehr. Gefährlich ist
stattdessen die Übergabe unmittelbar davor:
`this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und
`ensureDefaultGroup(tenantId)` liegen in `groups.service.ts` und sind
NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefert
`reassignDefaultBeforeDelete` still `false` (kein Ersatzkandidat
sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird
trotzdem gelöscht — der Mandant bleibt ohne Standardgruppe zurück
(Befund D, siehe (e)).
4. **`getAllActiveConfigs`** — die stillste Stelle im gesamten Bereich.
Liefert diese Abfrage nach dem Scharfschalten 0 Zeilen (sie ist bewusst
übergreifend und bleibt ungebunden, siehe (e)), stellt der LDAP-Abgleich
für JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne
sichtbare Änderung die Arbeit ein (Befund E). Eine Laufzeitwarnung bei
"0 aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer läuft
jede Minute, und auf einer frischen Installation ohne LDAP ist 0 der
Normalfall — eine Warnung wäre Dauerlärm, der nach kurzer Zeit ignoriert
wird und sein Signal verliert. Das Signal gehört deshalb hierhin und in
die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), nicht in den
Minutentakt des Planers.
## (e) Was dieser Durchlauf bewusst nicht löst
- **Befund A — `resolveEmailForWrite` muss übergreifend bleiben.** `email`
und `username` sind in `prisma/schema.prisma` plattformweit eindeutig
(`@unique`), nicht je Mandant. Würde diese Abfrage mitgebunden, sähe sie
einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und
der anschließende Schreibvorgang liefe in die plattformweite
Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten
Kollision (WINDOWS #15/T-Q3-01) würde ein P2002-Abbruch des gesamten
Sync-Laufs. Nach dem Scharfschalten liefert diese ungebundene Abfrage
IMMER "frei" (0 Zeilen unter jedem Mandantenkontext außer dem der
Adresse selbst) — ein bekannter, hier bewusst offen gelassener Punkt.
Die Lösung gehört nach Etappe 3, vermutlich als vierte
SECURITY-DEFINER-Funktion nach dem Muster der drei Funktionen des
Anmeldewegs (`auth_lookup_user_by_username`,
`auth_lookup_reset_token`, plus die dritte aus Etappe 1).
- **Befund D — die Standardgruppen-Übergabe an den Bereich `groups`.** Wie
in (d.3) beschrieben, ist der Löschzweig selbst bereits gebunden, aber
die Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup` in
`groups.service.ts` ist es nicht. Das ist eine Reihenfolgebedingung für
Etappe 4: der Bereich `groups` muss umgestellt sein, bevor scharf
geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung
ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der
Etappe 2 vorgesehen.
**Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN.** Der Bereich `groups`
ist umgestellt — `reassignDefaultBeforeDelete` und `ensureDefaultGroup`
laufen seit Aufgabe 2 dieses Plans vollständig über `forTenant()` bzw.
`withTenantTransaction()` (siehe Abschnitt "Bereich groups" unten und
`docs/mandantentrennung-zugriffsklassifikation.md`, Zeile
`groups.service.ts`/`group`, Stand `gebunden`). Die Reihenfolgebedingung
für Etappe 4 ist damit erfüllt. Der Befund oben bleibt unverändert stehen
— er beschreibt korrekt den Zustand zum Zeitpunkt der ldap-Umstellung.
- **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts`
und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber
kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT,
ob Controller künftig darüber gehen sollten statt eines erneuten
`forTenant()`-Aufrufs im Service — der Bereich `ldap` bindet weiterhin
dienst-intern, wie die vier Bestandsstellen in `ldap.service.ts` und die
drei in `auth.service.ts` es vormachen. Die Frage bleibt für die übrigen
Bereiche der Etappe 2 offen (siehe
`docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese
Etappe NICHT entscheidet").
## Bereich groups
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `groups`
(Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht
selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches
Modul sehen darf.
### (g1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen vierten
Abschnitt (`runGroupsAreaChecks`) und eine eigene Transaktionsmessung
(`runTransactionShapeMeasurement`) erweitert, beide gegen die Wegwerf-Datenbank
unter der Rolle ohne `BYPASSRLS`, mit den vier Policies für `Group`,
`GroupMembership`, `ModuleGrant` (aus `20260804130918_groups_rls_policies`)
und `TenantModuleActivation` (aus `20260909140000_rls_remaining_tenant_tables`)
WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`):
```
group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E)
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F)
tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
```
**NACHTRAG (260910-jab):** die beiden fett hervorgehobenen Zeilen oben
(`groupmembership-schreiben-fremder-benutzer-nicht-verhindert` und
`modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt`) sind ein
Messprotokoll vom 2026-09-09 und bleiben UNVERÄNDERT stehen — sie belegen,
dass die beiden Löcher existierten. Seit Migration
`20260910120000_rls_widen_membership_grant_and_platform_read` sind beide
Aussagen ÜBERHOLT: die Prüfungen sind umgekehrt (`groupmembership-schreiben-
fremder-benutzer-abgelehnt`, `modulegrant-fremde-gruppe-abgelehnt`), das
INSERT wird jetzt jeweils ABGEWIESEN statt zu gelingen. Siehe den neuen
Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die
aktuell beobachtete Ausgabe.
Die Belegzeile, die diesen Abschnitt trägt, ist `group-ungebunden-null-zeilen`:
der IDENTISCHE `SELECT "tenantId" FROM "Group"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 2 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle.
**Die Transaktionsmessung — namentliches Ergebnis.** Drei Formen wurden
gegen einen Client beobachtet, der die Erweiterungsform aus
`prisma-tenant.extension.ts` wortgleich nachbaut, jeweils mit
`pg_backend_pid()` und `current_tenant_id()` in jeder Teilabfrage plus einem
echten Lesezugriff auf `"Group"`:
```
[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}}
[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}}
[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}}
mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]
```
Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: `step1`
lief auf Verbindung 276749, `step2` auf Verbindung 276750 — zwei
verschiedene physische Verbindungen, obwohl beide Schritte denselben
Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem
Kopfkommentar von `prisma-tenant.extension.ts`: jede Modell-Operation eines
`$transaction`-Arrays auf dem gebundenen Client dispatcht durch
`$allOperations` und bekommt dadurch ihre EIGENE Ein-Element-Transaktion —
mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt
einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion
zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die
Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem
Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand
inkonsistent.
Form (ii) und Form (iii) bestanden beide die im Plan festgelegte
Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte
Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche,
sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber
durch die Tragweite dieses Bereichs geboten) beide Formen unter echter
Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN
gemeinsamen Client, alternierend TENANT-A/TENANT-B.
Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank
und war damit **nicht nachvollziehbar** — eine Zahl, die eine Entscheidung
trug, ohne dass jemand sie hätte nachprüfen können. Genau das Anti-Muster,
das dieses Projekt sich selbst verboten hat. Sie ist deshalb als
`runConcurrencyProbe` in `apps/api/scripts/rls-scratch-check.mjs`
nachgereicht worden und läuft seither bei jedem Werkzeuglauf mit. Als
Verletzung zählt beides: ein Aufruf, der einen fremden oder gar keinen
Mandantenkontext sieht, und ein Aufruf, der abbricht.
Gemessen wird, nicht behauptet:
- **Form (ii)** bricht unter dieser Last ab, mit Fehlern der Familie
`PrismaClientKnownRequestError: Transaction API error` (Code `P2028`).
Ursache: jede
`tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem
gebundenen Client löst selbst wieder eine VERSCHACHTELTE
Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil
`$allOperations` bei jedem Aufruf erneut feuert) — die äußere
interaktive Transaktion UND jede innere Verschachtelung belegen
gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last
reicht der Pool nicht mehr aus.
- **Form (iii)** besteht dieselbe Belastung ohne Verletzung — sie belegt
pro Aufruf genau eine Verbindung, ohne Verschachtelung.
Die konkreten Zahlen eines einzelnen Laufs stehen bewusst NICHT in diesem
Dokument, sondern fallen bei jeder Ausführung neu an; der Werkzeuglauf vom
2026-09-09 ergab 24 Verletzungen von 40 für Form (ii) und 0 von 40 für
Form (iii). **Geprüft** wird nur die Eigenschaft, auf die sich der Code
stützt — Form (iii) ohne Verletzung. Das Verhalten von Form (ii) läuft
daneben als ausgedruckte Beobachtung mit und ist bewusst KEINE Bedingung
für einen grünen Lauf: ab welcher Last sie bricht, hängt an
Verbindungsvorrat und Maschine.
Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2:
obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,
ist sie unter echter Nebenläufigkeit strukturell fragil und ein
Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die
Startreparatur ruft `ensureDefaultGroup` für mehrere Mandanten auf). Form
(iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als
auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen
Hilfsmittels `withTenantTransaction` in `prisma-tenant.extension.ts`.
### (g2) Signaltabelle je umzustellendem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `GroupsService.listForTenant` | Liefert 0 Gruppen statt der tatsächlich vorhandenen | Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt) |
| `GroupsService.getImpact` | Liefert `{memberCount:0, grantCount:0}` statt der tatsächlichen Zahlen | Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage |
| `GroupsService.listMembers` | Liefert 0 Mitglieder statt der tatsächlich vorhandenen | Die Mitgliederliste im Gruppen-Detail ist leer |
| `ModuleGrantsService.getMatrix` | Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen | Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe |
| `ModuleGrantsService.getUserAccess` | Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen | Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine" |
| `LdapService.syncBoundGroupsForTenant` (Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup`) | Der Zähler `defaultMarkerMoved` im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos | Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06) |
| `GroupsService.addUserToDefaultGroup` | Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts | Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln) |
### (g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups
Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung
beider Dateien:
- **`GroupsService.reassignDefaultBeforeDelete`** — zerstörend und still.
Zwei getrennte Stellen liefern `false`: die Gruppe selbst ist nicht
sichtbar (`findFirst` auf `Group` liefert 0 Zeilen), oder es ist kein
Ersatzkandidat sichtbar (beide `findFirst`-Fallbacks liefern 0 Zeilen).
Der Aufrufer im Verzeichnis-Sync (`syncBoundGroupsForTenant`) löscht die
Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die
Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist
Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist
ein Hauptzweck dieses Durchlaufs.
- **`GroupsService.ensureDefaultGroup`** — die einzige Stelle des Bereichs,
an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist
UMGEKEHRT gepolt: null gelesene Gruppen (`group.count` liefert 0 statt
der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern
"alles neu aufbauen". Bliebe der Zähler ungebunden, während der
Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen
Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme
ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE
aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst
durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex
`Group_one_default_per_tenant` fängt einen Teil der Fälle ab (P2002 beim
`group.create`, abgefangen und in `null` übersetzt) — aber nur, wenn der
Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit
Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab.
Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden,
nie einzeln (T-JTS-05).
- **`GroupsService.getImpact`** — die Zahlen des Löschdialogs. Zwei
Zählungen (`groupMembership.count`, `moduleGrant.count`) ohne
Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator
entscheidet auf dieser Grundlage über eine kaskadierende Löschung und
bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle
Gruppe angezeigt.
- **`GroupsService.addUserToDefaultGroup`** — stilles Zurückkehren ohne
sichtbare Standardgruppe (`group.findFirst` liefert 0 Zeilen statt der
einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER
Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht
zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust.
Gegenrichtung, ebenfalls festgehalten: `ModuleGrantsService.grant` (die
Aktivierungsprüfung: kein aktives `TenantModuleActivation` wirft
`BadRequestException`, statt still zu erteilen) und
`ModuleGrantsService.assertTargetBelongsToTenant` (die
Mandanten-Gegenprüfung: kein Treffer wirft `NotFoundException`) werfen bei
Leere LAUT und sind damit die harmlosen Stellen des Bereichs.
Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend
geschlossen: `apps/web/src/app/(portal)/admin/modules/grants/page.tsx`
schaltet die Freigabe-Matrix je Zelle einzeln (ein `POST` bzw. `DELETE` pro
Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den
gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu
einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied
zum `deleteMany`-mit-`notIn` des ldap-Bereichs.
### (g4) Was dieser Durchlauf bewusst nicht löst
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`groups` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und
`auth.service.ts` es vormachen.
- **Die Policies auf `GroupMembership` und `ModuleGrant` prüfen jeweils nur
eine Seite.** Gemessen in (g1): `GroupMembership` prüft ausschließlich die
Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer
Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die
Policy NICHT. `ModuleGrant` prüft ausschließlich die Mandantenkennung der
Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener
Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy
ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in
`addUserToDefaultGroup`, `assertTargetBelongsToTenant`) bleiben deshalb
der primäre Schutz gegen diese beiden Formen der Rechteausweitung und
werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt.
**NACHTRAG (260910-jab):** ÜBERHOLT — seit Migration
`20260910120000_rls_widen_membership_grant_and_platform_read` prüft
`GroupMembership` BEIDE Seiten (T-JTS-02 geschlossen) und `ModuleGrant`
zusätzlich beide möglichen Ziele (T-JTS-03 geschlossen, beide Zweige des
Entweder-oder D-04). Die Anwendungsprüfungen bleiben trotzdem bestehen —
sie sind bis zum Scharfschalten (#18) der einzige tatsächlich wirksame
Schutz, siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und
WINDOWS #19" unten.
### (g5) Fortschreibung des ldap-Abschnitts
Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der
Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf
geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt
in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt
tatsächlich vorliegt.
## Bereich tenders
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenders`
(Quick-Task 260909-laa) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, aber für einen Bereich, der überwiegend NICHT
umgestellt wird: von 23 Paaren sind fünf umzustellen, zehn bleiben bewusst
der plattformweite Ausschreibungskatalog (D-03), zwei sind bewusste
Fan-outs, und sechs zerfallen in eine übergreifende Hälfte (Etappe 3) und
eine mandantengebundene Hälfte (hier). Dieser Bereich trägt außerdem eine
Fehlerform, die `ldap` und `groups` nicht hatten: zwei Benachrichtigungswege,
die bei zu kleinem Leseergebnis nicht falsch handeln, sondern GAR NICHT.
### (t1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen fünften
Abschnitt (`runTendersAreaChecks`) erweitert, mit den fünf Policies für
`TenderEmailConfig`, `TenderNotificationPref`, `TenderRssFeedSource`,
`TenderSavedSearch` und `TenderTriage` (alle aus der ausgelieferten
Migration `20260909140000_rls_remaining_tenant_tables`) WORTGLEICH
extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe
dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
tendersavedsearch-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
tendersavedsearch-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenderSavedSearch" liefert 0 Zeile(n)
tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar: bestanden — forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen
tenderemailconfig-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendertriage-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendernotificationpref-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: false, forTenant(TENANT-B) sieht sie: false — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen: ERROR: new row violates row-level security policy for table "TenderRssFeedSource" — createPlatform darf deshalb nicht gebunden werden
tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit: bestanden — gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: Code 23505, Unique constraint failed — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler
Alle 32 Pruefungen bestanden.
```
**NACHTRAG (260910-jab):** die fett hervorgehobene Zeile oben
(`tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar`) ist ein
Messprotokoll vom 2026-09-09 und bleibt UNVERÄNDERT stehen — sie belegt,
dass WINDOWS #19 existierte. Seit Migration
`20260910120000_rls_widen_membership_grant_and_platform_read` ist diese
Aussage ÜBERHOLT: die Prüfung ist umgekehrt
(`tenderrssfeed-plattformzeile-gebunden-sichtbar`), die plattformweite Zeile
ist jetzt unter BEIDEN Mandantenkontexten SICHTBAR. Siehe den neuen
Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten für die
aktuell beobachtete Ausgabe.
Die Belegzeile, die diesen Abschnitt trägt, ist
`tendersavedsearch-ungebunden-null-zeilen`: der IDENTISCHE
`SELECT "tenantId" FROM "TenderSavedSearch"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 3 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle.
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
Bereich brauchte:
- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — das
GELINGEN (beide Nutzer von TENANT-A sind sichtbar) IST das bestandene
Ergebnis. Alle fünf Policies dieses Bereichs lauten schlicht
`"tenantId" = current_tenant_id()`, ohne Benutzerdimension — zwei Nutzer
DESSELBEN Mandanten sind füreinander vollständig sichtbar. Die
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
gebunden unsichtbar, weil `NULL = current_tenant_id()` in SQL nie wahr
ist. Das ist WINDOWS #19, hier nicht als ferne Sorge, sondern als der
Grund, warum `listForUser`, `createPlatform` und `remove` in
`tender-rss-feed.service.ts` NICHT gebunden werden — eine Bindung würde
die plattformweite Quelle für JEDEN Mandanten verschwinden lassen.
`tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` bestätigt die
Kehrseite: ein gebundenes `INSERT` mit `tenantId = NULL` wird von der
ausgelieferten Policy abgewiesen — `createPlatform` würde in genau diese
Abweisung laufen, würde man es binden.
**NACHTRAG (260910-jab):** ÜBERHOLT für `listForUser` — seit Migration
`20260910120000_rls_widen_membership_grant_and_platform_read` bindet
dieser Pfad (die neue Leseregel schließt Zeilen ohne Mandant ausdrücklich
ein). `createPlatform`/`remove` bleiben unverändert ungebunden, aus dem
jetzt ausdrücklichen Grund einer eigenen Schreibregel je Befehl (WINDOWS
#24).
Eine dritte Messung trägt die Fehlerbehandlung von Aufgabe 2:
`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` zeigt,
dass ein gebundenes `INSERT` auf ein `(userId, tenderId)`-Paar, dessen Zeile
existiert, aber einem anderen Mandanten gehört und deshalb unsichtbar ist,
an der Eindeutigkeitsbedingung scheitert (Postgres prüft Unique-Indizes
gegen die physischen Zeilen, unabhängig von der RLS-Sichtbarkeit) — NICHT
an einer Policy-Abweisung. Aus einem stillen Überschreiben wird dadurch ein
harter Fehler, der in Aufgabe 2 als verständliche deutsche Meldung
herauskommen muss, nicht als roher 500er.
TEIL 2 dieser Aufgabe hat zusätzlich nachgemessen, dass dieser Bereich
keine mandantengebundene Transaktion enthält (Befund A):
`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec`
liefert genau EINEN Treffer, `tender-fingerprint-backfill.service.ts:89`,
die Array-Form auf der plattformweiten Tabelle `Tender` (D-03), außerhalb
jeder Mandantenbindung. Der im Kopf von `prisma-tenant.extension.ts`
verlangte erneute Test vor jedem neuen `forTenant()`-Fall mit eigener
Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer
Fall an, `withTenantTransaction()` wird hier nicht gebraucht und auch
nicht eingeführt.
### (t2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|---|---|---|
| `TenderSavedSearchService.list` | Liefert 0 gespeicherte Suchprofile statt der tatsächlich vorhandenen | Die Suchprofil-Leiste im Ausschreibungs-Radar ist leer für einen Nutzer, der tatsächlich Profile gespeichert hat |
| `TenderSavedSearchService.create/update/remove` | Die Besitzprüfung (`findUnique` gebunden) liefert 0 Zeilen statt der eigenen Zeile | `PATCH`/`DELETE /saved-searches/:searchId` scheitert mit 404, obwohl das Profil existiert; `POST` legt scheinbar erfolgreich ein neues Profil an (unkritisch, betrifft nur den Lesepfad danach) |
| `TenderTriageService.listForUser` | Die Markierungsabfrage liefert 0 Treffer statt der tatsächlich vorhandenen | Die Trefferliste zeigt für bereits gelesene/favorisierte Ausschreibungen keine Markierung mehr — ein bereits gelesener Treffer erscheint wieder als ungelesen |
| `TenderTriageService.favoriteIds` | Liefert 0 favorisierte tenderIds statt der tatsächlich vorhandenen | Der Merklisten-Filter (`favOnly`) zeigt eine leere Liste für einen Nutzer, der tatsächlich Favoriten hat |
| `TenderNotificationPrefService.getForUser` | **Sonderfall**: kein Treffer bedeutet hier nicht "leer", sondern der Vorgabewert `daily` (D-01) | Ein Nutzer, der `off` gewählt hat, sieht in der Oberfläche wieder `daily` — ein zu kleines Leseergebnis setzt die Einstellung stillschweigend auf täglich zurück, statt sie leer zu lassen |
| `TenderEmailConfigService.getConfigForApi` | Der gebundene `findUnique` liefert 0 Zeilen statt der eigenen Konfiguration | `GET /email-config` liefert `null`; die Oberfläche zeigt "kein Postfach verbunden" für einen Nutzer, der tatsächlich eines hat |
| `TenderEmailConfigService.testConnection` | Der gebundene Rückgriff auf gespeicherte Zugangsdaten liefert 0 Zeilen statt der eigenen | Ein Verbindungstest mit leer gelassenem Formular (Rückgriff auf gespeicherte Zugangsdaten) schlägt fehl, obwohl gespeicherte Zugangsdaten existieren |
| `TenderRssFeedSourceService.listForUser`/`createPlatform`/`remove` (bewusst UNGEBUNDEN, WINDOWS #19) | Betrifft nicht diese drei Pfade selbst — sie binden nicht und liefern deshalb weiterhin die plattformweite Zeile korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3), nicht in dieser Umstellung | Würde man sie binden: leere Feed-Liste bzw. 404 beim Entfernen einer plattformweiten Quelle — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten. **NACHTRAG (260910-jab):** ÜBERHOLT für `listForUser` — dieser Pfad ist seit Migration `20260910120000_rls_widen_membership_grant_and_platform_read` GEBUNDEN (WINDOWS #19 geschlossen, Befund F: ungebunden hätte die Reparatur ihn sonst still auf nur die plattformweiten Zeilen reduziert). `createPlatform`/`remove` bleiben unverändert ungebunden (WINDOWS #24). |
| `TenderRssFeedSourceService.createForUser` (gebunden) | Der Zähler (`count`, gebunden) liefert 0 statt der tatsächlichen Anzahl eigener Feeds | Ein Nutzer, der bereits am Limit von 20 eigenen Feeds ist, könnte scheinbar unbegrenzt neue anlegen (harmlose Richtung: die Kappung aus T-17-10 wirkt nicht mehr, kein Datenverlust) |
### (t3) Welcher Code Leere als Abwesenheit deutet
**Sichtbare Formen** (Anzeige bleibt leer oder fällt auf einen Vorgabewert
zurück, jemand merkt es beim nächsten Blick auf die Oberfläche): siehe
Tabelle (t2) oben — Suchprofilliste, Triage-Markierung, Favoriten-Filter,
der `daily`-Sonderfall der Benachrichtigungseinstellung, und die
Postfach-Anzeige.
**Lautlose Formen — die zusätzliche Fehlerform dieses Bereichs.** Fünf
Stellen in den beiden Hintergrunddiensten deuten ein zu kleines
Leseergebnis nicht als Fehler, sondern als "nichts zu tun", und
protokollieren dabei NICHTS:
1. **`tender-digest.scheduler.ts`, `if (!candidates.length) return;`** —
liefert die gebundene Kandidatenabfrage innerhalb eines Profildurchlaufs
zu wenig, bricht der GESAMTE Digest für diesen Lauf ab, für ALLE
Mandanten, ohne Protokolleintrag.
2. **`tender-digest.scheduler.ts`, `if (!matches.length) continue;`** —
liefert die gebundene Treffer-Abfrage für einen Kandidaten zu wenig,
bekommt dieser eine Nutzer keine Post, der Lauf macht mit dem nächsten
Kandidaten weiter, ohne Protokolleintrag.
3. **`tender-digest.scheduler.ts`, `if (!user || !user.email) continue;`**
— liefert die gebundene Benutzer-Abfrage zu wenig, bekommt dieser
Nutzer keine Post, ohne Protokolleintrag.
4. **`tender-matching.service.ts`, `if (!fresh.length) continue;`** —
liefert die gebundene Abfrage der noch nicht benachrichtigten Treffer
innerhalb eines Profildurchlaufs zu wenig, bekommt dieser Nutzer keinen
Sofort-Alarm, ohne Protokolleintrag.
5. **`tender-matching.service.ts`, `if (!user || !user.email) continue;`**
— dieselbe Form wie Stelle 3, für den Sofort-Alarm-Pfad.
Keine dieser fünf Stellen protokolliert etwas — eine ausbleibende Warnung
erzeugt keine Fehlermeldung, keinen Protokolleintrag und keine Beschwerde,
außer der stillen Abwesenheit einer E-Mail, die niemand erwartet, weil
niemand wusste, dass sie hätte kommen sollen.
Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen
behauptet: weil `notifiedAt` auf `TenderMatch` nur nach ERFOLGREICHEM
Versand gestempelt wird (D-06), bleiben die betroffenen Zeilen auf
`notifiedAt IS NULL` stehen und werden beim nächsten Lauf erneut versucht.
Es geht also nichts verloren — es kommt nur nichts an, solange die Ursache
(z. B. ein nach dem Scharfschalten ungebunden gebliebener Lesepfad)
fortbesteht. Daraus ergibt sich das einzige nachprüfbare Signal dieser
Fehlerform: eine wachsende Zahl von `TenderMatch`-Zeilen mit
`notifiedAt IS NULL` bei gleichzeitig fehlendem Versandprotokoll. Dieses
Signal gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), NICHT
in diesen Durchlauf — Aufgabe 3 hält es hier fest, löst es aber nicht.
Eine Laufzeitwarnung an den fünf Stellen wurde erwogen und VERWORFEN, aus
demselben Grund wie bei `getAllActiveConfigs` im `ldap`-Durchlauf: beide
Hintergrunddienste laufen regelmäßig, und "kein passender Kandidat"/"kein
Konto mit Adresse" ist ein regulärer Zustand, kein Fehlerfall — eine
Warnung wäre Dauerlärm und verlöre ihr Signal.
### (t4) Was dieser Durchlauf bewusst nicht löst
- **WINDOWS #19 — nullbares `tenantId` bei `TenderRssFeedSource`.**
Gemessen in (t1): eine plattformweite Zeile ist unter JEDEM
Mandantenkontext unsichtbar, ein gebundenes Einfügen ohne Mandant wird
abgewiesen. `listForUser`, `createPlatform` und `remove` bleiben deshalb
bewusst UNGEBUNDEN, mit einem Codekommentar, der die Grenze benennt. Die
Policy-Semantik selbst (eine `tenantId IS NULL OR tenantId =
current_tenant_id()`-Lesevariante) gehört zu Etappe 3 und wird hier nicht
angefasst.
**NACHTRAG (260910-jab): ÜBERHOLT — WINDOWS #19 ist geschlossen.** Migration
`20260910120000_rls_widen_membership_grant_and_platform_read` ersetzt die
einfache Regel durch vier nach Befehl getrennte Regeln
(`tenant_platform_read_policy` schließt Zeilen ohne Mandant beim Lesen
ausdrücklich ein, die drei Schreibregeln verlangen weiterhin einen
Mandanten). `listForUser` ist seither GEBUNDEN (Befund F: ungebunden hätte
die Reparatur ihn sonst still auf nur die plattformweiten Zeilen
reduziert); `createPlatform`/`remove` bleiben bewusst ungebunden — beide
Pfade lassen sich unter der Anwendungsrolle grundsätzlich nicht
anlegen/entfernen (WINDOWS #24, eigener offener Punkt, verschwindet nicht
mit #19). Siehe den neuen Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und
WINDOWS #19" unten.
- **Die übergreifenden Hälften der beiden Hintergrunddienste.** Aufgabe 3
bindet nur die Je-Treffer-Hälften von `tender-digest.scheduler.ts` und
`tender-matching.service.ts`; die Kandidatenabfrage
(`tenderMatch.findMany`/`tenderSavedSearch.findMany`) bleibt bewusst
über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe
kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
Abschnitt "Der Hintergrunddienst als Falle".
**Nachtrag (260909-laa, Aufgabe 3): Je-Treffer-Hälften GESCHLOSSEN,
übergreifende Hälften ausdrücklich an Etappe 3 übergeben.** Innerhalb der
Kandidatenschleife von `tender-digest.scheduler.ts`
(`tenderNotificationPref.findUnique`, `tenderMatch.findMany`/`updateMany`,
`user.findUnique`) und der Profilschleife von `tender-matching.service.ts`
(`tenderMatch.upsert` in der Treffer-Anlage, sowie im nachgelagerten
Instant-Dispatch `tenderMatch.findMany`/`updateMany` und
`user.findUnique`) laufen jetzt alle Zugriffe über `forTenant()`,
gebunden an den Mandanten der jeweiligen Kandidaten-/Profilzeile — je EIN
gebundener Client pro Zeile, nicht neu je Modellzugriff. Belegt durch
Bindungstests je Methode UND einen Falsifizierungsnachweis (ein
probeweiser Rückbau der `user.findUnique`-Bindung im Instant-Dispatch von
`tender-matching.service.ts` machte genau den erwarteten Test rot, danach
zurückgenommen — siehe 260909-laa-SUMMARY.md). Die übergreifenden
Kandidaten-/Profilabfragen selbst
(`tenderMatch.findMany({distinct:['userId']})` bzw.
`tenderSavedSearch.findMany()`) bleiben UNVERÄNDERT ungebunden und tragen
im Code einen Kommentar, der sie als Etappe-3-Übergabe benennt — die
Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen, nicht
verwischt. Der Sonderfall, dass ein Nutzer Treffer unter zwei
verschiedenen Mandanten haben könnte (der Digest wählt das
denormalisierte `tenantId` der Kandidatenzeile über `distinct`, was bei
einem Mandantenwechsel veraltet sein kann), ist NICHT gelöst, sondern im
Code und hier benannt — Gegenstand von Etappe 3.
- **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich
`settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über
`SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts`
ist noch vollständig unbound (4 Rohtreffer, siehe Übersichtstabelle in
`docs/mandantentrennung-zugriffsklassifikation.md`). Nach dem
Scharfschalten fände diese ungebundene Abfrage keine SMTP-Zeile mehr —
Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
hier festgehalten, nicht gelöst.
**Nachtrag (260911-gwh):** `getDecryptedSmtpConfig(tenantId)` läuft seit
Aufgabe 2 dieses Laufs über `forTenant()` (GENAU EIN Klient `tenantPrisma`
je Aufruf). Die Reihenfolgebedingung ist damit ERFÜLLT — siehe
`docs/mandantentrennung-zugriffsklassifikation.md`, Bestandsaufnahme-Zeile
`settings.service.ts`/`smtpConfig`, und den Hintergrunddienst-Abschnitt
dort. Die Etappe-4-Vorabprüfung muss diese Bedingung ab jetzt NICHT mehr
führen.
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
und `groups` es vormachen.
- **Befund G — ein Administrator eines beliebigen Mandanten kann eine
plattformweite RSS-Quelle entfernen.** `TenderRssFeedSourceService.remove`
ist bereits heute ein einziger bedingter `deleteMany` mit der
Besitzbedingung in der Datenbank — das ist das gewünschte Muster, keine
Wiederholung des `ldap`-Fundes (Auflösung über die Kennung allein). Die
eine Beobachtung, die trotzdem festgehalten gehört: ein Administrator
EINES beliebigen Mandanten kann über diesen Pfad eine plattformweite
Quelle entfernen, die ALLE Mandanten speist — eine Produkt-/
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
Aufgabe.
### (t5) Was dieser Durchlauf bewusst nicht anfasst
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
`tender-dedup.service.ts`, `tender-fingerprint-backfill.service.ts`,
`tender-ingestion.service.ts`, `tender-matching.service.ts`/`tender`,
`tender-scheduler.service.ts`, `tenders.controller.ts`, `tenders.module.ts`)
und der beiden bewussten Fan-out-Adapter
(`adapters/email-alert.adapter.ts`, `adapters/rss.adapter.ts`) sind
geprüft und deliberat ungebunden — nicht übersehen. Jede der zwölf trägt
ihre Begründung im eigenen Dateikopf bzw. in D-03.
Befund I gehört ausdrücklich hierher: `tender-ingestion.service.spec.ts`
enthält eine Schutzprüfung, die den QUELLTEXT von
`tender-ingestion.service.ts` liest und gegen ein Vorkommen des
Bezeichners `forTenant` prüft ("never calls forTenant()"). In dieser einen
Datei darf deshalb auch kein ERKLÄRENDER Kommentar diesen Bezeichner
nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier
nur festgehalten, damit niemand sie beim Nachziehen der Begründungen
"freundlich kommentiert" und den Lauf rot macht.
## Bereich dkv
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dkv`
(Quick-Task 260909-mir) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, für einen Bereich mit einer dritten, eigenen
Fehlerform: nicht "eine Liste ist leer" (wie bei `ldap`/`groups`) und nicht
"ein Benachrichtigungsweg handelt gar nicht" (wie bei `tenders`), sondern
"ein einzelnes Objekt wird `null`, und `null` hat an dieser Stelle bereits
eine gültige, harmlose Bedeutung". Ein eingerichtetes Modul sieht danach
aus wie ein nie eingerichtetes.
### (d1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen sechsten
Abschnitt (`runDkvAreaChecks`) erweitert, mit den drei Policies für
`DkvInvoiceHistory`, `DkvModuleConfig` und `DkvVehicleMaster` (alle aus der
ausgelieferten Migration `20260909140000_rls_remaining_tenant_tables`)
WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`):
```
dkvmoduleconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
dkvmoduleconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DkvModuleConfig" liefert 0 Zeile(n)
dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile: bestanden — ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert 0 Zeile(n), obwohl 2 existieren — die Form, die der Planer-Startpfad heute benutzt: aus einer beliebigen-aber-vorhandenen Zeile wird KEINE Zeile, und der aufrufende Code liest das als "dieses Modul ist nicht eingerichtet"
dkvinvoicehistory-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
dkvvehiclemaster-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ERROR: new row violates row-level security policy for table "DkvVehicleMaster"
dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — Folge fuer Aufgabe 3 (Befund G): die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt
dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision: bestanden — gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' gelingt — der Mandant ist Teil des zusammengesetzten Schluessels, keine Kollision auf einer unsichtbaren fremden Zeile, keine P2002-Uebersetzung noetig
dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext: bestanden — TENANT-A: pid=286680, t="TENANT-A", rows=1; TENANT-B: pid=286679, t="TENANT-B", rows=1 — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext
Alle 41 Pruefungen bestanden.
```
Die Belegzeile, die diesen Abschnitt der Kritikschrift trägt, ist
`dkvmoduleconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId"
FROM "DkvModuleConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**,
nicht etwa die 2 tatsächlich vorhandenen — an der echten, ausgelieferten
Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.
Daneben trägt `dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile`
diesen Abschnitt zusätzlich, weil dieser Bereich an der entscheidenden
Stelle kein Mengenergebnis liest, sondern ein Einzelobjekt: dieselbe
Tabelle, dasselbe fehlende `set_config`, aber diesmal ein `SELECT ...
LIMIT 1` ohne jede Bedingung — exakt die Form, die `loadConfig()` ohne
Mandant (der Planer-Startpfad) heute über `findFirst()` benutzt. Auch
diese Abfrage liefert **0 Zeilen**, obwohl 2 existieren. Der Unterschied
zur ersten Belegzeile ist nicht die Zahl (beide sind 0), sondern die
Lesart: ein leeres `findMany`-Ergebnis ist im aufrufenden Code sichtbar
leer, ein leeres `findFirst`-Ergebnis wird zu `null`, und `null` hat in
`loadConfig()`/`onModuleInit()` bereits eine gültige, harmlose Bedeutung
("kein aktives Modul konfiguriert") — siehe (d3).
TEIL 2 hat zusätzlich die Nebenläufigkeitsform gemessen, auf die sich
`getHistory()` stützt (Befund C): zwei über `Promise.all` gleichzeitig
gestartete gebundene Einzelabfragen über DENSELBEN Klienten, hier für zwei
verschiedene Mandanten nachgebaut
(`dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`). Jede
Abfrage sah den Kontext, unter dem sie gestartet wurde (`TENANT-A`/
`TENANT-B`), und jede lieferte die richtige Zeilenzahl (je 1) — keine
Verletzung, kein Abbruch.
TEIL 3 hat nachgemessen, dass dieser Bereich keine mandantengebundene
Transaktion enthält (Befund C):
`grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec`
liefert **null Treffer** (Rückgabewert 1, keine Ausgabe). Der im Kopf von
`prisma-tenant.extension.ts` verlangte erneute Test vor jedem neuen
`forTenant()`-Fall mit eigener Transaktion ist damit für diesen Bereich
beantwortet: es fällt kein neuer Fall an, `withTenantTransaction()` wird
hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt. Die beiden
mehrschrittigen Stellen des Bereichs — der Ersetzen-Modus des
Fahrzeug-Imports (`deleteMany` gefolgt von `createMany`) und die
Zugangsdaten-Erhaltung in `saveConfig` (lesen, entschlüsseln, neu
verschlüsseln, schreiben) — bleiben deshalb so unatomar wie heute; sie in
eine Transaktion zu heben wäre eine Verhaltensänderung jenseits dieses
Auftrags, siehe (d5).
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
Bereich in dieser Form brauchte:
- `dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt`
(Befund I) — dieselbe Frage wie bei `TenderRssFeedSource` in `tenders`,
hier mit demselben Ergebnis: die ausgelieferten Policies dieses Bereichs
tragen keine eigene `WITH CHECK`-Klausel, also verwendet PostgreSQL
denselben `USING`-Ausdruck auch für neu geschriebene Zeilen — ein
gebundenes `INSERT` unter TENANT-A mit `tenantId = TENANT-B` wird
abgewiesen. Was PostgreSQL daraus für ein `INSERT` ableitet, ist eine
Eigenschaft der Datenbank, keine des Policy-Textes, deshalb gemessen und
nicht aus dem Text geschlossen.
- `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`
(Befund H) — der Gegenbefund zu `tenders`-Befund F: weil
`DkvVehicleMaster` die zusammengesetzte Eindeutigkeit `@@unique([tenantId,
kennzeichen])` trägt, gibt es hier KEINE Kollision auf einer unsichtbaren
fremden Zeile. Ein gebundenes `INSERT` unter TENANT-A auf ein
Kennzeichen, das unter TENANT-B bereits existiert, GELINGT — das
bestandene Ergebnis ist das Gelingen, nicht die Abweisung. Aufgabe 2/3
bauen deshalb keine P2002-Übersetzung für diesen Bereich; anders als bei
`TenderTriage` in `tenders` ist hier keine gebaut, weil keine gebraucht
wird — nachgemessen statt unterstellt.
### (d2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|---|---|---|
| `DkvService.getConfigForApi` | Der gebundene erste Lesezugriff (`loadConfig(tenantId)`) liefert `null` statt der eigenen Konfiguration | `GET /dkv/config` liefert `404 DKV module not yet configured`; die Oberfläche zeigt das Einrichtungsformular für ein Modul, das tatsächlich eingerichtet ist |
| `DkvService.getConfigForApi`, der zweite (rohe) Lesezugriff auf die Zugangsdaten | Läuft dieser gebundene `findUnique` leer (während der erste — sicher ausgewählte — noch träfe), bleibt `raw` `null`, der `try`-Block liefert `hasPassword=false` | Die Oberfläche meldet "kein Passwort hinterlegt" für ein Modul mit tatsächlich hinterlegtem Passwort — ohne Fehlermeldung, siehe (d3) Stelle 4 |
| `DkvService.saveConfig`, die Zugangsdaten-Erhaltung | Der gebundene erhaltende Lesezugriff liefert `null` statt der bestehenden Zeile, der `try/catch` schluckt das | Ein gespeichertes Passwort wird mit dem LEEREN Wert neu verschlüsselt — die zerstoerende Stelle, siehe (d3) Stelle 5 und T-MIR-07 |
| `DkvService.testConnection`, der Rückgriff auf gespeicherte Zugangsdaten | Der gebundene Lesezugriff liefert `null` statt der bestehenden Zeile | Der Verbindungstest schlägt mit einem Anmeldefehler des Postfachs fehl — die Meldung zeigt auf das Postfach, nicht auf die Datenbank |
| `DkvService._runPipeline`, der Konfigurations-Lesezugriff | Der gebundene `findUnique` liefert `null` statt der bestehenden Konfiguration | `_runPipeline` protokolliert `no config for tenant ...` als Warnung und `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.
**Nachtrag (260911-gwh):** der `settings`-Teil dieser Übergabe ist erfüllt —
`getDecryptedSmtpConfig(tenantId)` läuft seit Aufgabe 2 dieses Laufs über
`forTenant()`, siehe die Bestandsaufnahme-Zeile
`settings.service.ts`/`smtpConfig` in
`docs/mandantentrennung-zugriffsklassifikation.md` und (s4)(b). Erneut
gemessen: `dkv.seed.ts` ruft weiterhin
`ModuleRegistryService.seedModule()` (`module-registry.service.ts:206`,
`this.prisma.module.upsert`) — UNGEBUNDEN, aber bewusst und unverändert seit
260910-exd, weil `Module` der plattformweite Modulkatalog ohne `tenantId`-
Spalte ist (Befund E, `keine-mandantengebundene-tabelle`); "gebunden seit
260910-exd" trifft auf diesen Zugriff NICHT zu, gemessen statt aus dem Plan
abgeschrieben.
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
`tenders` es vormachen.
### (d5) Was dieser Durchlauf bewusst nicht anfasst
- **Die beiden mehrschrittigen Stellen bleiben unatomar (Befund C, TEIL
3).** Der Ersetzen-Modus des Fahrzeug-Imports (`deleteMany` gefolgt von
`createMany`) und die Zugangsdaten-Erhaltung in `saveConfig` (lesen,
entschlüsseln, neu verschlüsseln, schreiben) werden NICHT in eine
Transaktion gehoben — geprüft und bewusst gelassen, nicht übersehen. Der
im Kopf von `prisma-tenant.extension.ts` verlangte erneute Test ist mit
TEIL 3 dieser Aufgabe beantwortet: kein neuer Transaktionsfall,
`withTenantTransaction()` bleibt für diesen Bereich ungenutzt.
- **Die Ablagestruktur der Ausfuhrdateien bleibt unverändert (Befund F).**
Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je
Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).
## Bereich user
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `user`
(Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, für den Bereich, in dem das plattformweite
Eindeutigkeitsproblem tatsächlich wohnt: `username` und `email` sind im
Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält
als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN
(Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF
(Nachschlagen auf dem plattformweit eindeutigen Schlüssel `username`). Ein
Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung
über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen
Vorhabens.
### (u1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen siebten
Abschnitt (`runUserAreaChecks`) erweitert. Die Tabelle `"User"` wird dabei
NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs,
samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
Eindeutigkeitsbedingungen (`username`, `email`) und zwei Testzeilen in zwei
Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im
Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der
ausgelieferten Migration `20260618112133_rls_policies` geschnittene Fassung
(beide sind nach Normalisierung von Leerraum und abschließendem Semikolon
wortgleich — keine Ersetzung nötig), legt eine Tabelle `"Tenant"`
ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und
ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete
Ausgabe dieses Laufs (2026-09-10, gegen `tessera-ctl-db-1`, Adresse
`172.19.0.2`):
```
user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen
user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n)
user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an
user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"
user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette
user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt
user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n)
tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false
user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"]
Alle 53 Pruefungen bestanden.
```
Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und
auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette
sind:
- **Die Belegzeile zur Unsichtbarkeit**, `user-ungebunden-null-zeilen`: der
IDENTISCHE `SELECT "tenantId" FROM "User"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 4 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die
ungebundene Suche nach einem GÜLTIGEN Benutzernamen
(`user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`) — die
Form, die die Erstanlage-Prüfung beim Start heute benutzt.
- **Die Zeile zum falschen „frei"**,
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`:
dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen `bob`
(gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine
Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist.
- **Die Zeile zum harten Eindeutigkeitsfehler**,
`user-eindeutigkeit-greift-trotz-unsichtbarkeit`: unmittelbar danach ein
gebundenes `INSERT` unter TENANT-A mit genau diesem, angeblich freien
Benutzernamen `bob`. Die Ablehnung trägt SQLSTATE **23505**
(Eindeutigkeitsverletzung) — gelesen aus `err.meta.code`, nicht aus
`err.code` (das bei einem fehlgeschlagenen `$executeRaw` immer den
generischen Prisma-Code `P2010` trägt, empirisch gegen
`tessera-ctl-db-1` geprüft, siehe Kopfkommentar von `sqlStateOf()` im
Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser
Abschnitt in zwei anderen Zeilen ebenfalls misst
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`,
`user-ungebundenes-einfuegen-abgelehnt`). Diese Unterscheidung ist der
Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette,
die zweite wäre eine ganz andere Geschichte.
Zusätzlich gemessen, statt behauptet: `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`
hält sowohl das ungebundene `SELECT` (2 von 2 Zeilen sichtbar) als auch den
Systemkatalog (`pg_class.relrowsecurity` für `"Tenant"` = `false`)
gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser
Abschnitt selbst keinen Zeilenschutz eingeschaltet hat.
`user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` bildet die in
Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten
ungebunden lesen, je Mandant EIN gebundener `SELECT`, Ergebnisse
vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit
`BYPASSRLS`) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide
Mengen sind identisch: `["alice","bob","carol","dave"]`.
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
diesem Bereich:
```
$ grep -rn '\$transaction(' apps/api/src/user --include=*.ts | grep -v spec
$ echo $?
1
```
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
Aufgabe 2/3 nicht eingeführt.
**TEIL 3, Beleg statt Behauptung für Befund D** — die Aufrufermessung für
`findByUsername`:
```
$ grep -rn "findByUsername" apps/api/src packages
apps/api/src/user/user.service.ts:21: async findByUsername(username: string) {
```
Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1
(260909-eor) hat den Anmeldeweg auf die drei schmalen
SECURITY-DEFINER-Funktionen umgezogen; `auth.service.ts` sucht seither über
`auth_lookup_user_by_username` und nicht mehr über diese Methode. Die
Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden
(Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in
Aufgabe 2 richtiggestellt.
### (u2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `UserService.findById` | Der gebundene `findUnique` liefert `null` statt des eigenen Benutzers | `GET /users/:id` liefert `404 User not found`, obwohl der Benutzer existiert |
| `UserService.create` | Betrifft nicht das Lesen — ein gebundenes `INSERT` mit fremder Mandantenkennung wird von der Policy abgewiesen (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`) | `POST /users` scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil `tenantId` aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt |
| `UserService.update` | Der gebundene `update` über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, `user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`), nicht laut | `PATCH /users/:id` würfe ohne die vorgeschaltete Prüfung in `user.controller.ts` keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht |
| `UserService.deactivate`/`delete` | Dieselbe stille Form wie `update` | `DELETE /users/:id` bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler |
| Neue Methode: Plattform-Administratorsicht, Liste | Der Schleifentreiber (`tenant.findMany`, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen | Die Benutzerliste des `SUPER_ADMIN` ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs |
| Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung | Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht | `GET /users/:id`, `PATCH /users/:id`, `DELETE /users/:id` liefern für den `SUPER_ADMIN` `404`, obwohl der Benutzer existiert |
| `user.controller.ts`, `findAll` (ADMIN-Zweig) | Der gebundene `findMany` liefert 0 Benutzer statt der tatsächlich vorhandenen | Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus |
| Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) | Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen | `GET /users/me/avatar` liefert die vorhandene `404 No avatar set`, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3) |
| `UserService.findByUsername` (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung | Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D) |
| `AdminSeedService`, Erstanlage-Prüfung (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist | Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs |
| `AdminSeedService`, beide Zugriffe auf `tenant` (bewusst UNGEBUNDEN) | Betrifft nicht diese Pfade selbst — `Tenant` trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt | `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` misst die tragende Eigenschaft |
### (u3) Welcher Code Leere als Abwesenheit deutet
Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L,
namentlich benannt und nach ihrer Wirkung sortiert:
**Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen
Vorhaben):**
1. **`AdminSeedService.seedAdmin()`, die Erstanlage-Prüfung** — die
vollständige, im Auftrag beschriebene Kette in Reinform: eine
unsichtbare Zeile wird als Abwesenheit gelesen
(`user.findUnique({ where: { username } })` liefert nach dem
Scharfschalten `null`, nicht weil der Administrator fehlt, sondern weil
ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar
ist — `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`),
die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt
3 entfällt), und das Anlegen scheitert hart an der plattformweiten
Eindeutigkeit von `username`
(`user-eindeutigkeit-greift-trotz-unsichtbarkeit`, SQLSTATE 23505, KEINE
Zeilenschutz-Ablehnung). Weil `seedAdmin()` bewusst NICHT gekapselt ist
— der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start
weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren
Zeile eine **Startsperre**: die Anwendung startet nach dem
Scharfschalten nicht mehr, für jede bestehende Installation mit
gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern)
wird damit ungewollt zu einer Sperre für den Normalfall.
**Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im
Bereich):**
2. **`user.controller.ts`, `findAll` im ADMIN-Zweig** — eine leere Liste
heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine
Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen
kollidiert auf `username` bzw. `email`. Die Oberfläche zeigt dabei
nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen
Installation der Normalzustand.
3. **`user.controller.ts`, `findAll` im SUPER_ADMIN-Zweig** — dieselbe
Leere, eine Ebene höher: die Plattformverwaltung sieht eine
Installation ohne jeden Benutzer und würde, bliebe die neue
übergreifende Methode ungebunden statt als gebundene Schleife gebaut,
ebenfalls zum Neuanlegen verleiten.
4. **Eine gebundene Suche nach Benutzername oder Adresse** (Befund D,
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`)
— meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird
zur Kollisionserzeugung; genau deshalb bleibt `findByUsername`
ungebunden und übersetzt `UserService.create`/`update` die
Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in
eine verständliche Meldung (Aufgabe 2).
**Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand
dieses Durchlaufs):**
5. **`ldap.service.ts`, `upsertMappedUser`** — bereits gebunden, seit
260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht
anzufassen: die Identitätssuche entscheidet über Anlegen-oder-
Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die
Kette nicht erst nach dem Scharfschalten existiert — die
Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist
also bereits heute unsichtbar. Die Übersetzung der
Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt
in `user.service.ts` (Aufgabe 2), der in diesem Plan ohnehin angefasst
wird — dort und nicht in `ldap`.
**Harmlos (eine Stelle):**
6. **Die Selbstbedienungswege für Bild und Akzentfarbe** (Befund G) — ein
leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der
Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten.
**Was ein GEBUNDENER Nachschlageweg auf `username` mit dem Anmeldeweg
machen würde, und warum die Frage hier gegenstandslos ist:** der Anmeldeweg
läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen
und nicht mehr über `UserService.findByUsername` — belegt durch die
Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst),
nicht behauptet. Würde `findByUsername` dennoch gebunden, säße das Problem
nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern
genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe
Grund, aus dem `resolveEmailForWrite` im Bereich `ldap` ungebunden bleibt.
**Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift
gehörend:** ein gebundenes Ändern oder Löschen über die Kennung allein
trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung,
sondern schlicht mit null betroffenen Zeilen
(`user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`,
`user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen`) — die
vorgeschalteten Besitz- und Rollenprüfungen in `user.controller.ts` bleiben
deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt.
Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, SQLSTATE 42501).
Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus
Alarm.
### (u4) Was dieser Durchlauf bewusst nicht löst
Die plattformweite Eindeutigkeit von `username` und `email` selbst — die
Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur
wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das
ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten
gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie
festgehalten, nicht entschieden, und ausdrücklich nicht durch eine
Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2).
Die Übergabe in den noch nicht umgestellten Bereich `auth`: `auth.service.ts`
ist aus demselben Grund `gemischt` (Befund E) und NICHT Gegenstand dieses
Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat
drei bereits über `forTenant()` gebundene Schreibzugriffe (Anmeldezeitstempel,
Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf
`user`, die zu `getMe`, `changePassword` und `adminResetPassword` gehören.
Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über `$queryRaw` auf die
drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst `auth.service.ts` an
KEINER Stelle an.
Die offene Architekturfrage `req.tenantPrisma` — auch der Bereich `user`
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
`tenders` und `dkv` es vormachen.
### (u5) Was dieser Durchlauf bewusst NICHT anfasst
- `auth.service.ts` an keiner Stelle (siehe (u4)).
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht —
der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus
TEIL 3 oben.
- Schema und Migrationen nicht.
- Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder
(`user-files/avatars/`) nicht — anders als bei den Ausfuhrdateien des
Bereichs `dkv` (Befund F dort) ist die Dateibenennung hier
`{userId}.{ext}` und damit bereits kollisionsfrei über Mandanten hinweg;
es ist ohnehin keine Bindungsfrage.
Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind —
nicht übersehen.
**Nachtrag (260910-das, Aufgabe 3).** Wie in den vorherigen Durchläufen wird
der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum
Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3
tatsächlich umgesetzt haben.
*Tatsächlich umgesetzte Pfade gegen die angekündigten gehalten:* alle in (u2)
genannten Pfade sind wie beschrieben umgestellt. `UserService.findById`,
`create`, `update`, `deactivate`, `delete` laufen über `forTenant()`;
`create`/`update` übersetzen die plattformweite Eindeutigkeitsverletzung in
eine deutsche Konfliktmeldung, die weder Halter noch Mandant nennt.
`AdminSeedService.seedAdmin()` bindet die Erstanlage des Administrators an
den unmittelbar zuvor angelegten Mandanten und entschärft die Startsperre
(P2002 wird wie „Administrator existiert bereits" behandelt, jeder andere
Fehler bricht weiterhin ab). `UserController` bindet alle sieben eigenen
Zugriffe (ADMIN-Zweig der Benutzerliste, alle fünf Selbstbedienungswege) und
löst die drei Wege über die Kennung rollenabhängig auf. `findByUsername`
bleibt wie angekündigt ungebunden, mit richtiggestelltem Kopfkommentar.
*Die geschlossene Lücke im Selbstlösch-Riegel (Befund H):* der Vergleich
`user.id === currentUser.sub` griff nie, weil der Sitzungsnachweis kein Feld
`sub` trägt (`JwtStrategy.validate()` liefert exakt `{ id, username, role,
tenantId }`). Der Nachweis kommt aus der Reihenfolge selbst, nicht aus einer
Behauptung: `user.controller.spec.ts`, Test 6, wurde zuerst gegen die alte
Fassung ausgeführt — die Zeile `if (user.id === currentUser.sub)` wurde
probeweise wiederhergestellt, der Testlauf zeigte den erwarteten roten Test
(„promise resolved … instead of rejecting"), danach wurde auf
`currentUser.id` repariert und derselbe Testlauf grün. Die Wirkung ist eine
Verhaltensänderung: ein Administrator kann sein eigenes Konto seither nicht
mehr löschen — das ist die ursprüngliche, im Code bereits formulierte
Absicht, nicht neu erfunden.
*Die gewählte Form der Plattform-Administratorsicht, samt Beleg aus der
Messung:* wie in (u3)/Befund F angekündigt, laufen
`UserService.findAllForPlatformAdmin()` und `findByIdForPlatformAdmin()` als
Schleife über alle Mandanten (`this.prisma.tenant.findMany`, ungebunden, weil
`Tenant` keinen Zeilenschutz trägt) mit je EINEM gebundenen Lesezugriff im
Rumpf — dieselbe Form wie `AdminSeedService.ensureDefaultGroupsForAllTenants()`.
Der Beleg, dass diese Form die heutige Sicht erhält statt sie zu mindern,
kommt aus Aufgabe 1: `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen`
maß die Vereinigung der je-Mandant gebundenen `SELECT`s gegen eine über die
Wartungsrolle (mit `BYPASSRLS`) gemessene Gesamtmenge — beide Mengen waren
identisch (`["alice","bob","carol","dave"]`). In `user.service.spec.ts`
bestätigen Test 6 und Test 7 dasselbe am Code: je Mandant genau EIN
Protokolleintrag, die Sortierung nach Benutzername bleibt über die
zusammengeführten Teilmengen hinweg korrekt, und eine Kennungsauflösung für
einen Benutzer eines fremden Mandanten gelingt nachweislich über einen
gebundenen Lesezugriff.
*Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und
zurückgenommen:* in Aufgabe 2 wurde `UserService.findById` probeweise auf
den ungebundenen Klienten zurückgebaut — genau `user.service.spec.ts`, Test
4, wurde rot, mit der Meldung „erwarteter gebundener Aufruf
user.findUnique(tenant=t1) fehlt im Protokoll: []"; der Rückbau wurde
zurückgenommen, derselbe Testlauf danach wieder grün. In derselben Aufgabe
wurde zusätzlich `AdminSeedService.seedAdmin()`s gebundener
`tenantPrisma.user.create`-Aufruf probeweise auf den ungebundenen Basisclient
zurückgebaut — sechs Tests wurden rot (u. a. Test 9–12), alle mit der
Meldung „tenantPrisma.user.create is not a function", weil der ungebundene
Basisclient in der Testattrappe keine `create`-Methode auf `user` trägt; der
Rückbau wurde zurückgenommen, alle zehn Tests danach wieder grün. In Aufgabe
3 wurde `UserController.uploadAvatar`s gebundener Schreibzugriff probeweise
auf den ungebundenen Basisclient zurückgebaut — genau
`user.controller.spec.ts`, Test 7, wurde rot, mit der Meldung „Cannot read
properties of undefined (reading 'update')"; der Rückbau wurde
zurückgenommen, derselbe Testlauf danach wieder grün.
## Bereich module-registry
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `module-registry`
(Quick-Task 260910-exd) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie für den Bereich, der bei JEDER Modulanfrage entscheidet, wer
was benutzen darf: die Aktivierungsstufe (`TenantModuleActivation`) und die
Freigabestufe (`ModuleGrant`). Bleibt hier eine Abfrage ungebunden, sieht das
nach dem Scharfschalten nicht wie ein Fehler aus, sondern wie ein
Rechteentzug — die sichtbarste Ausprägung der umgekehrten Fehlerrichtung im
gesamten Vorhaben und zugleich die am wenigsten meldungswahrscheinliche.
### (m1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen achten
Abschnitt (`runModuleRegistryAreaChecks`) erweitert. Er setzt auf den vier
Tabellen auf, die `runGroupsAreaChecks` bereits mit Policies WORTGLEICH aus
den ausgelieferten Migrationen anlegt (`Group`, `GroupMembership`,
`ModuleGrant`, `TenantModuleActivation`) und legt selbst nur hinzu: die
Tabelle `"Module"` OHNE Zeilenschutz (die zu messende Eigenschaft selbst),
den Eindeutigkeitsindex auf `("tenantId","moduleId")` für
`"TenantModuleActivation"` (wortgleich aus `20260619103242_add_module_registry`
geschnitten), zwei Direkt-Freigabezeilen und die Mitgliedschaft (group-b,
user-a), die `runGroupsAreaChecks` unter gebundenem Kontext bewusst nicht
anlegen konnte. Die Zeile `grant-foreign-group` aus dem Abschnitt `groups`
wird wiederverwendet. Tatsächlich beobachtete Ausgabe dieses Laufs
(2026-09-10, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
modulegrant-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "ModuleGrant" liefert 0 Zeile(n), tatsaechlich vorhanden sind 5
tenantmoduleactivation-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenantModuleActivation" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
admin-kurzschluss-gebunden-liefert-nur-eigene-aktivierungen: bestanden — forTenant(TENANT-A) liefert 1 aktive Aktivierung(en): ["TENANT-A"]
direktfreigabe-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert fuer die Direkt-Freigabe-Abfrage (userId=user-a) 1 Zeile(n): ["grant-direct-a"]
gruppenpfad-gebunden-folgt-der-gruppenregel: bestanden — forTenant(TENANT-A) liefert ueber den Drei-Tabellen-Weg (Freigabe ueber Gruppe ueber Mitgliedschaft) fuer user-a 1 Zeile(n): ["grant-a"]
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT (Ergebnis: ["grant-a"]), obwohl die Regel auf "ModuleGrant" diese Zeile nachweislich durchlaesst (T-JTS-03) und die Mitgliedschaft (group-b, user-a) vorhanden ist — diese Verteidigung greift erst nach dem Scharfschalten
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"] — der Ausschluss aus Pruefung 6 kommt damit nachweislich von der Bindung, nicht vom Aufbau
module-tabelle-traegt-keinen-zeilenschutz: bestanden — ungebundenes SELECT auf "Module" liefert 2 Zeile(n): ["mod-1","mod-2"]; pg_class.relrowsecurity fuer "Module" = false
katalog-bindung-aendert-heute-nichts-an-der-ergebnismenge: bestanden — forTenant(TENANT-A) liefert ["mod-1","mod-2"], ungebunden liefert ["mod-1","mod-2"] — identisch, weil "Module" keine Regel traegt (Befund E: die Nichtbindung des Katalogs ist heute keine Rettung vor Unsichtbarkeit, sondern eine Frage der Wahrhaftigkeit der Aufzeichnung; sie wird erst zur Rettung, WENN Etappe 3 dieser Tabelle eine Regel gibt)
gebundener-join-auf-den-katalog-liefert-den-modulnamen: bestanden — forTenant(TENANT-A) liefert fuer den Verbund aus Aktivierung und Katalog: [{"moduleId":"mod-1","name":"Modul Eins"}]
aktivierung-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "TenantModuleActivation")
aktivierung-eindeutigkeit-traegt-den-mandanten-keine-unsichtbare-kollision: bestanden — gebundenes INSERT von (TENANT-A, mod-2) ist GELUNGEN, obwohl (TENANT-B, mod-2) bereits existiert und unter TENANT-A unsichtbar ist — der Eindeutigkeitsindex fuehrt mit der Mandantenkennung, genau die Entlastung, die die Bereiche `tenders` und `user` NICHT hatten (dort: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler)
freigabe-eindeutigkeitsindizes-fuehren-mit-der-mandantenkennung: bestanden — aus 20260804130130_add_groups_and_module_grants extrahiert: beide partiellen Eindeutigkeitsindizes auf "ModuleGrant" ("tenantId","moduleId","groupId") und ("tenantId","moduleId","userId") fuehren mit der Mandantenkennung, dieselbe Entlastung wie Pruefung 12, hier fuer die Freigabetabelle
Alle 66 Pruefungen bestanden.
```
**NACHTRAG (260910-jab):** die fett hervorgehobene Zeile oben
(`gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus`) ist ein
Messprotokoll vom 2026-09-10 und bleibt UNVERÄNDERT stehen. Ihre Behauptung
"die Regel auf ModuleGrant lässt diese Zeile durch (T-JTS-03)" ist seit
Migration `20260910120000_rls_widen_membership_grant_and_platform_read`
ÜBERHOLT: die Regel weist ein gebundenes Einfügen von `grant-foreign-group`
jetzt nachweislich ab (`modulegrant-fremde-gruppe-abgelehnt`); die Zeile
existiert in aktuellen Läufen nur noch, weil
`modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich` sie
über die Wartungsrolle (BYPASSRLS) anlegt. Der Meldetext dieser Prüfung ist
im Quelltext des Werkzeugs entsprechend richtiggestellt.
Die Belegzeile, die diesen Abschnitt trägt, ist
`modulegrant-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
"ModuleGrant"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
die 5 tatsächlich vorhandenen — an der echten, ausgelieferten Policy
gemessen. `tenantmoduleactivation-ungebunden-null-zeilen` misst dieselbe
Unsichtbarkeit für die Tabelle, die der Rollen-Kurzschluss für
ADMIN/SUPER_ADMIN als EINZIGE liest: auch hier fällt der
Verwaltungszugriff nach dem Scharfschalten aus.
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
diesem Bereich:
```
$ grep -rn '\$transaction(' apps/api/src/module-registry --include=*.ts | grep -v spec
$ echo $?
1
```
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
Aufgabe 2/3 nicht eingeführt.
**TEIL 3, Beleg statt Behauptung für Befund G** — die Aufrufermessung für
`isModuleActive` und `findActiveForTenant`:
```
$ grep -rn "isModuleActive" apps/api/src apps/web/src packages
apps/api/src/module-registry/module-registry.service.ts:133: async isModuleActive(tenantId: string, moduleSlug: string): Promise<boolean> {
$ grep -rn "findActiveForTenant" apps/api/src apps/web/src packages
apps/api/src/module-registry/module-registry.service.ts:35: async findActiveForTenant(tenantId: string) {
apps/api/src/module-registry/module-registry.controller.ts:51: * uses, not just tenant-wide activation. findActiveForTenant on
```
`isModuleActive` hat genau EINEN Treffer, die Definition selbst — ihr
Kopfkommentar behauptet "Used by ModuleGuard to gate access to
module-specific endpoints"; der Wächter ruft sie nachweislich nie auf
(`module.guard.ts` hält keinen eigenen Datenbankzugriff, siehe Befund D).
`findActiveForTenant` hat zwei Treffer, die Definition und eine
Prosa-Erwähnung im Kopfkommentar des Controllers ("stays unchanged for Plan
15-03's marketplace catalog") — auch sie hat keinen echten Aufrufer, der
Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient. Beide
Kommentare werden in Aufgabe 3 richtiggestellt, mit Bezug auf diese Messung.
### (m2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss (ADMIN/SUPER_ADMIN) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Sidebar und Marktplatz zeigen für JEDEN Administrator des Mandanten keine Module mehr; jede Modulroute antwortet mit 403 |
| `ModuleAccessService.getAccessibleModuleIds`, Direktweg (USER) | Der gebundene Freigabe-Lesezugriff über `userId` liefert 0 Zeilen statt der eigenen Direkt-Freigaben | Der Benutzer verliert genau die Module, die ihm direkt zugewiesen waren — ununterscheidbar von einem echten Entzug |
| `ModuleAccessService.getAccessibleModuleIds`, Gruppenweg (USER) | Der gebundene, verschachtelte Freigabe-Lesezugriff über die Gruppenmitgliedschaft liefert 0 Zeilen statt der Gruppen-Freigaben | Der Benutzer verliert alle über Gruppen geerbten Module, während seine Direkt-Freigaben unberührt bleiben — ein teilweiser, schwer zu erklärender Verlust |
| `ModuleAccessService.getAccessibleModuleIds`, Schnittmengenabfrage | Die gebundene Aktivierungsabfrage über `moduleId: { in: grantedIds }` liefert 0 Zeilen, obwohl Freigaben vorliegen | Ein Benutzer mit vorhandenen Freigaben sieht trotzdem kein Modul — von der leeren Vorgabemenge (kein Freigabe) nicht zu unterscheiden |
| `ModuleAccessService.findAccessibleModules` | Der Rückgabepunkt bei leerer `accessibleIds`-Menge liefert `[]`, ohne den Katalog überhaupt anzufragen | `GET /modules/active` liefert eine leere Liste; die Sidebar ist leer |
| `ModuleAccessService.getCatalogFlags` | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen statt der mandantenweit aktiven Module | Der Marktplatz zeigt für JEDES Modul `isActiveForTenant: false` UND `hasAccess: false` — eine ANDERE Unwahrheit als der Sperrhinweis: "nicht aktiviert" statt "nicht freigegeben" |
| `ModuleRegistryService.findActiveForTenant` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert 0 Zeilen | Betrifft heute keinen erreichbaren Pfad — die Falle liegt in einer KÜNFTIGEN Verdrahtung, siehe TEIL 3 |
| `ModuleRegistryService.activateForTenant` | Der gebundene Schreibzugriff schlägt fehl bzw. die Existenzprüfung des Katalogs (ungebunden) liefert `null` | `POST /modules/:id/activate` liefert `404 Module with id '...' not found`, obwohl das Modul existiert |
| `ModuleRegistryService.deactivateForTenant` | Der gebundene Lesezugriff auf die Aktivierung liefert `null` statt der vorhandenen Zeile | `POST /modules/:id/deactivate` liefert `404 Module '...' is not activated for this tenant` — LAUT, siehe (m3) |
| `ModuleRegistryService.isModuleActive` (heute ohne Aufrufer) | Der gebundene Aktivierungs-Lesezugriff liefert `null`, die Methode gibt `false` zurück | Betrifft heute keinen erreichbaren Pfad — die stille Falle für eine KÜNFTIGE Verdrahtung, siehe (m3) |
| `ModuleRegistryService.findAll`/`findBySlug`/`seedModule` (bewusst UNGEBUNDEN, Modulkatalog) | Betrifft nicht diese Pfade selbst — sie binden nicht und liefern deshalb weiterhin korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3, sobald `Module` eine Regel bekommt) | Würde man sie binden: der gesamte Modulkatalog verschwände für JEDEN Mandanten — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten |
| `ModuleAccessService`, Katalogzugriffe in `findAccessibleModules`/`getCatalogFlags` (bewusst UNGEBUNDEN) | Dieselbe Grenze wie oben, hier für die beiden Katalog-Lesezugriffe des Nachbardienstes | Dieselbe Folge: eine künftige Bindung würde den Katalog mandantenweit unsichtbar machen |
### (m3) Welcher Code Leere als Abwesenheit deutet
Nach Wirkung sortiert:
1. **`ModuleAccessService.getAccessibleModuleIds`, `grantedIds.length === 0`
→ `return new Set()`** — der Rückgabepunkt bei leerer Freigabemenge. Der
Vorgabezustand ist geschlossen (D-04/T-EXD-04); eine zu leer gebliebene
Freigabeabfrage sieht identisch aus wie ein Benutzer ohne jede Freigabe.
2. **`ModuleAccessService.getAccessibleModuleIds`, Rollen-Kurzschluss** —
`if (role === 'ADMIN' || role === 'SUPER_ADMIN')` liest ausschließlich
`tenantModuleActivation`; eine leere Aktivierungsliste liefert ein leeres
Set, ohne die Freigabestufe überhaupt zu befragen. Betrifft damit JEDEN
Administrator des Mandanten gleichzeitig.
3. **`ModuleAccessService.findAccessibleModules`, `accessibleIds.size === 0`
→ `return []`** — der Rückgabepunkt bei leerer Modulliste, bedient
`GET /modules/active` und damit die Sidebar.
4. **`ModuleAccessService.getCatalogFlags`** — eine leere Aktivierungsliste
lässt die zurückgegebene Map leer; der Controller
(`module-registry.controller.ts`, `findCatalog`) mappt einen fehlenden
Eintrag auf BEIDE Flags `false`. Das erzeugt eine ANDERE Unwahrheit als
der Sperrhinweis der Freigabestufe: "nicht aktiviert" statt "nicht
freigegeben" — der Marktplatz zeigt dem Benutzer den falschen Grund für
die Sperre.
5. **`ModuleGuard.canActivate`, die 403-Stelle** —
`if (!accessibleModuleIds.has(module.id)) throw new ForbiddenException(...)`.
Dieselbe Meldung für "wirklich keine Freigabe" und "die Aufösung hat
nichts gefunden" — siehe die Leitfrage dieses Abschnitts unten.
6. **`ModuleRegistryService.isModuleActive`, Vorgabewert `false`** — heute
ohne Aufrufer (TEIL 3), aber die stille Falle für morgen: `return
activation?.isActive === true` liefert bei `null` (leerer, gebundener
Lesezugriff) exakt dasselbe `false` wie eine echte, mandantenweite
Deaktivierung. Wird dieser Pfad morgen verdrahtet, ist er von Fall an
nicht mehr vom Rest dieses Abschnitts zu unterscheiden.
**Gegenrichtung, damit dieser Abschnitt nicht nur aus Alarm besteht:**
`ModuleRegistryService.deactivateForTenant` wirft bei leerer
Aktivierungsabfrage (`if (!activation) throw new NotFoundException(...)`)
LAUT — die Ausnahme meldet sich sofort und verständlich, statt ein stilles
`false` zu liefern. Dieselbe laute Richtung gilt für `activateForTenant` bei
unbekannter `moduleId`.
**Die Frage, die dieser Bereich vor allen anderen beantworten muss: welches
Signal unterscheidet "wirklich keine Freigabe" von "die Abfrage hat nichts
gefunden"?** Die Antwort lautet **keines** — schlicht, ohne Beschönigung.
Drei Stellen sehen heute identisch aus, ob der Benutzer tatsächlich keine
Freigabe hat oder ob eine gebundene Abfrage nach dem Scharfschalten leer
lief: dieselbe `ForbiddenException`-Meldung im Wächter
("Module '...' is not accessible for this user"), dieselbe leere Modulliste
mit Status 200 (`GET /modules/active`), kein einziger Protokolleintrag. Der
Zusatz, der diesen Bereich von allen vorherigen unterscheidet: der
Betroffene hat eine fertige, FALSCHE Erklärung zur Hand ("mein Administrator
hat mir das entzogen") und meldet deshalb keinen Fehler — anders als etwa
bei `LdapConfigScheduler`, wo niemand eine plausible Alltagserklärung für
ausbleibende Synchronisation hat.
Die eine Asymmetrie, die sich zu einem Signal machen LIESSE, wird hier
benannt und an Etappe 4 übergeben, nicht gelöst: nach dem Scharfschalten ist
ein zu kleines Ergebnis in diesem Bereich TOTAL und nicht selektiv — JEDER
Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, während die
Aktivierungs- und Freigabetabellen weiterhin Zeilen halten. Die
Vorabprüfung von Etappe 4 (`rls-preflight.mjs`) kann genau das feststellen:
aktive Aktivierungszeilen vorhanden, aber die Auflösung liefert für einen
bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den oben
genannten Stellen wird ERWOGEN und VERWORFEN, mit derselben Begründung wie
bei `getAllActiveConfigs` im Bereich `ldap` und den fünf Stellen im Bereich
`tenders`: eine leere Menge ist für einen Benutzer ohne Freigabe und auf
einer frischen Installation der Normalzustand, eine Warnung wäre Dauerlärm
und verlöre ihr Signal.
### (m4) Was dieser Durchlauf bewusst nicht löst
- ~~**T-JTS-02/T-JTS-03 bleiben aufgezeichnet und ungefixt.** Gemessen in
Aufgabe 1 (Prüfungen 6/7): die Regel auf `ModuleGrant` prüft nur die
Mandantenkennung der Zeile selbst, nicht die referenzierte Gruppe; die
Regel auf `GroupMembership` prüft nur die Gruppenseite. Die Bindung fügt
auf der LESESEITE eine zweite Verteidigung hinzu (der Drei-Tabellen-Weg
schließt die fremde Gruppe gebunden aus), aber erst nach Etappe 4 — die
Schreibseiten-Gegenprüfung in `module-grants.service.ts`
(`assertTargetBelongsToTenant`) bleibt deshalb der heutige Schutz und wird
durch diesen Durchlauf NICHT ersetzt.~~
**NACHTRAG (260910-jab): ÜBERHOLT — BEIDE Befunde sind geschlossen.**
Migration `20260910120000_rls_widen_membership_grant_and_platform_read`
lässt die Regel auf `GroupMembership` jetzt beide Seiten prüfen
(T-JTS-02) und die Regel auf `ModuleGrant` zusätzlich beide möglichen
Ziele (T-JTS-03, beide Zweige des Entweder-oder D-04). Die
Schreibseiten-Gegenprüfung in `module-grants.service.ts`
(`assertTargetBelongsToTenant`) bleibt TROTZDEM bestehen — sie ist bis
zum Scharfschalten (#18, Schalter weiterhin aus) der einzige tatsächlich
wirksame Schutz und wird NICHT ersetzt. Siehe den neuen Abschnitt
"Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" unten.
- **Die Regel für den Modulkatalog gehört zu Etappe 3.** Gemessen in
Aufgabe 1 (Prüfung 8/9, Befund E): `"Module"` trägt heute keinen
Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal.
Katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt
— dann verschwände der gesamte Katalog für jeden Mandanten. Diese
Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
- **Die offene Architekturfrage `req.tenantPrisma`** — auch dieser Bereich
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
`tenders`, `dkv` und `user` es vormachen.
### (m5) Was dieser Durchlauf bewusst NICHT anfasst
- `module.guard.ts` — geprüft (Befund D: hält keinen eigenen
Datenbankzugriff, seine Richtigkeit ist vollständig eine Funktion dessen,
was `ModuleAccessService` zurückgibt) und bewusst gelassen, keine Bindung
nötig.
- `module-registry.controller.ts` — geprüft (hält ebenfalls keinen eigenen
Datenbankzugriff) und bewusst gelassen.
- Das Frontend — geprüft und bewusst gelassen, keine Datei dieses Plans.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
**Nachtrag (260910-exd, Aufgabe 3).** Wie in den vorherigen Durchläufen wird
der Text oben NICHT umgeschrieben — er beschreibt korrekt den Stand zum
Zeitpunkt der Messung (Aufgabe 1); dieser Nachtrag hält fest, was Aufgabe 2/3
tatsächlich umgesetzt haben.
*Tatsächlich umgesetzte Pfade gegen die in (m2) angekündigten gehalten:*
alle in (m2) genannten Pfade sind wie beschrieben umgestellt.
`ModuleAccessService.getAccessibleModuleIds` bindet Kurzschlusszweig,
Direktweg, Gruppenweg und Schnittmenge über EINEN Klienten je Aufruf;
`getCatalogFlags` bindet ihren eigenen Aktivierungs-Lesezugriff, die
geschachtelte `getAccessibleModuleIds`-Auflösung erzeugt ihren eigenen
Klienten; `findAccessibleModules` erreicht den Katalog weiterhin über den
ungebundenen Klienten. `ModuleRegistryService.findActiveForTenant`,
`activateForTenant` und `isModuleActive` binden ihren jeweiligen
Aktivierungszugriff; `deactivateForTenant` bindet beide Aktivierungszugriffe
(Lesen, Schreiben) über EINEN Klienten. Alle sechs Katalogzugriffe in
`module-registry.service.ts` (`findAll`, `findBySlug`, die beiden
Katalog-Existenzprüfungen, die Katalogsuche in `isModuleActive`,
`seedModule`) und der eine Katalogzugriff in `module-access.service.ts`
(`findAccessibleModules`) blieben wie angekündigt ungebunden, mit
Kommentaren, die Messung und Bedingung trennen. Beide falschen
Kopfkommentare aus Befund G sind behandelt: `isModuleActive`s Kommentar ist
richtiggestellt (mit Bezug auf die Aufrufermessung aus TEIL 3); der
irreführende Satz im Kopfkommentar von `module-registry.controller.ts`
("findActiveForTenant on ModuleRegistryService stays unchanged for Plan
15-03's marketplace catalog") wurde NICHT mitgeändert — der Controller ist
nicht Teil dieses Plans. Die Feststellung, dass diese Aussage falsch ist
(der Marktplatz-Katalog wird nachweislich von `getCatalogFlags` bedient,
nicht von `findActiveForTenant`), steht stattdessen hier in (m5).
*Deviation (Rule 1/3): eine cross-area Testabhängigkeit brach durch die
Umstellung.* `tender-scheduler.service.spec.ts` instanziiert
`ModuleRegistryService` unmocked gegen einen hand-gerollten Fake ohne
`$extends` (derselbe Zweck wie in `ldap.service.spec.ts`: der Aktivierungs-
Aufruf soll echt sein, nicht ein Stand-in). Nach der Umstellung von
`activateForTenant` auf `forTenant()` scheiterte dieser Test mit
`prisma.$extends is not a function`. Behoben mit derselben Konvention wie
`ldap.service.spec.ts` — `forTenant` in dieser einen Datei über `vi.mock`
auf eine Identitätsfunktion gelegt (`forTenant: vi.fn((p) => p)`), weil die
Datei RLS-Bindungsmechanik nicht testet, nur das Poll-once-fan-out-many-
Verhalten des Schedulers. Kein anderer Aufrufer von `new
ModuleRegistryService(...)` existiert im Quelltext (geprüft).
*Deviation (Rule 3): die Klassifikationsdokument-Stände für
`module-access.service.ts` wurden bereits in Aufgabe 2 nachgezogen*, nicht
erst in dieser Aufgabe — `rls-access-inventory.spec.ts` ist Teil der von
Aufgabe 2 verlangten vollständigen Testsuite und wäre sonst am Ende von
Aufgabe 2 bereits rot gewesen. Diese Abweichung von der Aufgabenaufteilung
(die Klassifikationsdatei war für Aufgabe 3 vorgesehen) ist auf das
Notwendige beschränkt: nur die `Stand`-Spalte der beiden betroffenen Zeilen,
keine Begründung, keine Zahlen. Die vollständige Nachziehung (fünf
Bestandsaufnahme-Zeilen inklusive `module-registry.service.ts`,
Übersichtszeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst-
Abschnitt) erfolgte wie geplant in dieser Aufgabe.
*Falsifizierungsnachweise (Aufgabe 2 und 3), je einmal durchgeführt und
zurückgenommen:* in Aufgabe 2 wurde die Gruppenweg-Bindung der
Freigabe-Auflösung probeweise zurückgebaut (`tenantPrisma.moduleGrant` →
`this.prisma.moduleGrant` im Gruppenweg von `getAccessibleModuleIds`) —
genau `module-access.service.spec.ts`, Test "ModuleAccessService — Bindung
an forTenant() (260910-exd) > USER-Zweig bindet BEIDE
Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den
Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung", wurde rot, mit der
Meldung `expected 1 to be 2`; der Rückbau wurde zurückgenommen, derselbe
Testlauf danach wieder grün (23/23). In Aufgabe 3 wurde der
Schreibzugriff des Deaktivierens probeweise zurückgebaut
(`tenantPrisma.tenantModuleActivation.update` →
`this.prisma.tenantModuleActivation.update` in `deactivateForTenant`) —
genau `module-registry.service.spec.ts`, Test
"ModuleRegistryService.deactivateForTenant > bindet beide
Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen
Klienten", wurde rot, mit der Meldung "erwarteter gebundener Aufruf
tenantModuleActivation.update(tenant=t1) fehlt im Protokoll:
[{"tenantId":"t1","model":"tenantModuleActivation","method":"findUnique"}]:
expected false to be true"; der Rückbau wurde zurückgenommen, derselbe
Testlauf danach wieder grün (16/16).
*Die offene WINDOWS-Aufzeichnung.* Eintrag #23 (`deviation`) hält fest, dass
es kein Signal gibt, das "wirklich keine Freigabe" von "die Abfrage hat
nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der
begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und
`.planning/WINDOWS.md`.
**Nachtrag (260910-krx):** der oben in Befund E festgehaltene Befund zur
Reihenfolge — der Bereich `dashboard` erbt die Bindung der
Modul-Zugriffsauflösung, ohne dass eine Datei unter
`apps/api/src/module-registry` dafür angefasst werden muss — ist mit
Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: `dashboard.service.ts`
ruft `ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
in `getWidgets` unverändert auf, diese Auflösung bindet seit 260910-exd
bereits über `forTenant()`, und der Filter ist damit gebunden. Nachgeprüft
mit `grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/
module-access.service.ts` (ein Treffer) und mit einem Wachhund-Testfall in
`dashboard.service.spec.ts`, der den Modulkatalog aus dem
Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx
NICHT ein zweites Mal gebunden — keine Datei unter
`apps/api/src/module-registry` ist Teil dieses Plans.
## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19
Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab,
auf ausdrückliche Anweisung des Nutzers): die drei Regelfixe, ursprünglich
nach Etappe 2 vorgesehen, wurden vorgezogen. Folge: die fünf noch offenen
Bereiche der Etappe 2 messen ab jetzt gegen die NEUEN Regeln, mehrere
bereits abgeschlossene Bereiche (`groups`, `tenders`, `module-registry` oben)
haben gegen die ALTEN gemessen — diese Messungen stehen aufgezeichnet und
sind mit Nachträgen an den betroffenen Stellen versehen, nicht umgeschrieben.
### (r1) Tatsächlich beobachtete Ausgabe und Regelliste der lebenden Datenbank
Lauf vom 2026-09-10 gegen `tessera-ctl-db-1`, Adresse `172.19.0.2` (nur die
Zeilen der drei betroffenen Bereiche sowie der beiden aufsetzenden
`module-registry`-Prüfungen; die vollständige Ausgabe umfasst 74
Prüfungen):
```
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-schreiben-fremder-benutzer-abgelehnt: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit A-eigener Gruppe, aber fremder Benutzerkennung (user-b, TENANT-B) ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — der Ausschluss aus der vorigen Pruefung kommt damit nachweislich von der Regel, nicht vom Aufbau
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
modulegrant-fremde-gruppe-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId (group-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
modulegrant-fremder-benutzer-abgelehnt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder userId (user-b, TENANT-B) abgewiesen: ERROR: new row violates row-level security policy for table "ModuleGrant"
modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist ueber die Wartungsrolle (BYPASSRLS) weiterhin GELUNGEN — legt zugleich die Zeile 'grant-foreign-group' an, auf der zwei Pruefungen des Bereichs module-registry aufsetzen (Befund C)
tenderrssfeed-plattformzeile-gebunden-sichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: true, forTenant(TENANT-B) sieht sie: true
tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar: bestanden — forTenant(TENANT-A) liefert ["rss-a","rss-platform"] — eigene Zeile (rss-a) sichtbar: true, fremde Zeile (rss-b, TENANT-B) sichtbar: false
tenderrssfeed-ungebunden-nur-die-plattformzeile: bestanden — ungebundener SELECT auf "TenderRssFeedSource" liefert 1 Zeile(n): ["rss-platform"]
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen (tenant_insert_policy)
tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt: bestanden — gebundenes UPDATE auf die plattformweite Zeile traf keine Zeile (tenant_update_policy filtert sie heraus) — url unveraendert
tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt: bestanden — gebundenes DELETE auf die plattformweite Zeile traf 0 Zeilen (tenant_delete_policy filtert sie heraus)
searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar: bestanden — forTenant(TENANT-A) sieht die mandantenlose Zeile: false, forTenant(TENANT-B) sieht sie: false — bewusst UNVERAENDERT (widerlegte Praemisse)
gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus: bestanden — forTenant(TENANT-A) liefert 'grant-foreign-group' ueber denselben Drei-Tabellen-Weg NICHT — die Zeile wurde ueber die Wartungsrolle angelegt
gruppenpfad-ueber-die-wartungsrolle-liefert-die-fremde-gruppe-mit: bestanden — dieselbe Abfrage ueber die Verwaltungsrolle (BYPASSRLS) liefert ["grant-a","grant-foreign-group"]
Alle 74 Pruefungen bestanden.
```
Regelliste aus `pg_policies` der lebenden Datenbank (Systemkatalog, nicht die
Migrationsdatei):
```
GroupMembership#tenant_isolation_policy#ALL#(("groupId" IN (SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id()))) AND ("userId" IN (SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id()))))
ModuleGrant#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND (("groupId" IS NULL) OR ("groupId" IN (SELECT "Group".id FROM "Group" WHERE ("Group"."tenantId" = current_tenant_id())))) AND (("userId" IS NULL) OR ("userId" IN (SELECT "User".id FROM "User" WHERE ("User"."tenantId" = current_tenant_id())))))
SearchProvider#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_delete_policy#DELETE#("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_insert_policy#INSERT##WITH CHECK ("tenantId" = current_tenant_id())
TenderRssFeedSource#tenant_platform_read_policy#SELECT#(("tenantId" = current_tenant_id()) OR ("tenantId" IS NULL))
TenderRssFeedSource#tenant_update_policy#UPDATE#USING ("tenantId" = current_tenant_id())#WITH CHECK ("tenantId" = current_tenant_id())
```
### (r2) Signaltabelle — beide Fehlerrichtungen je Regel
| Regel | Zu streng (Falsch-Negativ, DoS) | Zu locker (Falsch-Positiv, Rechteausweitung) |
|---|---|---|
| `GroupMembership` | Wäre die neue Regel zu streng, würde eine tatsächlich mandanteneigene Mitgliedschaft unsichtbar — Signal: `groupmembership-folgt-join-auf-group` würde fehlschlagen (liefert heute korrekt 1 Zeile) | `groupmembership-schreiben-fremder-benutzer-abgelehnt` — Signal wäre ein GELUNGENES Einfügen einer fremden Benutzerkennung; heute abgewiesen |
| `ModuleGrant` | Eine eigene, korrekt referenzierende Freigabe würde unsichtbar — Signal: `modulegrant-gebunden-nur-eigene-zeile` würde fehlschlagen (heute 1 Zeile) | `modulegrant-fremde-gruppe-abgelehnt`/`modulegrant-fremder-benutzer-abgelehnt` — Signal wäre ein GELUNGENES Einfügen mit fremder Gruppen- bzw. Benutzerkennung; heute beide abgewiesen |
| `TenderRssFeedSource` (Lese-/Schreibsplit) | Zu streng bedeutet: die plattformweite Zeile bleibt trotz Bindung unsichtbar — Signal: `tenderrssfeed-plattformzeile-gebunden-sichtbar` würde fehlschlagen (heute sichtbar unter beiden Mandanten) ODER die eigene Zeile verschwindet — Signal: `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar` würde fehlschlagen | Zu locker bedeutet: ein Mandant kann die plattformweite Zeile ändern/entfernen — Signal: `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt`/`tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt` würden fehlschlagen (heute beide: 0 betroffene Zeilen) |
### (r3) Stellen, an denen Leere weiterhin als Abwesenheit gedeutet wird
Namentliche Liste, wie in den Abschnitten (d)/(g3)/(t3)/(u3)/(m3) oben je
Bereich geführt — hier bereichsübergreifend für die drei betroffenen
Tabellen, inklusive der NEUEN Stelle aus Befund F:
- `TenderRssFeedSourceService.listForUser` (**NEU seit 260910-jab, Aufgabe
2**) — vor dieser Reparatur lieferte der ungebundene Pfad nach dem
Scharfschalten NICHTS (eine schreiende Leere, die auffällt). Nach der
Reparatur würde er UNGEBUNDEN nur die plattformweiten Zeilen liefern — eine
kurze, glaubhafte Teilantwort, die NICHT auffällt (der Nutzer sieht
plattformweite Feeds und hat keinen Anlass zu melden, dass seine eigenen
fehlen). Deshalb gebunden — die einzige Stelle, die diese Aufgabe still
falsch gemacht hätte, wenn sie nicht mitgebunden worden wäre.
- Alle bereits in (t3) genannten fünf lautlosen Stellen in den beiden
Tender-Hintergrunddiensten bleiben unverändert lautlos — von dieser
Regeländerung nicht betroffen.
- `GroupMembership`/`ModuleGrant` selbst tragen keine "Leere als Abwesenheit
gedeutet"-Stelle im hier verstandenen Sinn (eine ABGEWIESENE Schreibung ist
ein harter Fehler, keine stille Leere) — die relevante Fehlerrichtung ist
hier die Rechteausweitung (r2), nicht die stille Leere.
### (r4) Was dieser Durchlauf bewusst nicht löst
- **Der Verwaltungsweg für plattformweite Zeilen fehlt.** Unter der
Anwendungsrolle lässt sich eine plattformweite `TenderRssFeedSource`-Zeile
weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil
jede Schreibregel einen Mandanten verlangt. `createPlatform`/`remove`
bleiben deshalb bewusst ungebunden. Als eigener offener Ledger-Eintrag
festgehalten (WINDOWS #24), damit dieser Rest nicht mit #19 verschwindet —
ein Verwaltungsweg (z. B. eine eigene Systemrolle oder ein expliziter
Admin-Bypass-Pfad) ist Gegenstand von Etappe 4.
- **Die fehlende Benutzerdimension der Ausschreibungsregeln.** Selbst
gemessen statt übernommen (`grep -rn "current_setting\|set_config"
apps/api/src apps/api/prisma/migrations`, 260910-jab): es existiert
GENAU EINE Sitzungsvariable, `app.current_tenant`
(`prisma-tenant.extension.ts`, `20260618112133_rls_policies`). Es gibt
KEINE zweite Sitzungsvariable für den Benutzer — Tabellen wie
`TenderSavedSearch` (siehe `tendersavedsearch-fremder-nutzer-desselben-
mandanten-sichtbar` oben) haben deshalb strukturell keine Möglichkeit,
eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche
Variable erst einzuführen. Nicht gebaut in diesem Durchlauf — die
anwendungsseitige `userId`-Filterung bleibt der einzige Schutz.
- **Die plattformweite Eindeutigkeit von Anmeldename und Adresse (WINDOWS
#22)** — unverändert, nicht Gegenstand dieses Plans.
### (r5) Was dieser Durchlauf bewusst nicht anfasst
- `SearchProvider` — die Regel bleibt wörtlich unverändert (`"tenantId" =
current_tenant_id()`), mit gemessener Begründung (widerlegte Prämisse,
siehe `docs/mandantentrennung-zugriffsklassifikation.md`).
- Die Regeln auf `Group` und `TenantModuleActivation` — beide unverändert,
nicht Teil der drei benannten Löcher.
- Der Schalter (`DATABASE_URL`, Rolle `tessera`) — bleibt aus. Die drei
Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der
lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen.
## Bereich dashboard
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dashboard`
(Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen
Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet
hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die
umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie
ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe
(w3).
### (w1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen neunten
Abschnitt (`runDashboardAreaChecks`) erweitert, unmittelbar nach
`runModuleRegistryAreaChecks` und vor `runTransactionShapeMeasurement`
aufgerufen. Er legt die beiden Wegwerf-Tabellen `DashboardLayout` und
`WidgetInstance` selbst neu an und benutzt die von
`runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider`
WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit
`extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
Regelstand NACH der Migration `20260910120000_rls_widen_membership_grant_and_platform_read`**
(die drei Regeln dieses Bereichs sind von jener Migration unverändert
gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem
aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-11, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen dieses Abschnitts sowie
die abschließende Summenzeile):
```
dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"]
dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten
dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"]
widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs
dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout"
widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance")
widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten
searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"]
searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)
searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider")
Alle 87 Pruefungen bestanden.
```
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
namentlich vorgezeichnet — die dreizehnte
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen
dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für
`DashboardLayout` und `SearchProvider` einen entsprechenden Testfall
vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf
87 (74 + 13).
**Die tragende Belegzeile ist `dashboardlayout-ungebunden-null-zeilen`:**
der IDENTISCHE `SELECT "tenantId" FROM "DashboardLayout"` ohne vorheriges
`set_config` liefert **0 Zeilen**, nicht die 4 tatsächlich vorhandenen —
an der echten, ausgelieferten Policy gemessen. `widgetinstance-ungebunden-
null-zeilen` misst dieselbe Unsichtbarkeit für die Widget-Tabelle.
**Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine
Vermutung.** Ein gebundenes `INSERT ... ON CONFLICT ("userId") DO UPDATE`
unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter
TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE `42501`
("new row violates row-level security policy (USING expression) for table
\"DashboardLayout\""), nicht mit einem Eindeutigkeitsfehler (`23505`) und
nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma
Client gemessen (nicht nur an rohem SQL), weil `saveLayout` in Wahrheit
`prisma.dashboardLayout.upsert()` aufruft, nicht `$executeRaw`: gegen eine
eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz
(`id`, `userId`, `tenantId`, `layouts`, `createdAt`, `updatedAt`) und einer
eigenen Wegwerf-Rolle ohne `BYPASSRLS` liefert derselbe Konfliktfall über
`tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create })`
einen **`PrismaClientUnknownRequestError`** — nicht den bekannten
`PrismaClientKnownRequestError` mit `.code === 'P2002'`, den der Bereich
`tenders` für seinen Eindeutigkeitsfall abfängt. `.code` und `.meta` sind
bei diesem Fehlertyp `undefined`; die einzige verlässliche Information
steht im rohen `.message`-Text, der den PostgreSQL-Fehler eingebettet
enthält (`code: "42501"`, `message: "new row violates row-level security
policy (USING expression) for table \"DashboardLayout\""`). **Das ist die
zentrale Abweichung von der Annahme, das `tenders`-P2002-Muster ließe sich
wörtlich übernehmen** — es lässt sich nicht, weil dieser Fehler eine andere
Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb
`Prisma.PrismaClientUnknownRequestError` ab (Prüfung auf die Fehlerklasse,
nicht auf `.code`) und übersetzt ihn in eine verständliche deutsche
Meldung — siehe (w4) für die Grenze dieser Behandlung.
**Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F,
WINDOWS #19).** Nachgeprüft mit derselben Anweisung wie zur Planungszeit,
diesmal gegen den aktuellen Quelltext (2026-09-11):
`grep -rn "searchProvider\|SearchProvider" apps packages prisma
--include=*.ts --include=*.mjs --include=*.js --include=*.sql
--include=*.json` (ohne `node_modules`, `dist/`, `.next/`). Ergebnis
unverändert: der einzige Schreibweg ist
`dashboard.service.ts:241` (`addSearchProvider` → `create`) mit
`tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode; keine
Seed-Datei (`apps/api/prisma/` enthält ausschließlich `migrations` und
`schema.prisma`), kein Skript, kein weiterer Schreibweg. Reichweite der
Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über
einen dynamisch gebildeten Modellnamen und deckt keine manuelle
Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad
erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich
datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne-
mandant-abgelehnt` weist ein gebundenes Einfügen mit `tenantId = NULL`
laut mit SQLSTATE `42501` ab, weil die Regel ohne eigene `WITH CHECK`-
Klausel ihre `USING`-Klausel dafür wiederverwendet.
### (w2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `DashboardService.getLayout` | Der gebundene Lesezugriff liefert `null` statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" | Der Rückgabepunkt liefert die Vorgabeanordnung `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette |
| `DashboardService.saveLayout` | Der gebundene Schreibzugriff trifft im `update`-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel `userId` — Konfliktmessung, siehe (w1) | `PUT /dashboard/layout` liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze |
| `DashboardService.getWidgets`, Widget-Lesezugriff | Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets | Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler |
| `DashboardService.addWidget` | Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung | `POST /dashboard/widgets` liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (`ForbiddenException` bereits im Controller) |
| `DashboardService.updateWidgetConfig`/`removeWidget`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4) |
| `DashboardService.getSearchProviders` | Der gebundene Lesezugriff auf `custom` liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt | Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J |
| `DashboardService.addSearchProvider` | Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter | — |
| `DashboardService.removeSearchProvider`, Besitzprüfung | Wie bei Widgets: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß |
### (w3) Welcher Code Leere als Abwesenheit deutet
**Backend, eine Stelle:** `DashboardService.getLayout` —
`if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`. Kein
Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe
Deutung wie bei `getWidgets` (leere Liste) und `getSearchProviders` (nur die
drei Konstanten).
**Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut
nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan
ändert an KEINER der beiden Dateien etwas:**
1. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt
`layouts` und `widgets` genau auf das, was `api.fetchLayout()` und
`api.fetchWidgets()` liefern. Der `catch`-Zweig
(`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk-
oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen
Fehlerzustand.
2. `apps/web/src/lib/stores/dashboard-store.ts`, `setEditMode`:
`if (prev && !mode && get().isDirty) { get().saveLayout(); }` — der
Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch
zurückgeschrieben, ohne dass jemand auf "Speichern" klickt.
3. `apps/web/src/components/dashboard/widgets/search-widget.tsx`: die
Rückfallprüfung `if (!cancelled && data.length > 0)` greift NIE, weil
`getSearchProviders` die drei Vorgaben immer voranstellt — `data` ist
nie leer, selbst wenn `custom` (die eigenen Suchmaschinen) nach dem
Scharfschalten leer geblieben wäre. Der `.catch()`-Rückfallzweig auf
`DEFAULT_PROVIDERS` feuert deshalb ebenfalls nie in diesem Fall.
**Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I):**
leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des
Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu
auf, `addWidget` legt echte neue `WidgetInstance`-Zeilen an (keine
Eindeutigkeitsbedingung über `(userId, widgetType)`, Dubletten häufen sich
also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus
schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer
"Speichern" geklickt hat → die `layouts`-Spalte der ursprünglichen Zeile
ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung
ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der
Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das
Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb
keinen Fehler — derselbe Mechanismus, der auch in `module-registry` (m3)
und `ldap` beschrieben ist, hier aber mit einer zusätzlichen, aktiven
Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem
der beiden anderen Bereiche gibt.
**Das Verhalten der Suchleiste (Befund J), präzise statt "still"
formuliert:** verschwinden die eigenen Suchmaschinen des Nutzers aus
`custom`, bleibt die Auswahlliste (`<select>`) dennoch gefüllt — mit den
drei Vorgaben. Sichtbar wird das nur, wenn der Nutzer VORHER eine eigene
Suchmaschine ausgewählt hatte: React setzt `value={selectedProviderId}`
auf dem `<select>`, aber `selectedProviderId` referenziert nach dem
Verschwinden keinen vorhandenen `<option>`-Wert mehr — der Browser zeigt in
diesem Fall (kein `<option>` mit passendem `value`) den ERSTEN Eintrag der
Liste an, also "Google", OHNE dass der interne React-State
`selectedProviderId` sich ändert oder irgendeine Meldung erscheint. Klickt
der Nutzer danach Suchen, greift `handleSearch`:
`providers.find((p) => p.id === selectedProviderId) ?? providers[0]` — der
`find` schlägt fehl (die eigene Suchmaschine ist nicht mehr in `providers`),
der Rückfall auf `providers[0]` (Google) greift, und eine Anfrage, die für
ein internes Werkzeug gedacht war, geht an eine externe Suchmaschine. Für
einen Nutzer, der die Anzeige "Google" im Dropdown nicht bewusst als
Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
### (w4) Was dieser Durchlauf bewusst nicht löst
- **Die plattformweite Eindeutigkeit von `DashboardLayout.userId`
(Befund K).** `userId` trägt `@unique` ohne Mandantenanteil — strukturell
dieselbe Kette wie WINDOWS #22 im Bereich `user`: unsichtbare Zeile,
falsches "frei", harter Eindeutigkeitsfehler. Anders als bei #22 ist der
Fehler hier aber GEMESSEN statt angenommen (siehe (w1), Prüfung 5) und
wird in Aufgabe 2 BEDINGT auf das gemessene Ergebnis behandelt: eine
verständliche deutsche Meldung statt eines rohen 500ers, indem
`Prisma.PrismaClientUnknownRequestError` abgefangen wird — NICHT
`.code === 'P2002'` wie im Bereich `tenders`, weil der gemessene Fehler
eine andere Prisma-Fehlerklasse ist. Die ehrliche Reparatur wäre eine
Schemaänderung (Eindeutigkeit mit Mandantendimension) und ist als
Produktentscheidung für Etappe 3 vorgemerkt, wie #22 es für denselben
Fall bereits tut.
- **Die fehlende Unterscheidbarkeit von "noch keine Anordnung gespeichert"
und "Anordnung nicht sichtbar".** Beide liefern identisch die
Vorgabeanordnung, Status 200, keinen Protokolleintrag. Die konkrete
Vorabprüfung für Etappe 4 (`rls-preflight.mjs`): eine physisch
vorhandene `DashboardLayout`-Zeile für einen bekannten Benutzer, aber der
gebundene Lesezugriff für dessen Mandanten liefert `null` — das
unterscheidet den echten Erstbenutzer-Fall (keine Zeile vorhanden) vom
Trennungsfehler (Zeile vorhanden, aber unsichtbar).
- **Eine Laufzeitwarnung an `getLayout`/`getWidgets`/`getSearchProviders`
wurde erwogen und VERWORFEN**, mit derselben Begründung wie bei
`getAllActiveConfigs` im Bereich `ldap`: eine leere Anordnung, eine leere
Widget-Liste oder keine eigene Suchmaschine sind auf einer frischen
Installation oder für einen neuen Benutzer der NORMALZUSTAND — eine
Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie
gebraucht wird. Das gilt hier NOCH ausgeprägter als in `ldap`, weil
praktisch jeder neue Benutzer diesen Zustand beim ersten Login durchläuft.
- Der offene Ledger-Eintrag zur beweisvernichtenden Schleife (angelegt in
Aufgabe 3, siehe `.planning/WINDOWS.md`) ist an dieselbe Bedingung
gebunden wie #18 — er wird erst
nach dem Scharfschalten beobachtbar.
### (w5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
keine Datei dieses Plans. `dashboard-store.ts` und `search-widget.tsx`
werden NICHT geändert.
- **Der Bereich `favorites`.** `FavoriteLink` hängt per Fremdschlüssel an
`WidgetInstance` (`apps/api/prisma/schema.prisma`,
`favoriteLinks FavoriteLink[]` auf `WidgetInstance`), ist aber ein
eigener Bereich mit eigener Umstellung (`apps/api/src/favorites/
favorites.service.ts`, ungebunden, sieben Rohtreffer laut
Klassifikationsdokument) — nicht Teil dieses Plans.
- **Der Bereich `module-registry`** samt der geerbten Entlastung aus
Befund E: `dashboard.service.ts` ruft
`ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
auf, und diese Auflösung bindet bereits seit 260910-exd
(`const tenantPrisma = forTenant(this.prisma, tenantId)` in
`module-access.service.ts`). Der Filter ist damit gebunden, OHNE dass
dieser Plan eine Datei unter `apps/api/src/module-registry` anfasst —
nachgeprüft mit `grep -n "const tenantPrisma = forTenant" apps/api/src/
module-registry/module-access.service.ts`, ein Treffer. Der eine
Katalogzugriff in `dashboard.service.ts` (`getWidgets`, `this.prisma.
module`) bleibt bewusst ungebunden — siehe die Begründung in der
Klassifikationstabelle, die Messung und Bedingung trennt.
- **Die Ungenauigkeit in `apps/api/src/groups/groups.service.ts` (Befund
L), zur Ausführungszeit gegengelesen und WÖRTLICH bestätigt, NICHT
behoben.** Der Kopfkommentar um Zeile 24 begründet die dortige
Join-Filterung mit "nach dem Ownership-Check-Muster aus
DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu:
`removeWidget` filtert NICHT zusätzlich in der Datenbankabfrage, sondern
vergleicht NACH dem Laden im JavaScript (`widget.userId !== userId`),
und dieser Vergleich läuft über die BENUTZER-, nicht die
Mandantenkennung. Die Datei `apps/api/src/groups/groups.service.ts`
wird von diesem Plan NICHT angefasst — diese Feststellung steht hier als
Richtigstellung, nicht als Änderung an der fremden Datei.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Bereich calendar
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `calendar`
(Quick-Task 260911-cwh), den neunten Bereich der Etappe und den einzigen
Dienst, der nicht bloß Daten hält, sondern ZUGANGSDATEN zu fremden Servern —
die verschlüsselten Exchange-, CalDAV- und ICS-Anmeldungen eines Nutzers.
Ein Quer-Lesen ist hier nicht Offenlegung eines Termins, sondern Offenlegung
der Anmeldung einer anderen Firma bei ihrem Mailserver. Die umgekehrte
Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie ein leerer
Kalender — und das Frontend verstärkt das, siehe (k3).
### (k1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen elften
Abschnitt (`runCalendarAreaChecks`) erweitert, unmittelbar nach
`runDashboardAreaChecks` und vor `runTransactionShapeMeasurement` aufgerufen.
Er legt die Wegwerf-Tabelle `CalendarSource` selbst neu an, mit SÄMTLICHEN
17 Spalten des Modells (nicht nur den für Roh-SQL nötigen — die Lehre aus
Prüfung 5b im Bereich `dashboard`: der generierte Client wählt standardmäßig
JEDE Spalte des Modells aus und scheitert mit P2022 an jeder fehlenden), mit
der Regel `extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
Regelstand NACH der Migration
`20260910120000_rls_widen_membership_grant_and_platform_read`**. Diese
zweite Migration wird zur LAUFZEIT geprüft (Prüfung
`calendarsource-regelstand-eindeutig`), nicht nur zur Planungszeit behauptet:
`extractPolicySql(readRlsWidenMigrationSql(), 'CalendarSource')` liefert
`null` — jene Migration trägt KEINE eigene Regel für `CalendarSource`, der
Stand aus `20260909140000` ist weiterhin der ausgelieferte, aktuelle
Regelstand. Fände sich dort doch eine Regel, bräche der Abschnitt mit einer
FEHLGESCHLAGENEN Prüfung ab, statt die abgelöste Regel weiterzumessen — die
Messfalle aus 260910-jab.
Vier der dreizehn neuen Prüfungen laufen über den GENERIERTEN Prisma-Client
(nicht nur an rohem SQL), weil `calendar.service.ts` in Wahrheit
`this.prisma.calendarSource.findMany/update/create()` aufruft, nicht
`$executeRaw` — dieselbe dashboard-Lehre: Prüfung 8
(`calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`)
vergleicht die zur Laufzeit aus `schema.prisma` gelesenen 17 Feldnamen des
Modells `CalendarSource` mit den tatsächlichen Spalten der Wegwerf-Tabelle
über `information_schema.columns` — sie steht VOR den Client-Prüfungen 9-12
und bricht den Abschnitt ab, wenn sie durchfällt, weil alle folgenden
Client-Messungen sonst wertlos wären.
Tatsächlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
`tessera-ctl-db-1`, Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen
dieses Abschnitts sowie die abschließende Summenzeile):
```
calendarsource-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "CalendarSource" (Befund G) — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
calendarsource-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model CalendarSource, 17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]; Spalten der Wegwerf-Tabelle (17): ["color","createdAt","domain","encryptedPassword","exchangeMode","id","isVisible","lastSyncAt","lastSyncError","name","syncIntervalMin","tenantId","type","updatedAt","url","userId","username"]
calendarsource-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["source-a1","source-a2"]
calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Quelle von 'user-a2' (anderer Benutzer, gleicher Mandant) mit encryptedPassword="enc(a2-passwort-platzhalter)" — die Regel auf "CalendarSource" kennt keine Benutzerdimension, die verschluesselten Zugangsdaten eines Kollegen DESSELBEN Mandanten sind auf Datenbankebene lesbar; die anwendungsseitige Filterung ueber die Benutzerkennung bleibt deshalb der einzige Schutz, bis die Etappe-3-Entscheidung (2) die Benutzerdimension nachzieht
calendarsource-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "CalendarSource" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
calendarsource-ungebundene-einzelabfrage-ueber-kennung-liefert-keine-zeile: bestanden — ungebundenes SELECT ueber die Kennung 'source-b1' (vorhanden) liefert 0 Zeile(n) — die Datenbankseite der drei Besitzpruefungen: ein ungebundenes Nachschlagen ueber eine vorhandene Kennung liefert null Zeilen, das ist der Weg in NotFoundException
calendarsource-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (Invalid `prisma.$executeRaw()` invocation: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "CalendarSource"`)
calendarsource-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'source-b1' (gehoert TENANT-B) trifft 0 Zeile(n); ueber die Wartungsrolle ist die Zeile danach noch vorhanden: true
calendarsource-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE ... WHERE id = 'source-b1' (gehoert TENANT-B) unter TENANT-A trifft 0 Zeile(n), lastSyncError bleibt unveraendert
calendarsource-generierter-client-gebundene-quellenliste-nur-eigener-mandant: bestanden — bound.calendarSource.findMany({ where: { userId: 'user-a1', isVisible: true } }) unter TENANT-A liefert 1 Zeile(n): ["source-a1"] — die Abfrage, die fetchAndCacheEvents stellt
calendarsource-generierter-client-ungebundene-quellenliste-null-zeilen: bestanden — dieselbe Abfrage auf dem UNGEBUNDENEN generierten Client liefert 0 Zeile(n) ohne Fehler — exakt der Wert, den getSources als "keine Quelle" und fetchAndCacheEvents als "keine Termine" weiterreicht
calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut: bestanden — bound.calendarSource.update unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (source-b1) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.calendarSource.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update.
calendarsource-generierter-client-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.calendarSource.create unter TENANT-A gelingt (id=62b383e2-4768-48f2-997a-cc5251174f8b, createdAt="2026-09-11T07:44:53.869Z"), gebunden lesbar: true — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig erzeugten Werte (id, createdAt, updatedAt) annimmt
Alle 101 Pruefungen bestanden.
```
Dreizehn neue Prüfungen (101 = 88 + 13), nicht zwölf wie in der Aufzählung
des Plans namentlich vorgezeichnet — die dreizehnte
(`calendarsource-regelstand-eindeutig`) wurde ergänzt, weil sie die
Messfalle aus 260910-jab zur LAUFZEIT prüft statt sie nur als Planungsprosa
festzuhalten, derselbe Grund, aus dem der Bereich `dashboard` eine
dreizehnte Prüfung ergänzt hatte.
**Die tragende Belegzeile ist `calendarsource-ungebunden-null-zeilen`:** der
IDENTISCHE `SELECT "tenantId" FROM "CalendarSource"` ohne vorheriges
`set_config` liefert **0 Zeilen**, nicht die 3 tatsächlich vorhandenen — an
der echten, ausgelieferten Policy gemessen.
**Das Wettlauf-Ergebnis aus Befund H/K ist gemessen, nicht vorweggenommen —
und es ist eine ANDERE Fehlerklasse als im Bereich `dashboard`.** Ein
gebundenes `bound.calendarSource.update({ where: { id: 'source-b1' }, ... })`
unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft eine
**`PrismaClientKnownRequestError` mit `.code === 'P2025'`** ("Record to
update not found") — NICHT die `PrismaClientUnknownRequestError`, die
`dashboardLayout.upsert()` im Bereich `dashboard` bei genau diesem
Wettlauf-Fall geworfen hat. Der Unterschied erklärt sich aus der Form des
Zugriffs: `dashboardLayout.upsert()` löst ein `INSERT ... ON CONFLICT`
aus, das die RLS-`USING`-Klausel beim `INSERT`-Zweig verletzt (SQLSTATE
`42501`, von Prisma als unbekannter Fehler durchgereicht); ein einfaches
`calendarSource.update({ where: { id } })` ohne Konfliktbehandlung sieht
unter der gebundenen Regel schlicht KEINE passende Zeile — dasselbe
Verhalten wie ein `UPDATE` über eine nicht existierende Kennung, das Prisma
grundsätzlich als P2025 meldet. **Das bedeutet für Aufgabe 2: keine der drei
Besitzprüfungsschreibpfade (`updateSource`/`deleteSource`/`testConnection`)
braucht eine neue Fehlerübersetzung** — P2025 wäre ohnehin nicht der Pfad,
über den ein Nutzer diese Zeile erreicht (die vorgeschaltete
`findUnique`-Besitzprüfung fängt den Fall vorher über `NotFoundException`
ab), sondern ausschließlich der Wettlauf-Fall der Aggregationsschleife
(Befund K, siehe (k2)).
### (k2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | Frontend lässt Signal durch? |
|---|---|---|---|
| `CalendarService.getSources` | Der gebundene Lesezugriff liefert eine leere Liste statt der vorhandenen Quellen | `GET /calendar/sources` liefert `[]`, Status 200, kein Fehler | Nein — `calendar-widget.tsx` zeigt `emptyNoSources` ("keine Quelle eingerichtet"), `calendar-settings-panel.tsx` zeigt `sourceEmpty`; beide ununterscheidbar vom echten Erstbenutzer-Zustand |
| `CalendarService.addSource` | Kein Leere-Fall in diese Richtung — die Mandantenkennung ist Pflichtparameter | Schlägt ohne Mandant im Controller bereits mit `ForbiddenException` fehl | — |
| `CalendarService.updateSource`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException('Calendar source not found')` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (k4)(d) für 403/404 | `calendar-settings-panel.tsx` fängt Schreibfehler bei `handleVisibilityToggle` (`catch { revert }`) bzw. beim Formular (`saveError`/`editSaveError`) — sichtbar als Fehlermeldung, NICHT als leerer Zustand |
| `CalendarService.deleteSource`, Besitzprüfung | Wie bei `updateSource`: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | Wie oben — Löschfehler werden sichtbar gemeldet, nicht verschluckt |
| `CalendarService.testConnection`, Besitzprüfung | Wie oben; zusätzlich der Wettlauf-Fall der beiden Rückschreibungen bei Erfolg/Fehler (Befund K) | `NotFoundException` bei fehlender/fremder Zeile; ein Wettlauf zwischen Nachschlagen und Rückschreiben würfe gemessen `PrismaClientKnownRequestError` (P2025, siehe (k1)) — heute strukturell ausgeschlossen, weil derselbe `tenantPrisma`-Klient beide Schritte trägt | `testResults`-State zeigt `'error'` — sichtbar, nicht verschluckt |
| `CalendarService.fetchAndCacheEvents` | Der gebundene Lesezugriff auf die Quellenliste liefert eine leere Liste statt der sichtbaren Quellen — `if (sources.length === 0) return [];` kehrt VOR dem Cache-Eintrag zurück, ein leeres Ergebnis wird also NIE zwischengespeichert, jeder Aufruf misst neu leer. Der Wettlauf-Fall der beiden Synchronstatus-Rückschreibungen (Befund K) ist gemessen: `PrismaClientKnownRequestError` (P2025) — strukturell ausgeschlossen durch EINEN `tenantPrisma`-Klient je Aufruf | `GET /calendar/events` liefert `[]`, Status 200, kein Fehler, keine Protokollzeile | Nein — `calendar-widget.tsx` zeigt bei leerem `events` (aber `hasSources === true`) `emptyNoEvents` ("keine Termine"); ein LAUTER Fehler von `fetchEvents()` ODER `fetchSources()` landet im selben `catch` und setzt ebenfalls `events = []` — die Unterscheidung zwischen "leer" und "Fehler" existiert im State nicht |
| `CalendarService.refreshCacheInBackground` | Ruft `fetchAndCacheEvents` mit der Mandantenkennung der urspünglichen Anfrage auf (kein eigener Kontext, siehe Befund B) — dasselbe Leere-Verhalten wie oben, zusätzlich verschluckt durch `.catch((error) => this.logger.warn(...))`: selbst ein LAUTER Fehler der Hintergrundauffrischung erzeugt nur eine Protokollzeile, kein Signal an den Aufrufer, der die Antwort bereits erhalten hat | Kein HTTP-Signal — die auslösende Anfrage ist bereits beantwortet, bevor die Hintergrundauffrischung beginnt | Kann das Frontend strukturell nicht erreichen — die Anfrage, die es ausgelöst hat, ist längst beantwortet |
### (k3) Welcher Code Leere als Abwesenheit deutet
**Backend, zwei Stellen (Befund I):** `CalendarService.getSources` liefert
bei null Treffern eine leere Liste, Status 200 — dieselbe Deutung wie überall
in dieser Etappe. `CalendarService.fetchAndCacheEvents`,
`if (sources.length === 0) return [];`: kehrt VOR dem Cache-Eintrag zurück,
ein leeres Quellenergebnis wird also nicht einmal für die TTL festgehalten,
sondern bei JEDEM Aufruf neu leer gemessen — anders als ein echter
Cache-Treffer, der fünf Minuten stehen bleibt. Die drei
Besitzprüfungspfade (`updateSource`, `deleteSource`, `testConnection`) sind
dagegen LAUT: ein zu kleines Nachschlagen wirft `NotFoundException`.
**Frontend, drei Dateien, zur Ausführungszeit erneut nachgeprüft (Befund
J), nicht aus dem Plan abgeschrieben — dieser Plan ändert an KEINER der drei
Dateien etwas:**
1. `apps/web/src/components/dashboard/widgets/calendar-widget.tsx`, Zeile
37: `if (sources.length === 0)` setzt `hasSources = false`, gerendert als
`emptyNoSources` ("keine Quelle eingerichtet"). Zeile 50:
`catch { // Silent fail — show empty state }` fängt jeden Fehler von
`fetchSources()` ODER `fetchEvents()` — bei einem Fehler bleibt
`hasSources` jedoch beim vorherigen Wert (nicht `false`) und `events`
wird auf `[]` gesetzt, wodurch die Render-Logik in den Zweig
`events.length === 0` fällt und `emptyNoEvents` ("keine Termine")
zeigt — NICHT `emptyNoSources`. Ein LAUTER Fehler (403, 500,
Netzwerkfehler) auf `GET /calendar/sources` oder `GET /calendar/events`
ist damit für den Nutzer vom echten "Quellen vorhanden, aber gerade
keine Termine" nicht zu unterscheiden — beide zeigen `emptyNoEvents`.
2. `apps/web/src/components/settings/calendar-settings-panel.tsx`, Zeile
49: `.catch(() => { // Silent fail — show empty state })` — hier bleibt
`sources` beim initialen `[]`, unabhängig davon, ob `fetchSources()` eine
echte leere Antwort ODER einen LAUTEN Fehler liefert; Zeile 127:
`sources.length === 0` zeigt `sourceEmpty`. Auf dieser Seite sind "keine
Quelle" und "Fehler beim Laden" damit VOLLSTÄNDIG ununterscheidbar — eine
schärfere Form derselben Verschluckung als im Widget.
3. `apps/web/src/components/settings/calendar-source-form.tsx`, Zeile 149:
`if (password) payload.password = password;` — nicht Leere, sondern
Weglassen; gehört hierhin, weil es die Erhaltungsfrage beantwortet (siehe
(k4)(b)): ein leer gelassenes Passwortfeld wird aus dem Sendeobjekt
WEGGELASSEN, nicht als leere Zeichenkette übertragen.
**Die Folgekette, abgegrenzt gegen `dashboard` (Befund J):** leerer Kalender
oder leere Quellenliste → der Nutzer liest das als "die Synchronisation ist
kaputt" oder "ich habe keine Quelle eingerichtet" → er legt seine Quelle
NEU an (`addSource`, gebunden, gelingt) → er tippt sein Exchange- oder
CalDAV-Passwort ein ZWEITES MAL in ein System, das gerade aussieht, als wäre
es defekt → die ursprüngliche Zeile bleibt unsichtbar liegen. Anders als bei
`dashboard` wird dabei NICHTS überschrieben (`CalendarSource` hat keinen
automatischen Rückschreibpfad wie `setEditMode` im Dashboard) — die
Zerstörung ist hier keine verlorene Aufzeichnung, sondern eine preisgegebene
Anmeldung: der Nutzer gibt Zugangsdaten zu einem fremden Mailserver in ein
System ein, das er für kaputt hält, und sobald die Ursache behoben ist
(Etappe 4), liegen ZWEI Quellen mit ZWEI Sätzen von Zugangsdaten für
denselben Server vor — Dubletten bei Quellen UND bei Terminen.
**Die Fehlerverschluckung als eigener Punkt:** selbst ein LAUTER
Backend-Fehler auf `GET /calendar/sources` oder `GET /calendar/events` (403,
500, Netzwerkfehler) ist für den Nutzer vom leeren Kalender nicht zu
unterscheiden — das Signal existiert ausschließlich im Netzwerkprotokoll des
Browsers und im API-Log, an keiner Stelle in der Benutzeroberfläche.
### (k4) Was dieser Durchlauf bewusst nicht löst
- **(a) Das Urteil zum Cache-Schlüssel**, vierteilig belegt, jedes Glied
einzeln nachgesehen: `eventCache` (Zeile 109 alt) wird mit
`${userId}:${from}:${to}` beschlüsselt. `userId` kommt aus
`calendar.controller.ts` `extractContext` (`req.user?.id`); das ist laut
`apps/api/src/auth/strategies/jwt.strategy.ts` `validate` (Zeile 27-34)
wörtlich `id: payload.sub`; `payload.sub` ist laut
`apps/api/src/auth/auth.service.ts` (Zeilen 143 und 332, beide
`sub: user.id`) die Datenbankkennung `User.id`; die trägt laut
`apps/api/prisma/schema.prisma` `model User` `@id @default(uuid())`.
**Urteil:** der Schlüssel trägt eine plattformweit eindeutige UUID, kein
Anmeldename — die Etappe-3-Entscheidung (1) des Users (Anmeldenamen
eindeutig PRO MANDANT statt plattformweit) betrifft `User.username` und
`User.email`, nicht `User.id`, und berührt den Schlüssel deshalb NICHT.
Der Schlüssel bleibt unverändert; das Urteil steht zusätzlich als
Kommentar unmittelbar über der `eventCache`-Zuweisung in
`calendar.service.ts` (Aufgabe 2).
- **(b) Der Befund zur Zugangsdaten-Erhaltung (Befund E)**, an vier Stellen
zur Ausführungszeit nachgelesen: `calendar.service.ts` `updateSource`
besitzt KEINEN Lesezugriff, der ein gespeichertes Passwort lädt, um es neu
zu verschlüsseln — die `dkv`-Form (lesen, entschlüsseln, neu
verschlüsseln) existiert hier nicht. Stattdessen: `if (dto.password !== undefined)`
entscheidet, ob überhaupt geschrieben wird — Feld FEHLT im Rumpf →
`encryptedPassword` bleibt im `update`-Aufruf gänzlich unerwähnt und damit
in der Datenbank unverändert; Feld LEER (`''`) → wird explizit auf `null`
gesetzt (Löschen); Feld GESETZT → wird verschlüsselt. Auf der Web-Seite
(`calendar-source-form.tsx`, Zeile 149) wird ein leer gelassenes
Passwortfeld WEGGELASSEN, nicht als leere Zeichenkette gesendet — die
Erhaltung läuft also über das Weglassen des Felds im Rumpf, nicht über
einen Lesezugriff, der nach dem Scharfschalten leerlaufen könnte. Die
beiden lesenden Stellen (`testConnection`, `fetchAndCacheEvents`)
entschlüsseln nur, um den Provider aufzurufen, und schreiben nichts
Entschlüsseltes zurück. Als drei Testfälle in Aufgabe 2 festgenagelt.
- **(c) Die fehlende Benutzerdimension der Regel (Befund G)**, gemessen in
(k1) (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`):
die verschlüsselten Exchange-/CalDAV-Zugangsdaten eines Kollegen
DESSELBEN Mandanten sind auf Datenbankebene lesbar, bis die
Etappe-3-Entscheidung (2) des Users die Benutzerdimension in die Regel
aufnimmt (`CalendarSource` steht dort ausdrücklich in der Liste). Bis
dahin bleiben der `userId`-Filter in `getSources`/`fetchAndCacheEvents`
und die drei Besitzprüfungen der EINZIGE Schutz.
- **(d) 403 statt 404 als Existenzpreisgabe zwischen Kollegen (Befund D):**
`updateSource`, `deleteSource` und `testConnection` werfen bei fremdem
Besitz `ForbiddenException('Not your calendar source')` (403), bei
unbekannter Kennung `NotFoundException('Calendar source not found')`
(404) — ein Kollege DESSELBEN Mandanten erfährt über 403 die Existenz
einer fremden Quellenkennung, ein Nutzer eines FREMDEN Mandanten bekommt
nach der Bindung durchgängig 404 (die Zeile ist für ihn unsichtbar).
Kennungen sind UUIDs, nicht erratbar. Die Antwortsemantik wird von diesem
Plan NICHT geändert (wäre eine API-Änderung außerhalb des Auftrags).
- **(e) Die fehlende Unterscheidbarkeit von "keine Quelle" und "Quelle
nicht sichtbar".** Beide liefern identisch eine leere Liste, Status 200,
keinen Protokolleintrag — siehe (k3). Die konkrete Vorabprüfung für
Etappe 4 (`rls-preflight.mjs`): physisch vorhandene
`CalendarSource`-Zeilen je Mandant über die Wartungsrolle zählen und mit
der gebundenen Zählung je Mandant vergleichen — jede Abweichung ist ein
Trennungsfehler, kein Erstbenutzer. Eine Laufzeitwarnung an
`getSources`/`fetchAndCacheEvents` wurde erwogen und VERWORFEN, mit
derselben Begründung wie bei `getAllActiveConfigs` im Bereich `ldap` und
bei `getLayout`/`getWidgets`/`getSearchProviders` im Bereich `dashboard`:
keine Quelle ist auf einer frischen Installation oder für einen neuen
Nutzer der NORMALZUSTAND — eine Warnung an dieser Stelle wäre Dauerlärm
und verlöre ihr Signal, bevor sie gebraucht wird.
### (k5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (k3) oben) und bewusst gelassen,
keine Datei dieses Plans. `calendar-widget.tsx`,
`calendar-settings-panel.tsx` und `calendar-source-form.tsx` werden NICHT
geändert.
- **Die drei Provider** (`ics.provider.ts`, `caldav.provider.ts`,
`exchange.provider.ts`) — reden mit echten Servern, werden in Aufgabe 2
NICHT ausgeübt, nur als Attrappen (`fetchEvents`/`testConnection` als
`vi.fn`) verwendet.
- **`testConnectionFromConfig`** — kein Datenbankzugriff, kein Mandant
(prüft eine Konfiguration, bevor sie gespeichert wird), unverändert.
- **Der Bereich `favorites`** — eigener Bereich mit eigener Umstellung,
nicht Teil dieses Plans.
- **Die Antwortsemantik 403/404** — siehe (k4)(d), bewusst nicht geändert.
- **Der Fremdkommentar in `apps/api/src/ldap/ldap-config.service.ts`**
(Zeile 24, nennt `CalendarSource` als Vorbild der Verschlüsselung) —
zutreffend (`CalendarCryptoService`, siehe Dezision `07-01` in
STATE.md), nicht zu ändern.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Bereich tenant
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenant`
(Quick-Task 260911-e2s) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Anders als jeder Bereich davor betrifft er nicht mandantengebundene Tabellen,
sondern die Mandantentabelle SELBST — `Tenant` hat per Definition keine
`tenantId`-Spalte und ist deshalb die einzige Tabelle, auf der es nichts zu
binden gibt. Der Bereich traegt trotzdem zwei Dinge, die alle neun Bereiche
davor vertagt oder uebersehen haben: die seit Etappe 1 offene Entscheidung
zum gebundenen Klienten auf dem Anfrageobjekt (Aufgabe 2), und einen Befund,
den die Erwartung "null Umstellungsarbeit" verdeckt haette — drei der acht
Zugriffe zaehlen ueber eine Relationseinbindung in die GESCHUETZTE Tabelle
`User` hinein (Aufgabe 3).
### (n1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen zwoelften
Abschnitt (`runTenantAreaChecks`) erweitert. Sechs der neun neuen Pruefungen
(4, 5, 6, 7, 8, 9) laufen ueber den GENERIERTEN CLIENT
(`prisma.tenant.findMany`/`findUnique`/`delete`, `bound.tenant.findMany`,
`bound.user.count`) statt ueber Roh-SQL — bewusst, weil der Relationszaehler
(`include: { _count: { select: { users } } }`), den `findAll`/`findOne`
tatsaechlich benutzen, eine Client-Form ist: Roh-SQL sieht ihn strukturell
nicht (Fehler 7 des Vorhabens — "Roh-SQL ist nicht der generierte Client").
Pruefung 1 liest zur Laufzeit jede der 34 ausgelieferten
`migration.sql`-Dateien und prueft auf `CREATE POLICY ... ON "Tenant"` sowie
`ALTER TABLE "Tenant"` — ausdruecklich EINSCHLIESSLICH
`20260910120000_rls_widen_membership_grant_and_platform_read`, die "Tenant"
nirgends nennt. Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11,
gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
tenant-keine-regel-in-allen-ausgelieferten-migrationen: bestanden — 34 Migrationsverzeichnisse gelesen, darunter "20260910120000_rls_widen_membership_grant_and_platform_read" — "Tenant" kommt darin nicht vor; 0 Verzeichnis(se) mit CREATE POLICY/ALTER TABLE auf "Tenant": []
tenant-gebunden-und-ungebunden-liefern-dieselben-zeilen: bestanden — Roh-SQL gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Wartungsrolle: ["TENANT-A","TENANT-B","TENANT-C"]
tenant-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Spalten aus CREATE TABLE "Tenant" in 20260618112124_auth_multi_tenancy (6): ["createdAt","id","isActive","name","slug","updatedAt"]; Spalten der Wegwerf-Tabelle (6): ["createdAt","id","isActive","name","slug","updatedAt"]
tenant-generierter-client-zeilen-gebunden-und-ungebunden-identisch: bestanden — generierter Client gebunden (TENANT-A): ["TENANT-A","TENANT-B","TENANT-C"]; ungebunden: ["TENANT-A","TENANT-B","TENANT-C"]; Roh-SQL-Vergleichswert (Pruefung 2): ["TENANT-A","TENANT-B","TENANT-C"]
tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten: bestanden — das ist die Zahl, die admin/tenants/page.tsx als Benutzeranzahl anzeigen wuerde — ungebunden: [{"id":"TENANT-A","userCount":0},{"id":"TENANT-B","userCount":0},{"id":"TENANT-C","userCount":0}]; Wartungszahl je Mandant: {"TENANT-B":2,"TENANT-A":2}
tenant-generierter-client-benutzerzaehler-gebunden-nur-eigener-mandant: bestanden — gebunden unter TENANT-A: A=2 (Wartungszahl=2), B=0 (Wartungszahl=2), C=0
tenant-loeschriegel-ungebunden-vakuum-fremdschluessel-faengt-laut: bestanden — ungebundener Relationszaehler ueber aktive Benutzer fuer TENANT-A=0, Wartungszahl=2 — der Riegel T-02-09 liesse das Loeschen durch; ungebundenes prisma.tenant.delete ueber den generierten Client wirft PrismaClientKnownRequestError (code P2003): Invalid `prisma.tenant.delete()` invocation: Foreign key constraint violated on the constraint: `User_tenantId_fkey` — die Zeile existiert ueber die Wartungsrolle danach noch: true; die referentielle Pruefung des Fremdschluessels umgeht den Zeilenschutz und faengt das Loeschen trotzdem ab
tenant-loeschen-ohne-benutzer-gelingt-wie-heute: bestanden — ungebundenes prisma.tenant.delete fuer TENANT-C (ohne Benutzer) ist NICHT fehlgeschlagen; ueber die Wartungsrolle danach noch vorhanden: false
tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt: bestanden — Fan-out je verbliebenem Mandanten (2): [{"id":"TENANT-A","gebunden":2,"wartung":2},{"id":"TENANT-B","gebunden":2,"wartung":2}]
Alle 110 Pruefungen bestanden.
```
Die Belegzeile, die diesen Abschnitt traegt, ist
`tenant-generierter-client-benutzerzaehler-ungebunden-null-fuer-jeden-mandanten`
(Pruefung 5): dieselbe Abfrage, die `findAll` heute stellt, liefert
UNGEBUNDEN fuer JEDEN Mandanten `userCount: 0`, waehrend die Wartungsrolle
fuer TENANT-A und TENANT-B je 2 aktive Benutzer zaehlt — das ist exakt die
Zahl, die `admin/tenants/page.tsx` nach dem Scharfschalten anzeigen wuerde.
Der Fremdschluessel `User_tenantId_fkey` (Pruefung 7, wortgleich aus Zeile
105 von `20260618112124_auth_multi_tenancy` geschnitten) faengt das daraus
folgende Loeschen zwar ab — aber laut (SQLSTATE 23503, Prisma-Code `P2003`),
nicht mit der verstaendlichen 400-Meldung des Riegels T-02-09.
### (n2) Signaltabelle je Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|---|---|---|---|
| `TenantController.findAll` | Der Relationszaehler (`include: { _count: { select: { users } } }`) laeuft ungebunden auf dem generierten Client (Pruefung 5): `userCount` ist 0 fuer JEDEN Mandanten, die Mandantenzeilen selbst bleiben vollstaendig (`Tenant` ohne Regel) | Die Mandantenliste zeigt jeden Mandanten mit 0 Benutzern — eine falsche Zahl, keine leere Liste | Ja — `admin/tenants/page.tsx` zeigt `tenant.userCount` ungeprueft an |
| `TenantController.findOne` | Dieselbe Form wie `findAll`, fuer eine einzelne Kennung | `userCount: 0` fuer den betrachteten Mandanten | Ja — dieselbe Anzeige (falls einzeln abgefragt) |
| `TenantController.create` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.create` | Keins (Controller-Ebene) | — |
| `TenantController.update` | Kein Datenbankzugriff des Controllers selbst — delegiert an `TenantService.update`, nach ungebundenem `findById` (Tenant ohne Regel, unveraendert) | Keins | — |
| `TenantController.remove` | Der Relationszaehler ueber AKTIVE Benutzer laeuft ungebunden: `_count.users` ist 0 (Pruefung 7), der Riegel T-02-09 passiert, `tenant.delete` laeuft — und trifft den Fremdschluessel `User_tenantId_fkey` (`ON DELETE RESTRICT`): SQLSTATE 23503, Prisma-Code `P2003`, HTTP 500 mit generischer Meldung statt der verstaendlichen 400 | Ein Loeschversuch schlaegt laut fehl statt mit "Cannot delete tenant with active users" | Teilweise — `handleDelete` in `admin/tenants/page.tsx` prueft `res.ok`, tut bei nicht-OK aber NICHTS sichtbares (siehe (n3)) |
| `TenantService.findAll` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt. Hat heute KEINEN Aufrufer (Befund L) | Keins (totes Codeglied) | — |
| `TenantService.findById` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt, wird von `TenantController.update` als Existenzpruefung benutzt | Keins | — |
| `TenantService.create` | Ungebunden, `Tenant` ohne Regel; ruft danach `groupsService.ensureDefaultGroup(tenant.id)` auf — dieser Aufruf laeuft seit 260909-jts vollstaendig gebunden ueber `forTenant()`/`withTenantTransaction()`, gebunden an den soeben angelegten Mandanten | Keins — die Standardgruppen-Anlage funktioniert nach dem Scharfschalten unveraendert | — |
| `TenantService.update` | Ungebunden, `Tenant` ohne Regel — unveraendert korrekt | Keins | — |
| `TenantGuard` | Kein Datenbankzugriff (Aufgabe 2 entfernt den letzten, ungenutzten Prisma-Aufruf) | Keins | — |
### (n3) Welcher Code eine falsche Zahl als Wahrheit deutet
Anders als bei jedem Bereich davor ist die gefaehrliche Form hier nicht
LEERE, sondern eine FALSCHE ZAHL, die sich als Wahrheit ausgibt.
**Backend:** `TenantController.findAll`/`findOne` liefern `userCount: 0` ohne
jedes Signal — kein Fehler, kein leeres Feld, eine plausibel aussehende Zahl,
die schlicht falsch ist. `TenantController.remove` laesst den Riegel T-02-09
passieren (Zaehler 0, obwohl aktive Benutzer existieren) und der
Fremdschluessel antwortet laut mit der falschen Botschaft (500 statt 400,
siehe (n1)/(n2)).
**Frontend**, namentlich mit Stelle:
- `apps/web/src/app/(portal)/admin/tenants/page.tsx` zeigt `tenant.userCount`
ungeprueft in der Tabellenzeile an (Zeile 209: `{tenant.userCount}`).
`fetchTenants` (Zeilen 44-55) prueft zwar `res.ok`, aber bei nicht-OK
passiert NICHTS sichtbares — kein Fehlertext, keine Markierung, die Liste
bleibt leer oder veraltet stehen (`catch { // silently fail }`).
`handleDelete` (Zeilen 122-133) prueft ebenfalls `res.ok`, aber bei
nicht-OK (der 500er aus dem Fremdschluessel) passiert wieder NICHTS: der
Bestaetigungsdialog (`deleteConfirm`) bleibt offen, `fetchTenants()` wird
nicht erneut aufgerufen — fuer den Administrator sieht das aus wie ein
Knopf, der nicht reagiert, nicht wie ein Fehler.
- `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`
faengt jede nicht-OK-Antwort in eine LEERE Liste
(`.then((res) => (res.ok ? res.json() : []))`) und jeden Netzwerkfehler in
ein stilles Nichts (`.catch(() => {})`) — der SUPER_ADMIN sieht im
Mandanten-Wechsel-Dropdown des Marktplatzes schlicht keine Mandanten, ohne
Hinweis, dass eine Abfrage fehlgeschlagen ist statt "es gibt keine".
Zur Ausfuehrungszeit an den genannten Dateien und Zeilen erneut zu pruefen —
Zeilennummern koennen sich verschieben.
### (n4) Was dieser Durchlauf bewusst nicht löst
**(a) Die Entscheidung zur Anfrageobjekt-Eigenschaft.** Gemessen (Befund B/C
der Planung, in Aufgabe 1 wiederholt): `apps/api/src/tenant/tenant.guard.ts`
und `apps/api/src/tenant/tenant.middleware.ts` setzen
`req.tenantPrisma = forTenant(this.prisma, tenantId)`, aber eine Volltextsuche
ueber `apps/api/src` (`grep -rn '\.tenantPrisma'`) findet ausserhalb dieser
beiden Dateien KEINEN Lesezugriff — nur drei Kommentare, die die Middleware
nennen. Die Middleware selbst ist NIRGENDS verdrahtet: weder `apps/api/src`
noch `apps/api/src/main.ts` enthalten ein `MiddlewareConsumer`, ein
`configure(` oder einen `.apply(...).forRoutes(...)`-Aufruf auf
`TenantMiddleware` — `app.module.ts` implementiert kein `NestModule`.
ENTSCHIEDEN (260911-e2s, Aufgabe 2): der Guard setzt nur noch
`req.tenantId`; `tenant.middleware.ts` ist GELOESCHT (eine nie aufgerufene
Kopie des Guards mit identischer Logik). GRUND: neun umgestellte Bereiche vor
diesem binden ausnahmslos dienst-intern, ein Klient je Methode
(`forTenant(this.prisma, tenantId)` in der jeweiligen Service-Methode) — die
Konvention ist durch neunfache Praxis entschieden, nicht durch diesen Plan
neu erfunden. Eine tote Verdrahtung, die wie ein Sicherheitsmechanismus
AUSSIEHT (ein gebundener Klient, scheinbar bereit zur Benutzung), ist
schlimmer als gar keine — sie suggeriert einem spaeteren Leser einen Schutz,
den es nicht gibt.
**(b) Die Erkennungsluecke der Bestandsaufnahme (Befund G).**
`rls-access-inventory.spec.ts` sammelt (Datei, Modell)-Paare ausschliesslich
ueber `this.prisma.<Modell>` und `<gebundener Client>.<Modell>` — eine
Relationseinbindung (`include:`, Relationszaehler `_count`) in eine ZWEITE
Tabelle erzeugt kein Paar und ist fuer das Werkzeug unsichtbar. In Aufgabe 1
erneut vermessen:
*Alle `_count`-Stellen ausserhalb von `tenant/`* (`grep -rn "_count"
apps/api/src --include=*.ts | grep -v spec`): `groups.service.ts:66` (auf
bereits GEBUNDENEM Klienten — harmlos, die Bindung schuetzt bereits) und
`tenders.controller.ts:405` (`groupBy` auf der plattformweiten, ungeschuetzten
`Tender`, kein Relationszugriff in eine zweite Tabelle — harmlos).
*Alle `include:`-Stellen* (`grep -rn "include:" apps/api/src --include=*.ts
| grep -v spec`, 19 Treffer in 8 Dateien): jede Stelle einzeln beurteilt —
aeusserer Aufruf gebunden oder nicht, aeussere Tabelle geschuetzt oder nicht,
eingebundene Tabelle geschuetzt oder nicht:
| Datei | Aeusserer Aufruf | Aeussere Tabelle geschuetzt | Eingebundene Tabelle geschuetzt | Urteil |
|---|---|---|---|---|
| `tenant.controller.ts` (3 Stellen: `findAll`/`findOne`/`remove`, vor Aufgabe 3) | ungebunden | Nein (`Tenant`) | JA (`User`) | GEFAEHRLICH — die einzige Auspraegung, in Aufgabe 3 behoben |
| `ldap-config.service.ts:309` (`getAllActiveConfigs`) | ungebunden (bewusst uebergreifend) | JA (`LdapConfig`) | eingebunden: `tenant`, `fieldMappings` | harmlos — die AEUSSERE Tabelle ist bereits geschuetzt, der bekannte Etappe-3-Fall (Benutzerdimension) bekommt dadurch nichts Neues |
| `tenders.controller.ts:612` (`sources`) | ungebunden | Nein (`Tender`, D-03 plattformweit) | Nein (`TenderSource`, ebenfalls plattformweit) | harmlos — beide Seiten plattformweit |
| übrige 14 Stellen (`groups.service.ts`, `module-grants.service.ts`, `dashboard.service.ts`, `dkv.service.ts`, `tender-*.service.ts`, `user.service.ts`) | ueberwiegend gebunden oder auf bereits geschuetzten/plattformweiten Tabellen | — | — | harmlos, einzeln nachgesehen |
Die gefaehrliche Auspraegung (ungebundener aeusserer Aufruf auf einer
UNGESCHUETZTEN Tabelle, Einbindung in eine GESCHUETZTE Tabelle) existierte im
gesamten API-Quelltext genau EINMAL: in diesem Bereich, vor Aufgabe 3.
ENTSCHEIDUNG gegen einen Ledger-Eintrag: die einzige Auspraegung wird in
diesem Plan behoben; die Wiederholung beider Messungen zur Ausfuehrungszeit
fand keine zweite — faende eine spaetere Wiederholung eine zweite
Auspraegung, waere DANN ein `gsd-tools windows append`-Eintrag anzulegen, mit
Verweis auf diesen Absatz.
**(c) Der Fremdschluessel als Rueckhalt, mit einer Luecke.**
`User_tenantId_fkey` faengt auch INAKTIVE Benutzer, waehrend der Riegel
T-02-09 nur AKTIVE zaehlt — ein Mandant mit ausschliesslich inaktiven
Benutzern ist heute wie nach diesem Plan nicht loeschbar (500 statt der
verstaendlichen 400). Bestehendes Verhalten, gemessen (Pruefung 7/8 zeigen
die Mechanik, nicht diesen Spezialfall direkt), nicht Gegenstand dieses
Auftrags.
**(d) `TenantService.findAll` ohne Aufrufer** (Befund L) — bleibt totes
Codeglied, nicht entfernt (Scope).
**(e) Der unerreichbare `null`-Zweig des Guards** — SUPER_ADMIN ohne
`tenantId` und ohne `x-tenant-id`-Header ist mit dem heutigen
Sitzungsnachweis unerreichbar (`User.tenantId` ist `String`, nicht nullbar),
bleibt aber unveraendert und wird in Aufgabe 2 als heutiges Verhalten
getestet, nicht umgebaut.
**(f) Der Header-Wert wird nicht gegen vorhandene Mandanten geprueft.** Ein
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken (D-10 wie
entworfen) — sie bindet an einen leeren Kontext, null Zeilen, kein Leck.
**(g) Die Mehrkosten des Fan-outs.** Nach Aufgabe 3 kostet `findAll` eine
gebundene Zaehlabfrage je Mandant statt eines Joins — bei einstelliger
Mandantenzahl belanglos, dieselbe Form wie
`UserService.findAllForPlatformAdmin`.
**(h) Die Etappe-4-Vorabpruefung.** Die Benutzerzahl je Mandant ueber die
Wartungsrolle gegen die gebundene Fan-out-Zaehlung ist dieselbe Pruefung wie
im Bereich `user` — kein eigener Eintrag noetig.
### (n5) Was dieser Durchlauf bewusst nicht anfasst
- Der direkte Prisma-Zugriff im Controller (Muster wie `user.controller.ts`)
— bleibt, Wartbarkeitsvermerk, keine Verschiebung in den Dienst.
- Die redundante `@UseGuards(RolesGuard)`-Klassenregistrierung neben der
globalen `APP_GUARD`-Registrierung von `RolesGuard`.
- Das Frontend — in (n3) beschrieben, nicht geaendert.
- `tenant.service.ts` — unveraendert.
- Die veraltete Tabellenliste im Abschnitt `## Mandantentrennung` von
`docs/anleitung-entwicklung.md` ("aktuell nur auf User,
PasswordResetToken, ..." — seit `20260909140000` sind es 23 Tabellen);
Aufgabe 3 aendert in jener Datei NUR die Absaetze zu Guard und Middleware,
diese Liste bleibt stehen und ist hier als bekannte Ungenauigkeit
festgehalten.
- Die historische Nennung der Middleware in
`docs/mandantentrennung-datenbankrolle.md:124` — beschreibt den Stand VOR
Etappe 1 korrekt, nicht zu aendern.
- Schema und Migrationen — geprueft und bewusst gelassen, `Tenant` bekommt
KEINE Regel.
## Bereich auth
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `auth`
(Quick-Task 260911-fh9) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Anders als jeder Bereich davor traegt dieser Bereich die Grenze, an der die
gesamte Mandantentrennung haengt: der Anmeldeweg (Benutzer suchen, BEVOR der
Mandant bekannt ist) wurde bereits in Etappe 1 (260909-eor) ueber drei enge
SECURITY-DEFINER-Funktionen geloest und bleibt in diesem Durchlauf
unangetastet; gegenstand dieses Durchlaufs sind ausschliesslich die drei
Methoden NACH der Anmeldung (`getMe`, `changePassword`,
`adminResetPassword`), die in Etappe 1 bewusst liegen gelassen wurden.
### (h1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen dreizehnten
Abschnitt (`runAuthAreaChecks`) erweitert — ausdruecklich GETRENNT vom
bestehenden `runAuthLookupChecks`: jener misst den Anmeldeweg VOR bekanntem
Mandanten (die drei Funktionen ueber `$queryRaw`), dieser die drei Methoden
NACH der Anmeldung (gebundener Modellzugriff ueber den GENERIERTEN Client).
Der neue Abschnitt setzt auf der von `runAuthLookupChecks` bereits angelegten
Wegwerf-Tabelle `"User"` auf (eingeschalteter und erzwungener Zeilenschutz,
wortgleiche Policy, zwei Zeilen in zwei Mandanten; seit `runTenantAreaChecks`
zusaetzlich mit Fremdschluessel auf `"Tenant"`) und ruestet sie um die fuenf
Spalten nach, die der generierte Client zusaetzlich braucht (`createdAt`,
`updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` — Befund G): dafuer
liest ein neuer Helfer `readSchemaModelScalarFieldNames('User')` die
skalaren Felder aus `schema.prisma` und filtert Relationsfelder ueber ihren
Typ heraus (`tenant`, `passwordResetTokens`, `groupMemberships`,
`moduleGrants` fallen weg, `role` bleibt — `Role` ist ein `enum`, kein
`model`). Diese Spaltenpruefung steht VOR den Client-Pruefungen und bricht
den Abschnitt ab, wenn sie durchfaellt (Lehre aus Pruefung 8 im Bereich
`calendar`).
Tatsaechlich beobachtete Ausgabe dieses Laufs (2026-09-11, gegen
`tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model User, skalare Felder ohne Relationen, 15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (15): ["accentColor","avatarPath","createdAt","displayName","email","id","isActive","lastLoginAt","ldapDn","mustChangePassword","passwordHash","role","tenantId","updatedAt","username"]
auth-anmeldefunktionen-security-definer-unveraendert: bestanden — 3 Funktion(en) unter 'auth_lookup_%' gefunden: ["auth_lookup_reset_token","auth_lookup_user_by_email","auth_lookup_user_by_username"]; je Funktion prosecdef/provolatile/proconfig/LIMIT-1: [{"proname":"auth_lookup_reset_token","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_email","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true},{"proname":"auth_lookup_user_by_username","prosecdef":true,"provolatile":"s","proconfig":["search_path=public, pg_temp"],"hatLimit1":true}]
auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz: bestanden — auth_lookup_user_by_username('alice') liefert 1 Zeile(n) mit 9 Schluessel(n): ["displayName","id","isActive","ldapDn","mustChangePassword","passwordHash","role","tenantId","username"] — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung nicht durch
auth-getme-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.user.findUnique({ where: { id: 'user-a' }, select: {...} }) (die Form von getMe) liefert null — das ist der Wert, den GET /auth/me nach dem Scharfschalten als leeren Rumpf ausliefert
auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer: bestanden — gebunden unter TENANT-A liefert findUnique({ where: { id: 'user-a' }, select: {...} }): {"id":"user-a","username":"alice","displayName":null,"role":"USER","tenantId":"TENANT-A","mustChangePassword":false,"passwordHash":"hash-a","ldapDn":null,"avatarPath":null,"accentColor":null}
auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { id: 'user-a' } }) (gehoert TENANT-A): null — ein Administrator von TENANT-B sieht 'user-a' nicht, die Datenbankseite von T-FH9-01
auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut: bestanden — ungebundenes prisma.user.update({ where: { id: 'user-a' }, data: {...} }) (die Form von changePassword) wirft PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — die Wartungsrolle liest danach weiterhin passwordHash="hash-a"
auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt: bestanden — gebunden unter TENANT-A liefert die Wartungsrolle danach passwordHash="hash-a-neu", updatedAt="2026-09-11T09:42:42.165Z" — bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt
auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut: bestanden — gebunden unter TENANT-B liefert update({ where: { id: 'user-a' } }) (gehoert TENANT-A) PrismaClientKnownRequestError (code P2025): Invalid `prisma.user.update()` invocation: An operation failed because it depends on one or more records that were required but not found. No record was found for an update. — ein ADMIN von TENANT-B kann das Kennwort von 'user-a' nicht setzen, gemessen statt behauptet; die Wartungsrolle liest danach weiterhin passwordHash="hash-a-neu"
auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf: bestanden — Fan-out ueber 2 Mandant(en): 'user-a' gefunden unter "TENANT-A", tenantId der Zeile="TENANT-A" — die Kennung allein ergibt den Mandanten des Ziels, weil User.id plattformweit eindeutig ist
Alle 120 Pruefungen bestanden.
```
Die tragende Belegzeile ist `auth-getme-generierter-client-ungebunden-liefert-null`: der IDENTISCHE `findUnique`, den `getMe` heute stellt, liefert UNGEBUNDEN `null`, waehrend die Wartungsrolle die Zeile sieht — das ist der Wert, den `GET /auth/me` nach dem Scharfschalten als leeren Rumpf ausliefert. Die `pg_proc`-Messung
(`auth-anmeldefunktionen-security-definer-unveraendert`) und der feste
Spaltensatz (`auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`)
belegen zusammen, dass die drei Anmeldefunktionen unangetastet sind und die
Spaltenerweiterung von `"User"` nichts Zusaetzliches preisgibt: alle drei
Funktionen sind weiterhin `SECURITY DEFINER`, `STABLE`, mit festem Suchpfad
`search_path=public, pg_temp` und `LIMIT 1`, und `auth_lookup_user_by_username`
liefert nach der Spaltenerweiterung weiterhin genau neun Spalten — weder
`avatarPath` noch `accentColor` noch `email` sind darunter.
Sechs der zehn Pruefungen laufen ueber den GENERIERTEN CLIENT
(`prisma.user.findUnique`/`update`, `bound.user.findUnique`/`update`), nicht
ueber Roh-SQL — bewusst, weil `findUnique` ohne `select` (die Form von
`changePassword`/`adminResetPassword`) und das `select` von `getMe` Client-
Formen sind: Roh-SQL sieht sie strukturell nicht (Fehler 7 des Vorhabens).
### (h2) Signaltabelle je Pfad
| Pfad | `@Public()` | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend laesst es durch? |
|---|---|---|---|---|
| `validateUser` (`POST /auth/login`) | ja | Sucht ueber `auth_lookup_user_by_username` (Funktion, unveraendert) — findet den Benutzer weiterhin, dann gebundener `lastLoginAt`-Schreibzugriff | Anmeldung funktioniert unveraendert | — |
| `requestPasswordReset` (`POST /auth/request-reset`) | ja | Sucht ueber `auth_lookup_user_by_email` (Funktion, unveraendert), dann gebundene Token-Anlage | Unveraendert, immer `200` (T-02-12) | — |
| `resetPassword` (`POST /auth/reset-password`) | ja | Sucht ueber `auth_lookup_reset_token` (Funktion, unveraendert), dann gebundenes Kennwort-Update und Token-Markierung | Unveraendert | — |
| `login`/`logout` | ja/nein | Kein Datenbankzugriff | Keins | — |
| `getMe` (`GET /auth/me`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` (`auth-getme-generierter-client-ungebunden-liefert-null`) statt der eigenen Zeile | `200` mit leerem Rumpf statt der Profildaten | Ja — verschluckt, siehe (h3) |
| `changePassword` (`POST /auth/change-password`) | nein | Vor diesem Plan: ungebundener `findUnique` liefert `null` | `UnauthorizedException('User not found or has no local password')`, `401` | Ja — als `networkError`, siehe (h3) |
| `adminResetPassword`, ADMIN-Zweig (`POST /auth/admin-reset-password/:userId`) | nein | Vor diesem Plan: ungebundener `findUnique` sieht Ziele JEDES Mandanten — Rechteausweitung ueber die Mandantengrenze (T-FH9-01), kein Frontend-Aufrufer | `BadRequestException('User not found')` nur, wenn die Kennung gar nicht existiert; ein fremdmandantiges Ziel wuerde heute GEFUNDEN | Kein UI-Aufrufer (gemessen, Befund D) |
| `adminResetPassword`, SUPER_ADMIN-Zweig | nein | Dieselbe Ausweitung; zusaetzlich keine Rollenpruefung des Ziels (T-FH9-04) | Dieselbe Meldung | Kein UI-Aufrufer |
Nach der Bindung (Aufgabe 2) loest der SUPER_ADMIN-Zweig den Mandanten des
Ziels ueber den gebundenen Fan-out `UserService.findByIdForPlatformAdmin`
auf — denselben Praezedenzfall, den `user.controller.ts` (`resolveTargetUser`,
260910-das) fuer seine eigene Rollenverzweigung benutzt; der ADMIN-Zweig
bindet dagegen an `currentUser.tenantId` aus dem Sitzungsnachweis.
### (h3) Welcher Code Leere als Abwesenheit deutet
Die `getMe`-Kette, alle vier Glieder namentlich: `getMe` liefert `null` (die
Belegzeile `auth-getme-generierter-client-ungebunden-liefert-null`) →
`AuthController.me` gibt `null` zurueck, ohne zu werfen → NestJS'
`ExpressAdapter.reply` sendet bei `isNil(body)` einen LEEREN Rumpf mit
Status `200` (`apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js`,
Methode `reply`) → `fetchCurrentUser` (`apps/web/src/lib/auth-actions.ts`)
laeuft mit `response.json()` auf den leeren Rumpf, faengt im `catch` und
liefert `null` → `apps/web/src/components/layout/header.tsx` (`if (u)
setUser(...)`) und `apps/web/src/components/settings/account-settings-form.tsx`
(`if (u) ...`) tun bei `null` NICHTS. Folge: die Portalhuelle rendert OHNE
angemeldeten Benutzer — kein Name, kein Avatar, `isAdmin` falsch, Admin-
Navigation weg. Der Auftrag vermutete hier "laut"; die Lesung ergibt
**verschluckt**: `fetchCurrentUser` liefert fuer "nicht angemeldet" und
"Zeile unsichtbar" denselben Wert `null` — das ist NICHT die Familie eines
lauten Fehlers, sondern dieselbe Familie wie WINDOWS #23/#25/#26. Die Seite
`apps/web/src/app/(portal)/change-password/page.tsx` haengt an demselben
Aufruf; `ForcePasswordChangeInterceptor` laesst `/auth/me` und
`/auth/change-password` ausdruecklich durch, weil der erzwungene
Kennwortwechsel `getMe` braucht.
`changePassword`: der gebundene `findUnique` liefert nach der Bindung `null`
fuer ein fremdmandantiges Ziel (kommt hier praktisch nicht vor — der
angemeldete Benutzer sucht sich selbst) → `UnauthorizedException('User not
found or has no local password')`, `401` → `auth-actions.ts` uebersetzt NUR
die woertliche Meldung `Current password is incorrect` in
`wrongCurrentPassword`, jede andere 401-Meldung in `networkError` — der
Nutzer saehe eine Netzwerkfehler-Meldung fuer einen Trennungsfehler. Der
Auftrag vermutete "falsches altes Kennwort"; gemessen ist es `networkError`.
`adminResetPassword`: `BadRequestException('User not found')`, `400`, kein
UI-Aufrufer (Befund D) — fuer einen API-Aufrufer bedeutet das "diesen
Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das NACH der
Bindung dieses Plans die RICHTIGE Antwort: sie macht keine Aussage ueber die
Existenz oder den Mandanten eines fremden Halters (dieselbe Form wie
T-DAS-08).
### (h4) Was dieser Durchlauf bewusst nicht löst
**(a) Etappe 3.** Die Etappe-3-Entscheidung (1) macht `username` (und
`email`) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag:
`auth_lookup_user_by_username(p_username)` (stuetzt sich heute auf `username
@unique` plattformweit; braucht kuenftig `(p_tenant_id, p_username)` — die
Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht
weiter), gegebenenfalls `auth_lookup_user_by_email`, `local.strategy.ts`
(kennt nur `username`/`password`, braucht eine Mandantenangabe VOR der
Suche), die `@unique`-Indizes auf `User`, `UserService.findByUsername`,
`resolveEmailForWrite` (ldap), die P2002-Uebersetzung in `UserService.create`/
`update` (WINDOWS #22). Dieser Plan ist dafuer NEUTRAL: die Bindung der drei
Methoden haengt am Claim `tenantId` (das es unabhaengig davon gibt, WIE der
Anmeldeweg den Mandanten ermittelt) und an `User.id` (plattformweite UUID,
Kette aus 260911-cwh: Schema → `auth.service.ts` `sub: user.id` →
`JwtStrategy.validate` → `@CurrentUser().id`) — nicht an `username`/`email`.
Unterlassen, damit Etappe 3 nicht schwerer wird: Selbstbedienung ueber
`req.tenantId` binden; den Mandanten irgendwo nach der Anmeldung aus
`username`/`email` ableiten; die Funktionen um Spalten erweitern.
**(b) Die Rechteausweitung ADMIN → SUPER_ADMIN im Schwesterweg.**
`apps/api/src/user/user.controller.ts`, `update` (Zeilen 172-208) prueft mit
T-02-08 nur, ob die Rolle `SUPER_ADMIN` NEU ZUGEWIESEN wird
(`dto.role === Role.SUPER_ADMIN`), nicht, ob das ZIEL diese Rolle bereits
HAT — `password`/`isActive` im `UpdateUserDto` gehen fuer ein bestehendes
`SUPER_ADMIN`-Ziel ungeprueft durch. `remove` (Zeilen 220-244) prueft
ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze.
Erneut gelesen (260911-fh9, Aufgabe 1): beide Luecken bestehen unveraendert
— `grep -n "Role.SUPER_ADMIN" apps/api/src/user/user.controller.ts` findet
nur die eine Stelle in `update` (`dto.role === Role.SUPER_ADMIN`), keine im
`user`-Objekt selbst. Kein Mandantenproblem, sondern Rechteausweitung
INNERHALB des Mandanten — derselbe Fall wie T-FH9-04 in diesem Plan, nur am
Schwesterweg. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist
`user.role === Role.SUPER_ADMIN` und der Aufrufer nicht `SUPER_ADMIN`,
`ForbiddenException`) — die Vorlage steht seit dieser Aufgabe in
`AuthService.adminResetPassword`. Ausserhalb der Erlaubnisliste dieses
Plans, deshalb Ledger-Eintrag statt Reparatur (Aufgabe 3, T-FH9-05).
**(c) Das Frontend, das `null` verschluckt.** Nicht angefasst (siehe (h3));
Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26.
**(d) Die fehlende Existenzpruefung des `x-tenant-id`-Werts.** Bekannt aus
`(n4)(f)` des Abschnitts `## Bereich tenant`; hier nur genannt, weil sie die
Entscheidung gegen `req.tenantId` fuer Selbstbedienung stuetzt — ein
SUPER_ADMIN kann per `x-tenant-id` eine erfundene Kennung schicken, sie
bindet an einen leeren Kontext, kein Leck. Nicht dieser Auftrag.
**(e) Die Etappe-4-Vorabpruefung.** Fuer einen bekannten Benutzer die Zeile
ueber die Wartungsrolle lesen und den gebundenen `findUnique` unter seinem
Claim-Mandanten daneben halten — dieselbe Form wie bei den Bereichen `user`
und `tenant`.
**(f) `changePassword` hat zwei verschiedene 401-Meldungen.**
`User not found or has no local password` (kein lokales Kennwort, z. B.
LDAP-Konto) vs. `Current password is incorrect` (falsches Kennwort) —
bestehendes Verhalten, betrifft nur die eigene Zeile des angemeldeten
Benutzers, unveraendert in diesem Plan.
### (h5) Was dieser Durchlauf bewusst nicht anfasst
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
`auth_lookup_reset_token`) und ihre Migration `20260909160000_auth_lookup_functions`
— belegt durch `git diff --name-only 6236b30 -- apps/api/prisma` (leer)
und die `pg_proc`-Messung aus (h1).
- Die drei `$queryRaw`-Aufrufe in `validateUser`, `requestPasswordReset`,
`resetPassword` — bleiben auf dem ungebundenen Klienten `this.prisma`.
- `local.strategy.ts` und `jwt.strategy.ts` — nur gelesen.
- `apps/api/src/auth/dto/admin-reset-password.dto.ts` — nur gelesen, kein
Mandantenfeld.
- `docs/mandantentrennung-datenbankrolle.md`, Abschnitt 3 — beschreibt die
Anordnung des Anmeldewegs korrekt, keine Aenderung noetig.
- `docs/anleitung-entwicklung.md` — nennt keine der drei Methoden.
- `apps/api/src/user/user.controller.ts` — nur gelesen (siehe (h4)(b)).
- Das Frontend (`header.tsx`, `account-settings-form.tsx`, `auth-actions.ts`,
`change-password/page.tsx`) — nur beschrieben, nicht geaendert.
- Schema und Migrationen.
- Die Kopfzeile in `auth.service.spec.ts` (Zeile 4-6), die auf ein Muster in
`ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als
Befund F genannt.
## Bereich favorites
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `favorites`
(Quick-Task 260911-gwh, zusammen mit `settings` der LETZTE Lauf von Etappe 2)
und beschreibt ihn zum Zeitpunkt seiner Umstellung. Die Leitfrage aus
Abschnitt (a) gilt unverändert weiter. Anders als jeder Bereich davor hängt
`FavoriteLink` über einen Fremdschlüssel an einer ZWEITEN mandantengebundenen
Tabelle (`WidgetInstance`), deren Zeilenschutz-Regel der Fremdschlüssel auf
Datenbankebene umgeht — eine Ausprägung von WINDOWS #27, hier zum ersten Mal
ausdrücklich mitgebaut statt nur benannt.
### (f1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
Abschnitt (`runFavoritesAreaChecks`) erweitert, mit der Policy für
`FavoriteLink` (aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert, nicht im
Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe dieses Laufs
(2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
favoritelink-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "FavoriteLink" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
favoritelink-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model FavoriteLink, skalare Felder ohne Relation, 10): ["createdAt","iconUrl","id","position","tenantId","title","updatedAt","url","userId","widgetId"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; gemessene "WidgetInstance"-Zeilen (Befund C, von runDashboardAreaChecks angelegt): [{"id":"widget-a1","userId":"user-a1","tenantId":"TENANT-A"},{"id":"widget-a2","userId":"user-a2","tenantId":"TENANT-A"},{"id":"widget-b1","userId":"user-b1","tenantId":"TENANT-B"}]
favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste: bestanden — ungebundenes prisma.favoriteLink.findMany({ where: { userId: 'user-a1', widgetId: 'widget-a1' }, orderBy: [{ position: 'asc' }, { title: 'asc' }] }) (die Form von list) liefert 0 Zeile(n), obwohl 2 tatsaechlich vorhanden sind — das ist der Wert, aus dem favorites-widget.tsx "Noch keine Favoriten." macht
favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen: bestanden — bound.favoriteLink.findMany unter TENANT-A liefert fuer (userId='user-a1', widgetId='widget-a1') 2 Zeile(n): ["fav-a1-1","fav-a1-2"] — die Zeile von user-a2 fehlt (anwendungsseitige Benutzerfilterung); ein gebundenes findMany({ where: { widgetId: 'widget-a2' } }) unter DEMSELBEN Mandanten liefert dagegen 1 Zeile(n) des Kollegen user-a2 (["fav-a2-1"]) — die Regel auf "FavoriteLink" kennt keine Benutzerdimension
favoritelink-besitzpruefung-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — bound.favoriteLink.findUnique({ where: { id: 'fav-a1-1' } }) unter TENANT-B (die Zeile gehoert TENANT-A) liefert null
favoritelink-gebundenes-loeschen-ueber-kennung-allein-fremder-mandant-scheitert-laut: bestanden — bound.favoriteLink.delete unter TENANT-B auf die unter TENANT-A liegende Zeile fav-a1-1 wirft PrismaClientKnownRequestError (code P2025): No record was found for a delete. — die Wartungsrolle liest die Zeile danach noch: true
favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-b1' (gehoert TENANT-B, unter TENANT-A per gebundenem widgetInstance.findUnique unsichtbar: null) GELINGT (id=fav-a1-fremdes-widget) — der Fremdschluessel prueft am Zeilenschutz VORBEI (dokumentiertes PostgreSQL-Verhalten); bound.favoriteLink.create unter TENANT-A mit widgetId="widget-gibt-es-nicht" scheitert mit PrismaClientKnownRequestError (code P2003): Foreign key constraint violated — der Unterschied zwischen beiden Antworten ist das Existenzorakel (T-GWH-05)
favoritelink-gebundenes-anlegen-eigener-mandant-gelingt: bestanden — bound.favoriteLink.create unter TENANT-A mit widgetId='widget-a1' gelingt (id=fav-a1-neu); die Wartungsrolle liest danach tenantId="TENANT-A", createdAt und updatedAt gesetzt
Alle 137 Pruefungen bestanden.
```
Die tragende Belegzeile ist `favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`: der
IDENTISCHE `findMany`, den `list` heute stellt, liefert UNGEBUNDEN `[]`,
während die Wartungsrolle zwei Zeilen sieht — das ist der Wert, aus dem
`favorites-widget.tsx` `Noch keine Favoriten.` macht (siehe (f3)).
Der Fremdschlüssel ist als mitgebaute Relation gemessen, nicht nur behauptet
(Befund C, WINDOWS #27): die Wegwerf-Tabelle `"FavoriteLink"` trägt
`FOREIGN KEY ("widgetId") REFERENCES "WidgetInstance"("id") ON DELETE CASCADE`
auf die von `runDashboardAreaChecks` bereits angelegte Tabelle; vorher wurde
über die Wartungsrolle gemessen, welche `WidgetInstance`-Zeilen tatsächlich
stehen (drei, siehe Belegausgabe von Prüfung 2), statt sie anzunehmen.
Das Ergebnis von Prüfung 7 (`favoritelink-fremdschluessel-prueft-am-zeilenschutz-vorbei`)
ist zweigeteilt und trägt eine Entscheidung für Aufgabe 2: ein gebundenes
`create` unter TENANT-A mit `widgetId` eines TENANT-B-Widgets GELINGT — der
Fremdschlüssel prüft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI
(dokumentiertes PostgreSQL-Verhalten: referentielle Integrität umgeht Row
Security). Derselbe Aufruf mit einer wirklich fehlenden `widgetId` scheitert
dagegen laut mit einer FK-Verletzung (Code P2003). Der Unterschied zwischen
beiden Antworten — "existiert nicht" (500/Fehler) vs. "gehört einem fremden
Mandanten" (gelingt) — ist ein Existenzorakel über Mandantengrenzen
(T-GWH-05, medium). Aufgabe 2 baut deshalb einen anwendungsseitigen
Besitzriegel in `create`, der beide Fälle auf dieselbe Antwort
(`Widget not found`) abbildet.
### (f2) Signaltabelle je umzustellendem Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Konkretes Signal | Frontend lässt es durch? |
|---|---|---|---|
| `list` (`GET /favorites?widgetId=`) | Ungebundener `findMany` liefert `[]` statt der eigenen Zeilen | `200 []` | Ja — `fetchFavorites` (`favorites-api.ts:22-29`) gibt `[]` durch, siehe (f3) |
| `create` (`POST /favorites`) | Ungebundenes `create` schreibt eine Zeile, die unter der Mandantenkennung des Aufrufers physisch korrekt liegt, aber der Icon-Suchpfad ist unverändert; der Riegel aus Aufgabe 2 prüft VOR dem Schreiben, ob `widgetId` existiert und dem Aufrufer gehört | `NotFoundException('Widget not found')`, 404, wenn das Widget nicht dem Aufrufer gehört | Ja — `createFavorite` (`favorites-api.ts:33-46`) wirft `Failed to create favorite` bei `!res.ok` |
| `update` (`PATCH /favorites/:id`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile, Vorprüfung greift bereits heute (userId-Vergleich) | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `updateFavorite` wirft `Failed to update favorite` |
| `remove` (`DELETE /favorites/:id`) | Dieselbe Form wie `update` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `deleteFavorite` wirft `Failed to delete favorite` |
| `getIconBytes` (`GET /favorites/:id/icon`) | Dieselbe Vorprüfung wie `update`/`remove` | `NotFoundException('FavoriteLink not found')`, 404 | Ja — `<img>`-Ladefehler, vom Widget nicht gesondert behandelt |
### (f3) Welcher Code Leere als Abwesenheit deutet
Die `list`-Kette, alle Glieder namentlich: `list` liefert `[]` (die Belegzeile
`favoritelink-liste-generierter-client-ungebunden-liefert-leere-liste`) →
`GET /favorites?widgetId=` antwortet `200 []` → `fetchFavorites`
(`favorites-api.ts:22-29`) prüft nur `!res.ok` (bei `200` erfüllt das nicht)
und gibt `[]` durch → `favorites-widget.tsx:212-213` zeigt
`t('favorites.empty')` = **`Noch keine Favoriten.`** (`de.json`, Zeile 219).
"Zeile unsichtbar" und "nie einen gespeichert" sind für das Frontend
derselbe Wert `[]` — das ist NICHT die Familie eines lauten Fehlers, sondern
dieselbe Familie wie WINDOWS #23/#25/#26/#28 (module-registry, dashboard,
calendar, auth).
`update`/`remove`/`getIconBytes` deuten Leere dagegen LAUT: die
Vorprüfung (`findUnique` → `null` oder fremder `userId`) wirft
`NotFoundException('FavoriteLink not found')`, 404 — `favorites-api.ts`
übersetzt das in `Failed to update/delete favorite`, das Widget setzt
`t('favorites.error')` im `catch`. Diese Richtung ist harmlos, weil ein zu
kleines Ergebnis dort bereits heute einen Fehler auslöst, der nicht mit dem
Scharfschalten neu entsteht.
### (f4) Was dieser Durchlauf bewusst nicht löst
- **(a) Die fehlende Benutzerdimension der Regel.** Prüfung 4
(`favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`)
zeigt zweigeteilt: die eigenen Zeilen kommen korrekt, UND ein gebundenes
`findMany` auf die `widgetId` eines Kollegen DESSELBEN Mandanten liefert
dessen Zeile ebenfalls — die Regel auf `FavoriteLink` kennt keine
Benutzerdimension (dieselbe Lehre wie bei `CalendarSource`,
`DashboardLayout`, `WidgetInstance`). Die anwendungsseitige
`userId`-Filterung bleibt bestehen und ist bis zur Etappe-3-Entscheidung
(2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben
Mandanten.
- **(b) Das Frontend.** Die `list`-Kette aus (f3) wird nicht geändert;
Ledger-Eintrag in Aufgabe 3.
- **(c) Die Mandantenquelle — warum der dashboard-Präzedenzfall und nicht
der auth-Präzedenzfall.** `favorites.controller.ts` `extractContext` liest
`req.tenantId ?? req.user?.tenantId` — WORTGLEICH mit
`dashboard.controller.ts`, unter dessen Bindung `WidgetInstance` liegt.
`FavoriteLink` hängt über `widgetId` an `WidgetInstance`; würde
`favorites` stattdessen an das Claim binden (wie `auth` für
Selbstbedienung), während `dashboard` an der Guard-Kennung bleibt, lägen
Widget und Link unter einem `x-tenant-id`-Wechsel eines SUPER_ADMIN in
verschiedenen Mandanten. `favorites-api.ts` sendet die Kopfzeile heute
nicht (`grep -rn "x-tenant-id" apps/web/src`: nur die vier
Marktplatz-Stellen) — die Entscheidung hängt an der Bauform, nicht am
heutigen Aufrufer.
- **(d) Die Etappe-4-Vorabprüfung.** Für einen bekannten Nutzer/Widget die
Favoritenzahl über die Wartungsrolle und über den gebundenen `findMany`
daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
### (f5) Was dieser Durchlauf bewusst nicht anfasst
- Der Icon-Proxy (`icon-discovery.service.ts`, Befund G) — erreicht die
Datenbank NICHT und nimmt KEINE Client-URL: `getIconBytes` holt nur die
GESPEICHERTE `iconUrl` einer Zeile, die der Aufrufer besitzt (T-QFIP-01);
`discoverFavoriteIconUrl` nimmt die Nutzer-URL nur für den
SSRF-gesicherten Abruf (T-08-05). Kein Mandantenbezug — unverändert, in
der Testdatei eine Attrappe.
- Die DTOs (`create-favorite.dto.ts`, `update-favorite.dto.ts`) — nur
gelesen, kein Mandantenfeld.
- `dashboard.controller.ts` — nur gelesen (Präzedenzfall für (f4)(c)).
- Das Frontend (`favorites-widget.tsx`, `favorites-api.ts`) — nur
beschrieben, nicht geändert.
- Schema und Migrationen.
## Bereich settings
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `settings`
(Quick-Task 260911-gwh) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Anders als jeder Bereich davor trägt dieser Bereich die Reihenfolgebedingung
Befund K aus den Läufen `tenders` und `dkv`: `getDecryptedSmtpConfig(tenantId)`
ist der einzige Versandpfad für Ausschreibungs- und DKV-Mails. Zusätzlich
trägt der Startpfad des Mailmoduls den SECHSTEN Fall der
Hintergrunddienst-Falle — anders als bei `dkv` (WINDOWS #21) verdeckt hier
eine Rückfallkette das Verstummen mit einem falschen Transport statt
schlichter Leere.
### (s1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen weiteren
Abschnitt (`runSettingsAreaChecks`) erweitert, mit der Policy für
`SmtpConfig` (aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables`) WORTGLEICH extrahiert. Die
Wegwerf-Tabelle trägt zusätzlich den Eindeutigkeitsindex
`SmtpConfig_tenantId_key` WORTGLEICH aus `20260629130000_add_missing_tables`
— ohne ihn misst Prüfung 8 nichts. Tatsächlich beobachtete Ausgabe dieses
Laufs (2026-09-11, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
smtpconfig-regelstand-eindeutig: bestanden — die *_rls_widen_membership_grant_and_platform_read-Migration (260910-jab) enthaelt KEINE eigene Regel fuer "SmtpConfig" — der Stand aus 20260909140000_rls_remaining_tenant_tables ist weiterhin der ausgelieferte, aktuelle Regelstand
smtpconfig-wegwerftabelle-deckt-alle-spalten-des-generierten-clients: bestanden — Schema-Felder aus schema.prisma (model SmtpConfig, skalare Felder ohne Relation, 10): ["createdAt","encryptedPassword","encryption","fromAddress","host","id","port","tenantId","updatedAt","username"]; Spalten der Wegwerf-Tabelle (10): [dieselben zehn]; Eindeutigkeitsindex "SmtpConfig_tenantId_key" ueber pg_indexes: ["CREATE UNIQUE INDEX \"SmtpConfig_tenantId_key\" ON public.\"SmtpConfig\" USING btree (\"tenantId\")"]
smtpconfig-startpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findFirst() (die Form von loadAnySmtpConfigForStartupTransport) liefert null, obwohl 2 Zeilen existieren — das ist der Wert, mit dem mail.module.ts nach dem Scharfschalten auf Umgebungsvariablen und zuletzt localhost:1025 zurueckfaellt, ein falscher Transport statt einer Meldung
smtpconfig-startpfad-ueber-wartungsrolle-zieht-beliebige-zeile: bestanden — dieselbe Abfrage ueber die Wartungsrolle liefert genau EINE Zeile, tenantId="TENANT-A" — nichts in der Abfrage bestimmt, WELCHER Mandant gezogen wird
smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null: bestanden — ungebundenes prisma.smtpConfig.findUnique({ where: { tenantId: 'TENANT-A' } }) (die Form von getDecryptedSmtpConfig) liefert null, waehrend die Wartungsrolle die Zeile liest
smtpconfig-versandpfad-generierter-client-gebunden-eigener-mandant-liefert-zugangsdaten: bestanden — gebunden unter TENANT-A liefert findUnique: host="smtp-a.example.invalid", fromAddress="a@example.invalid", encryptedPassword="enc(a-passwort-platzhalter)"
smtpconfig-versandpfad-generierter-client-gebunden-fremder-mandant-liefert-null: bestanden — gebunden unter TENANT-B liefert findUnique({ where: { tenantId: 'TENANT-A' } }): null
smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut: bestanden — ungebundenes prisma.smtpConfig.upsert(...) (die Form von saveSmtpConfig) wirft PrismaClientUnknownRequestError: ConnectorError ... SQLSTATE 42501, "new row violates row-level security policy for table \"SmtpConfig\"" — die Wartungsrolle liest danach weiterhin host="smtp-a.example.invalid"
smtpconfig-gebundenes-upsert-eigener-mandant-aktualisiert: bestanden — gebundenes upsert unter TENANT-A trifft die eigene Zeile (id=smtp-a), die Wartungsrolle liest danach den neuen Host, updatedAt gesetzt; smtp-b bleibt unveraendert
Alle 137 Pruefungen bestanden.
```
Die tragenden Belegzeilen sind drei: `smtpconfig-startpfad-generierter-client-ungebunden-liefert-null`
für den Startpfad, `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
für Befund K, und `smtpconfig-ungebundenes-upsert-auf-unsichtbare-zeile-scheitert-laut`
für den Speicherkonflikt. Prüfung 8 wirft — gemessen, nicht angenommen —
**`PrismaClientUnknownRequestError`**, dieselbe Fehlerklasse, die 260910-krx
für `DashboardLayout` gemessen hat (nicht `PrismaClientKnownRequestError`
mit `P2002`, die Form der Bereiche `tenders`/`user`): die Regel weist den
Schreibzugriff ab, bevor eine Eindeutigkeit überhaupt geprüft wird. Die
zugrundeliegende PostgreSQL-Meldung (SQLSTATE `42501`, "new row violates
row-level security policy") ist in der Belegausgabe wörtlich enthalten.
### (s2) Signaltabelle je Pfad
| Pfad | Verhalten HEUTE nach dem Scharfschalten (ohne diesen Plan) | Signal | Frontend |
|---|---|---|---|
| `getSmtpConfig` (`GET /settings/smtp`) | Ungebundener `findUnique` liefert `null` statt der eigenen Zeile | Controller gibt `null` → NestJS sendet `200` mit LEEREM Rumpf | Ja — verschluckt, siehe (s3) |
| `saveSmtpConfig` (`PUT /settings/smtp`) | Ungebundenes `upsert` scheitert am Eindeutigkeitsindex, weil die physisch vorhandene Zeile unsichtbar ist | `PrismaClientUnknownRequestError`, 500 | Nein — kein spezifischer Übersetzungspfad im Controller, roher 500 |
| `getDecryptedSmtpConfig` — Aufrufer `tender-mail.service.ts` (`resolveTransport`) | Ungebundener `findUnique` liefert `null` | `logger.warn('No SMTP configuration ... skipping tender mail send (will retry next run)')`, KEIN Wurf, Digest übersprungen | — (Hintergrunddienst, kein Frontend-Pfad) |
| `getDecryptedSmtpConfig` — Aufrufer `dkv-mail.service.ts` (`sendExportEmail`) | Dieselbe Form | `throw new Error('No SMTP configuration found for tenant ...')` | — (Hintergrunddienst) |
| `testSmtpConfig` (`POST /settings/smtp/test`) | Rückgriff auf `getDecryptedSmtpConfig` liefert `null`, `password`/`username` bleiben `undefined` (falls DTO leer) | Verbindungstest scheitert am Postfach, `{ success: false }` — irreführend, zeigt auf das Postfach statt auf die Datenbank | Ja — Testergebnis wird angezeigt |
| Startpfad `loadAnySmtpConfigForStartupTransport()` (`mail.module.ts`, beim Start) | Ungebundenes `findFirst()` liefert `null` statt einer beliebigen Zeile; die Rückfallkette von `mail.module.ts` greift (Priorität 2-4, zuletzt `localhost:1025`) — EIGENE Spalte, siehe (s4)(a) | Kein Fehler, ein FALSCHER, aber vorhandener Transport; `MailService` fängt jeden Transportfehler (T-02-12), Controller antwortet `200` | Ja — doppelt verdeckt |
### (s3) Welcher Code Leere als Abwesenheit deutet
Die `getSmtpConfig`-Kette, alle Glieder namentlich: `getSmtpConfig` liefert
`null` (Belegzeile `smtpconfig-versandpfad-generierter-client-ungebunden-liefert-null`
zeigt dieselbe Form für den Versandpfad) → `settings.controller.ts` gibt
`null` zurück, ohne zu werfen → NestJS' `ExpressAdapter.reply` sendet bei
`isNil(body)` einen LEEREN Rumpf mit Status `200` (dieselbe Adapter-Kette wie
in (h3), 260911-fh9) → `fetchSmtp` (`settings-api.ts:51-57`) prüft nur
`res.status === 404` (nicht erfüllt) und `!res.ok` (bei `200` nicht erfüllt),
dann `res.json()` auf den leeren Rumpf → wirft → `smtp-settings-form.tsx:76-78`
`.catch(() => { /* Silent fail */ })` → leeres Formular: "SMTP nicht
eingerichtet", während die Zugangsdaten physisch noch da sind. "Nicht
eingerichtet" und "Zeile unsichtbar" sind für das Frontend derselbe Zustand
— dieselbe Familie wie WINDOWS #23/#25/#26/#28.
Trägt der Administrator die Zugangsdaten unter diesem Eindruck neu ein, läuft
`saveSmtpConfig` als `upsert({ where: { tenantId } })`: unter der
UNGEBUNDENEN Form ist die Zeile unsichtbar, der Upsert versucht ein `INSERT`
und scheitert am Eindeutigkeitsindex `SmtpConfig_tenantId_key` —
`PrismaClientUnknownRequestError` (Prüfung 8, gemessen statt vorweggenommen;
dieselbe Fehlerklasse wie die `dashboard`-Lehre aus 260910-krx, NICHT `P2002`).
Die beiden Versandpfade reagieren unterschiedlich auf `null`
(`getDecryptedSmtpConfig`): `tender-mail.service.ts` protokolliert eine
Warnung und überspringt den Versand (Wiederholung beim nächsten Lauf),
`dkv-mail.service.ts` wirft einen Fehler, der die aufrufende Pipeline
abbricht — beide Formen sind bereits vor diesem Lauf so verdrahtet, ändern
sich hier nicht.
### (s4) Was dieser Durchlauf bewusst nicht löst
**(a) Der Startpfad — der sechste Fall der Hintergrunddienst-Falle,
ausgeschrieben statt still getroffen.** `mail.module.ts`'s
`MailerModule.forRootAsync({ useFactory: async ... })` ruft
`SettingsService.loadAnySmtpConfigForStartupTransport()`
(vormals `getStartupSmtpConfig()`) BEIM START, vor jedem Anfragekontext.
Zwei Zustände, beide gehören benannt:
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
Mandanten trägt der SMTP-Server und die Absenderadresse EINES beliebigen
Mandanten die Kennwort-Zurücksetzungs- und Willkommensmails ALLER
Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
- **Nach dem Scharfschalten** liefert `findFirst()` `null` →
`mail.module.ts` fällt auf Priorität 2 (`MAIL_*`), 3 (`TESSERA_SMTP_*`),
zuletzt 4 (`localhost:1025`, Mailhog) zurück → `MailService` fängt jeden
Transportfehler (`mail.service.ts:69-81`, T-02-12) und der Controller
antwortet `200`. Das Verstummen ist damit DOPPELT verdeckt: erst durch die
Rückfallkette (ein falscher, aber vorhandener Transport statt Leere), dann
durch das absichtliche Verschlucken im Versand.
Drei geprüfte Formen: **(a) An einen konkret aufgelösten Mandanten binden**
— nicht möglich, `useFactory` hat beim Start keinen Anfragekontext.
**(b) Umbau auf Transport je Versand** — abgelehnt als Funktion für DIESEN
Lauf, mit Grund: die Vorlage steht bereits in `DkvMailService`/
`TenderMailService` (Transport je Versand aus
`getDecryptedSmtpConfig(tenantId)`), `MailService` müsste dafür nur den
Mandanten entgegennehmen, den `requestPasswordReset` aus der Funktionszeile
bereits hat — ein Umbau des Mailmoduls, kein Bindungsumbau, NICHT dieser
Auftrag (siehe `<hard_constraints>`). **(c) Als benannte Altlast
weiterführen, mit Markierung** — GEWÄHLT: Methode umbenannt
(`loadAnySmtpConfigForStartupTransport()`, dkv-Präzedenzfall — ein Name, den
niemand für einen Anfrageweg hält), Kopfkommentar mit beiden Zuständen,
Modulkommentar in `mail.module.ts`, eigener Ledger-Eintrag.
**Die Unsymmetrie zu BEIDEN Präzedenzfällen:** `getAllActiveConfigs` (ldap)
ist heute korrekt und verstummt erst später; `loadAnyActiveConfigForScheduler`
(dkv, WINDOWS #21) ist heute bereits falsch und verstummt zusätzlich später,
ABER mit einer Protokollzeile ("no active config found — cron job not
registered"). Der Mail-Startpfad ist heute bereits falsch UND verstummt
später OHNE Protokollzeile, weil die Rückfallkette ihn überdeckt — das ist
eine DRITTE Ausprägung, keine der beiden Vorlagen deckt sie vollständig.
**Entscheidung: EIGENER Ledger-Eintrag statt Anschluss an #21.** Andere
Datei (`mail.module.ts`/`settings.service.ts` statt
`dkv-scheduler.service.ts`), andere Reparatur (Transport je Versand statt
Mehrmandanten-Planung), andere Verdeckungsform (Rückfallkette statt bloßer
Leere) — drei eigenständige Unterschiede, kein Wiederholungsfall von #21.
**(b) Befund K ist erfüllt.** Die Reihenfolgebedingung aus (t4) Befund K und
(d4) Übergaben-Absatz — `getDecryptedSmtpConfig(tenantId)` müsse gebunden
sein, bevor Etappe 4 scharfschaltet — ist mit Aufgabe 2 dieses Laufs
ERFÜLLT: die Methode läuft seither über GENAU EINEN Klienten `tenantPrisma`.
Beide Stellen bekommen in Aufgabe 3 einen Nachtrag (siehe (t4), (d4) unten in
diesem Dokument sowie den Hintergrunddienst-Abschnitt in
`docs/mandantentrennung-zugriffsklassifikation.md`). Die Etappe-4-Vorabprüfung
muss diese Bedingung ab jetzt NICHT mehr führen.
**(c) Das Frontend.** Die `getSmtpConfig`-Kette aus (s3) wird nicht
geändert; Ledger-Eintrag in Aufgabe 3 (Familie #28 — dieselbe
200-leerer-Rumpf-Kette wie `auth`).
**(d) Die Mandantenquelle `req.tenantId` im Controller.** Bewusst NICHT auf
das Claim umgestellt (anders als die Selbstbedienungs-Begründung aus
`auth`): `settings.controller.ts` ist eine ADMIN-Konfigurationsseite, für
die `req.tenantId` (per `x-tenant-id` für SUPER_ADMIN umschaltbar) die
RICHTIGE Quelle ist (D-10) — ein SUPER_ADMIN konfiguriert damit gezielt die
SMTP-Zugangsdaten eines ANDEREN Mandanten. Der Controller bleibt deshalb
unverändert und steht nicht in der Erlaubnisliste.
**(e) Die Etappe-4-Vorabprüfung.** Für einen bekannten Mandanten die
`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
### (s5) Was dieser Durchlauf bewusst nicht anfasst
- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
- `apps/api/src/settings/dto/smtp-config.dto.ts` — nur gelesen, kein
Mandantenfeld.
- `tender-mail.service.ts` (`resolveTransport`) — nur gelesen, ruft
weiterhin `getDecryptedSmtpConfig(tenantId)` unverändert auf.
- `dkv-mail.service.ts` (`sendExportEmail`) — dieselbe Form.
- `mail.service.ts` — nur gelesen; `mail.module.ts` wird für die
Umbenennung des Startpfad-Aufrufs angefasst (Aufgabe 2), hier nur
angekündigt.
- Das Frontend (`smtp-settings-form.tsx`, `settings-api.ts`) — nur
beschrieben, nicht geändert.
- Schema und Migrationen.
- `nodemailer` — kein echter Transport in irgendeinem Test dieses Laufs
(lokal gibt es keinen `mailhog`); in der neuen Testdatei per
`vi.mock('nodemailer')` ersetzt.
## Etappe 2 — Abschluss
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
jede klassifizierte Fundstelle in `apps/api/src` ist entweder gebunden oder
mit geschriebenem Grund an der Stelle ungebunden. Die Zahlen unten sind aus
den eigenen Messanweisungen des Klassifikationsdokuments abgeleitet
(Übersichtszeilen, Summenzeile, Klassen-Verteilung), nicht neu geschätzt.
**Läufe.** Zwölf Bereichs-/Regel-Läufe von 260909-ipc bis 260911-gwh (gezählt
aus `.planning/STATE.md`, Reihenfolge): `ldap` (260909-ipc), `groups`
(260909-jts), `tenders` (260909-laa), `dkv` (260909-mir), `user`
(260910-das), `module-registry` (260910-exd), `dashboard` (260910-krx), das
Regelschluss-Plan `tenders`/`SearchProvider` (260910-jab), `tenant`
(260911-e2s), `calendar` (260911-cwh), `auth` (260911-fh9), `favorites`/
`settings` (260911-gwh, dieser Lauf).
**Summenzeile der Übersichtstabelle, vorher/nachher.** Zum Kopf des
Klassifikationsdokuments (Stand 260909-eor): 227 Rohtreffer über 59
Datei-Modell-Paare. Nach Aufgabe 3 dieses Laufs — DERIVIERT aus der
Summenzeile in `docs/mandantentrennung-zugriffsklassifikation.md`, nicht
abgeschrieben: **68 ungebundene, 178 gebundene Rohtreffer** (Summe 246 —
mehr als 227, weil Aufgabe 2 dieses Laufs mit dem Widget-Besitzriegel einen
zusätzlichen gebundenen Rohtreffer einführt, der zur Planungszeit noch nicht
feststand). Jeder der 68 verbleibenden ungebundenen Rohtreffer ist einer der
in diesem Dokument (Abschnitte "## Bereich ...") oder im Klassifikationsdokument
namentlich benannten, bewusst ungebundenen Fälle — siehe die Liste unten.
**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3): **65 Paare**
(33 `muss-mandantengebunden`, 17 `keine-mandantengebundene-tabelle`, 13
`beides`, 2 `bewusst-uebergreifend`) — ein neues Paar
(`favorites.service.ts`/`widgetInstance`) gegenüber den 64 Paaren vor
diesem Lauf, plus zwei Paare, die nur ihre `Stand`-Spalte ändern
(`favorites.service.ts`/`favoriteLink`, `settings.service.ts`/`smtpConfig`).
**Bewusst ungebundene Reste je Bereich, mit Grund:**
- `tenders`: der D-03-Katalog (platform-global, `Tender`/`TenderSource`/
`TenderSourcePollConfig`), die zwei Fan-out-Adapter (E-Mail/RSS, ein Tick
pro Postfach bzw. Feed über alle Mandanten), die übergreifenden Hälften
der beiden Hintergrunddienste (Etappe-3-Übergabe), `createPlatform`/
`remove` in `tender-rss-feed.service.ts` (WINDOWS #24).
- `ldap`: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B),
`resolveEmailForWrite` (plattformweit eindeutiger Schlüssel, T-IPC-04).
- `dkv`: der Planer-Startpfad `loadAnyActiveConfigForScheduler()`
(WINDOWS #21).
- `user`: `findByUsername` (plattformweit eindeutiger Schlüssel), die
Erstanlage-Prüfung und beide `tenant`-Zugriffe in `admin-seed.service.ts`,
der Schleifentreiber `tenant.findMany` der Plattform-Administratorsicht.
- `module-registry`/`dashboard`: der Modulkatalog (`Module`, keine Regel
heute — Befund E).
- `auth`: die drei Anmeldefunktionen (`validateUser`,
`requestPasswordReset`, `resetPassword`) über die SECURITY-DEFINER-Wege.
- `tenant`: die Mandantentabelle selbst (`Tenant`, keine eigene
`tenantId`-Spalte, keine Regel in irgendeiner Migration).
- `settings`: der sechste Fall der Hintergrunddienst-Falle,
`loadAnySmtpConfigForStartupTransport()` (dieser Lauf, siehe (s4)(a)).
**Offene Ledger-Einträge dieser Etappe** (Nummern siehe `.planning/WINDOWS.md`,
Kopfzähler geprüft): #18 (Schalter aus), #20 (Verbindungsfehler, an dieselbe
Bedingung gebunden wie #18), #21 (dkv-Planer-Startpfad), #22
(plattformweite Eindeutigkeit von `username`/`email`), #23
(module-registry, unterscheidbares Signal fehlt), #24 (plattformweite
RSS-Verwaltung unter der Anwendungsrolle), #25 (dashboard, beweisvernichtende
Fehlerrichtung), #26 (calendar, verschluckte Leere), #27
(Relationszugriffe für die Bestandsaufnahme unsichtbar), #28 (auth,
verschluckte Leere), plus die drei neuen Einträge dieses Laufs (Startpfad
des Mailmoduls, verschluckte Leere `favorites`, verschluckte Leere
`settings` — Nummern siehe Aufgabe 3 dieses Plans).
**Prüfungen und Tests.** Werkzeug: siehe die Zeile `Alle N Prüfungen
bestanden.` des letzten Laufs von `rls-scratch-check.mjs` in dieser Aufgabe.
Tests: siehe die letzte Testausgabe von `npm --prefix apps/api run test` in
dieser Aufgabe.
**Was für Etappe 3 bleibt:**
- Der Anmeldeweg unter je Mandant eindeutigen Namen (`username`/`email`,
Etappe-3-Entscheidung (1)) — siehe (h4)(a).
- Die Benutzerdimension der Regeln (Etappe-3-Entscheidung (2)) — siehe
(k4)/(f4)(a) und die übrigen Bereiche mit derselben Beobachtung.
- Die Modulkatalog-Regel für `Module` (Befund E, `module-registry`) — sobald
eine Regel eingeführt wird, müssen die heute bewusst ungebundenen
Katalogzugriffe nachgezogen werden.
- Die Kennzeichnung der `bewusst-uebergreifend`-Stellen (Systemkontext,
siehe "Die drei Klassen" im Klassifikationsdokument).
- Der Mandantenwechsel im Ausschreibungs-Digest ((t4), der Sonderfall eines
Nutzers mit Treffern unter zwei verschiedenen Mandanten).
**Was Etappe 4 (`rls-preflight.mjs`) VOR dem Scharfschalten prüfen muss** —
die in den Bereichsabschnitten benannten Vorabprüfungen, als Liste (OHNE die
jetzt erfüllte Befund-K-Bedingung, die entfällt):
- `ldap`: das Verstummen von `getAllActiveConfigs` (Befund B).
- `dkv`: das Verstummen des Planer-Startpfads (WINDOWS #21).
- `tenders`: wachsende Zahl von `TenderMatch`-Zeilen mit `notifiedAt IS NULL`
ohne Versandprotokoll (t3).
- `module-registry`: aktive Aktivierungszeilen vorhanden, aber die
Auflösung liefert für einen bekannten Administrator eine leere Menge
(WINDOWS #23).
- `dashboard`: eine physisch vorhandene `DashboardLayout`-Zeile für einen
bekannten Benutzer, aber der gebundene Lesezugriff liefert `null`
(WINDOWS #25).
- `calendar`: physisch vorhandene `CalendarSource`-Zeilen je Mandant über
die Wartungsrolle zählen und mit der gebundenen Zählung vergleichen
(k4)(e).
- `auth`: einen bekannten Benutzer über die Wartungsrolle lesen und den
gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten
(WINDOWS #28).
- `favorites`: für einen bekannten Nutzer/Widget die Favoritenzahl über die
Wartungsrolle und über den gebundenen `findMany` daneben halten (f4)(d).
- `settings`: für einen bekannten Mandanten die `SmtpConfig`-Zeile über die
Wartungsrolle lesen und den gebundenen `findUnique` daneben halten
(s4)(e).
- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
Signal, NICHT an #21 angeschlossen.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.