docs(quick-260911-gwh): Fehlerrichtung fuer Bereiche favorites und settings messen und aufschreiben

- runFavoritesAreaChecks (8 Pruefungen) und runSettingsAreaChecks (9 Pruefungen)
  erweitern rls-scratch-check.mjs auf 137 bestandene Pruefungen
- FavoriteLink-Wegwerftabelle traegt den Fremdschluessel auf WidgetInstance
  (Befund C, WINDOWS #27), SmtpConfig-Wegwerftabelle den Eindeutigkeitsindex
- Ergebnis Pruefung 7: der Fremdschluessel prueft am Zeilenschutz vorbei
  (steuert den Besitzriegel in Aufgabe 2); Pruefung 8: ungebundenes upsert
  wirft PrismaClientUnknownRequestError, dieselbe Klasse wie 260910-krx
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Abschnitte
  ## Bereich favorites (f1-f5), ## Bereich settings (s1-s5) und
  ## Etappe 2 -- Abschluss; Nachtraege unter Befund K in (t4) und im
  Uebergaben-Absatz von (d4) -- die Reihenfolgebedingung ist erfuellt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 13:47:02 +02:00
parent 2a27d96bca
commit 88896d3b43
2 changed files with 1019 additions and 0 deletions
@@ -632,6 +632,14 @@ Warnung wäre Dauerlärm und verlöre ihr Signal.
(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.
@@ -943,6 +951,19 @@ 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.
@@ -2674,6 +2695,427 @@ Benutzers, unveraendert in diesem Plan.
`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 (siehe Summenzeile in
`docs/mandantentrennung-zugriffsklassifikation.md`, DERIVIERT aus den
Bereichszeilen, nicht abgeschrieben): siehe dortige Summenzeile für die
Endzahl.
**Klassen-Verteilung.** Siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
Abschnitt "Klassen-Verteilung", Stand 260911-gwh (Aufgabe 3) für die
Paarzahl und die Verteilung nach den vier Klassen.
**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