diff --git a/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md b/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md new file mode 100644 index 0000000..8618f3b --- /dev/null +++ b/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-PLAN.md @@ -0,0 +1,750 @@ +--- +phase: quick-260909-laa +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +requirements: [WINDOWS-20, ETAPPE-2-TENDERS] + +files_modified: + - apps/api/scripts/rls-scratch-check.mjs + - docs/mandantentrennung-etappe2-fehlerrichtung.md + - apps/api/src/tenders/tender-saved-search.service.ts + - apps/api/src/tenders/tender-saved-search.service.spec.ts + - apps/api/src/tenders/tender-triage.service.ts + - apps/api/src/tenders/tender-triage.service.spec.ts + - apps/api/src/tenders/tender-notification-pref.service.ts + - apps/api/src/tenders/tender-notification-pref.service.spec.ts + - apps/api/src/tenders/tender-email-config.service.ts + - apps/api/src/tenders/tender-email-config.service.spec.ts + - apps/api/src/tenders/tender-rss-feed.service.ts + - apps/api/src/tenders/tender-rss-feed.service.spec.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tenders.controller.spec.ts + - apps/api/src/tenders/tender-digest.scheduler.ts + - apps/api/src/tenders/tender-digest.scheduler.spec.ts + - apps/api/src/tenders/tender-matching.service.ts + - apps/api/src/tenders/tender-matching.service.spec.ts + - apps/api/src/tenders/tender-notifications.integration.spec.ts + - docs/mandantentrennung-zugriffsklassifikation.md + +estimate: + tokens: 150000 + raw_tokens: 150000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Jeder Zugriff des Bereichs `tenders`, der auf Rechnung genau eines Mandanten eine Tabelle mit nicht-nullbarem `tenantId` beruehrt, laeuft ueber einen gebundenen Client — die fuenf Nutzer-CRUD-Dienste vollstaendig, die beiden Hintergrunddienste in ihrer Je-Treffer-Haelfte." + - "Die zehn Paare des plattformweiten Ausschreibungskatalogs (D-03) und die zwei Fan-out-Adapter bleiben unangetastet, und das Klassifikationsdokument weist sie als BEWUSST ungebunden aus, nicht als offene Arbeit." + - "Die Grenze zu WINDOWS #19 (nullbares `tenantId` bei `TenderRssFeedSource`) ist gemessen, nicht angenommen: eine plattformweite Zeile ist unter JEDEM Mandantenkontext unsichtbar und ein gebundenes Einfuegen ohne Mandant wird abgewiesen. Die Policy-Semantik wurde NICHT angefasst." + - "Es existiert ein `tenders`-Abschnitt der Kritikschrift, der je umgestelltem Pfad das konkrete Signal nennt UND die zusaetzliche Fehlerform dieses Bereichs abdeckt: ein Benachrichtigungsweg, der nichts liest, sendet nichts — lautlos, nutzersichtbar nur als Ausbleiben." + - "Dass die ausgelieferten Policies dieses Bereichs KEINE Benutzerdimension haben — ein Nutzer desselben Mandanten bleibt fuer die Datenbank sichtbar — ist gemessen und festgehalten; die anwendungsseitige `userId`-Filterung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern und wird nicht entfernt." + - "Alle sieben angefassten Testdateien koennen rot werden, wenn eine Fundstelle ungebunden bleibt — nachgewiesen ueber zwei unterscheidbare Clients, nicht behauptet." + - "Die uebergreifenden Haelften der beiden Hintergrunddienste sind unveraendert und als Etappe-3-Uebergabe benannt; die Umstellung hat nicht in Etappe 3 hineingegriffen." + - "Klassifikationsdokument und `rls-access-inventory.spec.ts` zeigen fuer alle 23 Paare des Bereichs denselben, maschinell gemessenen Stand." + - "743+ Tests und die Typpruefung sind gruen, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; Schema, Migrationen, alle vier Compose-Dateien und beide Beispiel-Umgebungsdateien sind unveraendert; der Schalter bleibt AUS." + artifacts: + - apps/api/scripts/rls-scratch-check.mjs + - docs/mandantentrennung-etappe2-fehlerrichtung.md + - apps/api/src/tenders/tender-saved-search.service.ts + - apps/api/src/tenders/tender-triage.service.ts + - apps/api/src/tenders/tender-notification-pref.service.ts + - apps/api/src/tenders/tender-email-config.service.ts + - apps/api/src/tenders/tender-rss-feed.service.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tender-digest.scheduler.ts + - apps/api/src/tenders/tender-matching.service.ts + - docs/mandantentrennung-zugriffsklassifikation.md + key_links: + - "gebundener Client <-> die fuenf Policies `tenant_isolation_policy` auf TenderEmailConfig/TenderNotificationPref/TenderRssFeedSource/TenderSavedSearch/TenderTriage, wortgleich aus der ausgelieferten Migration `_rls_remaining_tenant_tables` extrahiert statt im Werkzeug nachgetippt" + - "`extractTriageContext` <-> die zehn Steuerungs-Aufrufstellen, die den Mandanten heute wegwerfen und ihn kuenftig durchreichen muessen — die einzige Stelle, an der ein vergessener Parameter den Umbau unvollstaendig macht" + - "nullbares `tenantId` von TenderRssFeedSource <-> `listForUser`/`createPlatform`/`remove` — die drei Pfade, die eine Bindung nach dem Scharfschalten strukturell zerstoeren wuerde (WINDOWS #19)" + - "gebundener Lesezugriff in der Schleife <-> die fuenf `continue`/`return`-Stellen der beiden Benachrichtigungswege, an denen ein zu kleines Leseergebnis lautlos zu 'nichts senden' wird" + - "`@@unique`-Schluessel ohne Mandantendimension (userId, userId_tenderId, tenderId_savedSearchId) <-> gebundenes `upsert` auf eine unsichtbare Zeile — die Stelle, an der aus stillem Ueberschreiben ein harter Fehler wird" + - "`rls-access-inventory.spec.ts` <-> Stand-Spalte des Klassifikationsdokuments fuer alle 23 Paare, einschliesslich der zwoelf bewusst ungebundenen" +--- + + +Der Bereich `tenders` ist der dritte Bereich der Etappe 2 — und der erste, der +mehrheitlich NICHT aus Umbau besteht. Von 23 Paaren sind fuenf umzustellen, zehn +duerfen nicht angefasst werden, zwei sind bewusste Fan-outs, und sechs zerfallen in +eine uebergreifende Haelfte (Etappe 3) und eine mandantengebundene Haelfte (hier). + +Zweck: Dieser Bereich haelt Geschaeftsgeheimnisse einzelner Nutzer — welche +Ausschreibungen ein Unternehmen beobachtet, welche es gespeichert, welche es +verworfen hat, und die Postfach-Zugangsdaten, aus denen es sie speist. Ein +Quer-Lesen ist hier kein Datenschutzmangel, sondern Wettbewerbsspionage. Dazu kommt +eine Fehlerform, die die beiden vorherigen Bereiche nicht hatten: zwei +Benachrichtigungswege, die bei zu kleinem Leseergebnis nicht falsch handeln, sondern +GAR NICHT — und niemand meldet eine Warnung, die nie ankam. + +Ergebnis: Die Kritikschrift bekommt einen `tenders`-Abschnitt samt der neuen, +lautlosen Fehlerform. Das Messwerkzeug bekommt die fuenf Policies dieses Bereichs +und drei Messungen, die es bisher nirgends gab: dass die Policies keine +Benutzerdimension haben, dass eine plattformweite Zeile ohne Mandant unter jedem +Kontext unsichtbar ist, und wie sich ein gebundenes `upsert` auf eine unsichtbare +Zeile verhaelt. Fuenf Nutzerdienste und die Je-Treffer-Haelften zweier +Hintergrunddienste sind gebunden, zwoelf Paare sind nachweislich und begruendet +NICHT gebunden, und das Klassifikationsdokument weist beides maschinell nach. + +Aufgabe 1 fuehrt bewusst, obwohl sie keinen Nutzernutzen liefert: sie ist der +Durchstich durch die gesamte Kette (ausgelieferte Policy -> Rolle ohne BYPASSRLS -> +Bindungsmuster -> die drei Sonderfaelle dieses Bereichs) und beantwortet die Fragen, +auf denen die Umstellung ruht, mit einer Messung statt mit einer Annahme. Erst +danach wird Dienstcode angefasst. + + + +@~/.claude/gsd-core/workflows/execute-plan.md +@~/.claude/gsd-core/templates/summary.md + + + +@.planning/STATE.md +@docs/mandantentrennung-zugriffsklassifikation.md +@docs/mandantentrennung-etappe2-fehlerrichtung.md +@apps/api/src/prisma/prisma-tenant.extension.ts +@apps/api/src/prisma/rls-access-inventory.spec.ts +@apps/api/scripts/rls-scratch-check.mjs +@apps/api/src/groups/groups.service.ts +@apps/api/src/groups/groups.service.spec.ts +@apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql +@apps/api/src/tenders/tender-saved-search.service.ts +@apps/api/src/tenders/tender-triage.service.ts +@apps/api/src/tenders/tender-notification-pref.service.ts +@apps/api/src/tenders/tender-email-config.service.ts +@apps/api/src/tenders/tender-rss-feed.service.ts +@apps/api/src/tenders/tenders.controller.ts +@apps/api/src/tenders/tender-digest.scheduler.ts +@apps/api/src/tenders/tender-matching.service.ts +@CLAUDE.md + + + + +Alles Folgende wurde am 2026-09-09 zur Planungszeit am lebenden Baum gemessen. Die +Zahlen und Zeilenangaben aus dem Auftrag waren Hinweise zum Aufschlagen, keine +Aenderungsvollmacht — jede Fundstelle wurde einzeln aufgeschlagen. + +**Ausgangsstand (jetzt gemessen, nicht aus einem Bericht zitiert):** + +- `npm --prefix apps/api run test` -> 53 Dateien, **743 Tests**, gruen, 5,19 s. +- `npm --prefix apps/api run test -- src/tenders` -> 29 Dateien, **378 Tests**, gruen. +- `npm --prefix apps/api run type-check` -> Rueckgabewert 0. +- `docker inspect tessera-ctl-db-1 ...` -> 172.19.0.2. **Eine Container-Adresse ist + veraenderlich und wird bei der Ausfuehrung neu ermittelt, nicht von hier + abgeschrieben.** +- `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs` + -> "Alle 23 Pruefungen bestanden.", Rueckgabewert 0. Die Lastprobe lief mit + 24 Verletzungen von 40 fuer Form (ii) und 0 von 40 fuer Form (iii) — das Fundament + ist damit JETZT belegt. +- `git status` sauber, HEAD `4cf7cea`. + +**Die 62 Rohtreffer, in die Treffer hineingesehen.** Die Bereichsuebersicht misst mit +`grep -ro "this\.prisma\.[a-zA-Z]*"`; `[a-zA-Z]*` erlaubt auch null Zeichen. Gemessen: +62 Rohtreffer, davon **genau einer kein Modellzugriff** — +`tender-fingerprint-backfill.service.ts:89`, `this.prisma.$transaction`. Tatsaechliche +Modellzugriffe: **61**. Die dokumentierte Bereichszahl 62 ist als Rohtrefferzahl +korrekt, taugt aber wieder nicht als Arbeitsvorrat. (Beim Bereich `groups` waren es +drei solche Treffer, hier einer — die Korrektur war also noetig und nicht +uebertragbar.) + +**Befund A — dieser Bereich hat KEINE mandantengebundene Transaktion, und das ist +die Antwort auf den Vorbehalt im Kopf der Erweiterung.** +`grep -rn '\$transaction(\s*async' apps/api/src --include=*.ts | grep -v spec` +liefert ausserhalb von `prisma-tenant.extension.ts` **null Treffer** — nach der +groups-Umstellung gibt es im gesamten Quelltext keine interaktive Transaktion mehr +ausserhalb des Hilfsmittels selbst. Die einzige Transaktion in `tenders` ist die +Array-Form in `tender-fingerprint-backfill.service.ts` auf der plattformweiten +Tabelle `Tender` (D-03) und damit ausserhalb jeder Mandantenbindung. Der +Kopfkommentar von `prisma-tenant.extension.ts` verlangt woertlich, vor jedem NEUEN +Fall mit eigener Transaktion erneut zu messen — in diesem Bereich faellt kein +solcher Fall an. `withTenantTransaction()` wird hier deshalb NICHT gebraucht und +darf auch nicht eingefuehrt werden: die beiden mehrschrittigen Stellen +(`saveConfig` liest und schreibt, `createForUser` zaehlt und schreibt) sind HEUTE +nicht atomar; sie in eine Transaktion zu heben waere eine Verhaltensaenderung +jenseits dieses Auftrags. + +**Befund B — die fuenf umzustellenden Dienste, einzeln aufgeschlagen.** Alle fuenf +sind Nutzer-CRUD; der Mandant ist an jeder Aufrufstelle bereits bekannt (siehe +Befund C). Alle betroffenen Tabellen ausser `TenderRssFeedSource` haben ein NICHT +nullbares `tenantId` (in `apps/api/prisma/schema.prisma` nachgesehen). + +| Datei | Modellzugriffe | Methoden ohne heutigen `tenantId`-Parameter | +|---|---|---| +| `tender-saved-search.service.ts` | 6 auf `tenderSavedSearch` | `list`, `update`, `remove` | +| `tender-rss-feed.service.ts` | 5 auf `tenderRssFeedSource` | `listForUser`, `createPlatform`, `remove` | +| `tender-email-config.service.ts` | 5 auf `tenderEmailConfig` | `getConfigForApi` (2 Zugriffe), `testConnection` | +| `tender-triage.service.ts` | 3 auf `tenderTriage` | `listForUser`, `favoriteIds` | +| `tender-notification-pref.service.ts` | 2 auf `tenderNotificationPref` | `getForUser` | + +**Befund C — der Mandant liegt an jeder Aufrufstelle bereits vor und wird nur +weggeworfen.** `TendersController.extractTriageContext(req)` liefert +`{ userId, tenantId, role }` und wirft 403, wenn eines fehlt. Zehn Aufrufstellen +destrukturieren heute nur `{ userId }` bzw. `{ userId, role }` und werfen den +Mandanten weg (Zeilen 178, 267, 323, 346, 385, 460, 506, 540, 555, 575 — zur +Planungszeit gezaehlt, bei der Ausfuehrung neu aufschlagen). Kein einziger neuer +Aufloesungsweg ist noetig; es ist ein Durchreichen, kein Umbau der Steuerung. + +**Befund D — WINDOWS #19 ist hier keine ferne Sorge, sondern der Grund, warum drei +RSS-Pfade NICHT binden duerfen.** `TenderRssFeedSource.tenantId` ist nullbar; eine +plattformweite Quelle (`userId = null`, `tenantId = null`, darunter die geseedete +`service.bund.de`-Quelle) traegt keinen Mandanten. Die ausgelieferte Policy lautet +`"tenantId" = current_tenant_id()` und vergleicht `NULL` nie gleich. Daraus folgt +fuer die drei Pfade, die plattformweite Zeilen beruehren: + +- `listForUser` liest `{ OR: [{userId: null}, {userId}] }` — gebunden verschwaenden + nach dem Scharfschalten die plattformweiten Quellen fuer JEDEN Mandanten. +- `createPlatform` schreibt `tenantId = null` — ein gebundenes Einfuegen liefe in + die WITH-CHECK-Wirkung derselben Policy. +- `remove` deckt den Verwaltungsfall ueber `{userId: null}` ab; gebunden koennte + niemand mehr eine plattformweite Quelle entfernen. Diesen einen `deleteMany` in + zwei Anweisungen zu zerlegen, um die persoenliche Haelfte zu binden, wuerde genau + das Pruef-/Nutzungsfenster wieder oeffnen, das der Dateikopf ausdruecklich + vermeidet — also nicht tun. + +Nur `createForUser` (Zaehler + Anlage, beide ausschliesslich auf persoenlichen +Zeilen mit gesetztem Mandanten) kann und muss binden. Die Datei endet damit im +Stand `gemischt`. Das ist die Bestaetigung der Grenze, nicht ihr Ueberschreiten: +die Policy-Semantik wird NICHT angefasst, keine Migration geschrieben. + +**Befund E — die Policies dieses Bereichs haben keine Benutzerdimension.** Alle +fuenf lauten schlicht `"tenantId" = current_tenant_id()` +(`20260909140000_rls_remaining_tenant_tables`, wortgleich nachgelesen). Zwei Nutzer +DESSELBEN Mandanten sind fuereinander damit vollstaendig sichtbar. Der Schutz gegen +Quer-Lesen zwischen Nutzern — Suchprofile, Triage-Zustand, Postfachanbindung — +haengt ausschliesslich an der anwendungsseitigen `userId`-Filterung, die alle fuenf +Dienste heute schon fuehren. Sie darf bei der Umstellung nicht mit dem Argument +"macht jetzt ohnehin die Datenbank" entfallen. Dieselbe Klasse Befund wie T-JTS-02/ +T-JTS-03 im Bereich `groups`, hier aber mit hoeherem Einsatz, weil es +Geschaeftsgeheimnisse sind. Zu messen, nicht aus dem Policy-Text zu schliessen. + +**Befund F — die Kehrseite der Bindung: `upsert` auf einen Schluessel ohne +Mandantendimension.** Drei Schreibpfade nutzen `upsert` auf einem `@@unique`, das +keinen Mandanten enthaelt: `tenderEmailConfig` (`userId @unique`), +`tenderNotificationPref` (`userId @unique`), `tenderTriage` +(`@@unique([userId, tenderId])`), dazu `tenderMatch` +(`@@unique([tenderId, savedSearchId])`) in Aufgabe 3. Ist die vorhandene Zeile unter +dem gebundenen Kontext unsichtbar (weil ihr denormalisiertes `tenantId` veraltet +ist — genau der Fall, den der Kopf von `tender-email-config.service.ts` selbst +benennt: "a user's tenant can in principle change"), faellt `upsert` in den +Anlage-Zweig und laeuft in die plattformweite Eindeutigkeitsbedingung. Aus einem +stillen Ueberschreiben wird ein harter Fehler. Das ist als Richtung besser als ein +Datenleck, aber es muss als verstaendliche Meldung herauskommen und nicht als 500. +`tender-saved-search.service.ts` fuehrt das Muster bereits vor (P2002 -> +`ConflictException` mit deutschem Text) — abschreiben statt neu erfinden. + +**Befund G — keine Luecke der ldap-Klasse (Aufloesung ueber die Kennung allein), mit +einer Einschraenkung.** Alle Besitzpruefungen wurden einzeln nachgesehen: +`tenderSavedSearch.update/remove` lesen zwar ueber `id` allein, pruefen danach aber +`existing.userId !== userId` und kollabieren Fehlen und Fremdbesitz zu derselben +404 — das ist das gewuenschte Muster. `tenderRssFeedSource.remove` ist bereits ein +einziger bedingter `deleteMany` mit der Besitzbedingung in der Datenbank. Triage, +Praeferenz und Postfach sind ueber `userId` verschluesselt. Es gibt hier also +KEINE Wiederholung des ldap-Fundes. Die eine Beobachtung, die trotzdem gehoert +festgehalten zu werden: ein Administrator eines beliebigen Mandanten kann ueber +`remove` eine PLATTFORMWEITE RSS-Quelle entfernen, die alle Mandanten speist. Das +ist eine Produkt-/Zustaendigkeitsfrage im Umfeld von WINDOWS #19, KEIN Auftrag +dieser Aufgabe — festhalten, nicht reparieren. + +**Befund H — die Testlage: die zweite Fehlerform, nicht die erste.** +`grep -rn "forTenant\|prisma-tenant\|vi.mock" apps/api/src/tenders/*.spec.ts` +liefert fuer alle sieben betroffenen Testdateien **keinen einzigen Treffer auf die +Erweiterung**. Es gibt also keinen Identitaets-Mock wie bei `ldap` — es gibt gar +keinen, genau wie bei `groups`. Nach der Umstellung liefe +`forTenant(this.prisma, tenantId)` gegen einen handgeschriebenen In-Memory-Fake ohne +`$extends`, und JEDER Test der Datei stuerzte ab: rot aus dem falschen Grund. Alle +sieben Dateien brauchen den Zwei-Client-Nachweis aus 260909-jts (`__makeBoundClient` +ueber DEMSELBEN Speicher, `forTenant` gemockt). Die vorhandenen Fakes sind +wiederverwendbar und werden nicht weggeworfen. + +Eine achte Datei ist betroffen, aber anders: `tenders.controller.spec.ts` uebergibt +ausschliesslich FAKE-Dienste (`makeFakeTriageService()` usw.), nie die echten. Sie +braucht keinen Mock der Erweiterung, sondern nur nachgezogene Erwartungen an die um +`tenantId` erweiterten Aufrufe. + +**Befund I — eine bestehende Schutzpruefung, die man mit einem Kommentar rot machen +kann.** `tender-ingestion.service.spec.ts` enthaelt den Test "never calls +forTenant()", der den QUELLTEXT von `tender-ingestion.service.ts` liest und gegen +ein Vorkommen dieses Bezeichners prueft. In dieser einen Datei darf deshalb auch +kein ERKLAERENDER Kommentar den Bezeichner nennen. Sie steht ohnehin auf der +Nicht-Anfassen-Liste; hier nur festgehalten, damit niemand sie beim Nachziehen der +Begruendungen "freundlich kommentiert" und den Lauf rot macht. + +**Befund J — welcher Code Leere als Abwesenheit deutet, und die neue lautlose Form +(Vorarbeit fuer Aufgabe 1, dort auszuformulieren und zu ergaenzen, nicht +abzuschreiben).** + +Sichtbare Formen (Anzeige bleibt leer, jemand merkt es): +`tenderSavedSearchService.list` (leere Profilliste), `tenderTriageService.listForUser` +(keine Gelesen-/Favoriten-Markierung in der Trefferliste), +`tenderNotificationPrefService.getForUser` (**Sonderfall**: kein Treffer bedeutet hier +nicht "leer", sondern der Vorgabewert `daily` — ein zu kleines Leseergebnis setzt +einen Nutzer, der `off` gewaehlt hat, stillschweigend auf taeglich zurueck; die eine +Stelle des Bereichs, an der zu wenig Lesen zu MEHR Handlung fuehrt), +`tenderEmailConfigService.getConfigForApi` (Oberflaeche meldet "kein Postfach" fuer +einen Nutzer, der eines hat), `tenderRssFeedSourceService.listForUser`/`remove` +(leere Quellenliste, bzw. 404 beim Entfernen). + +Lautlose Formen — die zusaetzliche Fehlerform dieses Bereichs, fuenf Stellen: +`tender-digest.scheduler.ts` `if (!candidates.length) return;` (der GESAMTE Digest +tut fuer alle Mandanten nichts), `if (!matches.length) continue;` und +`if (!user || !user.email) continue;` (dieser Nutzer bekommt keine Post); +`tender-matching.service.ts` `if (!fresh.length) continue;` und +`if (!user || !user.email) continue;` (dieser Nutzer bekommt keinen Sofort-Alarm). +Keine dieser Stellen protokolliert etwas. Eine ausbleibende Warnung erzeugt keine +Fehlermeldung, keinen Protokolleintrag und keine Beschwerde. + +Entlastung in dieselbe Richtung, ebenfalls nachgesehen statt geschlossen: weil +`notifiedAt` nur nach erfolgreichem Versand gestempelt wird, bleiben die betroffenen +`TenderMatch`-Zeilen auf `notifiedAt IS NULL` stehen und werden bei jedem Lauf erneut +versucht. Es geht also nichts verloren, es kommt nur nichts an — und daraus ergibt +sich das einzige nachpruefbare Signal dieser Fehlerform: eine wachsende Zahl von +`TenderMatch`-Zeilen mit `notifiedAt IS NULL` bei gleichzeitig fehlendem +Versandprotokoll. Dieses Signal gehoert in die Vorabpruefung von Etappe 4 +(`rls-preflight.mjs`), NICHT in diesen Durchlauf. + +Eine Laufzeitwarnung an den fuenf Stellen wurde erwogen und VERWORFEN, aus demselben +Grund wie bei `getAllActiveConfigs` im ldap-Durchlauf: "kein Konto mit Adresse" ist +seit WINDOWS #15 ein regulaerer Zustand, und der Digest laeuft taeglich. Eine +Warnung waere Dauerlaerm und verloere ihr Signal. + +**Befund K — die Abhaengigkeit vom noch nicht umgestellten Bereich `settings`.** +`tender-mail.service.ts` holt die SMTP-Angaben ueber +`SettingsService.getDecryptedSmtpConfig(tenantId)`; fehlt sie, liefern beide +Versandmethoden `false` und der Aufrufer laesst `notifiedAt` auf NULL stehen. Der +Bereich `settings` (4 Rohtreffer) ist noch nicht umgestellt. Nach dem Scharfschalten +faende diese ungebundene Abfrage keine SMTP-Zeile mehr — Ergebnis: kein Versand fuer +niemanden, mit Wiederholung bei jedem Lauf. Das ist eine Reihenfolgebedingung fuer +Etappe 4, genau wie Befund D des ldap-Durchlaufs es fuer `groups` war. Festhalten, +nicht hier loesen. + +**Befund L — die zwoelf Paare, die nicht angefasst werden duerfen.** Zehn Paare der +Klasse `keine-mandantengebundene-tabelle` (`tender-dedup.service.ts` x2, +`tender-fingerprint-backfill.service.ts`, `tender-ingestion.service.ts` x2, +`tender-matching.service.ts`/`tender`, `tender-scheduler.service.ts`, +`tenders.controller.ts` x2, `tenders.module.ts`) und zwei der Klasse +`bewusst-uebergreifend` (`adapters/email-alert.adapter.ts`, +`adapters/rss.adapter.ts`). Stichprobenweise gegen D-03 und die Dateikoepfe +geprueft: `tender-dedup.service.ts` und `tender-ingestion.service.ts` tragen die +Anweisung im Kopf ausgeschrieben, `tenders.module.ts` ebenso, beide Adapter +begruenden ihren Fan-out im Dateikopf. Bei der Ausfuehrung ist jede der zwoelf +Zeilen einzeln gegen D-03 bzw. den Dateikopf zu pruefen, nicht gegen diese Aufzaehlung. + +**Gewaehltes Muster (bewusst, nicht stillschweigend):** Der Mandantenkontext wird +weiterhin IM DIENST erzeugt (`const tenantPrisma = forTenant(this.prisma, tenantId) as any;`), +wie in `ldap`, `groups` und `auth.service.ts`. Der offene Befund `req.tenantPrisma` +(gesetzt in `tenant.middleware.ts` und `tenant.guard.ts`, nirgends gelesen) wird auch +von diesem Durchlauf AUSDRUECKLICH NICHT entschieden. Die Namenskonvention +`tenantPrisma` wird eingehalten, weil die Rohtrefferzaehlung des +Klassifikationsdokuments an ihr haengt. + +**Nicht angefasst:** `apps/api/prisma/schema.prisma`, `apps/api/prisma/migrations/`, +alle vier Compose-Dateien, `.env.example`, `.env.prod.example`. `DATABASE_URL` bleibt +auf der Rolle `tessera` mit BYPASSRLS — das Scharfschalten ist Etappe 4. Am +Verzeichnis (AD) wird nichts geaendert. `apps/web` wird nicht beruehrt: die +Umstellung ist rein dienstintern, kein Vertrag einer HTTP-Route aendert sich. + + + + + + Aufgabe 1: Die drei Sonderfaelle dieses Bereichs messen und die Fehlerrichtung fuer tenders schreiben + Der Container `tessera-ctl-db-1` laeuft; seine Adresse per `docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}'` NEU ermitteln (zur Planungszeit 172.19.0.2 — eine Container-Adresse ist veraenderlich und darf nicht aus diesem Plan abgeschrieben werden). + apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md + +Zuerst messen, dann die Kritik aus der Messung schreiben — nicht umgekehrt. Kein +Dienstcode in dieser Aufgabe. + +TEIL 1, `apps/api/scripts/rls-scratch-check.mjs`: einen fuenften Abschnitt +`runTendersAreaChecks(adminUrl, scratchRoleUrl, results)` nach dem Vorbild des +vorhandenen `runGroupsAreaChecks` ergaenzen und in `main()` nach diesem, aber VOR +`runTransactionShapeMeasurement` aufrufen — die Transaktionsmessung setzt auf den von +`runGroupsAreaChecks` angelegten Tabellen auf und darf ihre Voraussetzung nicht +verlieren; das ist beim Einhaengen zu pruefen, nicht anzunehmen. + +Die fuenf Policies werden NICHT im Werkzeug neu getippt. Sie kommen alle aus dem +Migrationsverzeichnis, das auf `_rls_remaining_tenant_tables` endet — das vorhandene +`readRemainingTenantTablesMigrationSql()` liest es bereits, `extractPolicySql()` +schneidet je Tabelle heraus. Gebraucht werden `TenderEmailConfig`, +`TenderNotificationPref`, `TenderRssFeedSource`, `TenderSavedSearch`, `TenderTriage`. +Findet die Extraktion eine der fuenf nicht, meldet der Abschnitt eine +FEHLGESCHLAGENE Pruefung `tenders-policies-aus-migration-gefunden` und bricht ab — +das Werkzeug darf nicht still mit einer geratenen Policy weitermessen. + +Der Abschnitt legt in der Wegwerf-Datenbank schlanke Tabellen an, die genau die +Spalten tragen, die die Policies und die Messungen brauchen. Die Nullbarkeit von +`TenderRssFeedSource."tenantId"` und die drei Eindeutigkeitsbedingungen ohne +Mandantendimension sind dabei KEIN Beiwerk, sondern der Gegenstand: sie muessen +angelegt werden wie im echten Schema (`TenderEmailConfig.userId` eindeutig, +`TenderNotificationPref.userId` eindeutig, `TenderTriage(userId, tenderId)` +eindeutig). Danach ENABLE plus FORCE ROW LEVEL SECURITY, die fuenf extrahierten +Policies, die Rechtevergabe an die Wegwerf-Rolle und Testzeilen: je Mandant +(TENANT-A, TENANT-B) je eine Zeile pro Tabelle, in `TenderSavedSearch` fuer TENANT-A +ZWEI Zeilen von ZWEI verschiedenen Nutzern, und in `TenderRssFeedSource` zusaetzlich +eine plattformweite Zeile ohne Mandanten und ohne Besitzer. + +Gemessen wird unter der Rolle ohne BYPASSRLS ueber das vorhandene +`forTenantQuery`-Hilfsmittel, mit diesen Kennungen: + +- `tendersavedsearch-gebunden-nur-eigener-mandant` — der gebundene SELECT unter + TENANT-A liefert die Zeilen von A und keine von B. +- `tendersavedsearch-ungebunden-null-zeilen` — DERSELBE SELECT ohne vorher gesetzten + Kontext liefert null Zeilen. Das ist die Belegzeile, die den ganzen Abschnitt der + Kritikschrift traegt; sie muss an der echten, ausgelieferten Policy haengen. +- `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` — der gebundene + SELECT unter TENANT-A liefert AUCH die Zeile des zweiten Nutzers. Diese Pruefung + gilt als bestanden, wenn die fremde Zeile sichtbar ist: sie belegt Befund E, + naemlich dass die Policy keine Benutzerdimension hat. Der Meldetext sagt das + ausdruecklich UND nennt die Folge — die anwendungsseitige `userId`-Filterung + bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern und darf nicht + entfernt werden. Ohne diesen Zusatz koennte eine bestandene Pruefung mit "ist + abgesichert" verwechselt werden. +- `tenderemailconfig-gebunden-nur-eigener-mandant`, + `tendertriage-gebunden-nur-eigener-mandant`, + `tendernotificationpref-gebunden-nur-eigener-mandant` — je eine Pruefung nach + demselben Muster wie die erste. +- `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar` — der gebundene + SELECT liefert unter TENANT-A UND unter TENANT-B jeweils NICHT die plattformweite + Zeile ohne Mandanten. Bestanden, wenn sie unter beiden Kontexten fehlt. Der + Meldetext benennt WINDOWS #19 und die Folge: `listForUser` darf nicht gebunden + werden, solange die Policy-Semantik unveraendert ist. +- `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt` — ein gebundenes + INSERT unter TENANT-A mit `tenantId = NULL` wird abgewiesen; die Abweisung ist das + bestandene Ergebnis. Der Meldetext nennt die Folge: `createPlatform` darf nicht + gebunden werden. +- `tendertriage-einfuegen-auf-unsichtbare-zeile-verletzt-eindeutigkeit` — unter + TENANT-A ein INSERT fuer ein Paar (`userId`, `tenderId`), dessen Zeile existiert, + aber zu TENANT-B gehoert und daher unsichtbar ist. Bestanden, wenn der Fehler eine + Verletzung der Eindeutigkeitsbedingung ist (nicht eine Policy-Abweisung). Der + Meldetext benennt Befund F und die Folge fuer Aufgabe 2: aus einem stillen + Ueberschreiben wird ein harter Fehler, der als verstaendliche Meldung + herauskommen muss. + +Das Werkzeug raeumt weiterhin ausschliesslich seine fest verdrahtete +Wegwerf-Datenbank ab und bekommt keine steuerbaren Namen (T-EOR-07 bleibt gueltig). +Kein bestehender Abschnitt wird veraendert; alle 23 bisherigen Pruefungen muessen +unveraendert weiterlaufen. + +TEIL 2, Beleg statt Behauptung fuer Befund A: nachmessen, dass dieser Bereich keine +mandantengebundene Transaktion enthaelt — mit +`grep -rn '\$transaction(' apps/api/src/tenders --include=*.ts | grep -v spec`. Das +Ergebnis (Zahl der Treffer, betroffene Datei, Form) wird in der Kritikschrift +festgehalten, samt der Feststellung, dass der im Kopf von +`prisma-tenant.extension.ts` verlangte erneute Test fuer diesen Bereich damit +beantwortet ist: kein neuer Fall, `withTenantTransaction()` wird nicht gebraucht. +Faellt das Ergebnis anders aus als in Befund A beschrieben, gilt die MESSUNG, und +die Abweichung wird ausgeschrieben, bevor Aufgabe 2 beginnt. + +TEIL 3, `docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt +`## Bereich tenders` ERWEITERN, nicht ein zweites Dokument anlegen. Die Leitfrage +aus Abschnitt (a) gilt unveraendert weiter und wird nicht wiederholt; der neue +Abschnitt verweist darauf und haelt im Kopf fest, dass er den Bereich `tenders` zum +Zeitpunkt seiner Umstellung beschreibt (Quick-Task 260909-laa). + +Inhalt, in ganzen Saetzen auf Deutsch, mit derselben Gliederung wie der +groups-Abschnitt: + +(t1) Die Messung — die TATSAECHLICH beobachtete Ausgabe des Laufs, hineinkopiert, +nicht nacherzaehlt, mit Datum und der bei der Ausfuehrung ermittelten Adresse. Die +Belegzeile ausdruecklich benennen. + +(t2) Signaltabelle je umgestelltem Pfad: Pfad, Verhalten bei zu wenig Ergebnis, +konkretes Signal mit Ort. Es muessen alle in Aufgabe 2 und 3 umgestellten Pfade +vorkommen, inklusive des Sonderfalls `getForUser` (Vorgabewert `daily` statt leer) +und der drei RSS-Pfade, die bewusst ungebunden bleiben und nach dem Scharfschalten +eine leere Liste bzw. eine 404 liefern. + +(t3) Welcher Code Leere als Abwesenheit deutet — getrennt nach der SICHTBAREN und +der LAUTLOSEN Form. Die lautlose Form ist der Kern dieses Abschnitts und bekommt +eigenen Raum: fuenf namentlich benannte Stellen, die Feststellung, dass keine davon +etwas protokolliert, die Entlastung ueber das offen bleibende `notifiedAt` samt dem +daraus folgenden einzigen nachpruefbaren Signal (wachsende Zahl unbenachrichtigter +Treffer ohne Versandprotokoll), und die begruendete Verwerfung einer +Laufzeitwarnung. + +(t4) Was dieser Durchlauf bewusst nicht loest: WINDOWS #19 samt der drei davon +betroffenen RSS-Pfade (mit dem Messergebnis als Beleg), die uebergreifenden +Haelften der beiden Hintergrunddienste als Etappe-3-Uebergabe, die Abhaengigkeit +vom noch nicht umgestellten Bereich `settings` (Befund K) als Reihenfolgebedingung +fuer Etappe 4, die offene Architekturfrage `req.tenantPrisma`, und die Beobachtung +aus Befund G, dass ein Administrator eines beliebigen Mandanten eine plattformweite +RSS-Quelle entfernen kann. + +(t5) Was dieser Durchlauf bewusst NICHT anfasst: die zwoelf Paare des +plattformweiten Katalogs und der beiden Fan-out-Adapter, mit der Feststellung, dass +sie geprueft und deliberat ungebunden sind — nicht uebersehen. Der Hinweis aus +Befund I gehoert hierher: in `tender-ingestion.service.ts` darf auch kein +erklaerender Kommentar den von der dortigen Schutzpruefung gesuchten Bezeichner +nennen. + + + DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example)" + + Das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (23 bisherige plus die neuen des tenders-Abschnitts) mit Rueckgabewert 0; `npm --prefix apps/api run test` meldet weiterhin 743 Tests gruen und die Typpruefung ist sauber; `docs/mandantentrennung-etappe2-fehlerrichtung.md` traegt einen Abschnitt `## Bereich tenders` mit der tatsaechlich beobachteten Ausgabe, einer Signaltabelle, dem eigenen Unterabschnitt zur lautlosen Fehlerform mit fuenf namentlich benannten Stellen, und den beiden Abschnitten zu dem, was bewusst offen bzw. unangetastet bleibt; Schema, Migrationen, Compose- und Beispiel-Umgebungsdateien sind unveraendert. + + + + Aufgabe 2: Die fuenf Nutzer-CRUD-Dienste binden und den Mandanten durch die Steuerung reichen + apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-saved-search.service.spec.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/tenders/tender-triage.service.spec.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-notification-pref.service.spec.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-email-config.service.spec.ts, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/tenders/tenders.controller.spec.ts + +Der Nachweis kommt VOR der Umstellung, sonst beweist er nichts (Befund H). Je +Dienst zuerst der Zwei-Client-Nachweis nach dem Muster aus +`apps/api/src/groups/groups.service.spec.ts`, dann der Umbau. + +- Der vorhandene In-Memory-Fake jeder Testdatei bekommt `__makeBoundClient(tenantId)`: + einen je Modell protokollierenden Wrapper um DIESELBEN Maps, sodass ein Aufruf + ueber den ungebundenen Fake und ein Aufruf ueber den gebundenen Client + unterscheidbar sind. `forTenant` wird per `vi.mock('../prisma/prisma-tenant.extension', ...)` + darauf gelenkt. Eine reine Identitaet (`(p) => p`) genuegt NICHT — sie ist genau + der ldap-Fehler, bei dem der Test in keiner Richtung etwas merkt. +- Je Dienst mindestens ein Test der Form "Methode X bindet ueber forTenant() an den + uebergebenen Mandanten" (`expect(forTenant).toHaveBeenCalledWith(prisma, 't1')` + PLUS der Nachweis, dass der Modellzugriff auf dem GEBUNDENEN Client stattfand, + ueber das Protokoll des Wrappers) — fuer jede umgestellte Methode, nicht nur eine + Stichprobe. +- Fuer `tender-rss-feed.service.ts` zusaetzlich der Gegentest: `listForUser`, + `createPlatform` und `remove` binden NICHT (`expect(forTenant).not.toHaveBeenCalled()`), + und `listForUser` liefert weiterhin die plattformweite Zeile ohne Besitzer mit. +- Fuer die drei `upsert`-Pfade (`tenderEmailConfig`, `tenderNotificationPref`, + `tenderTriage`) je ein Test, dass eine Eindeutigkeitsverletzung (P2002) als + verstaendliche deutsche Meldung herauskommt und nicht als roher Fehler. +- Alle bestehenden Besitz-/IDOR-Tests jeder Datei bleiben unveraendert bestehen und + gruen — die anwendungsseitige `userId`-Filterung wird durch die Bindung NICHT + ersetzt (Befund E). +- Falsifizieren, nicht behaupten: nach dem Umbau probeweise EINE Bindung + zurueckbauen und belegen, dass mindestens ein Test dadurch rot wird. Das Ergebnis + gehoert in den Bericht; der Rueckbau wird danach rueckgaengig gemacht. + + +Bindungsregel, die ueber jede einzelne Fundstelle entscheidet: gebunden wird genau +dann, wenn die beruehrte Zeilenmenge garantiert ein nicht-nullbares `tenantId` +traegt, das dem Mandanten des Aufrufers entspricht. Grundlage ist die Messung aus +Aufgabe 1, nicht dieser Text — weicht die Messung ab, gilt die Messung, und die +Abweichung wird im Bericht ausgeschrieben. + +Muster durchgehend wie in `groups`/`ldap`: +`const tenantPrisma = forTenant(this.prisma, tenantId) as any;`, Name `tenantPrisma` +beibehalten (die Rohtrefferzaehlung des Klassifikationsdokuments haengt daran). Der +gebundene Client wird je Methode einmal erzeugt, nicht je Zugriff. + +VOLLSTAENDIG BINDEN, alle Zugriffe: + +- `tender-saved-search.service.ts` — `list`, `create`, `update` (Lesepruefung UND + Schreibzugriff), `remove` (Lesepruefung UND Loeschung). `list`, `update` und + `remove` bekommen `tenantId` als zusaetzlichen Parameter. +- `tender-triage.service.ts` — `setTriage` (hat `tenantId` bereits), `listForUser` + und `favoriteIds` bekommen `tenantId`. +- `tender-notification-pref.service.ts` — `setForUser` (hat `tenantId` bereits), + `getForUser` bekommt `tenantId`. +- `tender-email-config.service.ts` — `saveConfig` (hat `tenantId` ueber `ctx`), + `getConfigForApi` und `testConnection` bekommen `tenantId`. Beide Lesezugriffe in + `getConfigForApi` binden. Die Sicherheitszusagen des Dateikopfs bleiben + unangetastet: die sichere Feldauswahl gilt weiter, der rohe Lesezugriff bleibt + methodenlokal, entschluesselte Zugangsdaten werden nicht protokolliert und nicht + zurueckgegeben. + +TEILWEISE BINDEN — `tender-rss-feed.service.ts`: + +- `createForUser` bindet BEIDES, den Zaehler und die Anlage. +- `listForUser`, `createPlatform` und `remove` bleiben UNGEBUNDEN. Jede der drei + bekommt einen kurzen Kommentar, der WINDOWS #19 nennt und die konkrete Folge einer + Bindung benennt (plattformweite Quellen verschwaenden fuer jeden Mandanten; das + Einfuegen ohne Mandanten wuerde abgewiesen; plattformweite Quellen liessen sich + nicht mehr entfernen). Den einen bedingten `deleteMany` in zwei Anweisungen zu + zerlegen ist ausdruecklich NICHT erlaubt — das oeffnete das Pruef-/Nutzungsfenster + wieder, das der Dateikopf vermeidet. Die Policy-Semantik wird NICHT angefasst, + keine Migration geschrieben. + +FEHLERBEHANDLUNG (Befund F, getragen von der Messung aus Aufgabe 1): die drei +`upsert`-Pfade auf Eindeutigkeitsbedingungen ohne Mandantendimension bekommen eine +Behandlung des Prisma-Fehlercodes P2002, die eine verstaendliche deutsche Meldung +liefert statt eines rohen Fehlers. Muster wortgleich aus +`tender-saved-search.service.ts` uebernehmen (dort bereits vorhanden), nicht neu +erfinden. Der Text nennt die Ursache in Alltagssprache; keine Fachbegriffe, keine +Fehlercodes im Text. + +STEUERUNG, `tenders.controller.ts`: die zehn Aufrufstellen, die heute nur +`{ userId }` bzw. `{ userId, role }` destrukturieren, reichen `tenantId` mit durch. +`extractTriageContext` liefert ihn bereits und wird NICHT veraendert; es entsteht +kein neuer Aufloesungsweg und keine neue Quelle fuer Mandant oder Nutzer. Die +Reihenfolge der Routen bleibt unangetastet (statische Routen vor `@Get(':id')` — +sonst 404-Verschattung, die kein Test dieser Ebene faengt). + +`tenders.controller.spec.ts`: die Erwartungen an die Fake-Dienste um den neuen +`tenantId`-Parameter nachziehen. Die Datei braucht KEINEN Mock der Erweiterung +(sie uebergibt ausschliesslich Fake-Dienste, nie die echten). + +NICHT ANFASSEN in dieser Aufgabe: `tender-digest.scheduler.ts`, +`tender-matching.service.ts` (das ist Aufgabe 3), die zwoelf Paare aus Befund L, +Schema, Migrationen, Compose-Dateien, Umgebungsdateien, `apps/web`. Keine neue +Transaktion einfuehren (Befund A). + + + npm --prefix apps/api run test && npm --prefix apps/api run type-check && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web)" + + Alle 743 bisherigen Tests plus die neuen Bindungsnachweise sind gruen und die Typpruefung ist sauber; die vier Dienste mit nicht-nullbarem Mandanten laufen vollstaendig ueber `forTenant()`, `tender-rss-feed.service.ts` bindet `createForUser` und begruendet die drei ungebundenen Pfade im Code mit WINDOWS #19; alle bestehenden Besitz-/IDOR-Tests sind unveraendert gruen; die Schutzpruefung "never calls forTenant()" in `tender-ingestion.service.spec.ts` ist weiterhin gruen; ein probeweiser Rueckbau EINER Bindung macht mindestens einen Test rot und das ist im Bericht festgehalten; Schema, Migrationen, Compose-, Umgebungsdateien und `apps/web` sind unveraendert. + + + + Aufgabe 3: Die Je-Treffer-Haelften der beiden Hintergrunddienste binden und beide Dokumente schliessen + apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-digest.scheduler.spec.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts, apps/api/src/tenders/tender-notifications.integration.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-etappe2-fehlerrichtung.md + +Wie in Aufgabe 2: erst der Zwei-Client-Nachweis, dann der Umbau. Beide Testdateien +und die gemeinsame Integrationsdatei haben heute keinen Mock der Erweiterung und +wuerden sonst aus dem falschen Grund rot (Befund H). + +- Je Datei ein Test, dass die UEBERGREIFENDE Abfrage NICHT bindet + (`tenderMatch.findMany` der Kandidatenliste im Digest, `tenderSavedSearch.findMany` + in der Sofortmeldung) und dass die Zugriffe INNERHALB der Schleife auf dem + gebundenen Client laufen, mit dem Mandanten der jeweiligen Zeile. +- Ein Test mit ZWEI Mandanten in einem Lauf, der belegt, dass je Durchlauf der + Schleife mit dem Mandanten DIESER Zeile gebunden wird und nicht einmal global mit + dem ersten. +- Ein Test der lautlosen Fehlerform: liefert der gebundene Lesezugriff auf den + Benutzer nichts, wird nichts versendet UND `notifiedAt` bleibt NULL (der Treffer + bleibt also wiederholbar). Das ist die Zusage, auf der die Entlastung in der + Kritikschrift beruht — sie muss von einem Test getragen werden, nicht von einer + Behauptung. +- Falsifizieren wie in Aufgabe 2: eine Bindung probeweise zurueckbauen, roten Test + belegen, zuruecknehmen, Ergebnis in den Bericht. + + +Die Trennlinie zwischen Etappe 2 und Etappe 3 wird hier gezogen und im Code +ausgeschrieben. Etappe 3 (Systemkontext fuer Hintergrundlaeufe) wird NICHT +vorweggenommen. + +`tender-digest.scheduler.ts`: +- Die Kandidatenabfrage (`tenderMatch.findMany` mit `notifiedAt: null`, `distinct`) + bleibt UNGEBUNDEN — sie ist der bewusste Fan-out ueber alle Mandanten. Ein + Kommentar benennt sie als Etappe-3-Uebergabe. +- Damit die Schleife ueberhaupt binden KANN, braucht sie einen Mandanten. Die + Kandidatenabfrage waehlt deshalb zusaetzlich das denormalisierte `tenantId` der + Treffer-Zeile aus. Dass diese Erweiterung zusammen mit `distinct` das erwartete + Ergebnis liefert, ist am Fake der Testdatei UND an der Prisma-Typpruefung + nachzuweisen, nicht anzunehmen. Der Sonderfall, dass ein Nutzer Treffer unter zwei + verschiedenen Mandanten haben koennte (denormalisierter Wert, Mandantenwechsel), + wird nicht geloest, sondern im Kommentar und in der Kritikschrift benannt. +- Innerhalb der Schleife binden: die Praeferenz-Abfrage, die Treffer-Abfrage und die + Benutzer-Abfrage, alle an den Mandanten der Kandidatenzeile. Der Schreibzugriff, + der `notifiedAt` stempelt, bindet ebenfalls. +- Der Aufruf des Mailversands bleibt unveraendert; welcher Mandant die SMTP-Angaben + bestimmt, wird nicht geaendert. + +`tender-matching.service.ts`: +- Die Profil-Abfrage (`tenderSavedSearch.findMany` ohne Filter) bleibt UNGEBUNDEN, + mit Kommentar als Etappe-3-Uebergabe. +- Der Lesezugriff auf den plattformweiten Katalog bleibt UNGEBUNDEN (D-03), mit + Kommentar. +- Innerhalb der Profilschleife binden, jeweils an `tenantId` des Profils bzw. des + Treffers: die Anlage der Treffer, die Abfrage der noch nicht benachrichtigten + Treffer, die Benutzer-Abfrage und der Schreibzugriff, der `notifiedAt` stempelt. + Der gebundene Client wird EINMAL je Profil erzeugt, nicht je Treffer — sonst + entsteht pro Zeile eine eigene Transaktion. +- Die vorhandene Fehlerbehandlung je Profil (ein defektes Profil darf den Lauf nicht + abbrechen) bleibt unveraendert. + +MASCHINELLE ABSICHERUNG UND DOKUMENTE: + +- `apps/api/src/prisma/rls-access-inventory.spec.ts` laufen lassen und AUS SEINER + AUSGABE den Stand je Paar ablesen. Der gemessene Wert gewinnt; er wird nicht aus + diesem Plan abgeschrieben. Erwartungsgemaess entstehen fuer diesen Bereich + gemischte Staende (Dateien, in denen dasselbe Modell gebunden UND ungebunden + vorkommt) — das ist der korrekte Ausdruck der `beides`-Klasse und kein Mangel. + Zeigt die Pruefung ein bisher unbekanntes Paar oder eine Erkennungsluecke, wird + sie geschlossen wie in 260909-jts (dort war es der Transaktionsparameter), bevor + das Dokument nachgezogen wird. +- `docs/mandantentrennung-zugriffsklassifikation.md` nachziehen: die Stand-Spalte + aller 23 tenders-Paare auf den gemessenen Wert; die Bereichszeile `tenders` in der + Uebersicht mit den dort dokumentierten Zaehlbefehlen NEU messen (nicht rechnen) + und die Summenzeile mitfuehren; die Klassenverteilung pruefen und nur aendern, + wenn die Pruefung tatsaechlich eine andere Paarzahl meldet. Der Abschnitt "Der + Hintergrunddienst als Falle" bekommt fuer die beiden tenders-Dateien einen + Nachtrag im Stil des ldap-Eintrags: Je-Treffer-Haelfte geschlossen, uebergreifende + Haelfte ausdruecklich an Etappe 3 uebergeben. Der urspruengliche Text bleibt + lesbar stehen, es wird nachgetragen und nicht neu geschrieben. +- Die zwoelf Paare aus Befund L einzeln gegen D-03 bzw. den jeweiligen Dateikopf + pruefen und ihre Begruendungsspalte so schaerfen, dass sie als BEWUSST ungebunden + lesbar ist und nicht als offener Rest. `tender-ingestion.service.ts` selbst wird + dabei NICHT bearbeitet (Befund I). +- `docs/mandantentrennung-etappe2-fehlerrichtung.md`: den in Aufgabe 1 geschriebenen + Abschnitt um das ergaenzen, was erst jetzt tatsaechlich vorliegt — welche Haelften + geschlossen sind, was an Etappe 3 uebergeben ist, und der Nachtrag zur + Reihenfolgebedingung gegenueber dem Bereich `settings` (Befund K). + + + DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && npm --prefix apps/api run test && npm --prefix apps/api run type-check && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && test -z "$(git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web)" + + Alle Tests (743 plus die neuen) und die Typpruefung sind gruen, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden; `rls-access-inventory.spec.ts` und das Klassifikationsdokument stimmen fuer alle 23 tenders-Paare ueberein, jede Zeile traegt einen gemessenen Stand; die zwoelf bewusst ungebundenen Paare sind als bewusst lesbar und `tender-ingestion.service.ts` ist unveraendert; beide Hintergrunddienste binden je Schleifendurchlauf an den Mandanten der jeweiligen Zeile und lassen ihre uebergreifende Abfrage kommentiert ungebunden; beide Dokumente sind geschlossen; Schema, Migrationen, Compose-, Umgebungsdateien und `apps/web` sind unveraendert. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser -> API | Sitzungsnachweis (JWT) und Modul-Wachter; `userId`/`tenantId`/`role` kommen ausschliesslich aus `extractTriageContext`, nie aus Rumpf oder Abfragezeichenkette | +| API -> PostgreSQL | Row-Level-Security. Der Schalter ist AUS (Rolle `tessera` mit BYPASSRLS); die Policies wirken heute nicht, sind aber ausgeliefert | +| Planer -> PostgreSQL | Zwei Cron-Laeufe ohne Anfragekontext (Digest, Sofortmeldung) — kein Mandant aus einer Sitzung, nur aus der gelesenen Zeile | +| API -> fremdes Postfach / fremder RSS-Host | Ausgehende Verbindungen mit gespeicherten, verschluesselten Zugangsdaten bzw. mit vom Nutzer gesetzten URLs | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-LAA-01 | Information Disclosure | Suchprofile, Triage-Zustand, Benachrichtigungseinstellung, Postfachanbindung — Quer-Lesen zwischen MANDANTEN | high | mitigate | Aufgabe 2 bindet alle Lesepfade der vier Dienste mit nicht-nullbarem Mandanten an `forTenant()`; Aufgabe 1 misst an der ausgelieferten Policy unter einer Rolle ohne BYPASSRLS, dass gebunden nur die eigene Zeile und ungebunden gar keine sichtbar ist | +| T-LAA-02 | Information Disclosure | dieselben Daten — Quer-Lesen zwischen NUTZERN desselben Mandanten | high | mitigate | Die Policies haben keine Benutzerdimension; Aufgabe 1 misst das ausdruecklich (`tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar`). Die anwendungsseitige `userId`-Filterung bleibt der einzige Schutz und wird in Aufgabe 2 nicht entfernt; alle bestehenden IDOR-Tests bleiben gruen | +| T-LAA-03 | Tampering | Schreibpfade der fuenf Dienste — Anlegen/Aendern/Loeschen auf fremde Rechnung | high | mitigate | Gebundene Schreibzugriffe; die Migration verzichtet bewusst auf eine getrennte WITH-CHECK-Klausel, die USING-Bedingung gilt damit auch fuer neue Zeilen. Zusaetzlich bleiben die Besitzpruefungen im Dienst bestehen | +| T-LAA-04 | Information Disclosure | gespeicherte Postfach-Zugangsdaten (`encryptedInboxCreds`) | high | mitigate | Aufgabe 2 laesst die sichere Feldauswahl unveraendert, bindet den rohen Lesezugriff ebenfalls und haelt entschluesselte Zugangsdaten methodenlokal — keine Protokollierung, keine Rueckgabe. Bestehende Zusagen des Dateikopfs werden nicht aufgeweicht | +| T-LAA-05 | Denial of Service | Benachrichtigungswege: ein zu kleines Leseergebnis sendet nichts, lautlos | high | mitigate | Aufgabe 1 schreibt die fuenf Stellen namentlich in die Kritikschrift samt dem einzigen nachpruefbaren Signal; Aufgabe 3 sichert per Test zu, dass `notifiedAt` bei ausbleibendem Versand NULL bleibt und der Treffer damit wiederholbar ist. Eine Laufzeitwarnung wurde erwogen und begruendet verworfen | +| T-LAA-06 | Denial of Service | WINDOWS #19 — plattformweite RSS-Quellen (`tenantId` nullbar) | medium | transfer | Aufgabe 1 misst die Grenze (plattformweite Zeile unter jedem Kontext unsichtbar, gebundenes Einfuegen ohne Mandant abgewiesen); Aufgabe 2 bindet die drei betroffenen Pfade deshalb NICHT und begruendet es im Code. Die Policy-Semantik gehoert zu Etappe 3 und wird hier nicht angefasst | +| T-LAA-07 | Denial of Service | gebundenes `upsert` auf einen Eindeutigkeitsschluessel ohne Mandantendimension bei veraltetem denormalisiertem Mandanten | medium | mitigate | Aufgabe 1 misst das Verhalten; Aufgabe 2 uebersetzt die Eindeutigkeitsverletzung nach dem bereits vorhandenen Muster in eine verstaendliche deutsche Meldung statt eines rohen Fehlers | +| T-LAA-08 | Tampering | ein Administrator eines beliebigen Mandanten kann eine plattformweite RSS-Quelle entfernen, die alle Mandanten speist | low | accept | Produkt-/Zustaendigkeitsfrage im Umfeld von WINDOWS #19, kein Auftrag dieses Durchlaufs. In Aufgabe 1 als Beobachtung in der Kritikschrift festgehalten, nicht repariert | +| T-LAA-09 | Elevation of Privilege | vorzeitiges Scharfschalten der Datenbankrolle im Rahmen dieses Durchlaufs | high | mitigate | `DATABASE_URL`, alle vier Compose-Dateien und beide Beispiel-Umgebungsdateien bleiben unveraendert; jede Aufgabe prueft das maschinell ueber ein `git diff --name-only`-Gate im ``-Block | +| T-LAA-SC | Tampering | Lieferkette (npm) | low | accept | Dieser Durchlauf installiert kein Paket — kein `npm install`, keine neue Abhaengigkeit. Das Paket-Legitimitaets-Gate faellt damit nicht an; wird waehrend der Ausfuehrung doch eine Installation noetig, ist das ein Anlass zum Anhalten und Nachfragen, nicht zum Nachziehen | + + + +1. `npm --prefix apps/api run test` — 743 bisherige Tests plus die neu + hinzugekommenen Bindungsnachweise, alle gruen, keine ausgelassene Datei. +2. `npm --prefix apps/api run type-check` — Rueckgabewert 0. +3. `DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}')` und + `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs` + — alle Pruefungen bestanden (23 bisherige plus die neuen), Rueckgabewert 0. +4. `git diff --name-only HEAD -- apps/api/prisma docker-compose.yml docker-compose.dev.yml docker-compose.prod.yml docker-compose.ci.yml .env.example .env.prod.example apps/web` + — leer. Schema, Migrationen, Compose, Umgebungsdateien und das Web bleiben + unberuehrt, der Schalter bleibt aus. +5. `rls-access-inventory.spec.ts` und `docs/mandantentrennung-zugriffsklassifikation.md` + stimmen fuer alle 23 tenders-Paare ueberein — nachgewiesen dadurch, dass die + Pruefung gruen ist, nicht durch Nachzaehlen von Hand. +6. Der Rueckbau-Nachweis aus Aufgabe 2 und Aufgabe 3 ist im Bericht festgehalten: + welche Bindung probeweise entfernt wurde, welcher Test dadurch rot wurde. +7. Ein lokal fehlender Mailserver (`ENOTFOUND mailhog`) ist umgebungsbedingt und + kein Mangel — nicht "reparieren". + + + +- Die fuenf Nutzer-CRUD-Dienste sind nach der Bindungsregel umgestellt: vier + vollstaendig, `tender-rss-feed.service.ts` teilweise mit im Code begruendeter + Grenze zu WINDOWS #19. +- Die Je-Treffer-Haelften der beiden Hintergrunddienste binden an den Mandanten der + jeweils gelesenen Zeile; ihre uebergreifenden Abfragen sind unveraendert und als + Etappe-3-Uebergabe kommentiert. +- Die zwoelf Paare des plattformweiten Katalogs und der beiden Fan-out-Adapter sind + unveraendert und im Klassifikationsdokument als bewusst ungebunden lesbar. +- `docs/mandantentrennung-etappe2-fehlerrichtung.md` traegt einen `tenders`-Abschnitt + mit gemessener Belegzeile, Signaltabelle und einem eigenen Unterabschnitt zur + lautlosen Fehlerform. +- Alle sieben umgestellten Testdateien koennen bei einer vergessenen Bindung rot + werden; das ist durch Rueckbau belegt, nicht behauptet. +- Alle vier Verifikationsschritte oben sind gruen. + + + +Create `.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-SUMMARY.md` when done +