Files
schalli b86675b549 docs(quick-260909-laa): Plan fuer Etappe 2 Bereich tenders
Drei Aufgaben: zuerst messen (fuenf Policies wortgleich aus der
ausgelieferten Migration, dazu die drei Sonderfaelle dieses Bereichs —
fehlende Benutzerdimension, nullbarer Mandant bei den RSS-Quellen,
Einfuegen auf eine unsichtbare Zeile), dann die fuenf Nutzerdienste
binden, dann die Je-Treffer-Haelften der beiden Hintergrunddienste und
beide Dokumente schliessen.

Zur Planungszeit gemessen statt zitiert: 743 Tests gruen, Typpruefung
sauber, Wegwerf-Werkzeug 23/23. Von den 62 Rohtreffern ist genau einer
kein Modellzugriff. Der Bereich hat keine mandantengebundene
Transaktion, das Hilfsmittel aus 260909-jts wird hier nicht gebraucht.
Keine der sieben Testdateien kennt die Erweiterung — sie waeren nach
der Umstellung aus dem falschen Grund rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 15:34:13 +02:00

53 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260909-laa 01 execute 1
true
WINDOWS-20
ETAPPE-2-TENDERS
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
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links
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.
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
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

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

<planning_time_findings>

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. </planning_time_findings>

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.

<threat_model>

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 <verify>-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
</threat_model>
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".

<success_criteria>

  • 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. </success_criteria>
Create `.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-SUMMARY.md` when done