Files
tessera-ctl/.planning/quick/260911-nke-mandantentrennung-etappe-3b-benutzerdime/260911-nke-PLAN.md
T

68 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260911-nke 01 execute 1
true
ETAPPE-3B
ENTSCHEIDUNG-2-2026-09-10
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
tokens raw_tokens tasks confidence
260000 260000 3 low
truths artifacts key_links
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.
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
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.
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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.
Aufgabe 1: Der eine Pfad durch alle Schichten — Funktion, Migration, Helfer, ein Dienst, eine Messung, eine Umkehrung [BLOCKING: Migration lokal anwenden] 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 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. 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*=\sforTenant(/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)" 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.

Aufgabe 2: Die uebrigen neun Tabellen — Messung je Tabelle, fuenf weitere Umkehrungen, zwoelf Umleitungen, 30 Aufrufstellen in sieben Diensten 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 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" ` — 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. 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)" 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.

Aufgabe 3: Den Aktenstand kohaerent machen — Kritikschrift, Klassifikation, Betriebsanleitung, Datenbankrolle, Auftrag, Ledger — und pushen 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 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. 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)" 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.

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

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