Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
68 KiB
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 |
|
|
|
|
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>
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 perdocker inspect(zur Planungszeit 172.19.0.2), 34 Migrationen,migrate status= up to date,pg_prockennt KEINcurrent_user_id. Pinned Binaryapps/api/node_modules/.bin/prisma. forTenant()inprisma-tenant.extension.ts:111, setzt genau EINE Sitzungsvariable, Array-Form mit zwei Eintraegen;prisma-tenant.extension.spec.ts:51haelt 'genau zwei Eintraege' fest — bleibt gueltig, wenn beideset_configin 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_policyje Tabelle aus20260909140000_rls_remaining_tenant_tables— AUSSERTenderRssFeedSource(vier befehlsgetrennte Regelntenant_platform_read_policy/tenant_insert_policy/tenant_update_policy/tenant_delete_policyaus 20260910120000).SearchProvidermandantenstreng, ausdruecklich NICHT gelockert (jab, widerlegte Praemisse). - Funktionen brauchen KEIN
GRANT EXECUTE: 20260909130000_rls_app_role vergibt fuercurrent_tenant_id()keines (PostgreSQL-Vorgabe: EXECUTE fuer PUBLIC). Das Wegwerf-Werkzeug vergibt es insetupScratchDatabase(Zeile ~142) als eigenen Guertel — fuercurrent_user_id()spiegeln. setupScratchDatabase(Zeile 100–148) TIPPTcurrent_tenant_id()inline.current_user_id()wird dagegen aus der neuen Migration GESCHNITTEN (RegexCREATE 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 Gatescalendarsource-regelstand-eindeutig(2857–2870) undfavoritelink-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
userIdin 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 —createPlatformundremovebleiben 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
Benutzerdimensionerneut ueber docs/, apps/api/src, apps/api/scripts, .planning/WINDOWS.md und nennt die Zahl. - Testkommandos:
npm --prefix apps/api run test(vitest, ZusammenfassungszeileTests 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, SchlusszeileAlle N Pruefungen bestanden.. - Kein
mailhoglokal:ENOTFOUND mailhogin Testausgaben ist Umgebung, kein Defekt.
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, warumNULLIF: 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): jeDROP POLICY tenant_isolation_policy ON "X";undCREATE 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):extractPolicySqlundpg_policiesbehalten 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-faehigeuserId, mandantenSTRENG):DROP POLICY tenant_isolation_policy, dann VIER Regelntenant_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_policyUSING("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, MusterreadRlsWidenMigrationSql).setupScratchDatabase: nach der getipptencurrent_tenant_id()die Funktioncurrent_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 zuforTenant(), bei jeder Aenderung dort hier nachziehen.runForTenantChecks(Zeile 181): drei Pruefungencurrent-user-id-ungesetzt-ist-null,current-user-id-leer-ist-null,current-user-id-gesetzt-liefert-wertueber$queryRawinnerhalb je einer Transaktion.- NEUE Bereichsfunktion
runUserDimensionChecks(adminUrl, scratchRoleUrl, results), im Hauptlauf NACHrunSettingsAreaChecksund VORrunConcurrencyProbeeingehaengt, mit einer inneren Tabellenroutine, die in Aufgabe 2 fuer alle zehn Tabellen wiederverwendet wird: Wegwerf-Tabelle schemagleich (Spaltenvergleich gegenreadSchemaModelScalarFieldNames('<Modell>'), Muster calendar Zeile ~2939–2960; bei Abweichung Pruefung<slug>-wegwerftabelle-deckt-alle-spalten-des-generierten-clientsFEHLGESCHLAGEN und Abschnitt abbrechen), Regel(n) WORTGLEICH aus der neuen Migration (fuer die acht Ein-Regel-TabellenextractPolicySql, fuer SearchProvider/TenderRssFeedSourceextractAllPolicySqlmit 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 mitbuildInlineExtendedClient(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 mituserId: 'user-a2'; erwartet SQLSTATE 42501 viasqlStateOf, 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 nurtendersavedsearch. - Extraktionsstelle 984 (TenderSavedSearch in
runTendersAreaChecks) auf die neue Migration umleiten. - Umkehrung von
tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar(1144) INrunTendersAreaChecks, nicht loeschen: die bestehende Messung ohne Benutzer bleibt unter dem Namentendersavedsearch-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 Pruefungtendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebundenMIT Benutzeruser-a1, die die Zeiless-a2NICHT 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.
(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.
(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> |
<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 statusleer. Der Schalter ist AUS. Dienstag ist nicht beruehrt. </success_criteria>