Files

256 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: quick-260911-nke
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [ETAPPE-3B, ENTSCHEIDUNG-2-2026-09-10]
files_modified:
- apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql
- 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/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
estimate:
tokens: 260000
raw_tokens: 260000
tasks: 3
confidence: low
must_haves:
truths:
- "Es gibt eine SQL-Funktion `current_user_id()`, die `app.current_user` liest und — nach dem Muster von `current_tenant_id()` — NULL liefert, wenn die Variable nicht gesetzt ist. Zusaetzlich liefert sie NULL, wenn die Variable auf den Leerstring gesetzt ist (`NULLIF`), weil der Helfer 'kein Benutzer' ausdruecklich als Leerstring sendet. Alle drei Faelle (ungesetzt, leer, gesetzt) sind an der echten Datenbank gemessen, nicht angenommen."
- "`forTenant(prisma, tenantId, userId?)` hat einen OPTIONALEN dritten Parameter. Die Form 'Helfer erweitern statt zweiten bauen' ist gewaehlt (Praezedenz `withTenantTransaction()`), weil der Detektor der Bestandsaufnahme (`rls-access-inventory.spec.ts`) `const X = forTenant(` erkennt — ein Schwesterhelfer `forTenantAndUser(` waere fuer ihn UNSICHTBAR, jeder damit gebundene Zugriff wuerde als ungebunden gezaehlt. Der Regex bleibt unveraendert und matcht die dreistellige Form (gemessen im Spec)."
- "Der Helfer setzt BEIDE Sitzungsvariablen in EINER getaggten Anweisung (`set_config('app.current_tenant', ...), set_config('app.current_user', ...)`), transaktionslokal, in der Array-Form von `$transaction` mit weiterhin GENAU ZWEI Eintraegen — das WINDOWS-#20-Muster (eine Verbindung fuer Kontext und Abfrage) ist unangetastet. Ohne `userId` wird `app.current_user` auf den Leerstring gesetzt, nicht weggelassen: ein Aufruf ohne Benutzer kann so nie einen Benutzer aus einer frueheren Transaktion derselben Verbindung erben, selbst wenn jemand spaeter `local=false` einfuehrt."
- "Die Regeln der ZEHN persoenlichen Tabellen (CalendarSource, DashboardLayout, FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension in der Form `tenantId = current_tenant_id() AND (current_user_id() IS NULL OR userId = current_user_id())`. Die `IS NULL OR`-Form ist die Sache selbst: ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht weiterhin den ganzen Mandanten — DAS macht die Aenderung fuer jeden heutigen Aufrufer wirkungslos und damit vor Dienstag sicher."
- "Die VIER Tabellen mit `userId`-Spalte, die KEINE persoenlichen Daten tragen (GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch), bleiben unveraendert, und der Grund steht im Kopf der Migration: Verwaltungsobjekte, Anmelde-Artefakt, Hintergrunddienst-Schreibweg."
- "Fuer die zwei Tabellen mit NULL-faehiger `userId` (SearchProvider, TenderRssFeedSource) ist Lesen von Schreiben getrennt (vier nach Befehl getrennte Regeln, Praezedenz 260910-jab): eine gemeinsame Zeile (`userId IS NULL`) bleibt fuer einen benutzergebundenen Aufruf LESBAR, aber nicht aenderbar oder entfernbar — weil ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst, sie auch zum Aendern freigaebe. Die Mandantenhaelfte dieser beiden Regeln ist NICHT angefasst: `SearchProvider` bleibt mandantenstreng (widerlegte Praemisse aus #19), `TenderRssFeedSource` behaelt die plattformweite Lesezulassung und die mandantenpflichtigen Schreibregeln aus 20260910120000."
- "Jede Nutzer-CRUD-Methode der acht umgestellten Dienste, die `userId` bereits in der Hand hat, reicht ihn an `forTenant()` weiter — 34 Aufrufstellen in 8 Dateien, zur Planungszeit gemessen und vom Executor gegen den lebenden Baum erneut gezaehlt. Hintergrunddienste (`tender-digest.scheduler.ts`) und Verwaltungswege (ldap, groups, user, tenant, auth, dkv, module-registry, `createPlatform`/`remove` in tender-rss-feed) rufen weiter OHNE Benutzer. Jeder umgestellte Dienst hat im Spec mindestens eine Zusicherung `toHaveBeenCalledWith(prisma, tenantId, userId)`, damit das Setzen des Benutzers festgenagelt ist und nicht still wieder verschwinden kann."
- "Kein anwendungsseitiger `userId`-Filter und keine Besitzpruefung wird entfernt. Die Datenbankregel ist ein ZWEITES Netz, kein Ersatz — dieselbe Regel wie fuer `assertTargetBelongsToTenant` in 260910-jab."
- "Je umgestellter Tabelle misst `rls-scratch-check.mjs` ueber den GENERIERTEN Client gegen eine schemagleiche Wegwerf-Tabelle (Spaltenvergleich zur Laufzeit gegen `schema.prisma`) mindestens vier Wahrheiten: Benutzer A sieht die eigene Zeile; Benutzer A sieht die Zeile von Benutzer B DESSELBEN Mandanten nicht; ein Aufruf ohne Benutzer sieht beide; ein Schreiben als Benutzer A mit der Kennung von B wird von der Datenbank abgewiesen (SQLSTATE 42501). Fuer die zwei NULL-faehigen Tabellen zusaetzlich: die gemeinsame Zeile ist fuer A lesbar und fuer A nicht entfernbar. Die Regeln werden WORTGLEICH aus der neuen Migration geschnitten, die Funktion `current_user_id()` ebenfalls — nicht im Werkzeug neu getippt."
- "ALLE loch-behauptenden Pruefungen sind umgedreht, nicht geloescht. Zur Planungszeit gemessen sind es SECHS, nicht drei (der Auftrag nennt drei): `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar`, `dashboardlayout-…-gebunden-sichtbar`, `widgetinstance-…-gebunden-sichtbar`, `searchprovider-…-gebunden-sichtbar`, `calendarsource-…-gebunden-sichtbar` und die Doppelaussage in `favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen`. Der Executor grept selbst erneut (`user-a2` im Werkzeug) und nennt die gefundene Zahl im SUMMARY. Weil die alten Pruefungen OHNE Benutzer massen und ein Aufruf ohne Benutzer per Absicht weiterhin beide sieht, wird jede alte Pruefung zu ZWEI neuen: die alte Messung bleibt unter neuem Namen als die gewollte Eigenschaft (`…-ohne-benutzer-sieht-beide`, mit Verweis auf den alten Befund und den alten Namen im Meldetext), und die Umkehrung misst MIT Benutzer (`…-benutzer-a-sieht-kollegen-nicht`). Kein alter Pruefungsname steht mehr als Kennung in der Ausgabe."
- "`extractPolicySql`/`extractAllPolicySql` lesen fuer alle zehn Tabellen aus der NEUEN Migration (elf Extraktionsstellen und die zwei `regelstand-eindeutig`-Gates, alle zur Planungszeit mit Zeilennummern benannt). Fuer die zwei befehlsgetrennten Tabellen ist `extractAllPolicySql` mit erwarteter Anzahl 4 der Weg. Wer eine Stelle vergisst, misst die abgeloeste Regel weiter — die Falle aus 260910-jab (Befund D)."
- "Jede Aufzeichnung, die 'keine Benutzerdimension' sagt, traegt am Ende einen datierten Nachtrag mit dem Namen der neuen Migration: Kritikschrift (t4/r4/w1/w4/k1/k4/f1/f4/Abschluss), Klassifikation (drei Bestandsaufnahme-Zeilen und 'Was diese Etappe NICHT entscheidet'), Betriebsanleitung, Kopfkommentar in `favorites.service.ts`, und der Etappe-3-Auftrag (3b als erledigt markiert). Die fuenf handgepflegten Abschnitte der Klassifikation bleiben ABGELEITET (Gates unveraendert aus 260911-mkj). WINDOWS.md bekommt einen neuen Eintrag fuer das, was 3b bewusst NICHT loest (siehe key_links), und der Zaehler wird aus dem JSON-Block abgeleitet."
- "Der Schalter bleibt AUS, `schema.prisma` unveraendert, keine Compose-/Umgebungsdatei, keine SECURITY-DEFINER-Funktion, kein Systemkontext, nichts in Active Directory. Baseline 1007 Tests / 62 Dateien / 137 Live-Pruefungen: am Ende JEDER Aufgabe frisch gemessen mindestens 1007 bestanden, mindestens 62 Dateien, Typpruefung sauber, Werkzeug 'Alle N Pruefungen bestanden' mit N > 137. `git status` sauber nach jedem Commit, nach dem letzten Commit gepusht."
artifacts:
- "apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql — NEU, handgeschrieben nach der Kopfform von 20260910120000: `current_user_id()` mit NULLIF, zehn Tabellen umgestellt (acht als eine Regel mit unveraendertem Namen `tenant_isolation_policy`, zwei als je vier befehlsgetrennte Regeln), vier Tabellen begruendet NICHT angefasst, der Satz zum Schalter"
- "apps/api/src/prisma/prisma-tenant.extension.ts — `forTenant(prisma, tenantId, userId?)`, Kopfkommentar um den Abschnitt 'Benutzerdimension (Etappe 3b)' ergaenzt; `withTenantTransaction()` bewusst OHNE dritten Parameter (kein Nutzer-CRUD-Aufrufer, Begruendung im Kommentar)"
- "apps/api/src/prisma/prisma-tenant.extension.spec.ts — drei neue Tests: ohne userId geht der Leerstring als Parameter; mit userId geht er als Template-PARAMETER, nicht als Text; das Feld hat weiterhin genau zwei Eintraege"
- "apps/api/src/groups/migration-sql.spec.ts — neuer describe-Block fuer die neue Migration im Stil der vier bestehenden: Funktion vorhanden, zehn Tabellen genannt, vier Ausnahmen namentlich im Kopf, `IS NULL OR` in jeder Regel, `NULLIF` in der Funktion"
- "apps/api/scripts/rls-scratch-check.mjs — Leser `readRlsUserDimensionMigrationSql()`, Funktions-Extraktion in `setupScratchDatabase`, `buildInlineExtendedClient(prisma, tenantId, userId?)` spiegelbildlich zum Helfer, ein Abschnitt `runUserDimensionChecks` mit den Messungen aller zehn Tabellen, sechs umgedrehte Pruefungen, dreizehn umgeleitete Extraktionsstellen"
- "8 Dienstdateien + 8 Spec-Dateien — 34 Aufrufstellen mit drittem Argument, je Dienst mindestens eine dreistellige Zusicherung"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md — neuer Abschnitt `## Regelschluss Benutzerdimension (Etappe 3b)` mit den fuenf ueblichen Unterabschnitten (b1–b5), plus datierte Nachtraege an den benannten Stellen"
- "docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-datenbankrolle.md, docs/anleitung-entwicklung.md, docs/mandantentrennung-etappe3-auftrag.md, .planning/WINDOWS.md — Nachtraege, neuer Ledger-Eintrag, 3b abgehakt"
key_links:
- "Die `IS NULL OR`-Form ist zugleich das, was die Aenderung sicher macht, und eine bewusst offene Flanke: ein Aufrufer, der den Benutzer VERGISST, sieht den ganzen Mandanten — heute exakt der Stand vor dieser Migration, also keine Verschlechterung, aber auch kein Netz. Das Netz dafuer sind die dreistelligen Spec-Zusicherungen je Dienst und ein neuer WINDOWS-Eintrag: 'Benutzer-Vergessen ist unsichtbar; ein Waechter, der jede Nutzer-CRUD-Aufrufstelle auf das dritte Argument prueft, ist NICHT gebaut' — mit dem Hinweis, dass die Bestandsaufnahme (`rls-access-inventory.spec.ts`) heute nur tenant-gebunden/ungebunden unterscheidet, nicht benutzer-gebunden."
- "Das Werkzeug misst nur, was es aus der Migration schneidet. Wird die Funktion `current_user_id()` im Werkzeug getippt statt geschnitten, misst es ein Wunschbild — deshalb die Extraktion mit Abbruch, wenn sie nichts findet (Muster ldap, T-IPC-08)."
- "Die alten Pruefungen massen OHNE Benutzer. Eine naive Umkehrung (`!bothUsersVisible` beim Aufruf ohne Benutzer) wuerde nach der Migration ROT — weil der Aufruf ohne Benutzer per Absicht beide sieht. Die Umkehrung muss MIT Benutzer messen, sonst behauptet die Pruefung das Gegenteil dessen, was die Regel tut."
- "Die Wegwerf-Tabellen muessen `createdAt`/`updatedAt` und jede weitere Spalte des Modells tragen (260910-krx-Falle); der Spaltenvergleich gegen `schema.prisma` (`readSchemaModelScalarFieldNames`, vorhanden ab Zeile ~2807) ist Pflicht je Tabelle, sonst faellt `create()` ueber den generierten Client mit einer Spaltenfehlermeldung durch und die Pruefung 'Schreiben abgelehnt' bestuende aus dem FALSCHEN Grund."
---
<objective>
Etappe 3b der Mandantentrennung: die Benutzerdimension in die Datenbankregeln der zehn persoenlichen Tabellen bringen, damit die Datenbank — nicht nur der Anwendungscode — Kollegen desselben Mandanten voneinander trennt (Produktentscheidung (2) des Users vom 2026-09-10). Reiner Datenbank- und Helfer-Umbau: eine zweite Sitzungsvariable `app.current_user`, eine Funktion `current_user_id()`, ein optionaler dritter Parameter an `forTenant()`, 34 Aufrufstellen, die den Benutzer setzen, und die Messung im Wegwerf-Werkzeug. Sechs Pruefungen, die das Loch bisher als erwartetes Verhalten festhielten, werden umgedreht. Jede Aufzeichnung, die 'keine Benutzerdimension' sagt, bekommt einen Nachtrag.
Purpose: Das zweite Netz. Heute trennt nur der Anwendungscode nach Benutzer (in jedem Bereich als Test festgenagelt); die Datenbank vergleicht ausschliesslich `tenantId`. Sieben Bereiche haben das als Befund festgehalten. Mit der `IS NULL OR`-Form ist der Umbau fuer jeden Aufrufer ohne Benutzer wirkungslos und blockiert das Live-Gehen am Dienstag 2026-09-15 nicht — der Schalter bleibt AUS, mit BYPASSRLS greift ohnehin keine Regel.
Output: eine handgeschriebene Migration (lokal angewendet), der erweiterte Helfer samt Spec, 8 umgestellte Dienste samt Specs, das erweiterte Wegwerf-Werkzeug (Abschnitt je Tabelle, sechs Umkehrungen, dreizehn Umleitungen), kohaerenter Aktenstand (Kritikschrift, Klassifikation, Betriebsanleitung, Datenbankrolle, Auftrag, Ledger), drei Commits, gepusht.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@docs/mandantentrennung-etappe3-auftrag.md
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
@apps/api/scripts/rls-scratch-check.mjs
@apps/api/src/prisma/rls-access-inventory.spec.ts
@docs/mandantentrennung-zugriffsklassifikation.md
@.planning/quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/260910-jab-PLAN.md
@.planning/quick/260911-mkj-windows-27-schliessen-relations-blindste/260911-mkj-PLAN.md
@./CLAUDE.md
**Zur Planungszeit gemessen (2026-09-11, HEAD 8829999) — vom Executor gegen den lebenden Baum ERNEUT zu pruefen, bevor er es als Bearbeitungsgrundlage nimmt (#3786):**
- Datenbank: Container `tessera-ctl-db-1`, IP per `docker inspect` (zur Planungszeit 172.19.0.2), 34 Migrationen, `migrate status` = up to date, `pg_proc` kennt KEIN `current_user_id`. Pinned Binary `apps/api/node_modules/.bin/prisma`.
- `forTenant()` in `prisma-tenant.extension.ts:111`, setzt genau EINE Sitzungsvariable, Array-Form mit zwei Eintraegen; `prisma-tenant.extension.spec.ts:51` haelt 'genau zwei Eintraege' fest — bleibt gueltig, wenn beide `set_config` in EINER Anweisung stehen.
- Detektor `rls-access-inventory.spec.ts:371`: `/const\s+(\w+)\s*=\s*forTenant\(/g` — ein dritter Parameter aendert am Match nichts; ein Schwesterhelfer wuerde NICHT matchen.
- `userId`-Spalten (schema.prisma): NOT NULL bei CalendarSource, DashboardLayout (`@unique`), FavoriteLink, TenderEmailConfig (`@unique`), TenderNotificationPref (`@unique`), TenderSavedSearch, TenderTriage, WidgetInstance, GroupMembership, PasswordResetToken, TenderMatch; NULL-faehig bei SearchProvider, TenderRssFeedSource (`// null = platform-wide`), ModuleGrant.
- Heutige Regeln der zehn Tabellen: eine `tenant_isolation_policy` je Tabelle aus `20260909140000_rls_remaining_tenant_tables` — AUSSER `TenderRssFeedSource` (vier befehlsgetrennte Regeln `tenant_platform_read_policy`/`tenant_insert_policy`/`tenant_update_policy`/`tenant_delete_policy` aus 20260910120000). `SearchProvider` mandantenstreng, ausdruecklich NICHT gelockert (jab, widerlegte Praemisse).
- Funktionen brauchen KEIN `GRANT EXECUTE`: 20260909130000_rls_app_role vergibt fuer `current_tenant_id()` keines (PostgreSQL-Vorgabe: EXECUTE fuer PUBLIC). Das Wegwerf-Werkzeug vergibt es in `setupScratchDatabase` (Zeile ~142) als eigenen Guertel — fuer `current_user_id()` spiegeln.
- `setupScratchDatabase` (Zeile 100–148) TIPPT `current_tenant_id()` inline. `current_user_id()` wird dagegen aus der neuen Migration GESCHNITTEN (Regex `CREATE OR REPLACE FUNCTION current_user_id\(\)[\s\S]*?LANGUAGE sql STABLE;`), Abbruch mit fehlgeschlagener Pruefung, wenn nichts gefunden wird.
- Extraktionsstellen, die nach der Migration die ABGELOESTE Regel messen wuerden (Zeilennummern zur Planungszeit): 975 TenderEmailConfig, 978 TenderNotificationPref, 981 TenderRssFeedSource (`extractAllPolicySql`, heute aus widen), 984 TenderSavedSearch, 987 TenderTriage, 1386 SearchProvider, 2430 DashboardLayout, 2433 WidgetInstance, 2436 SearchProvider, 2874 CalendarSource, 3816 FavoriteLink — plus die Gates `calendarsource-regelstand-eindeutig` (2857–2870) und `favoritelink-regelstand-eindeutig` (3799–3812), die heute behaupten, die widen-Migration habe KEINE eigene Regel, und deshalb aus 20260909140000 lesen. `smtpconfig-regelstand-eindeutig` (4104) bleibt unangetastet (nicht im Umfang).
- Loch-behauptende Pruefungen (SECHS, nicht drei; Suchbereich: `grep -n "user-a2"` in rls-scratch-check.mjs, alle Bereichsfunktionen): `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` (1144), `dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` (2536), `widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` (2573), `searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` (2732), `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` (2979), `favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen` (3941, Doppelaussage: eigene Zeilen kommen UND die Kollegenzeile ist ueber deren widgetId sichtbar — nur die zweite Haelfte dreht sich um). Fuer TenderEmailConfig, TenderNotificationPref, TenderTriage, TenderRssFeedSource gibt es heute KEINE Zwei-Nutzer-Pruefung im selben Mandanten (nur user-b in TENANT-B) — die kommen neu.
- Aufrufstellen, die `userId` in der Hand haben und den Benutzer setzen (34 in 8 Dateien): calendar.service.ts getSources/addSource/updateSource/deleteSource/testConnection/aggregateEvents (6); dashboard.service.ts getLayout/saveLayout/getWidgets/addWidget/updateWidgetConfig/removeWidget/getSearchProviders/addSearchProvider/removeSearchProvider (9); favorites.service.ts list/create/update/remove/getIconBytes (5); tender-email-config.service.ts getConfigForApi/saveConfig/testConnection (3); tender-notification-pref.service.ts getForUser/setForUser (2); tender-rss-feed.service.ts listForUser/createForUser (2 — `createPlatform` und `remove` bleiben ungebunden, WINDOWS #24); tender-saved-search.service.ts list/create/update/remove (4); tender-triage.service.ts setTriage/listForUser/favoriteIds (3). NICHT: tender-digest.scheduler.ts:135 (Hintergrunddienst, Etappe 3c), alle ldap/groups/user/tenant/auth/dkv/module-registry-Aufrufe (Verwaltung).
- Specs mit zweistelliger Zusicherung, die bleiben (kein Benutzer): ldap-config.service.spec.ts, ldap.service.spec.ts, tender-matching.service.spec.ts. Spec mit zweistelliger Zusicherung, die DREISTELLIG werden muss: tender-rss-feed.service.spec.ts:533.
- Aufzeichnungen 'keine Benutzerdimension' (zur Planungszeit): fehlerrichtung.md Zeilen 458 (t1), 571–657 (t4), 1741–1749 (r4), 1797/1801/1807 (w1), 1816 (w4), 2076 (k1), 2230–2234 (k4), 2744 (f1), 2810–2815 (f4), 3112 (Abschluss); klassifikation.md Zeilen 575/579/583 (Bestandsaufnahme calendarSource/widgetInstance/favoriteLink) und der Abschnitt 'Was diese Etappe NICHT entscheidet' (ab 646); anleitung-entwicklung.md:324; favorites.service.ts:26. Executor grept `Benutzerdimension` erneut ueber docs/, apps/api/src, apps/api/scripts, .planning/WINDOWS.md und nennt die Zahl.
- Testkommandos: `npm --prefix apps/api run test` (vitest, Zusammenfassungszeile `Tests N passed (N)` / `Test Files M passed (M)`), `npm --prefix apps/api run type-check`. Werkzeug: `TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs`, Schlusszeile `Alle N Pruefungen bestanden.`.
- Kein `mailhog` lokal: `ENOTFOUND mailhog` in Testausgaben ist Umgebung, kein Defekt.
</context>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Der eine Pfad durch alle Schichten — Funktion, Migration, Helfer, ein Dienst, eine Messung, eine Umkehrung [BLOCKING: Migration lokal anwenden]</name>
<files>apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql, 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</files>
<action>
Vorab messen, nicht erinnern: `git rev-parse HEAD` (erwartet 8829999), `git status --short` leer, DB-IP per `docker inspect tessera-ctl-db-1`, `migrate status` up to date, `SELECT count(*) FROM pg_proc WHERE proname='current_user_id'` = 0, Baseline-Testlauf mit Zahlen im SUMMARY.
**(1) Migration** `apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql`, Kopfform wie 20260910120000 (Kennung dieses Durchlaufs `260911-nke`, welche Dateien UNVERAENDERT stehen bleiben und warum — Prisma-Pruefsumme —, der Satz zum Schalter). Inhalt, in dieser Reihenfolge:
- `CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$ SELECT NULLIF(current_setting('app.current_user', true), ''); $$ LANGUAGE sql STABLE;` — mit Kommentar, warum `NULLIF`: der Helfer sendet 'kein Benutzer' als Leerstring (Begruendung unter (2)), und die Regeln muessen ungesetzt und leer gleich behandeln. Kein GRANT (Begruendung aus dem Kontext).
- Kopfabsatz 'Vierzehn Tabellen tragen `userId`, zehn davon sind persoenliche Daten': die vier Ausnahmen namentlich mit je einem Satz Grund (GroupMembership/ModuleGrant Verwaltungsobjekte, ein Admin sieht sie fuer andere; PasswordResetToken Anmelde-Artefakt, gelesen bevor ein Benutzer bekannt ist; TenderMatch wird vom Hintergrunddienst je Treffer geschrieben). Diese vier bekommen KEINE Anweisung.
- Acht Tabellen mit NOT-NULL-`userId` (CalendarSource, DashboardLayout, FavoriteLink, TenderEmailConfig, TenderNotificationPref, TenderSavedSearch, TenderTriage, WidgetInstance): je `DROP POLICY tenant_isolation_policy ON "X";` und `CREATE POLICY tenant_isolation_policy ON "X" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Name bewusst beibehalten (wie jab bei GroupMembership/ModuleGrant): `extractPolicySql` und `pg_policies` behalten je Tabelle eine Regel. Ein einzelner USING-Ausdruck genuegt hier, weil Lesen und Schreiben dieselbe Bedingung haben — WITH CHECK folgt USING, ein Einfuegen als Benutzer A mit Kennung B faellt durch.
- `SearchProvider` (NULL-faehige `userId`, mandantenSTRENG): `DROP POLICY tenant_isolation_policy`, dann VIER Regeln `tenant_user_read_policy` (FOR SELECT, USING `"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" IS NULL OR "userId" = current_user_id())`), `tenant_user_insert_policy` (FOR INSERT, WITH CHECK `"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`), `tenant_user_update_policy` (FOR UPDATE, USING und WITH CHECK wie insert), `tenant_user_delete_policy` (FOR DELETE, USING wie insert). Kommentar: die Mandantenhaelfte ist NICHT gelockert (jab (4), widerlegte Praemisse); die Trennung nach Befehl, weil ein USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst, sie auch zum Aendern/Entfernen freigaebe (jab (3)).
- `TenderRssFeedSource`: die vier jab-Regeln per DROP abloesen und unter DENSELBEN NAMEN neu anlegen — `tenant_platform_read_policy` USING `("tenantId" = current_tenant_id() OR "tenantId" IS NULL) AND (current_user_id() IS NULL OR "userId" IS NULL OR "userId" = current_user_id())`; die drei Schreibregeln `"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`. Kommentar: plattformweite Lesezulassung und Mandantenpflicht beim Schreiben aus 20260910120000 bleiben; WINDOWS #24 bleibt davon unberuehrt.
- Abschliessender Absatz 'Was diese Migration bewusst NICHT tut': kein Systemkontext (3c), keine SECURITY-DEFINER-Funktion (3a), und: ein Aufrufer, der den Benutzer vergisst, sieht den ganzen Mandanten — heute exakt der Stand davor.
Migration ANWENDEN: `DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma` (pinned Binary, NICHT `npx prisma`). Danach `migrate status` und `pg_policies`-Abfrage fuer die zehn Tabellen ins SUMMARY (woertlich, siehe verify). `schema.prisma` bleibt unangetastet — `git diff --name-only -- apps/api/prisma/schema.prisma` leer. Wenn du zu dem Schluss kommst, das Schema muesse sich aendern: ANHALTEN und melden, nicht aendern.
**(2) Helfer** `prisma-tenant.extension.ts`: Signatur `forTenant(prisma: PrismaClient, tenantId: string, userId?: string)`. Das getaggte Template wird zu EINER Anweisung mit zwei `set_config`-Aufrufen: `app.current_tenant` mit `${tenantId}`, `app.current_user` mit `${userId ?? ''}`, beide `true` (transaktionslokal). Das `$transaction`-Feld behaelt genau zwei Eintraege. Kopfkommentar um einen Abschnitt 'BENUTZERDIMENSION (Etappe 3b, 260911-nke)' ergaenzen: warum dritter Parameter statt Schwesterhelfer (Detektor-Regex `const X = forTenant(`, Praezedenz `withTenantTransaction()`), warum Leerstring statt Weglassen (kein Erben eines Benutzers aus einer frueheren Transaktion, selbst wenn spaeter jemand `local=false` einfuehrt; `current_user_id()` faltet '' auf NULL), und wer den Benutzer setzt (nur Nutzer-CRUD; Hintergrunddienste und Verwaltungswege nicht — die `IS NULL OR`-Form der Regeln macht das zur bewussten Eigenschaft). `withTenantTransaction()` bekommt KEINEN dritten Parameter — kein Nutzer-CRUD-Aufrufer nutzt es (nur groups, Verwaltung); ein unbenutzter Parameter waere Spekulation; ein Satz im Kommentar sagt das.
`prisma-tenant.extension.spec.ts`: Bestehende Tests lesen, dann drei neue: (a) ohne `userId` enthaelt die Parameterliste des Templates den Leerstring an zweiter Stelle und der Template-Text nennt `app.current_user`; (b) mit `userId` geht der Wert als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt); (c) das Feld hat weiterhin genau zwei Eintraege (den bestehenden Test bei Zeile 51 nicht schwaechen). Zusaetzlich EIN Test in `rls-access-inventory.spec.ts` NICHT noetig — stattdessen im SUMMARY belegen, dass der Detektor-Regex die dreistellige Form matcht (ein `node -e` mit dem Regex gegen die Zeile aus tender-saved-search.service.ts, Ausgabe woertlich).
**(3) Migrations-Textabgleich** `migration-sql.spec.ts`: fuenfter describe-Block `rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nke)` im Stil der vier bestehenden: Funktion mit `NULLIF` vorhanden; fuer jede der zehn Tabellen mindestens eine `CREATE POLICY … ON "X"`; jede CREATE-POLICY-Anweisung enthaelt `current_user_id() IS NULL OR`; genau 8 `DROP POLICY tenant_isolation_policy` und 4 DROPs auf `TenderRssFeedSource`; die vier Ausnahmen namentlich im Kopf; KEINE Anweisung auf GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch (Regex ueber Nicht-Kommentarzeilen).
**(4) Der eine Dienst** `tender-saved-search.service.ts`: alle vier `forTenant(this.prisma, tenantId)` werden `forTenant(this.prisma, tenantId, userId)`. Kopfkommentar (Zeile ~23) um einen Satz zur Benutzerdimension. Spec: fuer jede der vier Methoden `expect(forTenant).toHaveBeenCalledWith(prisma, tenantId, userId)` (die Mock-Form der Datei uebernehmen). Keine `where: { userId }`-Filterung entfernen.
**(5) Werkzeug** `rls-scratch-check.mjs`, nur der eine Pfad:
- `readRlsUserDimensionMigrationSql()` (Suffix `_rls_user_dimension_personal_tables`, Muster `readRlsWidenMigrationSql`).
- `setupScratchDatabase`: nach der getippten `current_tenant_id()` die Funktion `current_user_id()` aus der neuen Migration SCHNEIDEN und ausfuehren; `GRANT EXECUTE ON FUNCTION current_user_id() TO <Rolle>` spiegeln. Findet die Extraktion nichts: `fail(...)` mit klarer Meldung (kein getipptes Wunschbild).
- `buildInlineExtendedClient(prisma, tenantId, userId)` bekommt den optionalen dritten Parameter und sendet dieselbe Ein-Anweisungs-Form wie der Helfer (Leerstring ohne Benutzer). Kommentar: spiegelbildlich zu `forTenant()`, bei jeder Aenderung dort hier nachziehen.
- `runForTenantChecks` (Zeile 181): drei Pruefungen `current-user-id-ungesetzt-ist-null`, `current-user-id-leer-ist-null`, `current-user-id-gesetzt-liefert-wert` ueber `$queryRaw` innerhalb je einer Transaktion.
- NEUE Bereichsfunktion `runUserDimensionChecks(adminUrl, scratchRoleUrl, results)`, im Hauptlauf NACH `runSettingsAreaChecks` und VOR `runConcurrencyProbe` eingehaengt, mit einer inneren Tabellenroutine, die in Aufgabe 2 fuer alle zehn Tabellen wiederverwendet wird: Wegwerf-Tabelle schemagleich (Spaltenvergleich gegen `readSchemaModelScalarFieldNames('<Modell>')`, Muster calendar Zeile ~2939–2960; bei Abweichung Pruefung `<slug>-wegwerftabelle-deckt-alle-spalten-des-generierten-clients` FEHLGESCHLAGEN und Abschnitt abbrechen), Regel(n) WORTGLEICH aus der neuen Migration (fuer die acht Ein-Regel-Tabellen `extractPolicySql`, fuer SearchProvider/TenderRssFeedSource `extractAllPolicySql` mit erwarteter Laenge 4), Zeilen ueber die Wartungsrolle: `ss-a1`/`user-a1`/TENANT-A, `ss-a2`/`user-a2`/TENANT-A, `ss-b`/`user-b`/TENANT-B. Dann ueber den GENERIERTEN Client mit `buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1')` und ohne Benutzer: `<slug>-benutzer-a-sieht-eigene-zeile`, `<slug>-benutzer-a-sieht-kollegen-nicht`, `<slug>-ohne-benutzer-sieht-beide`, `<slug>-schreiben-als-a-mit-kennung-b-abgelehnt` (create ueber den benutzergebundenen Client mit `userId: 'user-a2'`; erwartet SQLSTATE 42501 via `sqlStateOf`, JEDER andere Fehler und jedes Gelingen = FEHLGESCHLAGEN mit Meldung; die Wegwerf-Tabelle muss dafuer jede Pflichtspalte des Modells tragen, sonst faellt es aus dem falschen Grund). In dieser Aufgabe nur `tendersavedsearch`.
- Extraktionsstelle 984 (TenderSavedSearch in `runTendersAreaChecks`) auf die neue Migration umleiten.
- Umkehrung von `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` (1144) IN `runTendersAreaChecks`, nicht loeschen: die bestehende Messung ohne Benutzer bleibt unter dem Namen `tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` (Meldetext: 'bis 260911-nke als Befund E unter dem Namen tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst'); dazu die neue Pruefung `tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden` MIT Benutzer `user-a1`, die die Zeile `ss-a2` NICHT liefert. Der alte Name darf nur noch im Meldetext vorkommen, nicht als Kennung.
Baseline am Ende: Tests >= 1007 / >= 62 Dateien, Typpruefung, Werkzeug 'Alle N Pruefungen bestanden' mit N > 137. Commit: `feat(quick-260911-nke): current_user_id(), Benutzerdimension in den Regeln, forTenant() mit userId — ein Pfad`. `git status --short` danach leer.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && M=apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql && test -f "$M" && grep -q 'CREATE OR REPLACE FUNCTION current_user_id()' "$M" && grep -q "NULLIF(current_setting('app.current_user', true), '')" "$M" && for T in CalendarSource DashboardLayout FavoriteLink SearchProvider TenderEmailConfig TenderNotificationPref TenderRssFeedSource TenderSavedSearch TenderTriage WidgetInstance; do grep -q "CREATE POLICY [a-z_]* ON \"$T\"" "$M" || { echo "FEHLT: Regel fuer $T"; exit 1; }; done && for T in GroupMembership ModuleGrant PasswordResetToken TenderMatch; do test 0 -eq "$(grep -v '^\s*--' "$M" | grep -c "ON \"$T\"")" || { echo "VERBOTEN: Anweisung auf $T"; exit 1; }; done && test -z "$(git diff --name-only 8829999 -- apps/api/prisma/schema.prisma docker-compose.yml docker-compose.prod.yml)" && test 0 -eq "$(git diff --name-only 8829999 | grep -c '^\.env' )" && grep -q 'userId?: string' apps/api/src/prisma/prisma-tenant.extension.ts && grep -q "app.current_user" apps/api/src/prisma/prisma-tenant.extension.ts && test 0 -eq "$(grep -c 'forTenantAndUser' apps/api/src/prisma/prisma-tenant.extension.ts)" && test 4 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/tenders/tender-saved-search.service.ts)" && node -e "const s=require('fs').readFileSync('apps/api/src/tenders/tender-saved-search.service.ts','utf8');const n=[...s.matchAll(/const\s+(\w+)\s*=\s*forTenant\(/g)].length;console.log('detektor-matches',n);process.exit(n===4?0:1)" && DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" apps/api/node_modules/.bin/prisma migrate status --schema apps/api/prisma/schema.prisma | tee /tmp/nke-migrate-status.txt && grep -qi 'up to date' /tmp/nke-migrate-status.txt && test 1 -eq "$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -At -c "SELECT count(*) FROM pg_proc WHERE proname='current_user_id'")" && POL=$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -At -c "SELECT tablename||'#'||policyname||'#'||coalesce(cmd,'')||'#'||coalesce(qual,'')||'#'||coalesce(with_check,'') FROM pg_policies WHERE tablename IN ('CalendarSource','DashboardLayout','FavoriteLink','SearchProvider','TenderEmailConfig','TenderNotificationPref','TenderRssFeedSource','TenderSavedSearch','TenderTriage','WidgetInstance','GroupMembership','ModuleGrant','PasswordResetToken','TenderMatch') ORDER BY tablename, policyname") && echo "$POL" && test 0 -eq "$(echo "$POL" | grep -E '^(CalendarSource|DashboardLayout|FavoriteLink|SearchProvider|TenderEmailConfig|TenderNotificationPref|TenderRssFeedSource|TenderSavedSearch|TenderTriage|WidgetInstance)#' | grep -vc 'current_user_id()')" && test 4 -eq "$(echo "$POL" | grep -c '^SearchProvider#')" && test 4 -eq "$(echo "$POL" | grep -c '^TenderRssFeedSource#')" && test 0 -eq "$(echo "$POL" | grep -E '^(GroupMembership|ModuleGrant|PasswordResetToken|TenderMatch)#' | grep -c 'current_user_id()')" && npm --prefix apps/api run type-check && npm --prefix apps/api run test 2>&1 | tee /tmp/nke-test1.txt | tail -8 && TP=$(grep -oE 'Tests +[0-9]+ passed \([0-9]+\)' /tmp/nke-test1.txt | grep -oE '[0-9]+' | head -1) && TF=$(grep -oE 'Test Files +[0-9]+ passed \([0-9]+\)' /tmp/nke-test1.txt | grep -oE '[0-9]+' | head -1) && test "${TP:-0}" -ge 1007 && test "${TF:-0}" -ge 62 && test 0 -eq "$(grep -cE '^ *Tests +[0-9]+ failed' /tmp/nke-test1.txt)" && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" | tail -3 && for K in current-user-id-ungesetzt-ist-null current-user-id-leer-ist-null current-user-id-gesetzt-liefert-wert tendersavedsearch-wegwerftabelle-deckt-alle-spalten-des-generierten-clients tendersavedsearch-benutzer-a-sieht-eigene-zeile tendersavedsearch-benutzer-a-sieht-kollegen-nicht tendersavedsearch-ohne-benutzer-sieht-beide tendersavedsearch-schreiben-als-a-mit-kennung-b-abgelehnt tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden; do echo "$OUT" | grep -q "^${K}: bestanden" || { echo "FEHLT/ROT: $K"; exit 1; }; done && test 0 -eq "$(echo "$OUT" | grep -c '^tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar:')" && N=$(echo "$OUT" | grep -oE 'Alle [0-9]+ Pruefungen bestanden' | grep -oE '[0-9]+') && test "${N:-0}" -gt 137 && git status --short | tee /tmp/nke-status1.txt && test -z "$(cat /tmp/nke-status1.txt)"</automated>
</verify>
<done>Migration existiert, ist lokal angewendet (`pg_proc` kennt `current_user_id`, `pg_policies` zeigt fuer alle zehn Tabellen `current_user_id()`, vier Regeln auf SearchProvider und TenderRssFeedSource, die vier Ausnahmen ohne Benutzerdimension), `schema.prisma`/Compose/.env unveraendert; `forTenant()` hat den optionalen dritten Parameter, Detektor-Regex matcht (Ausgabe `detektor-matches 4`); tender-saved-search setzt an vier Stellen den Benutzer; das Werkzeug misst die drei Funktionsfaelle und die vier Wahrheiten fuer TenderSavedSearch, die alte Loch-Pruefung ist umgedreht (alter Name nur noch im Meldetext); Tests >= 1007 / >= 62, Typpruefung sauber, Werkzeug 'Alle N Pruefungen bestanden' mit N > 137; committet, `git status` leer.</done>
</task>
<task type="auto">
<name>Aufgabe 2: Die uebrigen neun Tabellen — Messung je Tabelle, fuenf weitere Umkehrungen, zwoelf Umleitungen, 30 Aufrufstellen in sieben Diensten</name>
<files>apps/api/scripts/rls-scratch-check.mjs, 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/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</files>
<action>
Vorab: `git status --short` leer, Baseline aus Aufgabe 1 (Zahlen ins SUMMARY). Die Liste der 34 Aufrufstellen aus dem Kontext gegen den lebenden Baum ERNEUT zaehlen: `grep -n "forTenant(this.prisma" <acht Dateien>` — Abweichungen im SUMMARY nennen, nicht still uebergehen.
**(1) Dienste, 30 verbleibende Aufrufstellen in sieben Dateien** — jede Methode, die `userId` (oder `ctx.userId`) in der Hand hat und persoenliche Zeilen liest oder schreibt, ruft `forTenant(this.prisma, tenantId, userId)`: calendar.service.ts (6), dashboard.service.ts (9), favorites.service.ts (5), tender-email-config.service.ts (3), tender-notification-pref.service.ts (2), tender-rss-feed.service.ts `listForUser`/`createForUser` (2, `createPlatform`/`remove` bleiben ungebunden — WINDOWS #24, dort einen Satz ergaenzen), tender-triage.service.ts (3). `tender-digest.scheduler.ts:135` bleibt OHNE Benutzer (Hintergrunddienst, Etappe 3c — ein Satz im dortigen Kommentar, warum). Keine Methodensignatur aendert sich, kein Controller wird angefasst. KEIN anwendungsseitiger `userId`-Filter und KEINE Besitzpruefung wird entfernt (`provider.userId !== userId`, `where: { userId }` usw. bleiben — zweites Netz, wie `assertTargetBelongsToTenant` in jab). Kopfkommentare der sieben Dienste um je einen Satz ergaenzen; in `favorites.service.ts:26` den Satz 'kennt KEINE Benutzerdimension' durch den neuen Stand ersetzen (Migrationsname). Beobachtung fuer den Bericht (nicht aendern): Methoden, die eine Zeile per `findUnique({ where: { id } })` holen und danach `userId` vergleichen (`removeWidget`, `updateWidgetConfig`, `removeSearchProvider`, `updateSource`, `deleteSource`, favorites `update`/`remove`), sehen NACH dem Scharfschalten die Kollegenzeile als `null` — NotFound statt Forbidden, beides Abweisung; das kommt in b3 der Kritikschrift (Aufgabe 3).
Specs der sieben Dienste: je Datei fuer JEDE umgestellte Methode eine dreistellige Zusicherung `expect(forTenant).toHaveBeenCalledWith(prisma, tenantId, userId)` in der Mock-Form der jeweiligen Datei (`tender-rss-feed.service.spec.ts:533` von zwei- auf dreistellig heben; die zweistelligen Zusicherungen in ldap-config/ldap/tender-matching bleiben zweistellig — dort ist kein Benutzer gesetzt, das ist richtig so). Wo ein Spec `forTenant` nicht mockt, sondern den Dienst mit gemocktem Prisma laufen laesst, die Mock-Form der Datei uebernehmen und mindestens einen Aufruf pro Methode dreistellig festnageln.
**(2) Werkzeug — neun weitere Tabellen** ueber die Tabellenroutine aus Aufgabe 1 in `runUserDimensionChecks`, Slugs `calendarsource`, `dashboardlayout`, `favoritelink`, `searchprovider`, `tenderemailconfig`, `tendernotificationpref`, `tenderrssfeed`, `tendertriage`, `widgetinstance`, je die vier Wahrheiten plus `<slug>-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`. Besonderheiten: `@unique` auf `userId` bei DashboardLayout/TenderEmailConfig/TenderNotificationPref (eine Zeile je Nutzer — die Wegwerf-Tabelle traegt den Unique-Index, sonst prueft `create()` etwas anderes als die Produktion); `favoritelink` und `widgetinstance` brauchen ihre Fremdzeilen (Muster aus `runFavoritesAreaChecks`); fuer `searchprovider` und `tenderrssfeed` (vier Regeln je Tabelle, `extractAllPolicySql` mit Laenge 4, sonst FEHLGESCHLAGEN) zusaetzlich `<slug>-gemeinsame-zeile-fuer-benutzer-a-lesbar` (Zeile mit `userId NULL` — bei tenderrssfeed die plattformweite mit `tenantId NULL`, bei searchprovider eine mandantengebundene mit `userId NULL`) und `<slug>-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar` (`deleteMany` ueber den benutzergebundenen Client liefert `count: 0` — eine Regel ohne Befehlstrennung wuerde hier 1 liefern) sowie `<slug>-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar` (Gegenmessung ueber den mandanten-gebundenen Client ohne Benutzer; bei tenderrssfeed erwartet `count: 0`, weil die plattformweite Zeile die Mandantenpflicht der Schreibregel nicht erfuellt — das ist WINDOWS #24 und wird als bestanden mit diesem Verweis gemeldet, nicht als Loch neu entdeckt).
**(3) Fuenf weitere Umkehrungen** in den BESTEHENDEN Bereichsfunktionen (nicht in der neuen), nach dem Muster aus Aufgabe 1 — alte Messung ohne Benutzer bleibt unter neuem Namen `<slug>-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` mit Verweis auf alten Befund (G bzw. E) und alten Namen im Meldetext; neue Umkehrung MIT Benutzer `<slug>-benutzer-a-sieht-kollegen-nicht-gebunden`: `dashboardlayout` (2536), `widgetinstance` (2573), `searchprovider` (2732), `calendarsource` (2979 — der Meldetext nennt weiterhin, dass `encryptedPassword` eines Kollegen jetzt NICHT mehr lesbar ist), `favoritelink` (3941: die Doppelaussage trennen — `…-liefert-eigene-zeilen` behaelt NUR die erste Haelfte, die zweite Haelfte wird zu den zwei neuen Pruefungen). Danach `grep -n "user-a2"` erneut ueber das ganze Werkzeug und im SUMMARY die Zahl der gefundenen und umgedrehten Stellen nennen — der Auftrag sagte drei, die Planung fand sechs; wenn du mehr findest, drehst du auch die um.
**(4) Zwoelf Umleitungen** (Zeilennummern aus dem Kontext, erneut per grep bestaetigen): 975, 978, 981, 987, 1386, 2430, 2433, 2436, 2874, 3816 lesen aus `readRlsUserDimensionMigrationSql()`; 1386 und 2436 (SearchProvider) auf `extractAllPolicySql` mit Laenge 4 und Anwendung aller vier Regeln; 981 (TenderRssFeedSource, schon `extractAllPolicySql`) nur die Quelle wechseln. Die Gates `calendarsource-regelstand-eindeutig` (2857) und `favoritelink-regelstand-eindeutig` (3801) erweitern: widen hat KEINE eigene Regel UND die neue Migration hat GENAU EINE — dann aus der neuen lesen; Meldetext nennt den Wechsel. `smtpconfig-regelstand-eindeutig` unangetastet. Am Ende darf keine Bereichsfunktion fuer eine der zehn Tabellen noch aus `readRemainingTenantTablesMigrationSql()` oder `readRlsWidenMigrationSql()` schneiden (Gate in verify).
Baseline am Ende: Tests >= Stand nach Aufgabe 1, Dateien >= 62, Typpruefung, Werkzeug gruen mit N > Stand nach Aufgabe 1. Commit: `feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht`. `git status --short` leer.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && S=apps/api/scripts/rls-scratch-check.mjs && for D in src/calendar/calendar.service.ts src/dashboard/dashboard.service.ts src/favorites/favorites.service.ts src/tenders/tender-email-config.service.ts src/tenders/tender-notification-pref.service.ts src/tenders/tender-rss-feed.service.ts src/tenders/tender-saved-search.service.ts src/tenders/tender-triage.service.ts; do test 0 -eq "$(grep -c 'forTenant(this.prisma, [a-zA-Z.]*)' apps/api/$D)" || { echo "ZWEISTELLIG NOCH VORHANDEN in $D"; grep -n 'forTenant(this.prisma, [a-zA-Z.]*)' apps/api/$D; exit 1; }; done && test 6 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/calendar/calendar.service.ts)" && test 9 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/dashboard/dashboard.service.ts)" && test 5 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/favorites/favorites.service.ts)" && test 3 -eq "$(grep -cE 'forTenant\(this\.prisma, tenantId, userId\)' apps/api/src/tenders/tender-email-config.service.ts)" && test 2 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/tenders/tender-notification-pref.service.ts)" && test 2 -eq "$(grep -cE 'forTenant\(this\.prisma, (ctx\.)?tenantId, (ctx\.)?userId\)' apps/api/src/tenders/tender-rss-feed.service.ts)" && test 3 -eq "$(grep -c 'forTenant(this.prisma, tenantId, userId)' apps/api/src/tenders/tender-triage.service.ts)" && test 1 -eq "$(grep -c 'forTenant(this.prisma, tenantId)' apps/api/src/tenders/tender-digest.scheduler.ts)" && test 0 -eq "$(git diff --name-only 8829999 | grep -c 'controller\.ts$')" && for SP in src/calendar/calendar.service.spec.ts src/dashboard/dashboard.service.spec.ts src/favorites/favorites.service.spec.ts src/tenders/tender-email-config.service.spec.ts src/tenders/tender-notification-pref.service.spec.ts src/tenders/tender-rss-feed.service.spec.ts src/tenders/tender-saved-search.service.spec.ts src/tenders/tender-triage.service.spec.ts; do grep -qE 'expect\(forTenant\)\.toHaveBeenCalledWith\([^,()]+, [^,()]+, [^,()]+\)' apps/api/$SP || { echo "KEINE DREISTELLIGE ZUSICHERUNG expect(forTenant).toHaveBeenCalledWith(prisma, tenantId, userId) in $SP"; exit 1; }; done && test 0 -eq "$(grep -c 'kennt KEINE Benutzerdimension' apps/api/src/favorites/favorites.service.ts)" && for T in TenderEmailConfig TenderNotificationPref TenderSavedSearch TenderTriage DashboardLayout WidgetInstance CalendarSource FavoriteLink SearchProvider; do test 0 -eq "$(grep -c "extractPolicySql(remainingMigrationSql, '$T')" "$S")" || { echo "ALTE QUELLE fuer $T"; exit 1; }; done && test 0 -eq "$(grep -c "extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource')" "$S")" && test 0 -eq "$(grep -c "extractPolicySql([a-zA-Z]*, 'SearchProvider')" "$S")" && grep -q 'function readRlsUserDimensionMigrationSql' "$S" && for OLD in tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar; do test 0 -eq "$(grep -c "^\s*'${OLD}',\s*$" "$S")" || { echo "ALTER NAME NOCH KENNUNG: $OLD"; exit 1; }; grep -q "$OLD" "$S" || { echo "VERWEIS AUF ALTEN NAMEN FEHLT: $OLD"; exit 1; }; done && npm --prefix apps/api run type-check && npm --prefix apps/api run test 2>&1 | tee /tmp/nke-test2.txt | tail -8 && TP=$(grep -oE 'Tests +[0-9]+ passed \([0-9]+\)' /tmp/nke-test2.txt | grep -oE '[0-9]+' | head -1) && TF=$(grep -oE 'Test Files +[0-9]+ passed \([0-9]+\)' /tmp/nke-test2.txt | grep -oE '[0-9]+' | head -1) && test "${TP:-0}" -ge 1010 && test "${TF:-0}" -ge 62 && test 0 -eq "$(grep -cE '^ *Tests +[0-9]+ failed' /tmp/nke-test2.txt)" && DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" | tail -3 && for SL in calendarsource dashboardlayout favoritelink searchprovider tenderemailconfig tendernotificationpref tenderrssfeed tendersavedsearch tendertriage widgetinstance; do for SUF in wegwerftabelle-deckt-alle-spalten-des-generierten-clients benutzer-a-sieht-eigene-zeile benutzer-a-sieht-kollegen-nicht ohne-benutzer-sieht-beide schreiben-als-a-mit-kennung-b-abgelehnt; do echo "$OUT" | grep -q "^${SL}-${SUF}: bestanden" || { echo "FEHLT/ROT: ${SL}-${SUF}"; exit 1; }; done; done && for SL in searchprovider tenderrssfeed; do for SUF in gemeinsame-zeile-fuer-benutzer-a-lesbar gemeinsame-zeile-als-benutzer-a-nicht-entfernbar gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar; do echo "$OUT" | grep -q "^${SL}-${SUF}: bestanden" || { echo "FEHLT/ROT: ${SL}-${SUF}"; exit 1; }; done; done && for SL in tendersavedsearch dashboardlayout widgetinstance searchprovider calendarsource favoritelink; do echo "$OUT" | grep -q "^${SL}-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten: bestanden" || { echo "FEHLT/ROT: ${SL}-ohne-benutzer-…"; exit 1; }; echo "$OUT" | grep -q "^${SL}-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden" || { echo "FEHLT/ROT: ${SL}-…-gebunden"; exit 1; }; done && test 0 -eq "$(echo "$OUT" | grep -cE '^[a-z-]*-fremder-nutzer-desselben-mandanten(-gebunden)?-sichtbar:')" && test 0 -eq "$(echo "$OUT" | grep -c 'FEHLGESCHLAGEN')" && N=$(echo "$OUT" | grep -oE 'Alle [0-9]+ Pruefungen bestanden' | grep -oE '[0-9]+') && test "${N:-0}" -ge 200 && git status --short | tee /tmp/nke-status2.txt && test -z "$(cat /tmp/nke-status2.txt)"</automated>
</verify>
<done>Alle 34 Aufrufstellen dreistellig (6/9/5/3/2/2/4/3), der Scheduler zweistellig, kein Controller angefasst, jede der acht Spec-Dateien mit dreistelliger Zusicherung; Werkzeug misst je zehn Tabellen die fuenf Kennungen, fuer die zwei NULL-faehigen Tabellen die drei Zusatzkennungen, die sechs Umkehrungen sind da (alte Namen nur noch im Meldetext); keine Extraktion einer der zehn Tabellen liest mehr aus der alten Quelle; Tests >= 1010 / >= 62, Typpruefung, Werkzeug N >= 200 ohne FEHLGESCHLAGEN; committet, `git status` leer.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Den Aktenstand kohaerent machen — Kritikschrift, Klassifikation, Betriebsanleitung, Datenbankrolle, Auftrag, Ledger — und pushen</name>
<files>docs/mandantentrennung-etappe2-fehlerrichtung.md, docs/mandantentrennung-zugriffsklassifikation.md, docs/mandantentrennung-datenbankrolle.md, docs/anleitung-entwicklung.md, docs/mandantentrennung-etappe3-auftrag.md, .planning/WINDOWS.md</files>
<action>
Vorab: `git status --short` leer; `grep -rn "Benutzerdimension" docs apps/api/src apps/api/scripts .planning/WINDOWS.md` — jede Fundstelle einordnen (bereits nachgetragen / jetzt nachzutragen / historisch korrekt und zu lassen), Liste ins SUMMARY.
**(1) Kritikschrift** `docs/mandantentrennung-etappe2-fehlerrichtung.md`: neuer Abschnitt `## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)` VOR `## Etappe 2 — Abschluss`, mit den fuenf ueblichen Unterabschnitten in der Buchstabenform des Dokuments: `### (b1) Die Messung` (die tatsaechlich beobachtete Werkzeugausgabe der neuen Kennungen — WOERTLICH eingefuegt, nicht paraphrasiert — und die `pg_policies`-Liste der lebenden Datenbank fuer die zehn Tabellen), `### (b2) Signaltabelle — beide Fehlerrichtungen je Regel` (zu streng: ein Aufruf ohne Benutzer saehe nur einen Nutzer — gemessen NICHT der Fall; zu locker: ein Aufruf mit Benutzer saehe Kollegen — gemessen NICHT der Fall; und die bewusst offene Flanke: vergessener Benutzer = ganzer Mandant, mit dem neuen Ledger-Eintrag), `### (b3) Welcher Code Leere anders deutet als vorher` (die NotFound-statt-Forbidden-Beobachtung aus Aufgabe 2 je Methode, wirksam erst nach dem Scharfschalten), `### (b4) Was dieser Durchlauf bewusst nicht loest` (Systemkontext 3c, Anmeldenamen 3a, kein Waechter fuer das dritte Argument, WINDOWS #24 unveraendert), `### (b5) Was dieser Durchlauf bewusst nicht anfasst` (vier Tabellen mit Grund, SECURITY-DEFINER-Funktionen, Schema, Schalter). Dazu datierte Nachtraege `**Nachtrag (260911-nke):**` an JEDER Bestandsstelle, die 'keine Benutzerdimension' als Stand beschreibt: t1 (~458), t4, r4 (~1741–1749), w1 (~1797/1801/1807 — die drei zitierten Ausgabezeilen bleiben als historische Messung stehen, davor ein Satz, dass sie umgedreht sind, mit den neuen Namen), w4 (~1816), k1 (~2076), k4 (~2230–2234), f1 (~2744), f4 (~2810–2815), Abschluss (~3112). Nachtrag statt Umschreiben — die historische Messung bleibt lesbar.
**(2) Klassifikation** `docs/mandantentrennung-zugriffsklassifikation.md`: die drei Bestandsaufnahme-Zeilen (calendarSource ~575, widgetInstance ~579, favoriteLink ~583) bekommen in ihrer Begruendungsspalte den Zusatz 'Benutzerdimension seit 20260911120000 (260911-nke)'; im Abschnitt 'Was diese Etappe NICHT entscheidet' den Punkt zur Benutzerdimension (falls als eigener Punkt vorhanden — sonst am Punkt zu Etappe 3 (1)) mit `**Aufgelöst (260911-nke):**` in der Form der beiden bestehenden Aufloesungen. Die fuenf handgepflegten Abschnitte (Uebersicht je Bereich, Summenzeile, Klassen-Verteilung samt Ueberschrift, Hintergrunddienst-Abschnitt, Stand-Absatz) bleiben ABGELEITET und werden NICHT umgeschrieben — die Paarzahl aendert sich nicht (keine neue Fundstelle, nur ein drittes Argument); das Gate aus 260911-mkj laeuft in verify unveraendert mit und muss gruen bleiben. Klassen-Spalte bleibt `gebunden` — benutzer-gebunden ist keine neue Klasse (ein Satz im Stand-Absatz? NEIN — der Stand-Absatz ist Teil der abgeleiteten Abschnitte; stattdessen ein neuer datierter Absatz `**Stand 260911-nke:**` direkt UNTER dem Stand-260911-mkj-Absatz, der sagt, dass die Klassen unveraendert sind und die Benutzerdimension in der Begruendungsspalte steht).
**(3) Betriebsanleitung** `docs/anleitung-entwicklung.md:324`: den Halbsatz 'wo die Regel selbst keine Benutzerdimension kennt' durch den neuen Stand ersetzen (zehn Tabellen mit, vier ohne; `forTenant(prisma, tenantId, userId?)`; wer den Benutzer setzt und wer nicht).
**(4) Datenbankrolle** `docs/mandantentrennung-datenbankrolle.md`: einen Absatz zu `app.current_user`/`current_user_id()` neben dem zu `app.current_tenant` (Sitzungsvariablen, die die Rolle setzt; Leerstring = kein Benutzer; kein GRANT noetig, Begruendung). Nichts an den drei SECURITY-DEFINER-Kopfkommentaren.
**(5) Auftrag** `docs/mandantentrennung-etappe3-auftrag.md`: im Abschnitt '3b zuerst' oben einen Satz `**Erledigt (260911-nke, <Commit-Hashes>):** …` mit Migrationsname, gemessener Zahl der Umkehrungen (sechs, nicht drei — den Auftragstext darunter unveraendert lassen) und den Endzahlen (Tests/Dateien/Pruefungen). 3a und 3c unveraendert.
**(6) Ledger** `.planning/WINDOWS.md`: EIN neuer Eintrag `open`, Typ `deviation`, Datei `apps/api/src/prisma/prisma-tenant.extension.ts`: 'Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an `forTenant()` vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit `userId`-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen.' Tabelle UND JSON-Block, Zaehler aus dem JSON-Block abgeleitet (Muster mkj). Bestehende Eintraege unveraendert (#24 bleibt open).
Baseline pruefen (Tests/Typpruefung/Werkzeug, Zahlen ins SUMMARY), Erlaubnisliste gegen 8829999 (verify), Commit: `docs(quick-260911-nke): Aktenstand kohaerent — Regelschluss Benutzerdimension, Nachtraege, Ledger`. Dann `git push` (schlicht, Push-URL zeigt auf localhost:3002), `git status --short` leer, `git log origin/main..HEAD` leer — beides ins SUMMARY. SUMMARY-Pflicht: gemessene Endzahlen, Zahl der Umkehrungen, die Liste der Aufzeichnungsstellen, und die zwei Dinge, die ohne den User nicht gehen (3a Weg (i)/(ii); Etappe 4 Scharfschalten) — unveraendert aus dem Auftrag.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && F=docs/mandantentrennung-etappe2-fehlerrichtung.md && K=docs/mandantentrennung-zugriffsklassifikation.md && grep -q '^## Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)$' "$F" && for U in b1 b2 b3 b4 b5; do grep -qE "^### \($U\) " "$F" || { echo "UNTERABSCHNITT $U FEHLT"; exit 1; }; done && awk '/^## Regelschluss Benutzerdimension/{f=1} /^## Etappe 2 — Abschluss/{f=0} f && /tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden/{a=1} f && /calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden/{b=1} f && /20260911120000/{c=1} f && /NotFound/{d=1} END{ if(!a||!b||!c||!d){print "b1/b3: Werkzeugausgabe woertlich, Migrationsname oder NotFound-Beobachtung fehlt"; exit 1} }' "$F" && test "$(grep -c 'Nachtrag (260911-nke)' "$F")" -ge 9 && grep -q 'Benutzerdimension seit 20260911120000' "$K" && test "$(grep -c 'Benutzerdimension seit 20260911120000' "$K")" -ge 3 && grep -q 'Aufgelöst (260911-nke)' "$K" && grep -q '^\*\*Stand 260911-nke' "$K" && P=$(grep -cE '^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| (muss-mandantengebunden|bewusst-uebergreifend|keine-mandantengebundene-tabelle|beides) \| (gebunden|ungebunden|gemischt) \|' "$K") && test "$P" -ge 72 && grep -qE "^## Klassen-Verteilung \(nach \(Datei, Modell\)-Fundstellen, ${P} Paare\)" "$K" && grep -qE "^\| \*\*Summe\*\* \| \*\*${P}\*\* \|$" "$K" && for C in muss-mandantengebunden keine-mandantengebundene-tabelle beides bewusst-uebergreifend; do N=$(grep -cE "^\| apps/api/src/[^|]+ \| [a-zA-Z]+ \| ${C} \|" "$K"); grep -qE "^\| ${C} \| ${N} \|$" "$K" || { echo "KLASSEN-VERTEILUNG: ${C} nennt nicht ${N}"; exit 1; }; done && TU=0 && TB=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); done && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \|" "$K" || { echo "SUMMENZEILE nennt nicht ${TU}/${TB}"; exit 1; }; } && grep -q '^## Der Hintergrunddienst als Falle — sechs Fälle$' "$K" && test 0 -eq "$(grep -c 'keine Benutzerdimension kennt' docs/anleitung-entwicklung.md)" && grep -q 'current_user_id' docs/anleitung-entwicklung.md && grep -q 'app.current_user' docs/mandantentrennung-datenbankrolle.md && test 0 -eq "$(git diff 8829999 -- docs/mandantentrennung-datenbankrolle.md | grep -E '^-[^-]' | grep -ciE 'auth_lookup_(user_by_username|user_by_email|reset_token)')" && grep -q 'Erledigt (260911-nke' docs/mandantentrennung-etappe3-auftrag.md && W=.planning/WINDOWS.md && grep -q 'quick-260911-nke' "$W" && grep -q 'dritte Argument\|drittes Argument' "$W" && node -e "const s=require('fs').readFileSync('.planning/WINDOWS.md','utf8');const m=s.match(/\`\`\`json\n([\s\S]*?)\n\`\`\`/);if(!m){console.log('KEIN JSON-BLOCK');process.exit(1)}const j=JSON.parse(m[1]);const items=Array.isArray(j)?j:(j.items||j.windows||j.entries||[]);const open=items.filter(i=>i.status==='open').length;const nke=items.filter(i=>String(i.phase||i.source||'').includes('260911-nke')).length;console.log('open',open,'nke',nke,'total',items.length);if(nke<1)process.exit(1)" && PRISMA_CHANGED=$(git diff --name-only 8829999 -- apps/api/prisma | grep -v '^apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql$') && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/ANDERE MIGRATION GEAENDERT — verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 8829999) && echo "$CHANGED" && BAD=$(echo "$CHANGED" | grep -vE '^(apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration\.sql|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|dashboard|calendar|favorites)/[a-z-]+\.service(\.spec)?\.ts|apps/api/src/tenders/tender-digest\.scheduler\.ts|docs/mandantentrennung-(zugriffsklassifikation|etappe2-fehlerrichtung|datenbankrolle|etappe3-auftrag)\.md|docs/anleitung-entwicklung\.md|\.planning/.*)$') && { test -z "$BAD" || { printf 'AUSSERHALB DER ERLAUBNISLISTE:\n%s\n' "$BAD"; exit 1; }; } && test 0 -eq "$(echo "$CHANGED" | grep -cE '^(docker-compose|\.env|apps/api/prisma/schema\.prisma|apps/api/prisma/migrations/20260909160000)')" && npm --prefix apps/api run type-check && npm --prefix apps/api run test 2>&1 | tee /tmp/nke-test3.txt | tail -8 && TP=$(grep -oE 'Tests +[0-9]+ passed \([0-9]+\)' /tmp/nke-test3.txt | grep -oE '[0-9]+' | head -1) && TF=$(grep -oE 'Test Files +[0-9]+ passed \([0-9]+\)' /tmp/nke-test3.txt | grep -oE '[0-9]+' | head -1) && test "${TP:-0}" -ge 1010 && test "${TF:-0}" -ge 62 && test 0 -eq "$(grep -cE '^ *Tests +[0-9]+ failed' /tmp/nke-test3.txt)" && DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" | tail -2 && test 0 -eq "$(echo "$OUT" | grep -c 'FEHLGESCHLAGEN')" && N=$(echo "$OUT" | grep -oE 'Alle [0-9]+ Pruefungen bestanden' | grep -oE '[0-9]+') && test "${N:-0}" -ge 200 && grep -q "Alle ${N} Pruefungen bestanden" "$F" && git status --short | tee /tmp/nke-status3.txt && test -z "$(cat /tmp/nke-status3.txt)" && test -z "$(git log origin/main..HEAD --oneline)"</automated>
</verify>
<done>Neuer Regelschluss-Abschnitt mit b1–b5 und woertlicher Werkzeugausgabe; mindestens neun datierte Nachtraege in der Kritikschrift; drei Bestandsaufnahme-Zeilen und der NICHT-entscheidet-Punkt nachgezogen, abgeleitete Abschnitte weiter gruen (mkj-Gates); Betriebsanleitung, Datenbankrolle (ohne Aenderung an den SECURITY-DEFINER-Kopfkommentaren), Auftrag (3b erledigt) und Ledger (neuer Eintrag, Zaehler aus JSON) nachgezogen; Erlaubnisliste gegen 8829999 eingehalten, Schema/Compose/.env/3a-Migration unveraendert; Baseline gruen; committet, gepusht, `git status` leer, nichts unpushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Anwendungsrolle -> PostgreSQL (RLS) | Nach dem Scharfschalten ist die Datenbankregel das zweite Netz; heute (BYPASSRLS) ist sie wirkungslos |
| Nutzer A -> Nutzer B desselben Mandanten | Die Grenze, die diese Migration erstmals in der Datenbank zieht |
| Aufrufer mit Benutzer -> Aufrufer ohne Benutzer (Admin, Hintergrunddienst) | Die `IS NULL OR`-Form entscheidet, wer den ganzen Mandanten sieht |
| Wegwerf-Werkzeug -> ausgelieferte Migration | Das Werkzeug misst nur, was es aus der Migration schneidet |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-NKE-01 | Information Disclosure (Kollege liest gespeicherte Suchen, Kalender-Zugangsdaten `encryptedPassword`, Dashboard-Anordnung eines anderen Nutzers auf Datenbankebene) | Regeln der zehn persoenlichen Tabellen | high | mitigate | Aufgabe 1/2: `userId = current_user_id()`-Praedikat in jeder Regel; je Tabelle `…-benutzer-a-sieht-kollegen-nicht` und `…-schreiben-als-a-mit-kennung-b-abgelehnt` (42501) ueber den generierten Client gemessen; sechs Loch-Pruefungen umgedreht |
| T-NKE-02 | Elevation of Privilege (zu locker: ein Aufrufer, der den Benutzer VERGISST, sieht den ganzen Mandanten) | `forTenant()` ohne drittes Argument in einem Nutzer-CRUD-Pfad | medium | accept (mit Aufzeichnung) | Heute exakt der Stand vor der Migration — keine Verschlechterung. Netz: dreistellige Spec-Zusicherung je Dienst (Aufgabe 2), Gate 'keine zweistellige Form in den acht Dateien', neuer WINDOWS-Eintrag 'kein Waechter gebaut' (Aufgabe 3, key_links). Nicht mitigierbar ohne Waechter in der Bestandsaufnahme — Etappe 4 entscheidet |
| T-NKE-03 | Denial of Service (zu streng: Admin oder Hintergrunddienst sieht nur noch einen Nutzer) | `IS NULL OR`-Form, Scheduler, Verwaltungswege | high | mitigate | `…-ohne-benutzer-sieht-beide` je Tabelle; die alte Messung ohne Benutzer bleibt als `…-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten` bestehen; `tender-digest.scheduler.ts` bleibt zweistellig (Gate); `NULLIF` faltet Leerstring auf NULL (`current-user-id-leer-ist-null`) |
| T-NKE-04 | Tampering (ein einzelner USING-Ausdruck, der gemeinsame Zeilen zum Lesen einschliesst, gibt sie zum Aendern frei) | SearchProvider, TenderRssFeedSource | high | mitigate | Vier befehlsgetrennte Regeln je Tabelle (jab-Praezedenz); `…-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar` (count 0) und `…-gemeinsame-zeile-fuer-benutzer-a-lesbar` gemessen; Mandantenhaelfte unveraendert |
| T-NKE-05 | Repudiation (Werkzeug misst ein getipptes Wunschbild statt der ausgelieferten Regel; oder die abgeloeste Regel aus der alten Datei) | `rls-scratch-check.mjs` Extraktion | high | mitigate | Funktion UND Regeln aus der neuen Migration geschnitten, Abbruch bei Fehlschlag; zwoelf Umleitungen mit Gate 'keine alte Quelle fuer die zehn Tabellen'; `regelstand-eindeutig`-Gates erweitert |
| T-NKE-06 | Denial of Service (Live-Gehen am Dienstag durch diese Aenderung gefaehrdet) | Migration, `forTenant()` | critical | mitigate | Schalter AUS (Gate: Compose/.env unveraendert, `DATABASE_URL` -> `tessera` mit BYPASSRLS, keine Regel greift); `set_config` transaktionslokal, Leerstring ohne Benutzer; `migrate deploy` lokal vor jeder Messung; Gesamtsuite >= Baseline nach jeder Aufgabe; `schema.prisma` unveraendert — die Anwendung sendet lediglich eine zweite Sitzungsvariable, die nichts liest |
| T-NKE-07 | Tampering (Pruefsummen ausgelieferter Migrationen) | 20260909140000, 20260910120000 | high | mitigate | Nur eine NEUE Migrationsdatei; Gate `git diff 8829999 -- apps/api/prisma` ausser der neuen Datei leer |
| T-NKE-SC | Tampering | npm/pip/cargo installs | low | accept | Keine Paketinstallation; Lockfile ausserhalb der Erlaubnisliste (Gate in Aufgabe 3) |
</threat_model>
<verification>
- Aufgabe 1: Migration angewendet und in `pg_policies`/`pg_proc` sichtbar; Helfer dreistellig, Detektor-Regex matcht; ein Dienst, eine Tabelle, eine Umkehrung gemessen; Baseline >= 1007/62, Werkzeug > 137.
- Aufgabe 2: 34 dreistellige Aufrufstellen (6/9/5/3/2/2/4/3), Scheduler zweistellig, acht Specs dreistellig; je zehn Tabellen fuenf Kennungen, zwei Tabellen drei Zusatzkennungen, sechs Umkehrungen, keine alte Quelle; Baseline >= 1010/62, Werkzeug >= 200.
- Aufgabe 3: Regelschluss-Abschnitt b1–b5, >= 9 Nachtraege, Klassifikation nachgezogen mit weiterhin gruenen mkj-Gates, Anleitung/Datenbankrolle/Auftrag/Ledger; Erlaubnisliste gegen 8829999; gepusht.
- Durchgehend: `schema.prisma`, Compose, `.env*`, 20260909160000 (3a) unveraendert; kein AD; Schalter AUS.
</verification>
<success_criteria>
- Die Datenbank trennt Kollegen desselben Mandanten auf den zehn persoenlichen Tabellen — gemessen mit Benutzer A/B im selben Mandanten ueber den generierten Client, nicht angenommen.
- Ein Aufruf ohne Benutzer sieht weiterhin den ganzen Mandanten — gemessen, und als gewollte Eigenschaft samt offener Flanke aufgezeichnet.
- Sechs (nicht drei) loch-behauptende Pruefungen sind umgedreht, kein alter Name als Kennung, jeder alte Befund im Meldetext referenziert.
- Jede Aufzeichnung 'keine Benutzerdimension' traegt einen Nachtrag mit dem Migrationsnamen; die abgeleiteten Abschnitte sind weiterhin abgeleitet.
- Baseline gehalten, drei Commits, gepusht, `git status` leer. Der Schalter ist AUS. Dienstag ist nicht beruehrt.
</success_criteria>
<output>
Create `.planning/quick/260911-nke-mandantentrennung-etappe-3b-benutzerdime/260911-nke-SUMMARY.md` when done — mit den gemessenen Endzahlen (Tests/Dateien/Pruefungen), der Zahl der gefundenen und umgedrehten Loch-Pruefungen, der Liste der 34 Aufrufstellen wie im lebenden Baum gezaehlt, der Liste der Aufzeichnungsstellen, den drei Commit-Hashes, dem Push-Nachweis, und den zwei Dingen, die ohne den User nicht gehen (3a Weg (i)/(ii); Etappe 4 Scharfschalten).
</output>