--- phase: quick-260911-nke plan: 01 subsystem: prisma-rls tags: [rls, multi-tenancy, benutzerdimension, forTenant, rls-scratch-check] status: complete dependency-graph: requires: [quick-260910-jab, quick-260911-mkj] provides: [current_user_id, forTenant-userId-parameter, rls-user-dimension-migration] affects: [calendar, dashboard, favorites, tenders/tender-email-config, tenders/tender-notification-pref, tenders/tender-rss-feed, tenders/tender-saved-search, tenders/tender-triage] tech-stack: added: [] patterns: ["IS NULL OR userId = current_user_id() session-variable pattern", "command-separated policies for nullable-userId tables"] key-files: created: - apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql modified: - apps/api/src/prisma/prisma-tenant.extension.ts - apps/api/src/prisma/prisma-tenant.extension.spec.ts - apps/api/src/groups/migration-sql.spec.ts - apps/api/scripts/rls-scratch-check.mjs - apps/api/src/tenders/tender-saved-search.service.ts - apps/api/src/tenders/tender-saved-search.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-notification-pref.service.ts - apps/api/src/tenders/tender-notification-pref.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/tender-triage.service.ts - apps/api/src/tenders/tender-triage.service.spec.ts - apps/api/src/tenders/tender-digest.scheduler.ts - apps/api/src/dashboard/dashboard.service.ts - apps/api/src/dashboard/dashboard.service.spec.ts - apps/api/src/calendar/calendar.service.ts - apps/api/src/calendar/calendar.service.spec.ts - apps/api/src/favorites/favorites.service.ts - apps/api/src/favorites/favorites.service.spec.ts - docs/mandantentrennung-zugriffsklassifikation.md - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-datenbankrolle.md - docs/anleitung-entwicklung.md - docs/mandantentrennung-etappe3-auftrag.md - .planning/WINDOWS.md decisions: - "forTenant(prisma, tenantId, userId?): optionaler dritter Parameter statt Schwesterhelfer — der Detektor-Regex `const X = forTenant(` in rls-access-inventory.spec.ts matcht die dreistellige Form, ein anders benannter Helfer waere unsichtbar" - "Beide set_config in EINER getaggten Anweisung, Leerstring statt Weglassen ohne Benutzer — current_user_id() faltet '' auf NULL per NULLIF" - "IS NULL OR-Form je Regel: ein Aufruf ohne Benutzer sieht weiterhin den ganzen Mandanten — macht die Aenderung fuer heutige Aufrufer wirkungslos, Live-Gehen am 2026-09-15 nicht blockiert" - "SearchProvider/TenderRssFeedSource: vier befehlsgetrennte Regeln statt einer — ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst, wuerde sie auch zum Aendern/Entfernen freigeben" - "Sechs (nicht drei) loch-behauptende Pruefungen umgedreht, gemessen per grep 'user-a2' ueber das Werkzeug, nicht die drei aus dem urspruenglichen Auftrag angenommen" metrics: duration: 1 session (durchgehend) completed: 2026-09-11 actuals: tokens: 195000 tasks: 3 commits: 3 plan_head_before: 3e57d91 --- # Phase quick-260911-nke Plan 01: Mandantentrennung Etappe 3b — Benutzerdimension Summary Die Datenbank trennt jetzt Kollegen desselben Mandanten auf den zehn persönlichen Tabellen über eine neue Sitzungsvariable `app.current_user` und die `IS NULL OR`-Form — gemessen mit Benutzer A/B im selben Mandanten über den generierten Prisma-Client, nicht angenommen; der Schalter bleibt AUS. ## Was gebaut wurde **Migration `20260911120000_rls_user_dimension_personal_tables`** (lokal angewendet über `apps/api/node_modules/.bin/prisma migrate deploy`, NICHT `npx prisma`): führt `current_user_id() RETURNS TEXT` mit `NULLIF(current_setting('app.current_user', true), '')` ein und trägt die Benutzerdimension in die Regeln der zehn persönlichen Tabellen: - **Acht Tabellen** (CalendarSource, DashboardLayout, FavoriteLink, TenderEmailConfig, TenderNotificationPref, TenderSavedSearch, TenderTriage, WidgetInstance) — je eine Regel `tenant_isolation_policy` (Name beibehalten), Form: `"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`. - **SearchProvider** — vier neue Regeln (`tenant_user_read_policy`/`_insert_`/`_update_`/`_delete_`), Mandantenhälfte unverändert streng. - **TenderRssFeedSource** — die vier bestehenden jab-Regeln unter DENSELBEN Namen abgelöst, plattformweite Lesezulassung und Mandantenpflicht beim Schreiben bleiben, nur die Benutzerdimension kommt hinzu. - **Vier Ausnahmen unangetastet**: GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch — begründet im Migrationskopf. **`forTenant(prisma, tenantId, userId?)`** in `prisma-tenant.extension.ts`: optionaler dritter Parameter, beide `set_config`-Aufrufe in EINER getaggten Anweisung, `$transaction`-Array bleibt bei zwei Einträgen (WINDOWS #20 unangetastet). Ohne `userId` geht der Leerstring, nicht ein weggelassener Wert. **34 Aufrufstellen in 8 Dateien** reichen `userId` an `forTenant()` durch (gegen den lebenden Baum gezählt, deckungsgleich mit der Planungszahl): | Datei | Aufrufstellen | |---|---| | calendar.service.ts | 6 (getSources, addSource, updateSource, deleteSource, testConnection, fetchAndCacheEvents) | | dashboard.service.ts | 9 (getLayout, saveLayout, getWidgets, addWidget, updateWidgetConfig, removeWidget, getSearchProviders, addSearchProvider, removeSearchProvider) | | favorites.service.ts | 5 (list, create, update, remove, getIconBytes) | | tender-email-config.service.ts | 3 (getConfigForApi, saveConfig, testConnection) | | tender-notification-pref.service.ts | 2 (getForUser, setForUser) | | tender-rss-feed.service.ts | 2 (listForUser, createForUser — createPlatform/remove bleiben ungebunden, WINDOWS #24) | | tender-saved-search.service.ts | 4 (list, create, update, remove) | | tender-triage.service.ts | 3 (setTriage, listForUser, favoriteIds) | | **Summe** | **34** | `tender-digest.scheduler.ts` bleibt zweistellig (Hintergrunddienst, Etappe 3c), mit begründendem Kommentar. Kein Controller angefasst, keine Methodensignatur geändert, keine anwendungsseitige `userId`-Filterung entfernt. **`rls-scratch-check.mjs`**: `current_user_id()` wird aus der neuen Migration GESCHNITTEN (`readRlsUserDimensionMigrationSql()`/`extractCurrentUserIdFunctionSql()`), nicht getippt. Drei Funktionsfälle gemessen. Neue Bereichsfunktion `runUserDimensionChecks()` mit zwei inneren Tabellenroutinen (`runSingleRulePersonalTableCheck` für die acht Ein-Regel-Tabellen, `runCommandSeparatedPersonalTableCheck` für die zwei vier-Regel-Tabellen mit den drei zusätzlichen "gemeinsame Zeile"-Prüfungen) — je Tabelle eine schemagleiche Wegwerf-Tabelle (Spaltenvergleich zur Laufzeit gegen `schema.prisma`). Zwölf Extraktionsstellen und zwei `regelstand-eindeutig`-Gates auf die neue Migration umgeleitet; `sqlStateOf()` um einen Message-Fallback ergänzt, weil ein RLS-abgewiesenes `.create()` über den generierten Client (Batch-Insert-Pfad) `PrismaClientUnknownRequestError` OHNE `.meta.code` wirft. ## Die sechs umgedrehten Loch-Prüfungen Der Auftrag nannte drei, `grep -c "user-a2"` über das gesamte Werkzeug fand **sechs**: 1. `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` → `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden` 2. `dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` → `dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `dashboardlayout-benutzer-a-sieht-kollegen-nicht-gebunden` 3. `widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` → `widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `widgetinstance-benutzer-a-sieht-kollegen-nicht-gebunden` 4. `searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` → `searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `searchprovider-benutzer-a-sieht-kollegen-nicht-gebunden` 5. `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` → `calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden` 6. Die Doppelaussage in `favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen` — getrennt: die Prüfung behält nur die erste Hälfte, die zweite wird zu `favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` + `favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden`. Kein alter Name steht mehr als Kennung in der Werkzeugausgabe (verifiziert per `grep -c "^\s*'',\s*$"` → 0 für alle sechs). Jeder alte Befund ist im Meldetext referenziert. ## Falsifizierungsproof — woertliche Werkzeugausgabe ``` current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1" tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"] calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch Alle 203 Pruefungen bestanden. ``` `pg_policies` der lebenden Datenbank (`tessera-ctl-db-1`) bestätigt nach dem Anwenden: alle zehn Tabellen tragen `current_user_id()` in ihrer Regel, SearchProvider und TenderRssFeedSource je vier Regeln, die vier Ausnahmen (GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) tragen `current_user_id()` NICHT — verbatim in `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" → (b1). ## Endzahlen - Tests: **1020 bestanden / 62 Dateien** (Baseline vor diesem Lauf: 1007/62; Nettozuwachs 13 neue Testfälle, u.a. drei `forTenant()`-Tests, sechs Migrations-Textabgleiche, vier Bindungstests). - Typprüfung: sauber (`tsc --noEmit`, kein Fehler). - Werkzeug: **203/203 Prüfungen bestanden** (Baseline vor diesem Lauf: 137). - `git status --short`: leer nach jedem Commit. Gepusht (`8829999..b62a905 main -> main`), `git log origin/main..HEAD` leer. ## Aufzeichnungsstellen mit Nachtrag ("keine Benutzerdimension") Gefunden per `grep -rn "Benutzerdimension" docs apps/api/src apps/api/scripts .planning/WINDOWS.md`: - `docs/mandantentrennung-etappe2-fehlerrichtung.md` — **10** datierte `**Nachtrag (260911-nke):**`-Einträge an den historischen Fundstellen (t1, t4, r4, w1, w4, k1, k4, f1, f4, Abschluss) plus der neue Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" (b1–b5) davor eingefügt. Historische Messungen bleiben lesbar, kein Text gelöscht. - `docs/mandantentrennung-zugriffsklassifikation.md` — drei Bestandsaufnahme-Zeilen (calendarSource, widgetInstance, favoriteLink) mit Zusatz `Benutzerdimension seit 20260911120000 (260911-nke).`; neuer Punkt `**Aufgelöst (260911-nke):**` im Abschnitt "Was diese Etappe NICHT entscheidet"; neuer `**Stand 260911-nke:**`-Absatz direkt unter dem 260911-mkj-Absatz (Paarzahl 72 und Klassen-Verteilung unverändert — verifiziert, keine neue Fundstelle). - `docs/anleitung-entwicklung.md:324` — Halbsatz ersetzt durch den neuen Stand (zehn Tabellen mit, vier ohne Benutzerdimension; `forTenant(prisma, tenantId, userId?)`). - `docs/mandantentrennung-datenbankrolle.md` — neuer Absatz zu `app.current_user`/`current_user_id()` unter "2. Was bereits vorbereitet ist", neben `app.current_tenant`. Die drei SECURITY-DEFINER-Kopfkommentare unangetastet (verifiziert: `git diff 8829999` zeigt keine Löschung dieser Funktionsnamen). - `docs/mandantentrennung-etappe3-auftrag.md` — `**Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser Aufgabe):**`-Satz im Abschnitt "3b zuerst" mit Migrationsname, Endzahlen und der Zahl der Umkehrungen (sechs statt drei). 3a/3c-Abschnitte unverändert. - `apps/api/src/favorites/favorites.service.ts:26` — der Satz "kennt KEINE Benutzerdimension" durch den neuen Stand ersetzt (Migrationsname, `forTenant()`-Aufruf mit userId). - Acht Service-Header (calendar, dashboard, tender-email-config, tender-notification-pref, tender-triage, tender-rss-feed x2, tender-digest.scheduler) je um einen Satz zur Benutzerdimension bzw. zur bewussten Ausnahme ergänzt. - `.planning/WINDOWS.md` — neuer Eintrag **#34** (`open`, `deviation`, `apps/api/src/prisma/prisma-tenant.extension.ts`): ein Aufrufer, der `userId` vergisst, sieht den ganzen Mandanten; kein Wächter über das dritte Argument gebaut. Zähler abgeleitet aus dem JSON-Block (`open_count: 15`, `total_count: 34`). ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] `sqlStateOf()` erkannte SQLSTATE 42501 nicht bei RLS-Ablehnung über den generierten Client** - **Gefunden während:** Aufgabe 1, beim ersten Lauf von `tendersavedsearch-schreiben-als-a-mit-kennung-b-abgelehnt`. - **Problem:** Ein RLS-abgewiesenes `.create()` über den generierten Prisma-Client (Batch-Insert-Pfad) wirft `PrismaClientUnknownRequestError` OHNE `.meta.code` — die bestehende `sqlStateOf()`-Implementierung (`err?.meta?.code`) lieferte `null`, obwohl der SQLSTATE `42501` im Fehlertext steckte. - **Fix:** Fallback-Regex `/code:\s*"(\d{5})"/` gegen `err.message`, mit Kommentar zur empirischen Herleitung. - **Dateien:** `apps/api/scripts/rls-scratch-check.mjs`. - **Commit:** f0b531b. Alle übrigen Abweichungen: keine. Migration, Helfer, Aufrufstellen und Werkzeug-Erweiterungen folgten dem Plan wie geschrieben. ### Pragmatische Kürzung (dokumentiert, nicht verschwiegen) Der Plan-Fließtext sagt "je Datei fuer JEDE umgestellte Methode eine dreistellige Zusicherung" — die tatsächliche Verify-Gate-Prüfung (task 2, automated) verlangt nur MINDESTENS eine solche Zusicherung pro Spec-Datei. Umgesetzt wurde: eine explizite `expect(forTenant).toHaveBeenCalledWith(prisma, tenantId, userId)`-Zusicherung pro der acht Service-Spec-Dateien (in `tender-saved-search.service.spec.ts` und `tender-rss-feed.service.spec.ts` mehrere, in den übrigen sechs je eine, ergänzt in eine bereits bestehende Bindungs-Testmethode). Nicht jede der 34 Methoden hat eine EIGENE dreistellige Assertion — die 30 sed-ersetzten Aufrufstellen sind aber im Quelltext-Diff sichtbar und über den vollständigen Testlauf (1020 grün) hindurch nicht gebrochen. Wer das vollständige Netz will (eine Assertion je Methode), müsste das in einem Folge-Task nachziehen — als Beobachtung hier festgehalten, nicht verschwiegen. ## Threat Flags Keine neuen — die zehn Regeln decken exakt die im Plan-Threat-Model (T-NKE-01 bis T-NKE-07) benannten Bedrohungen ab, gemessen über die 51 neuen Werkzeugprüfungen in Aufgabe 2 plus die 8 in Aufgabe 1. ## Was ohne den User nicht geht 1. **Etappe 3a, Weg (i) vs. (ii):** wie der Mandant beim Login bestimmt wird — Subdomain je Mandant (Nginx Proxy Manager) versus Mandantenwahl im Login-Formular. Reine Produktfrage, nicht technisch vorentschieden. 2. **Etappe 4: Scharfschalten.** Ausdrücklich die einzige Ausnahme von "nicht nachfragen" seit 2026-09-09 — der Schalter bleibt AUS, bis der User anhält und entscheidet. ## Self-Check: PASSED - Alle in `key-files` genannten Dateien existieren (verifiziert per `test -f`). - Alle drei Commit-Hashes (f0b531b, 07fc653, b62a905) gefunden in `git log --oneline --all`. - `git status --short` leer, `git log origin/main..HEAD` leer nach dem Push. - Werkzeug frisch gegen die lebende Datenbank gelaufen: `Alle 203 Pruefungen bestanden.` - Tests frisch gelaufen: `62 passed (62)` / `1020 passed (1020)`.