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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user