--- phase: quick-260910-jab plan: 01 subsystem: mandantentrennung-datenbankrolle tags: [rls, postgresql, multi-tenancy, security, module-grants, groups, tenders] status: complete dependency-graph: requires: [T-JTS-02, T-JTS-03, WINDOWS-19] provides: [T-JAB-01..15-mitigations, rls-widen-migration-20260910120000] affects: [apps/api/prisma, apps/api/src/groups, apps/api/src/tenders, apps/api/src/module-registry, apps/api/scripts/rls-scratch-check.mjs] tech-stack: added: [] patterns: - "Vier nach Befehl getrennte RLS-Policies (SELECT/INSERT/UPDATE/DELETE) statt einer permissiven USING-Klausel, wenn Lesen und Schreiben unterschiedliche Sichtbarkeitsregeln brauchen" - "Loch-behauptende Wegwerf-Pruefungen werden UMGEKEHRT statt geloescht, mit Verweis auf den alten Pruefungsnamen und die alte Befundkennung im Meldetext" - "Eine Zeile, auf der spaetere Pruefungen aufsetzen, wird nach einer Regelverschaerfung ueber die Wartungsrolle (BYPASSRLS) bereitgestellt, wenn der urspruengliche Erzeugungsweg (gebundenes INSERT) jetzt abgewiesen wird" key-files: created: - apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql modified: - apps/api/scripts/rls-scratch-check.mjs - apps/api/src/groups/migration-sql.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/groups/groups.service.ts - apps/api/src/groups/module-grants.service.ts - apps/api/src/groups/module-grants.service.spec.ts - apps/api/src/module-registry/module-access.service.ts - apps/api/src/prisma/rls-coverage.spec.ts - docs/mandantentrennung-zugriffsklassifikation.md - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-datenbankrolle.md - .planning/WINDOWS.md decisions: - "GroupMembership-Regel prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer, mit UND verknuepft), nach dem Join-Muster von PasswordResetToken" - "ModuleGrant-Regel prueft zusaetzlich beide moeglichen Ziele (Gruppe/Benutzer) mit Leer-Zulassung, weil D-04 Gruppe und Benutzer als Entweder-oder fuehrt" - "TenderRssFeedSource bekommt vier nach Befehl getrennte Policies statt einer, weil ein einzelner USING-Ausdruck auch UPDATE/DELETE mitregelt" - "SearchProvider bewusst NICHT angefasst — die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile)" - "TenderRssFeedSourceService.listForUser wird gebunden (einziger Anwendungscode-Pfad, den die Reparatur sonst still falsch gemacht haette); createPlatform/remove bleiben bewusst ungebunden" - "WINDOWS #24 neu angelegt: der Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit der Schliessung von #19" metrics: duration: "~70min" completed: 2026-09-10 actuals: tokens: 34770 tasks: 3 commits: 3 plan_head_before: 93444aa91e8fb0739ee5bbf023f7ccbbd3cee38e --- # Quick 260910-jab: Die drei zu kurz greifenden Datenbankregeln schliessen — Summary Eine handgeschriebene, lokal angewandte PostgreSQL-Migration schliesst T-JTS-02 (GroupMembership prueft nur die Gruppenseite), T-JTS-03 (ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht wohin sie zeigt) und WINDOWS #19 (plattformweite TenderRssFeedSource-Zeilen waeren nach dem Scharfschalten fuer JEDEN Mandanten unsichtbar gewesen); das Wegwerf-Werkzeug misst alle drei Reparaturen an der lebenden Datenbank, mit den drei loch-behauptenden Pruefungen umgekehrt statt geloescht. ## Ausgangslage — selbst gemessen (nicht uebernommen) Zu Beginn von Aufgabe 1 frisch gemessen, gegen HEAD `93444aa`: - **Tests:** 833 bestanden, 56 Dateien (`npm --prefix apps/api run test`) - **Typpruefung:** sauber (`npm --prefix apps/api run type-check`) - **Wegwerf-Werkzeug:** 66/66 Pruefungen bestanden (`rls-scratch-check.mjs` gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`, zur Laufzeit ermittelt) Diese drei Zahlen decken sich mit der in der Aufgabenstellung genannten Baseline — hier trotzdem selbst nachgemessen, nicht abgeschrieben (die Vorgabe der Aufgabenstellung verlangt genau das). ## Am Ende gemessen - **Tests:** 839 bestanden, 56 Dateien (+6, alle aus dem neuen `migration-sql.spec.ts`-Beschreibungsblock) - **Typpruefung:** sauber - **Wegwerf-Werkzeug:** 74/74 Pruefungen bestanden (+8: siehe Aufschlüsselung unten unter "Neue/umgekehrte Pruefungen") ## Bestandszaehlung ueber Mandantengrenzen (Befund H, vor Anwendung der Migration) Gegen die lokale Datenbank `tessera` ausgefuehrt, vor dem Anwenden der neuen Migration: ``` memberships-cross-tenant | 0 grants-cross-tenant-group | 0 grants-cross-tenant-user | 0 total-memberships | 3 total-grants | 4 total-tenants | 1 tenderrssfeed-rows | 2 tenderrssfeed-platform-rows | 1 searchprovider-rows | 0 searchprovider-null-tenant | 0 ``` Ergebnis null — es gibt lokal keine Zeile, die von der verschärften Regel unsichtbar würde. Für das Testsystem ist das NICHT gemessen und muss vor Etappe 4 als eigene Vorabprüfung wiederholt werden (siehe Befund H im Plan). ## Was gebaut wurde ### Aufgabe 1 — Migration + Wegwerf-Werkzeug Neue Migration `20260910120000_rls_widen_membership_grant_and_platform_read` (lokal angewandt via `prisma migrate deploy` — **nicht** über `npx prisma`, das versuchte ungefragt Prisma 8.0.0-rc.13 herunterzuladen und wurde abgebrochen; stattdessen `apps/api/node_modules/.bin/prisma`, die im Projekt gepinnte Version 6.19.3, dieselbe, die `rls-scratch-check.mjs` selbst verwendet): 1. **`GroupMembership`** — die Regel prüft jetzt `groupId IN (...)` UND `userId IN (...)`, beide gegen den laufenden Mandanten. 2. **`ModuleGrant`** — die Regel prüft weiterhin `tenantId = current_tenant_id()` UND zusätzlich `groupId IS NULL OR groupId IN (...)` UND `userId IS NULL OR userId IN (...)`. 3. **`TenderRssFeedSource`** — vier Regeln statt einer: `tenant_platform_read_policy` (SELECT, schließt `tenantId IS NULL` ein), `tenant_insert_policy`/`tenant_update_policy`/`tenant_delete_policy` (verlangen ausnahmslos einen Mandanten). 4. **`SearchProvider`** — unverändert, nur der erklärende Absatz im Migrationskopf. Regelliste der lebenden Datenbank nach Anwendung (`pg_policies`, Auszug): ``` GroupMembership#tenant_isolation_policy#ALL#(groupId IN (...) AND userId IN (...)) ModuleGrant#tenant_isolation_policy#ALL#(tenantId = ... AND (groupId IS NULL OR ...) AND (userId IS NULL OR ...)) SearchProvider#tenant_isolation_policy#ALL#(tenantId = current_tenant_id()) TenderRssFeedSource#tenant_delete_policy#DELETE#(tenantId = current_tenant_id()) TenderRssFeedSource#tenant_insert_policy#INSERT##WITH CHECK (tenantId = current_tenant_id()) TenderRssFeedSource#tenant_platform_read_policy#SELECT#(tenantId = current_tenant_id() OR tenantId IS NULL) TenderRssFeedSource#tenant_update_policy#UPDATE#USING(...)#WITH CHECK(...) ``` **Neue/umgekehrte Pruefungen im Wegwerf-Werkzeug** (66 → 74): | Kennung | Art | Ergebnis | |---|---|---| | `groupmembership-schreiben-fremder-benutzer-abgelehnt` | Umkehr (T-JTS-02) | bestanden — Insert mit echtem `user-b` abgewiesen | | `groupmembership-fremder-benutzer-ueber-die-wartungsrolle-weiterhin-moeglich` | neu | bestanden — Gegenmessung über Wartungsrolle gelingt | | `modulegrant-fremde-gruppe-abgelehnt` | Umkehr (T-JTS-03) | bestanden | | `modulegrant-fremder-benutzer-abgelehnt` | neu (zweiter D-04-Zweig) | bestanden | | `modulegrant-fremde-gruppe-ueber-die-wartungsrolle-weiterhin-moeglich` | neu (legt `grant-foreign-group` an) | bestanden | | `tenderrssfeed-plattformzeile-gebunden-sichtbar` | Umkehr (WINDOWS #19) | bestanden | | `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar` | neu | bestanden | | `tenderrssfeed-ungebunden-nur-die-plattformzeile` | neu (Befund F) | bestanden | | `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt` | neu | bestanden | | `tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt` | neu | bestanden | | `searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar` | neu (eigener Bereich) | bestanden | Extraktion umgeleitet: `readRlsWidenMigrationSql()` liest die neue Datei für `GroupMembership`/`ModuleGrant`; `extractAllPolicySql()` (neu, mit globalem Regex-Flag) liefert alle vier `TenderRssFeedSource`-Policies. Die Meldung von `gruppenpfad-gebunden-schliesst-die-fremde-gruppe-aus` im Bereich `module-registry` ist richtiggestellt (behauptete vorher fälschlich, die Regel lasse die fremde Zeile durch). ### Aufgabe 2 — `listForUser` binden `TenderRssFeedSourceService.listForUser(userId, tenantId)` läuft jetzt über `forTenant()`. `TendersController.listRssFeeds` reicht die Mandantenkennung aus dem Sitzungsnachweis durch (Aufrufform geprüft, nicht die Antwort). `createPlatform`/`remove` bleiben bewusst ungebunden. Fünf Aufzeichnungen im Quelltext (drei Kopfkommentare in `tender-rss-feed.service.ts`, `module-access.service.ts`, `groups.service.ts`, `module-grants.service.ts`, `rls-coverage.spec.ts`) nennen die neue Migration und beschreiben die neue Regel statt der alten. Die Mandanten-Gegenprüfung (`assertTargetBelongsToTenant`) bleibt unverändert bestehen — zwei bestehende Testfälle in `module-grants.service.spec.ts` wurden um einen Kommentar ergänzt, der festhält, warum sie nach der Regeländerung NICHT entbehrlich geworden sind. **Deviation, gemessen statt geglaubt (siehe ``-Vorgabe des Plans):** die Behauptung "kein Anwendungscode muss sich ändern" trifft für GENAU EINEN Pfad nicht zu — `TenderRssFeedSourceService.listForUser`. Vor der Regeländerung lieferte der ungebundene Pfad nach dem Scharfschalten NICHTS (gemessen: `tenderrssfeed-ungebunden-nur-die-plattformzeile` würde ohne Bindung 0 statt 1 Zeile liefern). Nach der Regeländerung liefert derselbe ungebundene Pfad NUR die plattformweiten Zeilen — eine kurze, glaubhafte Teilantwort statt einer schreienden Leere. Das Binden gehört deshalb zur Reparatur selbst, nicht daneben: ohne diese Bindung hätte 260910-jab einen Pfad still von "meldet sich laut" auf "täuscht Vollständigkeit vor" verschlechtert. **Falsifizierungsnachweis** (Bindungsaufruf zurückgenommen, Test rot gesehen, Rücknahme zurückgenommen): ``` Backup genommen → const tenantPrisma = ... entfernt, this.prisma direkt verwendet npm --prefix apps/api run test -- src/tenders/tender-rss-feed.service.spec.ts × listForUser() bindet — forTenant() wird mit der uebergebenen Mandantenkennung aufgerufen AssertionError: expected "spy" to be called with arguments: [ {…(4)}, 'tenant-a' ] Number of calls: 0 Test Files 1 failed (1) Tests 1 failed | 30 passed (31) → Rücknahme rückgängig gemacht (Backup wiederhergestellt), erneuter Lauf: 31/31 grün ``` Genau EIN Test wurde rot, wie erwartet — kein anderer Test hing versehentlich an dieser Bindung. **Rule 1 (Auto-Fix):** `listRssFeeds` destrukturierte `feeds.map(({userId, ...rest}) => ...)`. Da `listForUser` jetzt über `forTenant(...) as any` läuft, wurde `feeds` implizit `any`, und TypeScript meldete `TS7031: Binding element 'ownerUserId' implicitly has an 'any' type`. Behoben mit expliziter `(... : any)`-Annotation, Muster aus anderen `.map((g: any) => ...)`-Stellen derselben Datei. Typpruefung danach wieder sauber. ### Aufgabe 3 — Aktenstand kohärent - **`.planning/WINDOWS.md`**: #19 auf `fixed` (Tabelle + JSON-Block, `resolved_at: 2026-09-10T12:35:40.000Z`), Beleg nennt die neue Migration und die Prüfungen für beide Fehlerrichtungen des Lese-/Schreibsplits. #18/#20/#21/#22/#23 unverändert offen. Neuer Eintrag **#24** (offen, Kind `deviation`): der Verwaltungsweg für plattformweite Zeilen unter der Anwendungsrolle fehlt — weder Anlegen noch Entfernen ist unter der Anwendungsrolle möglich, in der alten wie in der neuen Regel. Kopfzahlen aus dem JSON-Block abgeleitet und mit einem eigenen Python-Skript gegengeprüft (identisch zum Verify-Skript des Plans): 6 offen, 17 behoben, 1 zurückgestellt, 24 gesamt. - **Klassifikation**: #19-Block beantwortet, die vier betroffenen Bestandsaufnahme-Zeilen nachgezogen, Übersichtszeile `tenders` (35/27, gemessen mit den beiden im Dokument selbst geführten grep-Anweisungen) und Summenzeile (107/135) neu aus dem Quelltext abgeleitet — beide mit dem exakten `awk`-Prüfskript des Plans gegengeprüft. - **Kritikschrift**: neuer Abschnitt `## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19` mit (r1) tatsächlich beobachteter Ausgabe + Regelliste aus der lebenden Datenbank, (r2) Signaltabelle mit beiden Fehlerrichtungen je Regel, (r3) den Stellen, an denen Leere weiterhin als Abwesenheit gedeutet wird (inklusive der neuen Stelle aus Befund F), (r4) was dieser Durchlauf nicht löst, (r5) was er nicht anfasst. Fünf überholte Bestandsstellen mit Nachträgen versehen (der Punkt in (g4), der Punkt in (t4), die Signaltabellenzeile der drei RSS-Pfade, DREI aufgezeichnete Werkzeugausgaben — eine mehr als die im Plan als Minimum genannten zwei, weil beim Durchsuchen eine dritte literale Zitatstelle im Abschnitt `module-registry` gefunden wurde — und der Punkt in (m4)). Alle alten Messprotokolle bleiben wörtlich stehen, die Nachträge stehen daneben. - **Selbst ausgeführte Messung zur fehlenden Benutzerdimension** (Aufgabe 3, wie vom Plan verlangt): `grep -rn "current_setting\|set_config" apps/api/src apps/api/prisma/migrations` findet GENAU EINE Sitzungsvariable, `app.current_tenant` (`prisma-tenant.extension.ts`, `20260618112133_rls_policies`). Es gibt KEINE zweite Sitzungsvariable für den Benutzer — Tabellen wie `TenderSavedSearch` haben deshalb strukturell keine Möglichkeit, eine Benutzerdimension auf Datenbankebene durchzusetzen, ohne eine solche Variable erst einzuführen. Ergebnis in (r4) der Kritikschrift festgehalten, nicht gebaut. - **Betriebsanleitung**: die eine Stelle, die #19 als offen führte, nennt jetzt den Auflösungsstand und WINDOWS #24. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] `TenderRssFeedSource`-Wegwerf-UPDATE-Prüfung zielte auf eine nicht existierende Spalte `label`** - **Gefunden während:** Aufgabe 1, erster Lauf des Wegwerf-Werkzeugs nach dem Hinzufügen der neuen UPDATE-Prüfung - **Problem:** `tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt` versuchte `SET label = ...`, aber die im selben Abschnitt angelegte Wegwerf-Tabelle hat keine `label`-Spalte (`id, url, userId, tenantId`) — die Prüfung "bestand", aber aus dem FALSCHEN Grund (SQL-Fehler `42703` statt der beabsichtigten RLS-Abweisung). - **Fix:** Spalte auf `url` geändert, die tatsächlich existiert; danach bestand die Prüfung aus dem richtigen Grund (`tenant_update_policy` filtert die Zielzeile heraus, 0 betroffene Zeilen). - **Dateien:** `apps/api/scripts/rls-scratch-check.mjs` - **Commit:** `f4f3115` **2. [Rule 1 - Bug] Implizites `any` beim Destrukturieren in `listRssFeeds`** - **Gefunden während:** Aufgabe 2, Typprüfung nach dem Binden von `listForUser` - **Problem:** `feeds.map(({ userId: ownerUserId, ...rest }) => ...)` löste `TS7031` aus, weil `feeds` durch die neue `forTenant(...) as any`-Bindung implizit `any` wurde. - **Fix:** explizite `(... : any)`-Annotation am Destrukturierungsparameter. - **Dateien:** `apps/api/src/tenders/tenders.controller.ts` - **Commit:** `6b23735` ### Package-Legitimitätsgatter **3. [Rule 3 – ausgeschlossen, keine Installation]** `npx prisma migrate deploy` versuchte beim Ausführen ungefragt, `prisma@8.0.0-rc.13` herunterzuladen (weiter als die im Projekt gepinnte 6.19.3) — abgebrochen (`pkill`), stattdessen `apps/api/node_modules/.bin/prisma` verwendet, die lokale, im Lockfile gepinnte Version. Keine Installation fand statt, daher kein Checkpoint nötig — dokumentiert, weil es beinahe eine unbeabsichtigte Fremdversion in den Migrationslauf eingeschleust hätte. Keine weiteren Abweichungen — alle übrigen Aufgaben wie im Plan beschrieben ausgeführt. ## Known Stubs Keine. ## Threat Flags Keine neuen — das Threat-Model des Plans (T-JAB-01 bis T-JAB-15) deckt alle in diesem Durchlauf berührten Flächen bereits ab; keine neue, dort nicht erfasste Angriffsfläche entstanden. ## Self-Check: PASSED - `apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql` — FOUND - Commit `f4f3115` (Aufgabe 1) — FOUND (`git log --oneline --all | grep f4f3115`) - Commit `6b23735` (Aufgabe 2) — FOUND - Commit `03fb3bf` (Aufgabe 3) — FOUND - `rls-scratch-check.mjs` meldet 74/74 bestanden gegen die lebende Datenbank — bestätigt (letzter Lauf vor diesem Bericht) - `.planning/WINDOWS.md` besteht das plan-eigene Python-Kohärenzskript — bestätigt - `npm --prefix apps/api run test` — 839/839 grün, `type-check` sauber — bestätigt