feat(laa-01): messe die drei Sonderfaelle des Bereichs tenders und schreibe die Fehlerrichtung

rls-scratch-check.mjs bekommt einen fuenften Abschnitt (runTendersAreaChecks)
mit den fuenf Policies von TenderEmailConfig/TenderNotificationPref/
TenderRssFeedSource/TenderSavedSearch/TenderTriage, wortgleich aus der
ausgelieferten Migration extrahiert. Neun neue Pruefungen belegen: die
Policies haben keine Benutzerdimension (Befund E), eine plattformweite
RSS-Zeile ist unter jedem Mandantenkontext unsichtbar und ein gebundenes
Einfuegen ohne Mandant wird abgewiesen (WINDOWS #19), und ein gebundenes
upsert auf eine unsichtbare Zeile verletzt die Eindeutigkeitsbedingung,
nicht die Policy (Befund F). Alle 32 Pruefungen (23 bisherige + 9 neue)
bestehen. Nachmessung bestaetigt Befund A: genau eine $transaction in
diesem Bereich, Array-Form auf der plattformweiten Tabelle Tender,
ausserhalb jeder Mandantenbindung — withTenantTransaction() wird hier
nicht gebraucht.

docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt einen
`## Bereich tenders`-Abschnitt mit der beobachteten Messausgabe, einer
Signaltabelle je umzustellendem Pfad und einem eigenen Unterabschnitt zur
lautlosen Fehlerform der beiden Hintergrunddienste (fuenf benannte
Stellen, keine protokolliert etwas).

743 Tests gruen, Typpruefung sauber, kein Schema-/Migrations-/Compose-/
Umgebungsdatei-Diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 15:43:35 +02:00
parent b86675b549
commit 349814747e
2 changed files with 527 additions and 0 deletions
@@ -375,6 +375,223 @@ geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt
in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt
tatsächlich vorliegt.
## Bereich tenders
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `tenders`
(Quick-Task 260909-laa) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, aber für einen Bereich, der überwiegend NICHT
umgestellt wird: von 23 Paaren sind fünf umzustellen, zehn bleiben bewusst
der plattformweite Ausschreibungskatalog (D-03), zwei sind bewusste
Fan-outs, und sechs zerfallen in eine übergreifende Hälfte (Etappe 3) und
eine mandantengebundene Hälfte (hier). Dieser Bereich trägt außerdem eine
Fehlerform, die `ldap` und `groups` nicht hatten: zwei Benachrichtigungswege,
die bei zu kleinem Leseergebnis nicht falsch handeln, sondern GAR NICHT.
### (t1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen fünften
Abschnitt (`runTendersAreaChecks`) erweitert, mit den fünf Policies für
`TenderEmailConfig`, `TenderNotificationPref`, `TenderRssFeedSource`,
`TenderSavedSearch` und `TenderTriage` (alle aus der ausgelieferten
Migration `20260909140000_rls_remaining_tenant_tables`) WORTGLEICH
extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich beobachtete Ausgabe
dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
```
tendersavedsearch-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
tendersavedsearch-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "TenderSavedSearch" liefert 0 Zeile(n)
tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar: bestanden — forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen
tenderemailconfig-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendertriage-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tendernotificationpref-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar: bestanden — forTenant(TENANT-A) sieht die Platform-Zeile: false, forTenant(TENANT-B) sieht sie: false — WINDOWS #19: eine plattformweite RSS-Quelle ist unter JEDEM Mandantenkontext unsichtbar; listForUser/createPlatform/remove duerfen deshalb nicht gebunden werden, solange die Policy-Semantik unveraendert ist
tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT mit tenantId=NULL abgewiesen: ERROR: new row violates row-level security policy for table "TenderRssFeedSource" — createPlatform darf deshalb nicht gebunden werden
tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit: bestanden — gebundenes INSERT auf die unsichtbare (userId,tenderId)-Kombination scheitert an der Eindeutigkeitsbedingung, nicht an der Policy: Code 23505, Unique constraint failed — Befund F: aus einem stillen Ueberschreiben wird bei Aufgabe 2 ein harter, verstaendlich uebersetzter Fehler
Alle 32 Pruefungen bestanden.
```
Die Belegzeile, die diesen Abschnitt trägt, ist
`tendersavedsearch-ungebunden-null-zeilen`: der IDENTISCHE
`SELECT "tenantId" FROM "TenderSavedSearch"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 3 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle.
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
Bereich brauchte:
- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — das
GELINGEN (beide Nutzer von TENANT-A sind sichtbar) IST das bestandene
Ergebnis. Alle fünf Policies dieses Bereichs lauten schlicht
`"tenantId" = current_tenant_id()`, ohne Benutzerdimension — zwei Nutzer
DESSELBEN Mandanten sind füreinander vollständig sichtbar. Die
anwendungsseitige `userId`-Filterung, die alle fünf umzustellenden
Dienste bereits führen, bleibt deshalb der einzige Schutz gegen
Quer-Lesen zwischen Nutzern und wird bei der Umstellung NICHT entfernt.
- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — die
plattformweite RSS-Zeile (`userId`/`tenantId` beide `NULL`, wie der
geseedete `service.bund.de`-Feed) ist unter TENANT-A UND TENANT-B
gebunden unsichtbar, weil `NULL = current_tenant_id()` in SQL nie wahr
ist. Das ist WINDOWS #19, hier nicht als ferne Sorge, sondern als der
Grund, warum `listForUser`, `createPlatform` und `remove` in
`tender-rss-feed.service.ts` NICHT gebunden werden — eine Bindung würde
die plattformweite Quelle für JEDEN Mandanten verschwinden lassen.
`tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` bestätigt die
Kehrseite: ein gebundenes `INSERT` mit `tenantId = NULL` wird von der
ausgelieferten Policy abgewiesen — `createPlatform` würde in genau diese
Abweisung laufen, würde man es binden.
Eine dritte Messung trägt die Fehlerbehandlung von Aufgabe 2:
`tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` zeigt,
dass ein gebundenes `INSERT` auf ein `(userId, tenderId)`-Paar, dessen Zeile
existiert, aber einem anderen Mandanten gehört und deshalb unsichtbar ist,
an der Eindeutigkeitsbedingung scheitert (Postgres prüft Unique-Indizes
gegen die physischen Zeilen, unabhängig von der RLS-Sichtbarkeit) — NICHT
an einer Policy-Abweisung. Aus einem stillen Überschreiben wird dadurch ein
harter Fehler, der in Aufgabe 2 als verständliche deutsche Meldung
herauskommen muss, nicht als roher 500er.
TEIL 2 dieser Aufgabe hat zusätzlich nachgemessen, dass dieser Bereich
keine mandantengebundene Transaktion enthält (Befund A):
`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec`
liefert genau EINEN Treffer, `tender-fingerprint-backfill.service.ts:89`,
die Array-Form auf der plattformweiten Tabelle `Tender` (D-03), außerhalb
jeder Mandantenbindung. Der im Kopf von `prisma-tenant.extension.ts`
verlangte erneute Test vor jedem neuen `forTenant()`-Fall mit eigener
Transaktion ist damit für diesen Bereich beantwortet: es fällt kein neuer
Fall an, `withTenantTransaction()` wird hier nicht gebraucht und auch
nicht eingeführt.
### (t2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|---|---|---|
| `TenderSavedSearchService.list` | Liefert 0 gespeicherte Suchprofile statt der tatsächlich vorhandenen | Die Suchprofil-Leiste im Ausschreibungs-Radar ist leer für einen Nutzer, der tatsächlich Profile gespeichert hat |
| `TenderSavedSearchService.create/update/remove` | Die Besitzprüfung (`findUnique` gebunden) liefert 0 Zeilen statt der eigenen Zeile | `PATCH`/`DELETE /saved-searches/:searchId` scheitert mit 404, obwohl das Profil existiert; `POST` legt scheinbar erfolgreich ein neues Profil an (unkritisch, betrifft nur den Lesepfad danach) |
| `TenderTriageService.listForUser` | Die Markierungsabfrage liefert 0 Treffer statt der tatsächlich vorhandenen | Die Trefferliste zeigt für bereits gelesene/favorisierte Ausschreibungen keine Markierung mehr — ein bereits gelesener Treffer erscheint wieder als ungelesen |
| `TenderTriageService.favoriteIds` | Liefert 0 favorisierte tenderIds statt der tatsächlich vorhandenen | Der Merklisten-Filter (`favOnly`) zeigt eine leere Liste für einen Nutzer, der tatsächlich Favoriten hat |
| `TenderNotificationPrefService.getForUser` | **Sonderfall**: kein Treffer bedeutet hier nicht "leer", sondern der Vorgabewert `daily` (D-01) | Ein Nutzer, der `off` gewählt hat, sieht in der Oberfläche wieder `daily` — ein zu kleines Leseergebnis setzt die Einstellung stillschweigend auf täglich zurück, statt sie leer zu lassen |
| `TenderEmailConfigService.getConfigForApi` | Der gebundene `findUnique` liefert 0 Zeilen statt der eigenen Konfiguration | `GET /email-config` liefert `null`; die Oberfläche zeigt "kein Postfach verbunden" für einen Nutzer, der tatsächlich eines hat |
| `TenderEmailConfigService.testConnection` | Der gebundene Rückgriff auf gespeicherte Zugangsdaten liefert 0 Zeilen statt der eigenen | Ein Verbindungstest mit leer gelassenem Formular (Rückgriff auf gespeicherte Zugangsdaten) schlägt fehl, obwohl gespeicherte Zugangsdaten existieren |
| `TenderRssFeedSourceService.listForUser`/`createPlatform`/`remove` (bewusst UNGEBUNDEN, WINDOWS #19) | Betrifft nicht diese drei Pfade selbst — sie binden nicht und liefern deshalb weiterhin die plattformweite Zeile korrekt. Das Risiko liegt in einer KÜNFTIGEN Bindung (Etappe 3), nicht in dieser Umstellung | Würde man sie binden: leere Feed-Liste bzw. 404 beim Entfernen einer plattformweiten Quelle — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten |
| `TenderRssFeedSourceService.createForUser` (gebunden) | Der Zähler (`count`, gebunden) liefert 0 statt der tatsächlichen Anzahl eigener Feeds | Ein Nutzer, der bereits am Limit von 20 eigenen Feeds ist, könnte scheinbar unbegrenzt neue anlegen (harmlose Richtung: die Kappung aus T-17-10 wirkt nicht mehr, kein Datenverlust) |
### (t3) Welcher Code Leere als Abwesenheit deutet
**Sichtbare Formen** (Anzeige bleibt leer oder fällt auf einen Vorgabewert
zurück, jemand merkt es beim nächsten Blick auf die Oberfläche): siehe
Tabelle (t2) oben — Suchprofilliste, Triage-Markierung, Favoriten-Filter,
der `daily`-Sonderfall der Benachrichtigungseinstellung, und die
Postfach-Anzeige.
**Lautlose Formen — die zusätzliche Fehlerform dieses Bereichs.** Fünf
Stellen in den beiden Hintergrunddiensten deuten ein zu kleines
Leseergebnis nicht als Fehler, sondern als "nichts zu tun", und
protokollieren dabei NICHTS:
1. **`tender-digest.scheduler.ts`, `if (!candidates.length) return;`** —
liefert die gebundene Kandidatenabfrage innerhalb eines Profildurchlaufs
zu wenig, bricht der GESAMTE Digest für diesen Lauf ab, für ALLE
Mandanten, ohne Protokolleintrag.
2. **`tender-digest.scheduler.ts`, `if (!matches.length) continue;`** —
liefert die gebundene Treffer-Abfrage für einen Kandidaten zu wenig,
bekommt dieser eine Nutzer keine Post, der Lauf macht mit dem nächsten
Kandidaten weiter, ohne Protokolleintrag.
3. **`tender-digest.scheduler.ts`, `if (!user || !user.email) continue;`**
— liefert die gebundene Benutzer-Abfrage zu wenig, bekommt dieser
Nutzer keine Post, ohne Protokolleintrag.
4. **`tender-matching.service.ts`, `if (!fresh.length) continue;`** —
liefert die gebundene Abfrage der noch nicht benachrichtigten Treffer
innerhalb eines Profildurchlaufs zu wenig, bekommt dieser Nutzer keinen
Sofort-Alarm, ohne Protokolleintrag.
5. **`tender-matching.service.ts`, `if (!user || !user.email) continue;`**
— dieselbe Form wie Stelle 3, für den Sofort-Alarm-Pfad.
Keine dieser fünf Stellen protokolliert etwas — eine ausbleibende Warnung
erzeugt keine Fehlermeldung, keinen Protokolleintrag und keine Beschwerde,
außer der stillen Abwesenheit einer E-Mail, die niemand erwartet, weil
niemand wusste, dass sie hätte kommen sollen.
Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen
behauptet: weil `notifiedAt` auf `TenderMatch` nur nach ERFOLGREICHEM
Versand gestempelt wird (D-06), bleiben die betroffenen Zeilen auf
`notifiedAt IS NULL` stehen und werden beim nächsten Lauf erneut versucht.
Es geht also nichts verloren — es kommt nur nichts an, solange die Ursache
(z. B. ein nach dem Scharfschalten ungebunden gebliebener Lesepfad)
fortbesteht. Daraus ergibt sich das einzige nachprüfbare Signal dieser
Fehlerform: eine wachsende Zahl von `TenderMatch`-Zeilen mit
`notifiedAt IS NULL` bei gleichzeitig fehlendem Versandprotokoll. Dieses
Signal gehört in die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), NICHT
in diesen Durchlauf — Aufgabe 3 hält es hier fest, löst es aber nicht.
Eine Laufzeitwarnung an den fünf Stellen wurde erwogen und VERWORFEN, aus
demselben Grund wie bei `getAllActiveConfigs` im `ldap`-Durchlauf: beide
Hintergrunddienste laufen regelmäßig, und "kein passender Kandidat"/"kein
Konto mit Adresse" ist ein regulärer Zustand, kein Fehlerfall — eine
Warnung wäre Dauerlärm und verlöre ihr Signal.
### (t4) Was dieser Durchlauf bewusst nicht löst
- **WINDOWS #19 — nullbares `tenantId` bei `TenderRssFeedSource`.**
Gemessen in (t1): eine plattformweite Zeile ist unter JEDEM
Mandantenkontext unsichtbar, ein gebundenes Einfügen ohne Mandant wird
abgewiesen. `listForUser`, `createPlatform` und `remove` bleiben deshalb
bewusst UNGEBUNDEN, mit einem Codekommentar, der die Grenze benennt. Die
Policy-Semantik selbst (eine `tenantId IS NULL OR tenantId =
current_tenant_id()`-Lesevariante) gehört zu Etappe 3 und wird hier nicht
angefasst.
- **Die übergreifenden Hälften der beiden Hintergrunddienste.** Aufgabe 3
bindet nur die Je-Treffer-Hälften von `tender-digest.scheduler.ts` und
`tender-matching.service.ts`; die Kandidatenabfrage
(`tenderMatch.findMany`/`tenderSavedSearch.findMany`) bleibt bewusst
über alle Mandanten hinweg ungebunden und ist als Etappe-3-Übergabe
kommentiert — siehe `docs/mandantentrennung-zugriffsklassifikation.md`,
Abschnitt "Der Hintergrunddienst als Falle".
- **Befund K — die Abhängigkeit vom noch nicht umgestellten Bereich
`settings`.** `tender-mail.service.ts` holt die SMTP-Angaben über
`SettingsService.getDecryptedSmtpConfig(tenantId)`; `settings.service.ts`
ist noch vollständig unbound (4 Rohtreffer, siehe Übersichtstabelle in
`docs/mandantentrennung-zugriffsklassifikation.md`). Nach dem
Scharfschalten fände diese ungebundene Abfrage keine SMTP-Zeile mehr —
Ergebnis: kein Versand für niemanden, mit Wiederholung bei jedem Lauf
(dieselbe Entlastung wie in (t3)). Das ist eine Reihenfolgebedingung für
Etappe 4, genau wie Befund D des `ldap`-Durchlaufs es für `groups` war —
hier festgehalten, nicht gelöst.
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
`tenders` entscheidet sie nicht — er bindet dienst-intern, wie `ldap`
und `groups` es vormachen.
- **Befund G — ein Administrator eines beliebigen Mandanten kann eine
plattformweite RSS-Quelle entfernen.** `TenderRssFeedSourceService.remove`
ist bereits heute ein einziger bedingter `deleteMany` mit der
Besitzbedingung in der Datenbank — das ist das gewünschte Muster, keine
Wiederholung des `ldap`-Fundes (Auflösung über die Kennung allein). Die
eine Beobachtung, die trotzdem festgehalten gehört: ein Administrator
EINES beliebigen Mandanten kann über diesen Pfad eine plattformweite
Quelle entfernen, die ALLE Mandanten speist — eine Produkt-/
Zuständigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieser
Aufgabe.
### (t5) Was dieser Durchlauf bewusst nicht anfasst
Die zwölf Paare des plattformweiten Ausschreibungskatalogs (D-03,
`tender-dedup.service.ts`, `tender-fingerprint-backfill.service.ts`,
`tender-ingestion.service.ts`, `tender-matching.service.ts`/`tender`,
`tender-scheduler.service.ts`, `tenders.controller.ts`, `tenders.module.ts`)
und der beiden bewussten Fan-out-Adapter
(`adapters/email-alert.adapter.ts`, `adapters/rss.adapter.ts`) sind
geprüft und deliberat ungebunden — nicht übersehen. Jede der zwölf trägt
ihre Begründung im eigenen Dateikopf bzw. in D-03.
Befund I gehört ausdrücklich hierher: `tender-ingestion.service.spec.ts`
enthält eine Schutzprüfung, die den QUELLTEXT von
`tender-ingestion.service.ts` liest und gegen ein Vorkommen des
Bezeichners `forTenant` prüft ("never calls forTenant()"). In dieser einen
Datei darf deshalb auch kein ERKLÄRENDER Kommentar diesen Bezeichner
nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier
nur festgehalten, damit niemand sie beim Nachziehen der Begründungen
"freundlich kommentiert" und den Lauf rot macht.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang