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