--- 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