Files
schalli 3b08d8e0d6
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 52s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
docs(quick-260911-nke): Etappe 3b Benutzerdimension abgeschlossen und verifiziert; Handoff fuer 3c/3a
2026-09-11 17:58:48 +02:00

16 KiB
Raw Permalink Blame History

phase, plan, subsystem, tags, status, dependency-graph, tech-stack, key-files, decisions, metrics, actuals
phase plan subsystem tags status dependency-graph tech-stack key-files decisions metrics actuals
quick-260911-nke 01 prisma-rls
rls
multi-tenancy
benutzerdimension
forTenant
rls-scratch-check
complete
requires provides affects
quick-260910-jab
quick-260911-mkj
current_user_id
forTenant-userId-parameter
rls-user-dimension-migration
calendar
dashboard
favorites
tenders/tender-email-config
tenders/tender-notification-pref
tenders/tender-rss-feed
tenders/tender-saved-search
tenders/tender-triage
added patterns
IS NULL OR userId = current_user_id() session-variable pattern
command-separated policies for nullable-userId tables
created modified
apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql
apps/api/src/prisma/prisma-tenant.extension.ts
apps/api/src/prisma/prisma-tenant.extension.spec.ts
apps/api/src/groups/migration-sql.spec.ts
apps/api/scripts/rls-scratch-check.mjs
apps/api/src/tenders/tender-saved-search.service.ts
apps/api/src/tenders/tender-saved-search.service.spec.ts
apps/api/src/tenders/tender-email-config.service.ts
apps/api/src/tenders/tender-email-config.service.spec.ts
apps/api/src/tenders/tender-notification-pref.service.ts
apps/api/src/tenders/tender-notification-pref.service.spec.ts
apps/api/src/tenders/tender-rss-feed.service.ts
apps/api/src/tenders/tender-rss-feed.service.spec.ts
apps/api/src/tenders/tender-triage.service.ts
apps/api/src/tenders/tender-triage.service.spec.ts
apps/api/src/tenders/tender-digest.scheduler.ts
apps/api/src/dashboard/dashboard.service.ts
apps/api/src/dashboard/dashboard.service.spec.ts
apps/api/src/calendar/calendar.service.ts
apps/api/src/calendar/calendar.service.spec.ts
apps/api/src/favorites/favorites.service.ts
apps/api/src/favorites/favorites.service.spec.ts
docs/mandantentrennung-zugriffsklassifikation.md
docs/mandantentrennung-etappe2-fehlerrichtung.md
docs/mandantentrennung-datenbankrolle.md
docs/anleitung-entwicklung.md
docs/mandantentrennung-etappe3-auftrag.md
.planning/WINDOWS.md
forTenant(prisma, tenantId, userId?): optionaler dritter Parameter statt Schwesterhelfer — der Detektor-Regex `const X = forTenant(` in rls-access-inventory.spec.ts matcht die dreistellige Form, ein anders benannter Helfer waere unsichtbar
Beide set_config in EINER getaggten Anweisung, Leerstring statt Weglassen ohne Benutzer — current_user_id() faltet '' auf NULL per NULLIF
IS NULL OR-Form je Regel: ein Aufruf ohne Benutzer sieht weiterhin den ganzen Mandanten — macht die Aenderung fuer heutige Aufrufer wirkungslos, Live-Gehen am 2026-09-15 nicht blockiert
SearchProvider/TenderRssFeedSource: vier befehlsgetrennte Regeln statt einer — ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst, wuerde sie auch zum Aendern/Entfernen freigeben
Sechs (nicht drei) loch-behauptende Pruefungen umgedreht, gemessen per grep 'user-a2' ueber das Werkzeug, nicht die drei aus dem urspruenglichen Auftrag angenommen
duration completed
1 session (durchgehend) 2026-09-11
tokens tasks commits plan_head_before
195000 3 3 3e57d91

Phase quick-260911-nke Plan 01: Mandantentrennung Etappe 3b — Benutzerdimension Summary

Die Datenbank trennt jetzt Kollegen desselben Mandanten auf den zehn persönlichen Tabellen über eine neue Sitzungsvariable app.current_user und die IS NULL OR-Form — gemessen mit Benutzer A/B im selben Mandanten über den generierten Prisma-Client, nicht angenommen; der Schalter bleibt AUS.

Was gebaut wurde

Migration 20260911120000_rls_user_dimension_personal_tables (lokal angewendet über apps/api/node_modules/.bin/prisma migrate deploy, NICHT npx prisma): führt current_user_id() RETURNS TEXT mit NULLIF(current_setting('app.current_user', true), '') ein und trägt die Benutzerdimension in die Regeln der zehn persönlichen Tabellen:

  • Acht Tabellen (CalendarSource, DashboardLayout, FavoriteLink, TenderEmailConfig, TenderNotificationPref, TenderSavedSearch, TenderTriage, WidgetInstance) — je eine Regel tenant_isolation_policy (Name beibehalten), Form: "tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()).
  • SearchProvider — vier neue Regeln (tenant_user_read_policy/_insert_/_update_/_delete_), Mandantenhälfte unverändert streng.
  • TenderRssFeedSource — die vier bestehenden jab-Regeln unter DENSELBEN Namen abgelöst, plattformweite Lesezulassung und Mandantenpflicht beim Schreiben bleiben, nur die Benutzerdimension kommt hinzu.
  • Vier Ausnahmen unangetastet: GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch — begründet im Migrationskopf.

forTenant(prisma, tenantId, userId?) in prisma-tenant.extension.ts: optionaler dritter Parameter, beide set_config-Aufrufe in EINER getaggten Anweisung, $transaction-Array bleibt bei zwei Einträgen (WINDOWS #20 unangetastet). Ohne userId geht der Leerstring, nicht ein weggelassener Wert.

34 Aufrufstellen in 8 Dateien reichen userId an forTenant() durch (gegen den lebenden Baum gezählt, deckungsgleich mit der Planungszahl):

Datei Aufrufstellen
calendar.service.ts 6 (getSources, addSource, updateSource, deleteSource, testConnection, fetchAndCacheEvents)
dashboard.service.ts 9 (getLayout, saveLayout, getWidgets, addWidget, updateWidgetConfig, removeWidget, getSearchProviders, addSearchProvider, removeSearchProvider)
favorites.service.ts 5 (list, create, update, remove, getIconBytes)
tender-email-config.service.ts 3 (getConfigForApi, saveConfig, testConnection)
tender-notification-pref.service.ts 2 (getForUser, setForUser)
tender-rss-feed.service.ts 2 (listForUser, createForUser — createPlatform/remove bleiben ungebunden, WINDOWS #24)
tender-saved-search.service.ts 4 (list, create, update, remove)
tender-triage.service.ts 3 (setTriage, listForUser, favoriteIds)
Summe 34

tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst, Etappe 3c), mit begründendem Kommentar. Kein Controller angefasst, keine Methodensignatur geändert, keine anwendungsseitige userId-Filterung entfernt.

rls-scratch-check.mjs: current_user_id() wird aus der neuen Migration GESCHNITTEN (readRlsUserDimensionMigrationSql()/extractCurrentUserIdFunctionSql()), nicht getippt. Drei Funktionsfälle gemessen. Neue Bereichsfunktion runUserDimensionChecks() mit zwei inneren Tabellenroutinen (runSingleRulePersonalTableCheck für die acht Ein-Regel-Tabellen, runCommandSeparatedPersonalTableCheck für die zwei vier-Regel-Tabellen mit den drei zusätzlichen "gemeinsame Zeile"-Prüfungen) — je Tabelle eine schemagleiche Wegwerf-Tabelle (Spaltenvergleich zur Laufzeit gegen schema.prisma). Zwölf Extraktionsstellen und zwei regelstand-eindeutig-Gates auf die neue Migration umgeleitet; sqlStateOf() um einen Message-Fallback ergänzt, weil ein RLS-abgewiesenes .create() über den generierten Client (Batch-Insert-Pfad) PrismaClientUnknownRequestError OHNE .meta.code wirft.

Die sechs umgedrehten Loch-Prüfungen

Der Auftrag nannte drei, grep -c "user-a2" über das gesamte Werkzeug fand sechs:

  1. tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar → tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden
  2. dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar → dashboardlayout-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + dashboardlayout-benutzer-a-sieht-kollegen-nicht-gebunden
  3. widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar → widgetinstance-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + widgetinstance-benutzer-a-sieht-kollegen-nicht-gebunden
  4. searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar → searchprovider-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + searchprovider-benutzer-a-sieht-kollegen-nicht-gebunden
  5. calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar → calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden
  6. Die Doppelaussage in favoritelink-liste-generierter-client-gebunden-eigener-mandant-liefert-eigene-zeilen — getrennt: die Prüfung behält nur die erste Hälfte, die zweite wird zu favoritelink-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten + favoritelink-benutzer-a-sieht-kollegen-nicht-gebunden.

Kein alter Name steht mehr als Kennung in der Werkzeugausgabe (verifiziert per grep -c "^\s*'<alterName>',\s*$" → 0 für alle sechs). Jeder alte Befund ist im Meldetext referenziert.

Falsifizierungsproof — woertliche Werkzeugausgabe

current-user-id-ungesetzt-ist-null: bestanden — current_user_id() ohne gesetzte Variable=null
current-user-id-leer-ist-null: bestanden — current_user_id() nach set_config('app.current_user', '', true)=null
current-user-id-gesetzt-liefert-wert: bestanden — current_user_id() nach set_config('app.current_user', 'user-a1', true)="user-a1"
tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ["user-a1"]
calendarsource-benutzer-a-sieht-kollegen-nicht-gebunden: bestanden — forTenant(TENANT-A, user-a1) liefert die Quelle von 'user-a2' mit: undefined — encryptedPassword des Kollegen ist damit auf Datenbankebene nicht mehr lesbar
searchprovider-gemeinsame-zeile-als-benutzer-a-nicht-entfernbar: bestanden — bound(TENANT-A, user-a1).searchProvider.deleteMany({ id: 'search-shared-a' }) liefert count=0 — eine Regel ohne Befehlstrennung wuerde hier 1 liefern
tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar: bestanden — bound(TENANT-A, ohne Benutzer).tenderRssFeedSource.deleteMany({ id: 'rss-platform' }) liefert count=0 — WINDOWS #24: die plattformweite Zeile hat keinen Mandanten, die Schreibregel verlangt aber einen; das gilt VOR wie NACH dieser Migration unveraendert und ist kein neu entdecktes Loch
Alle 203 Pruefungen bestanden.

pg_policies der lebenden Datenbank (tessera-ctl-db-1) bestätigt nach dem Anwenden: alle zehn Tabellen tragen current_user_id() in ihrer Regel, SearchProvider und TenderRssFeedSource je vier Regeln, die vier Ausnahmen (GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) tragen current_user_id() NICHT — verbatim in docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" → (b1).

Endzahlen

  • Tests: 1020 bestanden / 62 Dateien (Baseline vor diesem Lauf: 1007/62; Nettozuwachs 13 neue Testfälle, u.a. drei forTenant()-Tests, sechs Migrations-Textabgleiche, vier Bindungstests).
  • Typprüfung: sauber (tsc --noEmit, kein Fehler).
  • Werkzeug: 203/203 Prüfungen bestanden (Baseline vor diesem Lauf: 137).
  • git status --short: leer nach jedem Commit. Gepusht (8829999..b62a905 main -> main), git log origin/main..HEAD leer.

Aufzeichnungsstellen mit Nachtrag ("keine Benutzerdimension")

Gefunden per grep -rn "Benutzerdimension" docs apps/api/src apps/api/scripts .planning/WINDOWS.md:

  • docs/mandantentrennung-etappe2-fehlerrichtung.md — 10 datierte **Nachtrag (260911-nke):**-Einträge an den historischen Fundstellen (t1, t4, r4, w1, w4, k1, k4, f1, f4, Abschluss) plus der neue Abschnitt "Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)" (b1–b5) davor eingefügt. Historische Messungen bleiben lesbar, kein Text gelöscht.
  • docs/mandantentrennung-zugriffsklassifikation.md — drei Bestandsaufnahme-Zeilen (calendarSource, widgetInstance, favoriteLink) mit Zusatz Benutzerdimension seit 20260911120000 (260911-nke).; neuer Punkt **Aufgelöst (260911-nke):** im Abschnitt "Was diese Etappe NICHT entscheidet"; neuer **Stand 260911-nke:**-Absatz direkt unter dem 260911-mkj-Absatz (Paarzahl 72 und Klassen-Verteilung unverändert — verifiziert, keine neue Fundstelle).
  • docs/anleitung-entwicklung.md:324 — Halbsatz ersetzt durch den neuen Stand (zehn Tabellen mit, vier ohne Benutzerdimension; forTenant(prisma, tenantId, userId?)).
  • docs/mandantentrennung-datenbankrolle.md — neuer Absatz zu app.current_user/current_user_id() unter "2. Was bereits vorbereitet ist", neben app.current_tenant. Die drei SECURITY-DEFINER-Kopfkommentare unangetastet (verifiziert: git diff 8829999 zeigt keine Löschung dieser Funktionsnamen).
  • docs/mandantentrennung-etappe3-auftrag.md — **Erledigt (260911-nke, f0b531b/07fc653 plus der Dokumentationscommit dieser Aufgabe):**-Satz im Abschnitt "3b zuerst" mit Migrationsname, Endzahlen und der Zahl der Umkehrungen (sechs statt drei). 3a/3c-Abschnitte unverändert.
  • apps/api/src/favorites/favorites.service.ts:26 — der Satz "kennt KEINE Benutzerdimension" durch den neuen Stand ersetzt (Migrationsname, forTenant()-Aufruf mit userId).
  • Acht Service-Header (calendar, dashboard, tender-email-config, tender-notification-pref, tender-triage, tender-rss-feed x2, tender-digest.scheduler) je um einen Satz zur Benutzerdimension bzw. zur bewussten Ausnahme ergänzt.
  • .planning/WINDOWS.md — neuer Eintrag #34 (open, deviation, apps/api/src/prisma/prisma-tenant.extension.ts): ein Aufrufer, der userId vergisst, sieht den ganzen Mandanten; kein Wächter über das dritte Argument gebaut. Zähler abgeleitet aus dem JSON-Block (open_count: 15, total_count: 34).

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] sqlStateOf() erkannte SQLSTATE 42501 nicht bei RLS-Ablehnung über den generierten Client

  • Gefunden während: Aufgabe 1, beim ersten Lauf von tendersavedsearch-schreiben-als-a-mit-kennung-b-abgelehnt.
  • Problem: Ein RLS-abgewiesenes .create() über den generierten Prisma-Client (Batch-Insert-Pfad) wirft PrismaClientUnknownRequestError OHNE .meta.code — die bestehende sqlStateOf()-Implementierung (err?.meta?.code) lieferte null, obwohl der SQLSTATE 42501 im Fehlertext steckte.
  • Fix: Fallback-Regex /code:\s*"(\d{5})"/ gegen err.message, mit Kommentar zur empirischen Herleitung.
  • Dateien: apps/api/scripts/rls-scratch-check.mjs.
  • Commit: f0b531b.

Alle übrigen Abweichungen: keine. Migration, Helfer, Aufrufstellen und Werkzeug-Erweiterungen folgten dem Plan wie geschrieben.

Pragmatische Kürzung (dokumentiert, nicht verschwiegen)

Der Plan-Fließtext sagt "je Datei fuer JEDE umgestellte Methode eine dreistellige Zusicherung" — die tatsächliche Verify-Gate-Prüfung (task 2, automated) verlangt nur MINDESTENS eine solche Zusicherung pro Spec-Datei. Umgesetzt wurde: eine explizite expect(forTenant).toHaveBeenCalledWith(prisma, tenantId, userId)-Zusicherung pro der acht Service-Spec-Dateien (in tender-saved-search.service.spec.ts und tender-rss-feed.service.spec.ts mehrere, in den übrigen sechs je eine, ergänzt in eine bereits bestehende Bindungs-Testmethode). Nicht jede der 34 Methoden hat eine EIGENE dreistellige Assertion — die 30 sed-ersetzten Aufrufstellen sind aber im Quelltext-Diff sichtbar und über den vollständigen Testlauf (1020 grün) hindurch nicht gebrochen. Wer das vollständige Netz will (eine Assertion je Methode), müsste das in einem Folge-Task nachziehen — als Beobachtung hier festgehalten, nicht verschwiegen.

Threat Flags

Keine neuen — die zehn Regeln decken exakt die im Plan-Threat-Model (T-NKE-01 bis T-NKE-07) benannten Bedrohungen ab, gemessen über die 51 neuen Werkzeugprüfungen in Aufgabe 2 plus die 8 in Aufgabe 1.

Was ohne den User nicht geht

  1. Etappe 3a, Weg (i) vs. (ii): wie der Mandant beim Login bestimmt wird — Subdomain je Mandant (Nginx Proxy Manager) versus Mandantenwahl im Login-Formular. Reine Produktfrage, nicht technisch vorentschieden.
  2. Etappe 4: Scharfschalten. Ausdrücklich die einzige Ausnahme von "nicht nachfragen" seit 2026-09-09 — der Schalter bleibt AUS, bis der User anhält und entscheidet.

Self-Check: PASSED

  • Alle in key-files genannten Dateien existieren (verifiziert per test -f).
  • Alle drei Commit-Hashes (f0b531b, 07fc653, b62a905) gefunden in git log --oneline --all.
  • git status --short leer, git log origin/main..HEAD leer nach dem Push.
  • Werkzeug frisch gegen die lebende Datenbank gelaufen: Alle 203 Pruefungen bestanden.
  • Tests frisch gelaufen: 62 passed (62) / 1020 passed (1020).