6 Commits

Author SHA1 Message Date
schalli ff23c82220 docs(quick-260910-krx): Etappe 2 Bereich dashboard abgeschlossen, Luecke behoben
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 54s
Tessera CI/CD / Build & Publish Images (push) Successful in 27s
2026-09-11 09:16:40 +02:00
schalli 6e7120648b fix(quick-260910-krx): Konfliktklasse ueber den generierten Client messen statt sie zu behaupten 2026-09-11 09:16:39 +02:00
schalli b286bfb1a3 docs(quick-260910-krx): complete Bereich dashboard Etappe 2 plan 2026-09-11 09:05:58 +02:00
schalli 67b50240d6 feat(quick-260910-krx): Suchmaschinen gebunden, Katalog begruendet offen, Klassifikation nachgezogen
Aufgabe 3 — TDD zuerst (7 weitere Faelle in dashboard.service.spec.ts, 20
vorher/27 nach dieser Aufgabe), dann die Umstellung:

- getSearchProviders/addSearchProvider/removeSearchProvider laufen ueber
  forTenant(); removeSearchProvider fuehrt Besitzpruefung UND Schreibzugriff
  ueber DENSELBEN gebundenen Klienten. Die drei Vorgabe-Suchmaschinen aus
  der Konstante bleiben unveraendert vorangestellt.
- Der eine Katalogzugriff (this.prisma.module in getWidgets) bleibt
  begruendet ungebunden: Messung und Bedingung getrennt (Tabelle traegt
  heute keinen Zeilenschutz, wirkungslos statt katastrophal — katastrophal
  erst, wenn Etappe 3 eine Regel gibt), unter Berufung auf die bestehende
  Werkzeugpruefung module-tabelle-traegt-keinen-zeilenschutz statt einer
  neuen Behauptung. Ein Wachhund-Testfall haelt den Katalogzugriff aus dem
  Bindungsprotokoll heraus (und beweist zuerst, dass der Katalogpfad
  tatsaechlich durchlaufen wird, nicht nur theoretisch geprueft ist).
- docs/mandantentrennung-zugriffsklassifikation.md an allen fuenf
  handgepflegten Stellen nachgezogen: vier Bestandsaufnahme-Zeilen (inkl.
  eigenstaendiger Nachpruefung der widerlegten SearchProvider-Praemisse),
  Uebersichtszeile (13/0 -> 1/12), Summenzeile (95/147), Klassen-Verteilung
  (unveraendert 63 Paare, ausdruecklich vermerkt), Hintergrunddienst-
  Abschnitt (dashboard hat keinen sechsten Fall, mit Messanweisung), "Was
  diese Etappe NICHT entscheidet" (dienst-interner forTenant()-Weg wie alle
  sieben Bereiche vor ihm).
- .planning/WINDOWS.md traegt Eintrag #25 (offen, Tabelle + JSON): die
  beweisvernichtende Schleife (leeres Dashboard -> Neuaufbau ->
  automatisches Zurueckschreiben -> ueberschriebene Anordnung, Widget-
  Dubletten) samt der Vorabpruefung fuer Etappe 4 und dem Verweis auf #22
  fuer die verwandte Eindeutigkeitsfrage.

Zwei weitere Falsifizierungsnachweise durchgefuehrt: (1) den Katalogzugriff
probeweise gebunden (tenantPrisma.module.findMany) — acht Tests werden rot
mit "TypeError: Cannot read properties of undefined (reading 'findMany')",
weil `module` bewusst nicht in der Testdouble-Bindungsliste steht; Rueckbau
zurueckgenommen, 27/27 wieder gruen. (2) den Stand von dashboardLayout in
der Klassifikationsdatei probeweise auf "ungebunden" gesetzt —
rls-access-inventory.spec.ts wird rot mit "Abweichender Stand (Dokument vs.
Quelltext): ... dokumentiert=ungebunden, gemessen=gebunden"; Ruecknahme,
Testlauf wieder gruen (10/10).

Baseline gehalten: 858 Tests / 56 Dateien gruen, Typpruefung sauber,
Wegwerf-Werkzeug 87/87. Schalter bleibt aus.
2026-09-11 09:03:23 +02:00
schalli e0ce594c5a feat(quick-260910-krx): Anordnung und Widgets an forTenant() gebunden
Aufgabe 2 — TDD zuerst (20 Faelle in dashboard.service.spec.ts, 8 vorher/12
neu, Zwei-Klienten-Nachweis ueber __makeBoundClient nach dem Muster von
module-access.service.spec.ts), dann die Umstellung:

- getLayout/saveLayout laufen GEMEINSAM gebunden (ein Testfall nagelt das
  fest); saveLayout uebersetzt eine gebundene Konflikt-Schreibung
  (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 — NICHT P2002)
  in eine deutsche ConflictException.
- getWidgets/addWidget/updateWidgetConfig/removeWidget laufen gebunden;
  die drei Besitzpruefungen ueber die Benutzerkennung bleiben unveraendert
  bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension).
  updateWidgetConfig/removeWidget fuehren Besitzpruefung UND Schreibzugriff
  ueber DENSELBEN gebundenen Klienten.
- dashboard.controller.ts reicht den bereits aufgeloesten Mandanten bei
  getLayout/updateWidgetConfig/removeWidget durch (keine neue
  Vertrauensquelle, weiterhin aus extractContext/Sitzungsnachweis).
- Der Modulkatalog und die vier Suchmaschinenzugriffe bleiben in dieser
  Aufgabe unveraendert (Aufgabe 3).
- Falsifizierungsnachweis durchgefuehrt: tenantPrisma.widgetInstance.delete
  probeweise auf this.prisma zurueckgebaut — Test "Widget entfernen: ebenso,
  beide Abfragen ueber denselben Klienten" wird rot mit "erwarteter
  gebundener Aufruf widgetInstance.delete(tenant=tenant-1) fehlt im
  Protokoll"; Rueckbau zurueckgenommen, Testlauf wieder gruen (20/20).

Zwei dokumentierte Abweichungen (Rule 3): (1) Befund A hatte fuer
widgetInstance sieben Treffer vorhergesagt, gemessen sind sechs (macht
zusammen mit dashboardLayout acht statt neun) — der Verify-Schwellwert
wird entsprechend auf >=8 gelesen. (2) Die Stand-Spalte fuer
dashboardLayout/widgetInstance in der Klassifikationsdatei wird bereits
hier minimal nachgezogen (nicht erst in Aufgabe 3), weil
rls-access-inventory.spec.ts sonst am Ende dieser Aufgabe rot waere —
derselbe Praezedenzfall wie 260910-exd, Aufgabe 2.

Baseline gehalten: 851 Tests / 56 Dateien gruen (839 + 12 neue), Typpruefung
sauber, Wegwerf-Werkzeug 87/87.
2026-09-11 08:54:22 +02:00
schalli 6744918a01 feat(quick-260910-krx): Fehlerrichtung fuer Bereich dashboard gemessen
Aufgabe 1 — misst die umgekehrte Fehlerrichtung des Bereichs dashboard an
den Regeln nach Migration 20260910120000, VOR der Umstellung:

- rls-scratch-check.mjs bekommt einen neunten Abschnitt
  (runDashboardAreaChecks) mit 13 neuen, namentlich benannten Pruefungen
  gegen die aus der ausgelieferten Migration geschnittenen Regeln fuer
  DashboardLayout, WidgetInstance und SearchProvider. Alle 87 Pruefungen
  bestehen (74 bisherige + 13 neue).
- Die Konfliktmessung (Befund K) ist gemessen, nicht angenommen: ein
  gebundenes INSERT ... ON CONFLICT auf eine unter dem Mandanten
  unsichtbare Zeile scheitert laut mit SQLSTATE 42501. Zusaetzlich am
  echten generierten Prisma Client gemessen: prisma.dashboardLayout.upsert()
  wirft PrismaClientUnknownRequestError (nicht P2002) — das tenders-Muster
  laesst sich deshalb nicht woertlich uebernehmen.
- Die widerlegte Praemisse zu SearchProvider (WINDOWS #19) ist in diesem
  Durchlauf eigenstaendig nachgeprueft, mit benannter Suchreichweite.
- docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt
  "## Bereich dashboard" (w1)-(w5) samt der beweisvernichtenden Schleife
  (leeres Dashboard -> Neuaufbau -> automatisches Zurueckschreiben ->
  ueberschriebene Anordnung) und einen Nachtrag im Abschnitt
  "## Bereich module-registry" zur geerbten Bindungsentlastung.

Baseline gehalten: 839 Tests / 56 Dateien gruen, Typpruefung sauber.
Schalter bleibt aus.
2026-09-11 08:46:46 +02:00
10 changed files with 1717 additions and 62 deletions
+8 -5
View File
@@ -4,11 +4,11 @@ milestone: v1.2
current_phase: 17
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
status: verified
stopped_at: "Quick 260910-jab abgeschlossen: T-JTS-02/T-JTS-03/WINDOWS #19 geschlossen (Migration 20260910120000). WINDOWS #24 neu offen (Verwaltungsweg fuer plattformweite Zeilen fehlt)."
last_updated: "2026-09-10T12:35:40.000Z"
stopped_at: "Quick 260910-krx abgeschlossen: Bereich dashboard der Etappe 2 (Mandantentrennung) umgestellt, 12/13 Zugriffe gebunden, WINDOWS #25 neu offen"
last_updated: "2026-09-11T07:05:45.197Z"
last_activity: 2026-09-10
last_activity_desc: Quick 260910-jab — drei zu kurz greifende RLS-Regeln geschlossen (GroupMembership beide Seiten, ModuleGrant beide Ziele, TenderRssFeedSource Lese-/Schreibsplit), listForUser gebunden, Aktenstand kohaerent
state_head: e1586a41dd58432c489486abd408b406841e1a7f
state_head: 67b50240d6a3f0dca2c2bfbd0a129269f9a7ab53
progress:
total_phases: 17
completed_phases: 3
@@ -118,6 +118,7 @@ Progress: [██████████] 100%
| Phase 17 P03 | 62min | 3 tasks | 13 files |
| Phase quick-260909-ipc P01 | 55min | 3 tasks | 8 files |
| Phase quick-260910-jab P01 | 70min | 3 tasks | 16 files |
| Phase quick-260910-krx P01 | 26min | 3 tasks | 7 files |
## Accumulated Context
@@ -295,6 +296,7 @@ Recent decisions affecting current work:
- [Phase 17]: [260909-ipc]: resolveEmailForWrite() bleibt dauerhaft ungebunden (Befund A, T-IPC-04) — email/username sind plattformweit @unique
- [Phase 17]: [260909-ipc]: getAllActiveConfigs()/onApplicationBootstrap() bleiben dauerhaft ungebunden (Befund B) — Klasse von (ldap-config.service.ts, ldapConfig) korrigiert auf beides
- [Phase 17]: [260909-ipc]: Standardgruppen-Uebergabe (groups.service.ts) bewusst nicht angefasst — Reihenfolgebedingung fuer Etappe 4
- [Phase 17]: 260910-krx: Bereich dashboard vollstaendig umgestellt — 12 gebunden, 1 begruendet ungebunden (Modulkatalog); getLayout/saveLayout gemeinsam gebunden; saveLayout uebersetzt PrismaClientUnknownRequestError (nicht P2002) in deutsche Konfliktmeldung; WINDOWS #25 fuer die beweisvernichtende Schleife offen angelegt
### Pitfalls & Anti-Patterns
@@ -381,6 +383,7 @@ None yet.
| 260910-das | Mandantentrennung Etappe 2, Bereich user — Benutzerverwaltung, die schwerste Fehlerklasse des Vorhabens (Fremdzugriff hier ist Rechteausweitung ueber Mandantengrenzen, nicht blosse Sichtbarkeit). Alle 17 Zugriffe eingeordnet: Verwaltungswege gebunden, Eindeutigkeits- und Suchwege bewusst ungebunden, jede Entscheidung mit Begruendung am Ort. **Startsperre entschaerft — der schwerwiegendste Fund:** nach dem Scharfschalten haette eine FRISCHE Installation ihren ersten Administrator nicht anlegen koennen und die Anwendung waere gar nicht erst gestartet. Kette (Glied fuer Glied belegt): die Startpruefung liefert `null` — nicht weil der Admin fehlt, sondern weil ohne Mandantenkontext keine Zeile sichtbar ist — also wird angelegt, das laeuft in den plattformweit eindeutigen Anmeldenamen, und weil `seedAdmin()` ungekapselt in `onApplicationBootstrap` haengt, bricht der Start ab. Betroffen waere jede Installation mit gesetzten Admin-Umgebungswerten gewesen; auf dem bestehenden System nie aufgefallen, weil dort der Admin laengst existiert. Jetzt bindet die Erstanlage an den eine Anweisung zuvor angelegten Mandanten und faengt GENAU den Doppelanlage-Fall ab — jeder andere Fehler bricht den Start weiterhin ab (beide Haelften einzeln nachgewiesen). **Riegel repariert, der seit seiner Entstehung wirkungslos war:** die Sperre gegen das Loeschen des eigenen Kontos verglich gegen ein Feld, das der Sitzungsnachweis gar nicht traegt (`sub`; er traegt `id`, `username`, `role`, `tenantId`) — ein Administrator konnte sein eigenes Konto loeschen. Kein Mandantenproblem, gefunden weil dieser Durchlauf jede Zeile aufschlaegt. **Zwei Falschaussagen in eigenen Artefakten berichtigt:** der Kopfkommentar von `findByUsername` behauptete, sie muesse fuer den mandantenuebergreifenden Anmeldeweg ungebunden bleiben — der laeuft seit Etappe 1 ueber die SECURITY-DEFINER-Funktionen, und die Methode hat gemessen NULL Aufrufer; und die Klassifikationszeile der Erstanlage behauptete, es gebe strukturell keinen Mandanten zum Binden, obwohl er eine Anweisung vorher entsteht (Klasse auf `beides` korrigiert). **SUPER_ADMIN-Sicht** war nach dem Scharfschalten in JEDER heutigen Form kaputt (ungebunden null Zeilen, gebunden stille Funktionsminderung) — jetzt Schleife ueber alle Mandanten mit gebundenem Rumpf, neues Fundstellenpaar `(user.service.ts, tenant)`. **Steuerungsschicht hatte gar keine Tests** (7 der 17 Zugriffe plus die gesamte Rollenlogik) — `user.controller.spec.ts` neu. Plan-Pruefer fand einen Blocker: vier handgepflegte Dokumentstellen benannt, nur zwei abgesichert — also derselbe Fehler, den der Plan verhindern sollte; nachgebessert mit herleitenden statt fest verdrahteten Pruefungen, in vier Einzelmutationen falsifiziert. **Verifiziert 10/10** (810/810 Tests, Typpruefung sauber, 53/53 Live-Pruefungen; Selbstloesch-Riegel und Klassenverteilung vom Pruefer eigenhaendig nachgerechnet) | 2026-09-10 | b848ba6,888f660,3a9391d | [260910-das-mandantentrennung-etappe-2-bereich-user-](./quick/260910-das-mandantentrennung-etappe-2-bereich-user-/) |
| 260910-exd | Mandantentrennung Etappe 2, Bereich module-registry — der Berechtigungs-Anfrageweg. `module.guard.ts` laeuft bei JEDER Modulanfrage und hat null eigene Datenbankzugriffe; seine Richtigkeit ist vollstaendig eine Funktion dessen, was dieser Bereich liefert. Endstand 7 ungebunden / 10 gebunden. **Eine Vorgabe des Auftrags war falsch und wurde widerlegt:** angeblich wuerde eine Bindung des Modulkatalogs ihn fuer jeden Mandanten unsichtbar machen — gemessen ueber alle 23 `ENABLE ROW LEVEL SECURITY`-Zeilen steht `Module` auf keiner, die Tabelle traegt gar keinen Zeilenschutz und kein `tenantId`. Eine Bindung waere heute WIRKUNGSLOS, nicht katastrophal; katastrophal wird sie erst, wenn Etappe 3 der Tabelle eine Regel gibt. Handlung unveraendert (Katalog bleibt ungebunden), aber Messung und Bedingung sind in Code und Dokument jetzt getrennt — eine richtige Handlung mit falscher Begruendung haelt nur, bis sich jemand auf die Begruendung verlaesst. **Die Kernfrage ehrlich beantwortet:** es gibt KEIN Signal, das 'wirklich keine Freigabe' von 'die Abfrage hat nichts gefunden' unterscheidet. Nach dem Scharfschalten saehe ein Unterlauf nicht wie ein Fehler aus, sondern wie 'du hast keine Module' — leere Seitenleiste, leerer Marktplatz, jeder Modulaufruf abgewiesen, fuer den Betroffenen nicht von einem absichtlichen Entzug zu unterscheiden. Dreifach festgehalten: im Text, als Testfall in `module.guard.spec.ts` (zwei Aufrufe, identische Meldung, gegeneinander gehalten) und als WINDOWS #23 mit konkreter Etappe-4-Vorabpruefung. **Erstmals eine Entlastung, die strukturell haelt:** die Kette unsichtbare Zeile -> falsches 'frei' -> 23505 kann hier nicht auftreten, weil die Eindeutigkeitsschluessel die Mandantenkennung fuehren — erster von sechs Bereichen, in dem sie abwesend statt umgangen ist; entsprechend wurde KEINE Absicherung eingebaut, die nichts absichert. **Wieder zwei Kopfkommentare mit Falschaussagen** (`isModuleActive` 'Used by ModuleGuard', `findActiveForTenant` als Marktplatz-Lieferant), beide Methoden mit null Aufrufern — berichtigt. `module-registry.service.ts` hatte trotz 11 der 17 Zugriffe und aller Schreibwege GAR KEINE Testdatei. Reihenfolge-Entlastung: der Dashboard-Filter wird mitgebunden, ohne dass eine `dashboard`-Datei angefasst wird. Zwei Abweichungen, beide vom eigenen Pruefgatter erzwungen und geprueft: eine Identitaets-Attrappe in `tender-scheduler.service.spec.ts` (zulaessig — jene Datei prueft Planer-Verhalten, die Bindung ist in `module-registry.service.spec.ts` mit zwei Klienten belegt) und eine Stand-Spalte, die eine Aufgabe frueher nachgezogen werden musste. **Verifiziert 9/9** (833/833 Tests, Typpruefung sauber, 66/66 Live-Pruefungen; Klassenverteilung und Summenzeile vom Pruefer eigenhaendig nachgerechnet). Eine Zahl in der Zusammenfassung (7 statt 6 neue Bindungsnachweise) vom Pruefer nachgezaehlt und berichtigt | 2026-09-10 | 7d45e2f,3df7268,9c0eefe | [260910-exd-mandantentrennung-etappe-2-bereich-modul](./quick/260910-exd-mandantentrennung-etappe-2-bereich-modul/) |
| 260910-jab | **Die drei zu kurz greifenden Datenbankregeln geschlossen** — auf ausdrueckliche Anweisung des Users VORGEZOGEN, entgegen der geplanten Reihenfolge (urspruenglich nach Etappe 2, damit jeder Bereich gegen einen stabilen Regelstand misst; der User entschied anders, weil offene Loecher vergessen werden). Erster Durchlauf dieser Serie, der die DATENBANK aendert statt nur Anwendungscode — neue Migration `20260910120000_rls_widen_membership_grant_and_platform_read`. **T-JTS-02:** `GroupMembership` prueft jetzt BEIDE Seiten (Gruppe UND Benutzer gehoeren zum Mandanten) statt nur die Gruppenseite. **T-JTS-03:** `ModuleGrant` prueft zusaetzlich, dass die referenzierte Gruppe bzw. der referenzierte Benutzer zum selben Mandanten gehoert; `assertTargetBelongsToTenant` bleibt als zweite Verteidigungslinie bestehen. **WINDOWS #19:** `TenderRssFeedSource` bekommt VIER nach Befehl getrennte Regeln — Lesen schliesst plattformweite Zeilen ein, Einfuegen/Aendern/Loeschen verlangen weiter einen Mandanten (eine einzige lockere Regel haette jedem Mandanten erlaubt, gemeinsame Quellen zu aendern und zu loeschen, weil `USING` auch UPDATE und DELETE regelt). **Halbe Praemisse von #19 widerlegt:** bei `SearchProvider` gibt es gar keinen Codeweg, der eine mandantenlose Zeile erzeugt — Schreibweg verlangt den Mandanten, Vorgaben sind Konstanten (05-02); als widerlegte Annahme geschlossen, nicht als geloestes Problem, strenge Regel bleibt. **DREI Pruefungen schrieben die Loecher als erwartetes Verhalten fest** (meine eigene Suche fand nur zwei, der Planer die dritte) — alle drei UMGEDREHT statt geloescht, mit Verweis auf den urspruenglichen Befund: der ausfuehrbare Beleg, dass das Loch existierte, bleibt mit umgekehrtem Vorzeichen erhalten. **Die Reparatur erzeugte an einer Stelle selbst den Fehler, gegen den sie antritt:** `listForUser` haette nach der Regelaenderung die plattformweiten, aber nicht die persoenlichen Quellen geliefert — aus einer leeren Liste, die schreit, waere eine kurze geworden, die luegt; deshalb mitgebunden. **Messfalle abgefangen:** `extractPolicySql()` las nur die alten Migrationsverzeichnisse und haette nach der neuen Migration still die ABGELOESTE Regel weitergemessen. **Werkzeugfalle abgefangen:** der uebliche Aufrufweg haette beim Einspielen eine neue Prisma-Hauptversion nachgeladen; stattdessen die im Projekt festgelegte Fassung benutzt. Neuer offener Ledger-Eintrag #24: plattformweite Zeilen lassen sich unter der Anwendungsrolle weder anlegen noch entfernen — in alter wie neuer Regel. **Verifiziert 11/11 mit vier ZERSTOERENDEN Gegenproben** (jede Regel und die neue Bindung einzeln zurueckgedreht, jedes Mal schlug genau die zustaendige Pruefung fehl, danach byte-identisch wiederhergestellt). 839/839 Tests, Typpruefung sauber, 74/74 Live-Pruefungen; Regeltexte vom Orchestrator in der LAUFENDEN Datenbank gegengelesen | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
| 260910-krx | Mandantentrennung Etappe 2, Bereich dashboard — 12 von 13 Zugriffen gebunden, der Modulkatalog bleibt bewusst ungebunden (Messung und Bedingung getrennt: heute ohne Zeilenschutz, daher wirkungslos, katastrophal erst wenn Etappe 3 eine Regel setzt). **Erster Bereich, in dem die Besitzpruefungen von Anfang an richtig waren:** dieselbe Bauform, die in ldap und dkv je eine Luecke riss (nachschlagen, dann loeschen), vergleicht hier dazwischen gegen die angemeldete Person — nichts zu reparieren, nur zu bestaetigen und durch Tests festzunageln. **Beweisvernichtungs-Schleife belegt, nicht vermutet (WINDOWS #25, offen):** nach dem Scharfschalten liefert bei unsichtbarer Zeile die Vorgabe, die Oberflaeche uebernimmt sie ohne Fehlerzustand, und das Verlassen des Bearbeitungsmodus schreibt AUTOMATISCH zurueck — der Nutzer ueberschreibt seine urspruengliche Anordnung selbst, ohne es zu merken; dazu haeufen sich Widget-Dubletten, weil es keine Eindeutigkeit ueber (userId, widgetType) gibt. Gehoert in die Etappe-4-Vorabpruefung, nicht in diesen Umbau. Suchleiste: der Rueckfallzweig feuert nie leer, weil drei Vorgaben immer vorangestellt sind — die eigenen Suchmaschinen verschwinden schlicht. ist plattformweit eindeutig ohne Mandantenanteil (Familie WINDOWS #22). **Der Verifizierer fand eine Luecke der bekannten Art:** die Behauptung, ein gebundener Konfliktschreibvorgang werfe (nicht den P2002-Fall von tenders/user), stuetzte sich auf eine NICHT committete Ad-hoc-Messung — Pruefung 5 mass nur Roh-SQL, kein Test uebte den catch-Zweig. Nachgereicht (6e71206): Messung ueber den GENERIERTEN Client (Konstruktorname geprueft), dabei die Wegwerf-Tabelle korrigiert, der Roh-SQL nie aufgefallen war (createdAt/updatedAt fehlten, der echte Client scheiterte sofort mit P2022); zwei Tests fuer den catch-Zweig, durch Rueckbau falsifiziert. Klassifikation: fremde Datei mit ungenauem Kopfkommentar bewusst NICHT angefasst, Ungenauigkeit in (w5) festgehalten. **Verifiziert 10/11, Luecke behoben** (860/860 Tests, Typpruefung sauber, 88/88 Live-Pruefungen; alle drei Falsifizierungsnachweise vom Pruefer eigenhaendig reproduziert) | 2026-09-11 | 6744918,e0ce594,67b5024,6e71206 | [260910-krx-mandantentrennung-etappe-2-bereich-dashb](./quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/) |
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
| 260910-jab | Die drei zu kurz greifenden Datenbankregeln geschlossen — T-JTS-02, T-JTS-03, WINDOWS #19 (bewusste Reihenfolge-Abweichung, vorgezogen auf Nutzerwunsch, statt wie geplant nach Etappe 2). Neue, handgeschriebene, lokal angewandte Migration `20260910120000_rls_widen_membership_grant_and_platform_read`: `GroupMembership` prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer), `ModuleGrant` prueft zusaetzlich beide moeglichen Ziele mit Leer-Zulassung (D-04), `TenderRssFeedSource` bekommt vier nach Befehl getrennte Regeln (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt weiterhin einen Mandanten — die Trennung ist noetig, weil ein einzelner USING-Ausdruck sonst auch UPDATE/DELETE mitregelt). `SearchProvider` bewusst NICHT angefasst: die WINDOWS-#19-Praemisse ist fuer dieses Modell widerlegt (kein Codeweg erzeugt eine mandantenlose Zeile). Drei loch-behauptende Pruefungen im Wegwerf-Werkzeug UMGEKEHRT statt geloescht (66→74 Pruefungen), mit Verweis auf die alten Pruefungsnamen und Befundkennungen im Meldetext. Genau EIN Anwendungspfad musste mitgebunden werden (`TenderRssFeedSourceService.listForUser`) — sonst haette die Reparatur ihn still von 'liefert nach dem Scharfschalten nichts' auf 'liefert nur die plattformweiten Zeilen, taeuscht Vollstaendigkeit vor' verschlechtert; Falsifizierungsnachweis gefuehrt (Bindung zurueckgenommen, genau ein Test rot, zurueckgesetzt). WINDOWS #19 geschlossen mit Beleg, WINDOWS #24 neu angelegt (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle fehlt weiterhin — verschwindet nicht mit #19). Aktenstand kohaerent: Klassifikation, Kritikschrift (neuer Abschnitt "Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19" mit Signaltabelle beider Fehlerrichtungen je Regel), Betriebsanleitung, WINDOWS.md — fuenf ueberholte Bestandsstellen mit Nachtraegen versehen, alte Messprotokolle bleiben woertlich stehen. Selbst gemessen statt uebernommen: Baseline 833/56 Tests, 66/66 Live-Pruefungen; Endstand 839/56, 74/74; keine zweite Sitzungsvariable fuer den Benutzer gefunden (nur `app.current_tenant`). Rule-1-Fix: implizites `any` in `tenders.controller.ts` nach der Bindung behoben. `npx prisma` versuchte ungefragt Prisma 8 herunterzuladen — abgebrochen, lokale gepinnte 6.19.3 verwendet | 2026-09-10 | f4f3115,6b23735,03fb3bf | [260910-jab-mandantentrennung-die-drei-zu-kurz-greif](./quick/260910-jab-mandantentrennung-die-drei-zu-kurz-greif/) |
@@ -422,8 +425,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
Last session: 2026-09-10T12:35:40.000Z
Last session: 2026-09-11T07:05:44.595Z
Resumed: 2026-09-09 — Sitzung ueber /gsd-resume-work fortgesetzt; Einstiegspunkt Etappe 2 (Bereich ldap), Auswahl des Nutzers steht aus.
Stopped at: Quick 260910-jab abgeschlossen — die drei zu kurz greifenden Datenbankregeln (T-JTS-02, T-JTS-03, WINDOWS #19) sind geschlossen, VORGEZOGEN auf ausdruecklichen Nutzerwunsch vor die restlichen fuenf offenen Bereiche der Etappe 2 (dashboard 13, calendar 12, tenant 8, favorites 7, auth 5, settings 4 — sechs, nicht fuenf, `settings` war bereits vorher als Reihenfolgebedingung fuer Etappe 4 markiert, Befund K). Diese fuenf/sechs Bereiche messen ab jetzt gegen die NEUEN Regeln aus 20260910120000_rls_widen_membership_grant_and_platform_read. WINDOWS #19 fixed, WINDOWS #24 neu offen (Verwaltungsweg fuer plattformweite Zeilen unter der Anwendungsrolle). Naechster Schritt laut vorheriger Uebergabe: Bereich dashboard (13 Zugriffe), danach wie zuvor geplant. Der User hat am 2026-09-09 gesagt, die restlichen Bereiche sollen ohne Rueckfrage durchlaufen; beim Scharfschalten (Etappe 4) wird ausdruecklich angehalten. ZWEI PRODUKTENTSCHEIDUNGEN DES USERS VOM 2026-09-10, beide fuer Etappe 3 (nach Abschluss von Etappe 2): (1) ANMELDENAMEN PRO MANDANT EINDEUTIG, nicht plattformweit — m.schmidt darf es bei Firma A und Firma B geben. Folge: `User.username`/`User.email` von `@unique` auf `@@unique([tenantId, username])`/`@@unique([tenantId, email])` umstellen, und der Anmeldeweg muss den Mandanten kennen, BEVOR er die Benutzerzeile sucht (heute kommt der Mandant erst AUS der Zeile; die drei SECURITY-DEFINER-Funktionen aus Etappe 1 suchen ueber den Namen allein). Ueblicher Weg: eigene Adresse je Mandant (Subdomain) oder Mandantenwahl beim Login. Loest zugleich den bewusst ungebundenen `resolveEmailForWrite`-Pfad (ldap) und die Kollisionskette unsichtbar->frei->P2002 (tenders, user). (2) KOLLEGEN DERSELBEN FIRMA STRIKT GETRENNT — jeder sieht nur seine eigenen gespeicherten Suchen, Favoriten, Dashboard-Anordnung. Heute trennt das nur der Anwendungscode; alle Regeln lesen `tenantId = current_tenant_id()` ohne Benutzerdimension. Folge: zweite Sitzungsvariable `app.current_user` samt `current_user_id()`, an jeder Bindungsstelle mitgesetzt, und Benutzerdimension in den Regeln der nutzerbezogenen Tabellen (TenderSavedSearch, TenderTriage, TenderNotificationPref, TenderEmailConfig, TenderRssFeedSource persoenliche Zeilen, DashboardLayout, WidgetInstance, SearchProvider, FavoriteLink, CalendarSource). Danach faengt die Datenbank auch einen Programmierfehler ab, der heute unbemerkt bliebe. Beides eigene Umbauten; Reihenfolge: erst Etappe 2 zu Ende, dann diese beiden als Etappe 3, dann Scharfschalten.
Stopped at: Quick 260910-krx abgeschlossen: Bereich dashboard der Etappe 2 (Mandantentrennung) umgestellt, 12/13 Zugriffe gebunden, WINDOWS #25 neu offen
Resume file: None
Last activity: 2026-09-10 - Completed quick task 260910-jab: Die drei zu kurz greifenden Datenbankregeln (T-JTS-02, T-JTS-03, WINDOWS #19) geschlossen
+16 -3
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 6
open_count: 7
waived_count: 1
fixed_count: 17
total_count: 24
last_updated: 2026-09-10T12:35:40.000Z
total_count: 25
last_updated: 2026-09-11T09:01:00.000Z
---
# Broken Windows Ledger
@@ -39,6 +39,7 @@ last_updated: 2026-09-10T12:35:40.000Z
| 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | |
| 23 | quick-260910-exd | deviation | apps/api/src/module-registry/module-access.service.ts | | Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3). | open | | 2026-09-10T09:28:45.310Z | |
| 24 | quick-260910-jab | deviation | apps/api/src/tenders/tender-rss-feed.service.ts | | Was das Schliessen von WINDOWS #19 NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite RSS-Quelle (TenderRssFeedSource, tenantId NULL) weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel (Einfuegen/Aendern/Entfernen) ausdruecklich einen Mandanten verlangt (tenant_insert_policy/tenant_update_policy/tenant_delete_policy, 20260910120000_rls_widen_membership_grant_and_platform_read). Betroffen sind zwei Pfade in TenderRssFeedSourceService: createPlatform() (setzt tenantId=NULL, ein gebundenes INSERT liefe in die WITH-CHECK-Klausel und wuerde abgewiesen) und remove() (deckt fuer Administratoren auch das Entfernen einer plattformweiten Zeile ab; ein gebundenes DELETE traefe sie nie). Beide bleiben deshalb bewusst ungebunden — das ist KEINE Folge dieser Reparatur, sondern bestand bereits vor 260910-jab identisch, weil die vom Ledger vorgegebene #19-Semantik Schreibzugriffe ausdruecklich an einen Mandanten bindet. Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): ein Verwaltungsweg fuer plattformweite Zeilen (Anlegen/Entfernen unter der Anwendungsrolle) muss gebaut werden, BEVOR die Rolle umgeschaltet wird — sonst kann kein Administrator nach dem Scharfschalten mehr eine plattformweite Quelle pflegen. Eigener Eintrag, damit dieser Rest nicht mit #19 verschwindet. | open | | 2026-09-10T12:35:40.000Z | |
| 25 | quick-260910-krx | deviation | apps/web/src/lib/stores/dashboard-store.ts | | Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier. | open | | 2026-09-11T09:01:00.000Z | |
````json
[
@@ -329,6 +330,18 @@ last_updated: 2026-09-10T12:35:40.000Z
"reason": "",
"recorded_at": "2026-09-10T12:35:40.000Z",
"resolved_at": null
},
{
"id": 25,
"kind": "deviation",
"phase": "quick-260910-krx",
"file": "apps/web/src/lib/stores/dashboard-store.ts",
"line": null,
"description": "Die beweisvernichtende Auspraegung der umgekehrten Fehlerrichtung im Bereich dashboard: ein nach dem Scharfschalten (WINDOWS #18) zu klein gebliebenes Leseergebnis auf getLayout sieht nicht wie ein Fehler aus, sondern wie eine leere Vorgabeanordnung. Drei Stellen greifen ineinander (docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich dashboard', (w3)): (1) DashboardService.getLayout liefert bei fehlendem Datensatz {lg:[],md:[],sm:[],xs:[],xxs:[]} statt eines Fehlers; (2) apps/web/src/lib/stores/dashboard-store.ts, loadDashboard setzt layouts/widgets ungeprueft auf das Ergebnis, der catch-Zweig feuert nur bei Netzwerk-/Statusfehlern, nicht bei einer erfolgreichen leeren Antwort; (3) dieselbe Datei, setEditMode(false) schreibt bei isDirty automatisch zurueck, sobald der Bearbeitungsmodus verlassen wird — ohne dass der Nutzer auf Speichern klickt. Die Folge: der Nutzer haelt ein leeres Dashboard fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen ('das Widget-System spinnt', 'meine Einstellungen sind weg'), baut seine Anordnung neu auf (addWidget legt echte neue WidgetInstance-Zeilen an, keine Eindeutigkeitsbedingung ueber (userId, widgetType), Dubletten haeufen sich bei wiederholtem Neuaufbau an), und das automatische Zurueckschreiben ueberschreibt die layouts-Spalte der urspruenglichen Zeile — die einzige Aufzeichnung der urspruenglichen Anordnung ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Zusaetzlich, kleiner: apps/web/src/components/dashboard/widgets/search-widget.tsx laesst bei einem zu kleinen custom-Ergebnis die eigenen Suchmaschinen des Nutzers aus der Auswahlliste verschwinden (der Rueckfallzweig auf DEFAULT_PROVIDERS feuert nie, weil getSearchProviders die drei Vorgaben immer voranstellt), und handleSearch faellt bei unbekannter Auswahl auf providers[0] (Google) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann an eine externe Suchmaschine. Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): eine physisch vorhandene DashboardLayout-Zeile fuer einen bekannten Benutzer, aber der gebundene Lesezugriff fuer dessen Mandanten liefert null — das unterscheidet den echten Erstbenutzer-Fall vom Trennungsfehler. An dieselbe Bedingung gebunden wie #18 — beobachtbar erst nach dem Scharfschalten. Das Frontend wird von 260910-krx NICHT geaendert, dieser Eintrag beschreibt es nur. Die verwandte, strukturelle Eindeutigkeitsfrage von DashboardLayout.userId (plattformweit @unique ohne Mandantenanteil) ist derselbe Fall wie WINDOWS #22 im Bereich user — dort mitgefuehrt, kein zweiter Eintrag hier.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-11T09:01:00.000Z",
"resolved_at": null
}
]
````
@@ -0,0 +1,247 @@
---
phase: quick-260910-krx
plan: 01
subsystem: database
tags: [prisma, postgresql, rls, multi-tenancy, nestjs, vitest]
requires:
- phase: quick-260910-jab
provides: die drei geschlossenen Datenbankregeln (GroupMembership, ModuleGrant, TenderRssFeedSource) und den unveraendert strengen SearchProvider-Regelstand, gemessen in Migration 20260910120000
- phase: quick-260910-exd
provides: die bereits gebundene Modul-Zugriffsaufloesung (module-access.service.ts), die der Widget-Modulfilter dieses Bereichs ohne eigenes Zutun erbt
provides:
- Bereich dashboard vollstaendig umgestellt — zwoelf mandantengebundene Datenbankzugriffe ueber forTenant(), ein Katalogzugriff begruendet ungebunden
- Neunter Werkzeugabschnitt in rls-scratch-check.mjs (runDashboardAreaChecks), 13 neue Pruefungen, 87/87 gesamt
- Zwei-Klienten-TDD-Nachweis in dashboard.service.spec.ts, 8 -> 27 Testfaelle
- Vollstaendig nachgezogene Klassifikationsdatei (fuenf handgepflegte Stellen)
- Offener WINDOWS-Ledger-Eintrag #25 fuer die beweisvernichtende Fehlerrichtung dieses Bereichs
affects: [quick-260910-*, etappe-3-mandantentrennung, etappe-4-scharfschalten]
actuals:
tokens: 25254
tasks: 3
commits: 3
tech-stack:
added: []
patterns:
- "forTenant()-Bindung dienst-intern je Methode (kein req.tenantPrisma), wie alle sieben Bereiche vor diesem"
- "Zwei-Klienten-TDD-Nachweis via __makeBoundClient (Bindungsprotokoll: tenantId/Modell/Methode)"
key-files:
created: []
modified:
- apps/api/scripts/rls-scratch-check.mjs
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dashboard.service.spec.ts
- apps/api/src/dashboard/dashboard.controller.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- .planning/WINDOWS.md
key-decisions:
- "getLayout/saveLayout binden GEMEINSAM ueber denselben Klienten (Testfall festgenagelt) — nie getrennt auf gebunden/ungebunden, sonst geht die Konfliktpruefung gegen eine Zeile auf, die der Schreibzugriff nicht mehr sieht"
- "saveLayout uebersetzt eine PrismaClientUnknownRequestError (RLS-Konflikt) in eine deutsche ConflictException — NICHT das tenders-P2002-Muster, weil die gemessene Fehlerklasse eine andere ist"
- "Modulkatalog bleibt begruendet ungebunden, mit Messung/Bedingung getrennt, unter Berufung auf die bestehende module-registry-Pruefung statt einer neuen Behauptung"
- "Die drei Besitzpruefungen ueber die Benutzerkennung bleiben unveraendert bestehen — die Bindung ERGAENZT sie, ersetzt sie nicht (die Regeln dieses Bereichs kennen keine Benutzerdimension)"
patterns-established:
- "Wachhund-Testfall, der VOR der Protokollpruefung beweist, dass der zu schuetzende Codepfad ueberhaupt durchlaufen wurde"
requirements-completed: [WINDOWS-18, ETAPPE-2-DASHBOARD]
coverage:
- id: D1
description: "13 Datenbankzugriffe des Bereichs dashboard vollstaendig entschieden: 12 gebunden (forTenant), 1 begruendet ungebunden (Modulkatalog)"
requirement: "ETAPPE-2-DASHBOARD"
verification:
- kind: unit
ref: "apps/api/src/dashboard/dashboard.service.spec.ts (27 Faelle)"
status: pass
- kind: integration
ref: "apps/api/scripts/rls-scratch-check.mjs (87/87 Pruefungen, davon 13 neue im Abschnitt runDashboardAreaChecks)"
status: pass
human_judgment: false
- id: D2
description: "Kritikschrift fuer den Bereich dashboard inklusive der beweisvernichtenden Schleife und der eigenstaendig nachgepruften widerlegten SearchProvider-Praemisse"
requirement: "WINDOWS-18"
verification:
- kind: other
ref: "docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt '## Bereich dashboard' (w1)-(w5)"
status: pass
human_judgment: false
- id: D3
description: "Klassifikationsdatei an allen fuenf handgepflegten Stellen nachgezogen und maschinell gegen den Quelltext geprueft"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts (10/10)"
status: pass
human_judgment: false
duration: 26min
completed: 2026-09-11
status: complete
---
# Quick 260910-krx: Mandantentrennung Etappe 2, Bereich dashboard — Summary
**Dreizehn Datenbankzugriffe des Bereichs `dashboard` (Widget-Anordnung, platzierte Widgets, eigene Suchmaschinen) vollständig entschieden: zwölf gebunden über `forTenant()`, ein Katalogzugriff begründet ungebunden — gemessen an den Regeln nach Migration 20260910120000, mit der beweisvernichtenden Fehlerrichtung dieses Bereichs vorab in der Kritikschrift festgehalten.**
## Performance
- **Duration:** 26 min
- **Started:** 2026-09-11T06:36:42Z
- **Completed:** 2026-09-11T09:02:xx (dritter Task-Commit)
- **Tasks:** 3
- **Files modified:** 7
## Accomplishments
- Neunter Abschnitt (`runDashboardAreaChecks`) in `apps/api/scripts/rls-scratch-check.mjs` mit 13 neuen, namentlich benannten Prüfungen gegen die aus der ausgelieferten Migration `20260909140000_rls_remaining_tenant_tables` geschnittenen Regeln für `DashboardLayout`, `WidgetInstance` und `SearchProvider`. Alle 87 Prüfungen bestehen (74 bisherige + 13 neue).
- Die Konfliktmessung (Befund K) ist gemessen, nicht angenommen: ein gebundenes `INSERT ... ON CONFLICT` auf eine unter dem Mandanten unsichtbare Zeile scheitert laut mit SQLSTATE 42501 — und am echten generierten Prisma Client (nicht nur an rohem SQL) gemessen: `prisma.dashboardLayout.upsert()` wirft `PrismaClientUnknownRequestError`, NICHT die bekannte `P2002`-Form, die der Bereich `tenders` abfängt.
- `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` gemeinsam gebunden; `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` gebunden, die drei Besitzprüfungen über die Benutzerkennung bleiben unverändert bestehen; `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` gebunden, die drei Vorgabe-Suchmaschinen aus der Konstante bleiben unverändert; der eine Katalogzugriff (`module`) bleibt begründet ungebunden.
- `dashboard.controller.ts` reicht den bereits aufgelösten Mandanten bei den fünf Handlern durch, die ihn zuvor verworfen hatten (`getLayout`, `updateWidgetConfig`, `removeWidget`, `getSearchProviders`, `removeSearchProvider`) — keine neue Vertrauensquelle, weiterhin ausschließlich aus `extractContext`/dem Sitzungsnachweis.
- `dashboard.service.spec.ts`: Zwei-Klienten-Nachweis über `__makeBoundClient` nach dem Muster von `module-access.service.spec.ts`, 8 → 27 Testfälle (19 neue, abgezählt), alle acht bestehenden Fälle unverändert grün.
- `docs/mandantentrennung-etappe2-fehlerrichtung.md`: neuer Abschnitt `## Bereich dashboard` mit (w1)-(w5), inklusive der beweisvernichtenden Schleife (leeres Dashboard → Neuaufbau → automatisches Zurückschreiben → überschriebene Anordnung, Widget-Dubletten) und einem Nachtrag im Abschnitt `## Bereich module-registry` zur geerbten Bindungsentlastung.
- `docs/mandantentrennung-zugriffsklassifikation.md`: alle fünf handgepflegten Stellen nachgezogen und maschinell gegen den Quelltext geprüft (`rls-access-inventory.spec.ts` grün).
- `.planning/WINDOWS.md`: neuer offener Eintrag #25 (Tabelle + JSON) für die beweisvernichtende Fehlerrichtung, mit der konkreten Vorabprüfung für Etappe 4 und dem Verweis auf #22 für die verwandte Eindeutigkeitsfrage.
- Drei Falsifizierungsnachweise durchgeführt, zurückgenommen und mit Testname/Fehlermeldung dokumentiert (siehe unten).
## Task Commits
Each task was committed atomically:
1. **Aufgabe 1: Die Fehlerrichtung messen und aufschreiben** - `6744918` (feat)
2. **Aufgabe 2: Anordnung und Widgets binden** - `e0ce594` (feat, TDD)
3. **Aufgabe 3: Suchmaschinen binden, Katalog begründet offen, Dokumente nachziehen** - `67b5024` (feat, TDD)
_Alle drei Tasks waren TDD-Tasks (Aufgabe 2/3) bzw. eine Messaufgabe (Aufgabe 1); jeder Task ist als ein Commit gelandet, weil RED/GREEN innerhalb desselben Arbeitsschritts vor dem Commit durchlaufen und verifiziert wurde._
## Files Created/Modified
- `apps/api/scripts/rls-scratch-check.mjs` - neunter Abschnitt `runDashboardAreaChecks`, 13 neue Prüfungen (74 → 87 gesamt)
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` - neuer Abschnitt `## Bereich dashboard` (w1)-(w5) + Nachtrag in `## Bereich module-registry`
- `apps/api/src/dashboard/dashboard.service.ts` - alle 12 mandantengebundenen Zugriffe auf `forTenant()` umgestellt, Modulkatalog begründet ungebunden, Konfliktübersetzung in `saveLayout`
- `apps/api/src/dashboard/dashboard.service.spec.ts` - Zwei-Klienten-TDD-Nachweis, 8 → 27 Fälle
- `apps/api/src/dashboard/dashboard.controller.ts` - fünf Handler reichen den Mandanten durch
- `docs/mandantentrennung-zugriffsklassifikation.md` - alle fünf handgepflegten Stellen nachgezogen
- `.planning/WINDOWS.md` - neuer offener Eintrag #25
## Decisions Made
- **getLayout/saveLayout bewusst gemeinsam gebunden, nie getrennt** — ein eigener Testfall nagelt das fest, weil ein Auseinanderfallen von Lese- und Schreibhälfte die Konfliktprüfung auf eine Zeile aufgehen ließe, die der jeweils andere Halbschritt nicht mehr sieht (dieselbe Familie wie WINDOWS #22 im Bereich `user`).
- **Konfliktübersetzung über `Prisma.PrismaClientUnknownRequestError`, nicht `.code === 'P2002'`.** Gemessen in Aufgabe 1 am echten generierten Prisma Client gegen eine Wegwerf-Datenbank: `dashboardLayout.upsert()` wirft bei einem RLS-Konflikt auf eine unsichtbare, aber physisch vorhandene Zeile eine andere Prisma-Fehlerklasse als der Eindeutigkeitsfehler, den der Bereich `tenders` abfängt. Das `tenders`-Muster ließ sich deshalb nicht wörtlich übernehmen — dokumentiert in (w1)/(w4) der Kritikschrift, damit die Abweichung nicht stillschweigend untergeht.
- **Modulkatalog begründet ungebunden, mit Messung und Bedingung getrennt**, unter Berufung auf die bestehende Werkzeugprüfung `module-tabelle-traegt-keinen-zeilenschutz` aus dem Bereich `module-registry` statt einer neu behaupteten Messung.
- **Die drei Besitzprüfungen über die Benutzerkennung bleiben unverändert bestehen** — die Bindung ergänzt sie, ersetzt sie nicht. Zwei Testfälle je Prüfung nageln das fest (Besitzprüfung greift weiterhin bei fremdem Widget/fremder Suchmaschine).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Verify-Schwellwert von Aufgabe 2 korrigiert (B ≥ 9 → B ≥ 8)**
- **Found during:** Aufgabe 1 (Zaehlkontrolle) und bestätigt bei der Verify-Ausführung von Aufgabe 2
- **Issue:** Befund A des Plans sagte für `widgetInstance` sieben Rohtreffer über sechs Zeilen voraus ("eine Zeile trägt zwei Vorkommen"). Tatsächlich gemessen (`grep -o "this\.prisma\.widgetInstance" apps/api/src/dashboard/dashboard.service.ts | wc -l`): genau sechs, jede Zeile genau ein Vorkommen. Zusammen mit `dashboardLayout` (2) ergibt das für Aufgabe 2 acht zu bindende Rohtreffer, nicht neun — der Gesamtwert für den Bereich (dreizehn) hält trotzdem exakt, siehe Task-1-Messung.
- **Fix:** Der in Aufgabe 2 manuell ausgeführte Verify-Befehl wurde mit der korrigierten Schwelle (`B -ge 8`) statt der im Plantext genannten (`B -ge 9`) interpretiert — dieselbe Vollständigkeit (alle 8 real vorhandenen Zugriffe gebunden), nur der falsche Zahlenwert korrigiert. Die Plandatei selbst wurde nicht verändert.
- **Files modified:** keine zusätzlichen — betrifft nur die Interpretation des Verify-Befehls
- **Verification:** `B=8` gemessen und akzeptiert; `C=6` (forTenant-Aufrufstellen je Methode) unverändert exakt getroffen
- **Committed in:** `e0ce594` (Aufgabe-2-Commit, Deviation im Commit-Text dokumentiert)
**2. [Rule 3 - Blocking] Klassifikationsdatei bereits in Aufgabe 2 minimal nachgezogen**
- **Found during:** Aufgabe 2, beim ersten vollständigen `npm run test`
- **Issue:** `rls-access-inventory.spec.ts` vergleicht die `Stand`-Spalte der Klassifikationsdatei live gegen den Quelltext. Nach der Bindung von `dashboardLayout`/`widgetInstance` in Aufgabe 2 stand die Datei noch auf `ungebunden` (die vollständige Nachziehung war für Aufgabe 3 vorgesehen) — die Prüfung wäre am Ende von Aufgabe 2 rot gewesen, "Baseline gehalten" wäre verletzt. Exakter Präzedenzfall: 260910-exd, Aufgabe 2, dieselbe Deviation, dort ebenfalls dokumentiert.
- **Fix:** Nur die `Stand`-Spalte der zwei betroffenen Zeilen (`dashboardLayout`, `widgetInstance`) auf `gebunden` gesetzt, mit einem kurzen Verweis auf die vollständige Nachziehung in Aufgabe 3 — keine Zahlen, keine Übersichtszeile, keine Summenzeile in Aufgabe 2 angefasst.
- **Files modified:** `docs/mandantentrennung-zugriffsklassifikation.md`
- **Verification:** `npm --prefix apps/api run test` grün (851/851) nach dem minimalen Nachzug
- **Committed in:** `e0ce594` (Aufgabe-2-Commit)
**3. [Rule 3 - Blocking] Scope-Allowlist von Aufgabe 2 um die Klassifikationsdatei erweitert**
- **Found during:** Aufgabe 2, beim Ausführen der Erlaubnislisten-Prüfung des Plans
- **Issue:** Die im Plantext für Aufgabe 2 genannte Erlaubnisliste (`UNEXPECTED=...`) nennt `docs/mandantentrennung-zugriffsklassifikation.md` nicht — eine direkte Folge derselben Abweichung wie oben (Deviation 2). Ohne die Erweiterung hätte die Erlaubnislisten-Prüfung eine notwendige, dokumentierte Änderung als "unerwartet" gemeldet.
- **Fix:** Die Datei bei der manuellen Ausführung der Erlaubnislisten-Prüfung als erlaubt behandelt (sie steht ohnehin in der Gesamt-`files_modified`-Liste des Plans und im Umfang von Aufgabe 3) — dieselbe Begründung wie Deviation 2.
- **Files modified:** keine zusätzlichen
- **Verification:** Erlaubnislisten-Prüfung grün mit der Erweiterung; die plan-weite Erlaubnisliste (gegen alle sieben `files_modified`) stimmt am Ende von Aufgabe 3 exakt überein (verifiziert)
- **Committed in:** `e0ce594` (Aufgabe-2-Commit)
**4. [Rule 2 - Missing critical] Wachhund-Testfall für den Modulkatalog verschärft, bevor er real geprüft wurde**
- **Found during:** Aufgabe 3, bei der Vorbereitung des ersten Falsifizierungsnachweises
- **Issue:** Der ursprünglich geschriebene Wachhund-Test (`expectNeverBound(prisma, 'module')`) rief `getWidgets` mit leerer `mockMap` auf — der Katalogzugriff wurde dadurch nie erreicht (früher Rückgabepunkt bei `boundSlugs.length === 0`). Der Test hätte auch dann grün gemeldet, wenn der Katalogzugriff versehentlich gebunden worden wäre, solange er nie aufgerufen wird — eine wirkungslose Prüfung.
- **Fix:** Test um ein Widget mit zugeordnetem Modul-Slug und eine zugehörige Katalogzeile erweitert, plus eine explizite Prüfung `expect(prisma.module.findMany).toHaveBeenCalled()` VOR der Wachhund-Prüfung, die beweist, dass der Pfad tatsächlich durchlaufen wurde.
- **Files modified:** `apps/api/src/dashboard/dashboard.service.spec.ts`
- **Verification:** Der verschärfte Test besteht mit dem korrekten Setup und schlägt beim ersten Falsifizierungsnachweis (Katalog probeweise gebunden) tatsächlich fehl — siehe Falsifizierungsnachweise unten
- **Committed in:** `67b5024` (Aufgabe-3-Commit)
---
**Total deviations:** 4 auto-fixed (3× Rule 3 — blockierende Verify-/Scope-Korrekturen aufgrund eines Planungsfehlers in Befund A, 1× Rule 2 — verschärfter Wachhund-Test)
**Impact on plan:** Keine Funktionsänderung, keine Verwässerung der Prüftiefe — im Gegenteil, Deviation 4 hat eine bestehende Prüflücke geschlossen, bevor sie unbemerkt geblieben wäre. Deviations 1-3 korrigieren einen bereits im Plantext selbst als möglich benannten Zählfehler (Zaehlkontrolle, Befund A) auf den tatsächlich gemessenen, korrekten Wert.
## Falsifizierungsnachweise
Alle drei durchgeführt, zurückgenommen und mit Testname/Meldung dokumentiert (Rule 5, "Falsification proofs get forgotten"):
1. **Aufgabe 2 — Bindung probeweise zurückgebaut.** `tenantPrisma.widgetInstance.delete` in `removeWidget` auf `this.prisma.widgetInstance.delete` zurückgebaut. Test **"Widget entfernen: ebenso, beide Abfragen über denselben Klienten"** wurde rot mit: `AssertionError: erwarteter gebundener Aufruf widgetInstance.delete(tenant=tenant-1) fehlt im Protokoll: [{"tenantId":"tenant-1","model":"widgetInstance","method":"findUnique"}]: expected false to be true`. Rückbau zurückgenommen, Testlauf wieder grün (20/20).
2. **Aufgabe 3 — Katalogzugriff probeweise gebunden.** `this.prisma.module.findMany` in `getWidgets` auf `tenantPrisma.module.findMany` geändert (`module` ist bewusst nicht Teil der Testdouble-Bindungsliste `BOUND_MODEL_NAMES`). Acht Tests wurden rot, u. a. der Wachhund-Test **"Wachhund: der Modulkatalog taucht im Bindungsprotokoll nie auf..."**, mit: `TypeError: Cannot read properties of undefined (reading 'findMany')` an `dashboard.service.ts:175`. Rückbau zurückgenommen, Testlauf wieder grün (27/27).
3. **Aufgabe 3 — Bestandsaufnahme-Zeile probeweise falsch gesetzt.** `Stand` der Zeile `dashboard.service.ts`/`dashboardLayout` in `docs/mandantentrennung-zugriffsklassifikation.md` auf `ungebunden` gesetzt (Quelltext ist tatsächlich `gebunden`). Test **"der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein"** (`rls-access-inventory.spec.ts`) wurde rot mit: `AssertionError: Abweichender Stand (Dokument vs. Quelltext): apps/api/src/dashboard/dashboard.service.ts::dashboardLayout — dokumentiert=ungebunden, gemessen=gebunden`. Zurückgenommen, Testlauf wieder grün (10/10).
## Issues Encountered
Keine unerwarteten Blocker. Die einzige nennenswerte Beobachtung war die eigene Kommentar-Textkontamination beim ersten Entwurf des Modulkatalog-Begründungskommentars in `dashboard.service.ts`: eine erklärende Kopfzeile enthielt wörtlich die Zeichenkette `this.prisma.module`, was die naive Rohtreffer-Zählung (`grep -o "this\.prisma\.[a-zA-Z]*"`) fälschlich auf zwei statt einen Treffer trieb. Behoben, bevor die Klassifikationsdatei nachgezogen wurde, indem der Kommentar umformuliert wurde, ohne den literalen Ausdruck zu wiederholen — sonst wäre die Übersichtszeile mit einem falschen Wert festgeschrieben worden. `rls-access-inventory.spec.ts` selbst war davon nicht betroffen, weil es Kommentare vor der Analyse entfernt.
## Known Stubs
Keine. Alle neun umgestellten Methoden liefern echte, aus der Datenbank gelesene bzw. geschriebene Daten; keine hartkodierten leeren Werte oder Platzhaltertexte wurden eingeführt.
## Threat Flags
Keine neue, nicht im Threat-Modell erfasste Angriffsfläche gefunden — alle Änderungen bleiben innerhalb der im Plan bereits benannten Grenzen (T-KRX-01 bis T-KRX-SC).
## User Setup Required
None - keine externe Dienstkonfiguration nötig. Der Schalter (`DATABASE_URL`, Rolle `tessera`) bleibt unverändert aus.
## Next Phase Readiness
- Der Bereich `dashboard` ist für Etappe 2 abgeschlossen: zwölf gebundene Zugriffe, ein begründet ungebundener, keiner unentschieden.
- Offener Ledger-Eintrag #25 (beweisvernichtende Schleife) wartet auf die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`) — konkret benannt: eine physisch vorhandene `DashboardLayout`-Zeile für einen bekannten Benutzer, deren gebundener Lesezugriff `null` liefert.
- Die strukturelle Eindeutigkeitsfrage von `DashboardLayout.userId` (Befund K) bleibt an WINDOWS #22 gebunden — keine Schemaentscheidung in dieser Etappe.
- Nächster Bereich der Etappe 2 gemäß Klassen-Verteilung: `auth` (8 ungebunden/5 gebunden, unverändert seit Etappe 1) oder `calendar`/`tenant`/`favorites`/`settings` (alle vier bislang unverändert bei 0 gebunden).
---
*Phase: quick-260910-krx*
*Completed: 2026-09-11*
## Self-Check: PASSED
Alle sieben `files_modified` sowie diese SUMMARY.md wurden geprüft (`[ -f "$f" ]`) — alle vorhanden. Alle drei Task-Commit-Hashes (`6744918`, `e0ce594`, `67b5024`) wurden gegen `git log --oneline --all` geprüft — alle vorhanden. Keine fehlenden Elemente.
## Nachtrag nach der Verifikation (2026-09-11)
Der Verifizierer fand eine Luecke: die staerkste technische Behauptung dieser
Zusammenfassung — dass ein gebundener Konfliktschreibvorgang in `saveLayout`
`PrismaClientUnknownRequestError` wirft und nicht den P2002-Fall der Bereiche
`tenders`/`user` — stuetzte sich auf eine **nicht committete Ad-hoc-Messung**.
Pruefung 5 im Werkzeug mass nur mit Roh-SQL, nie ueber den generierten Client;
kein Test uebte den `catch`-Zweig. Dieselbe Fehlerart wie in `groups`
(260909-jts), wo eine Lastprobe nur im Fliesstext stand.
Nachgereicht:
- `rls-scratch-check.mjs`: Pruefung 5b
`dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown`,
ueber `bound.dashboardLayout.upsert(...)` auf dem gebundenen GENERIERTEN
Client; geprueft wird der Konstruktorname des geworfenen Fehlers. Werkzeug
jetzt 88/88.
- Dabei aufgefallen: die Wegwerf-Tabelle `DashboardLayout` hatte kein
`createdAt`/`updatedAt` — Roh-SQL merkte das nie, der generierte Client
scheiterte sofort mit P2022 (Spalte fehlt). Tabelle an das Schema angeglichen.
Genau der Grund, mit dem echten Client zu messen.
- `dashboard.service.spec.ts`: zwei Tests fuer den `catch`-Zweig — die
Uebersetzung von `PrismaClientUnknownRequestError` in `ConflictException`,
und dass ein `PrismaClientKnownRequestError` (P2002) unveraendert
durchgereicht wird, weil er hier NICHT der gemessene Fall ist. Falsifiziert:
mit `if (false && ...)` im `catch` wird genau der erste Test rot, 28 bleiben
gruen; wiederhergestellt, `git diff` leer.
Endstand nach Nachtrag: 860 Tests gruen, Typpruefung sauber, 88/88.
@@ -0,0 +1,152 @@
---
phase: quick-260910-krx
verified: 2026-09-11T09:15:00Z
status: gaps_found
score: 10/11 must-haves verified
covered_files:
- ".planning/WINDOWS.md"
- ".planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-PLAN.md"
- ".planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-SUMMARY.md"
- "apps/api/scripts/rls-scratch-check.mjs"
- "apps/api/src/dashboard/dashboard.controller.ts"
- "apps/api/src/dashboard/dashboard.service.spec.ts"
- "apps/api/src/dashboard/dashboard.service.ts"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md"
- "docs/mandantentrennung-zugriffsklassifikation.md"
covered_digest: "v1:sha256:76946e6dc0468eaade2fe79aaa847c342e94364af5040814f495ba1e2c460d09"
behavior_unverified: 0
overrides_applied: 0
gaps:
- truth: "Die gemessene Konfliktbehandlung in saveLayout (PrismaClientUnknownRequestError statt P2002) ist im committeten Werkzeug reproduzierbar belegt und durch einen Testfall abgesichert."
status: partial
reason: >
Die im SUMMARY und in docs/mandantentrennung-etappe2-fehlerrichtung.md
(Zeilen 1812-1831) als "zentrale Abweichung vom tenders-Muster"
dargestellte Kernaussage — dass `tenantPrisma.dashboardLayout.upsert()`
am ECHTEN, generierten Prisma Client tatsaechlich einen
`PrismaClientUnknownRequestError` wirft, nicht `PrismaClientKnownRequestError`
mit `.code === 'P2002'` — ist NICHT im committeten `rls-scratch-check.mjs`
abgebildet. Die dortige Pruefung Nr. 5
(`dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut`,
Zeile 2586-2599 des Skripts) ruft ausschliesslich `tx.$executeRaw` mit
handgeschriebenem `INSERT ... ON CONFLICT ("userId") DO UPDATE` auf —
an keiner Stelle des Skripts wird `.upsert()` ueber den generierten
Prisma Client aufgerufen (grep nach `dashboardLayout.upsert` im ganzen
Skript: 0 Treffer). Die staerkere, spezifischere Behauptung (generierter
Client, andere Prisma-Fehlerklasse als P2002) stammt laut Dokument aus
einem separaten, nicht committeten Testaufbau ("eigens dafuer angelegte
Wegwerf-Datenbank... eigene Wegwerf-Rolle ohne BYPASSRLS") und ist damit
nicht unabhaengig nachvollziehbar. Zusaetzlich: kein Testfall in
`dashboard.service.spec.ts` exercised den `catch`-Zweig von `saveLayout`
(`grep -n "ConflictException\|PrismaClientUnknownRequestError"
dashboard.service.spec.ts` — 0 Treffer) — ein Refactoring, das den
`catch`-Block entfernt oder auf `.code === 'P2002'` umstellt, wuerde von
keinem Test erkannt.
artifacts:
- path: "apps/api/scripts/rls-scratch-check.mjs"
issue: "Pruefung 5 misst nur rohes SQL ($executeRaw), nicht den generierten Prisma-Client-Aufruf .upsert(), der in dashboard.service.ts tatsaechlich verwendet wird."
- path: "apps/api/src/dashboard/dashboard.service.spec.ts"
issue: "Kein Testfall ruft saveLayout mit einem Mock auf, der PrismaClientUnknownRequestError wirft, und prueft die Uebersetzung in ConflictException."
missing:
- "Entweder: Erweiterung von rls-scratch-check.mjs um eine Pruefung, die tatsaechlich prisma.dashboardLayout.upsert() (generierter Client) gegen die Konfliktzeile aufruft und die beobachtete Fehlerklasse (err.constructor.name bzw. instanceof-Pruefung) protokolliert."
- "Oder: ein Unit-Test in dashboard.service.spec.ts, der tenantPrisma.dashboardLayout.upsert im Fake mit einem Prisma.PrismaClientUnknownRequestError-Wurf versieht und erwartet, dass saveLayout eine ConflictException wirft — analog zum bestehenden Wachhund-Muster dieser Datei."
- "Reproduzierbarer Beleg (Skript oder Test), der im Repository landet, statt einer nur im Dokumentationstext behaupteten, einmaligen Ad-hoc-Messung."
---
# Quick Task 260910-krx: Mandantentrennung Etappe 2, Bereich `dashboard` — Verification Report
**Task Goal:** Bind the tenant-bound access sites in `apps/api/src/dashboard/dashboard.service.ts`, leave the platform-wide module catalogue unbound with the measured reason, create the missing spec coverage, record the evidence-destroying loop, and leave the classification document's five hand-maintained sections in sync.
**Verified:** 2026-09-11T09:15:00Z
**Status:** gaps_found (one partial gap — see below; all other checked items pass)
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | 13 Datenbankzugriffe vollstaendig entschieden: 12 gebunden, 1 begruendet ungebunden, keiner unentschieden | ✓ VERIFIED | `git show c7d93f2:.../dashboard.service.ts \| grep -oE "this\.prisma\.[a-zA-Z]+" \| wc -l` = 13 (2 dashboardLayout, 1 module, 4 searchProvider, 6 widgetInstance) at base; current file: 12 `tenantPrisma.*` call sites over 9 `forTenant()` invocations + 1 `this.prisma.module.findMany` (unbound, catalogue). Zero occurrences of `tenantPrisma.module.` (negative gate confirmed by direct grep of the committed file). |
| 2 | Lesen/Schreiben derselben Tabelle nie auf gebunden/ungebunden aufgeteilt; getLayout/saveLayout gemeinsam gebunden; drei Besitzpruefungen laufen ueber denselben Klienten | ✓ VERIFIED | Read `dashboard.service.ts`: every method creates exactly one `const tenantPrisma = forTenant(this.prisma, tenantId)` and both queries of `updateWidgetConfig`, `removeWidget`, `removeSearchProvider` use that same `tenantPrisma`. Test "Anordnung lesen und speichern sind GEMEINSAM gebunden" and "keine Methode ... erzeugt mehr als EINEN gebundenen Klienten" pin this down; both pass (27/27). |
| 3 | Umgekehrte Fehlerrichtung an den echten Regeln nach Migration 20260910120000 gemessen, mit Signal je Pfad | ✓ VERIFIED | Re-ran `rls-scratch-check.mjs` live against `tessera-ctl-db-1` (172.19.0.2): "Alle 87 Pruefungen bestanden." (74 baseline + 13 new, matching the SUMMARY's claim exactly). `docs/mandantentrennung-etappe2-fehlerrichtung.md` `## Bereich dashboard` (w1)/(w2) present with per-path signal table. |
| 4 | Beweisvernichtende Schleife ausdruecklich benannt: in Kritikschrift UND offener WINDOWS-Eintrag mit Etappe-4-Vorabpruefung | ✓ VERIFIED | `(w3)` in fehlerrichtung.md describes the loop (empty dashboard → rebuild → auto-writeback → overwritten row → duplicate widgets) at the two actual web files. `.planning/WINDOWS.md` entry #25 present in both the markdown table and the JSON block, `status: open`, names the concrete Etappe-4 preflight check and cross-references #22. Frontend files (`apps/web/**`) confirmed untouched by `git diff --name-only c7d93f2..HEAD`. |
| 5 | Widerlegte SearchProvider-Praemisse eigenstaendig nachgeprueft, mit benannter Reichweite | ✓ VERIFIED | `(w1)` names the exact search instruction and scope limits (no dynamic model-name write path, no manual DB edit covered). Database-side defense reproduced live: `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden` with SQLSTATE 42501. |
| 6 | Geerbte Entlastung (Modul-Zugriffsaufloesung bereits gebunden seit 260910-exd) nachgeprueft, nicht doppelt repariert | ✓ VERIFIED | `git diff --name-only c7d93f2..HEAD -- apps/api/src/module-registry/` returns nothing — no file under that path touched. `dashboard.service.ts` calls `this.moduleAccessService.getAccessibleModuleIds(...)` unchanged, with an explicit comment that it "already binds internally". |
| 7 | Modulkatalog bleibt ungebunden, MESSUNG und BEDINGUNG getrennt, beruft sich auf bestehende Werkzeugpruefung | ✓ VERIFIED | End-of-file comment block in `dashboard.service.ts` (lines 340-354) separates MESSUNG (`pg_class.relrowsecurity` false, cites `module-tabelle-traegt-keinen-zeilenschutz`) from BEDINGUNG (becomes catastrophic once Etappe 3 adds a rule). `module-tabelle-traegt-keinen-zeilenschutz: bestanden` reproduced live. |
| 8 | Testlage deckt vorher ungetestete Pfade ab; vergessener Bindungsaufruf wird rot | ✓ VERIFIED | `dashboard.service.spec.ts`: 27 test cases (8 pre-existing, unchanged; 19 new, counted). Reverted `tenantPrisma.widgetInstance.delete` → `this.prisma.widgetInstance.delete` in `removeWidget`: exactly the claimed test failed with the exact claimed message; restored, 27/27 green again (independently reproduced, see Behavioral Spot-Checks). |
| 9 | Regeln kennen keine Benutzerdimension; Besitzpruefungen ueber Benutzerkennung bleiben einziger Schutz, unveraendert | ✓ VERIFIED | `widget.userId !== userId` present in `updateWidgetConfig` and `removeWidget`; `provider.userId !== userId` present in `removeSearchProvider` — all three intact, all comparing against the session-sourced `userId` parameter, not touched by the binding. Three `*-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` checks pass (gelingen IS the expected/passing result), confirming the DB layer alone has no user dimension. |
| 10 | Baseline gehalten am Ende jeder Aufgabe (≥839 Tests, ≥56 Dateien, saubere Typpruefung, Werkzeug 0) | ✓ VERIFIED | Per orchestrator's independent measurement: 858/858 tests green across 56 files, `type-check` exit 0. Independently reproduced for the two most relevant suites (`dashboard.service.spec.ts` 27/27, `rls-access-inventory.spec.ts` 10/10) and for `rls-scratch-check.mjs` (87/87, live run). |
| 11 | Schalter bleibt AUS; kein Schema-, Migrations-, Compose- oder Umgebungs-Datei angefasst | ✓ VERIFIED | `git diff --name-only c7d93f2..HEAD` touches exactly 9 files (confirmed independently), none under `apps/api/prisma`, no compose/env file. `groups.service.ts` and `apps/api/src/module-registry/**` confirmed untouched. |
| 12 | Die gemessene Konfliktbehandlung in `saveLayout` ist im committeten Werkzeug reproduzierbar belegt und durch einen Test abgesichert | ✗ FAILED (partial) | See `gaps` in frontmatter. The committed `rls-scratch-check.mjs` only measures a raw-SQL `$executeRaw ... ON CONFLICT` path (reproduced live: SQLSTATE 42501), never the actual generated `prisma.dashboardLayout.upsert()` call that `saveLayout` uses. The specific, stronger claim in the SUMMARY/critique document — that the generated client throws `PrismaClientUnknownRequestError` rather than the `P2002`-shaped `PrismaClientKnownRequestError` — is attributed to a separate, non-committed ad-hoc measurement and is not independently reproducible from repo state. No unit test exercises `saveLayout`'s `catch` branch (0 hits for `ConflictException`/`PrismaClientUnknownRequestError` in `dashboard.service.spec.ts`). |
**Score:** 10/11 truths fully verified, 1 partial gap (0 present-but-behavior-unverified truths; the one gap is a documented, provable absence, not an uncertainty).
### Deferred Items
None identified — no later-phase success criteria in the current milestone roadmap were found to cover this gap; this quick task is not tied to a numbered ROADMAP phase, so no deferral cross-check applies.
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/scripts/rls-scratch-check.mjs` | 9th section `runDashboardAreaChecks`, 13 named checks | ✓ VERIFIED | Present at line 2425; live re-run: "Alle 87 Pruefungen bestanden." |
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich dashboard` with (w1)-(w5) + Nachtrag in `## Bereich module-registry` | ✓ VERIFIED | Section at line 1745; (w4)/(w5) at 1930/1966; Nachtrag at 1614. |
| `apps/api/src/dashboard/dashboard.service.ts` | 12 bound, 1 unbound-with-reason | ✓ VERIFIED | Confirmed by direct read and grep counts above. |
| `apps/api/src/dashboard/dashboard.controller.ts` | 5 handlers pass tenant through | ✓ VERIFIED | All 8 handlers call `extractContext(req)` and pass `tenantId` positionally after `userId`, matching service signatures. |
| `apps/api/src/dashboard/dashboard.service.spec.ts` | Two-client TDD proof, 8→27 cases, all 8 original green | ✓ VERIFIED | 27/27 passing; describe block 1 (getWidgets — Modulfilter) unchanged with 8 cases. |
| `docs/mandantentrennung-zugriffsklassifikation.md` | 4 Bestandsaufnahme rows, overview row, sum row, class distribution, background-service section, "Was NICHT entscheidet" | ✓ VERIFIED | All five confirmed by direct read; `rls-access-inventory.spec.ts` 10/10 (machine-gated consistency). |
| `.planning/WINDOWS.md` | Open entry for evidence-destroying loop, table + JSON | ✓ VERIFIED | Entry #25 present in both forms, `status: open`. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|----|--------|---------|
| `getLayout`/`saveLayout` | Same `DashboardLayout` row | Shared `userId` uniqueness key, no tenant component | ✓ WIRED | Both bound over the same `tenantPrisma` per call; co-binding test present and passing. |
| `dashboard.controller.ts` | `dashboard.service.ts` | `extractContext(req)` → positional `tenantId` argument | ✓ WIRED | All 8 handlers pass `tenantId` from session-derived context, no new trust source introduced. |
| `dashboard.service.ts` `getWidgets` | `module-access.service.ts` `getAccessibleModuleIds` | Direct call, already bound since 260910-exd | ✓ WIRED | Confirmed unchanged; `module-registry` files untouched by this diff. |
| `dashboard.service.ts` `saveLayout` catch branch | `ConflictException` | `instanceof Prisma.PrismaClientUnknownRequestError` | ⚠️ WIRED BUT UNTESTED | Code path exists and reads correctly, but no test exercises it — see gap above. |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `rls-scratch-check.mjs` reproducibly passes against live DB | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` | "Alle 87 Pruefungen bestanden." | ✓ PASS |
| `rls-access-inventory.spec.ts` reproducibly passes | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` | 10/10 | ✓ PASS |
| Falsification proof 1 (removeWidget binding reverted) | Edited `tenantPrisma.widgetInstance.delete` → `this.prisma.widgetInstance.delete`, ran suite, reverted | Exact claimed test failed with exact claimed message; restored clean, 27/27 green | ✓ PASS |
| Falsification proof 2 (module catalogue bound) | Edited `this.prisma.module.findMany` → `tenantPrisma.module.findMany`, ran suite, reverted | 8 tests failed incl. the named watchdog, `TypeError: Cannot read properties of undefined (reading 'findMany')` at `dashboard.service.ts:175` — matches SUMMARY verbatim; restored clean, 27/27 green | ✓ PASS |
| Falsification proof 3 (classification `Stand` set wrong) | Edited row 320 `gebunden`→`ungebunden`, ran inventory spec, reverted | Failed with exact claimed mismatch message; restored clean, 10/10 green | ✓ PASS |
| `saveLayout` ConflictException translation | Searched for a test exercising it | 0 hits for `ConflictException`/`PrismaClientUnknownRequestError` in `dashboard.service.spec.ts` | ✗ FAIL (gap, see above) |
### Anti-Patterns Found
None (`TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/placeholder scan of all 9 changed files: no hits). No hardcoded-empty-return stubs found; `getLayout`'s default-arrangement return and `getWidgets`'/`getSearchProviders`' empty-list behavior are explicitly documented as intentional, pre-existing "emptiness as absence" semantics, not stubs introduced by this task.
### Requirements Coverage
No `.planning/REQUIREMENTS.md` entries exist for `WINDOWS-18` or `ETAPPE-2-DASHBOARD` (this is a quick task, not tracked against the formal requirements ledger) — not orphaned, simply out of scope for that document's tracking.
### Human Verification Required
None — the one gap found (saveLayout conflict-translation test/measurement coverage) is a provable absence, not an item requiring human judgment to establish the facts. It is reported as a `gaps_found` item so a human can decide whether to accept it via an override or request a follow-up fix.
## Gaps Summary
Twelve of thirteen checked truths verify cleanly against the codebase, and three separate, independently-reproduced falsification proofs (all three claimed in the SUMMARY) matched verbatim — this is a well-evidenced piece of work overall, with the classification document, the WINDOWS ledger entry, the critique document sections, the ownership checks, and the "module catalogue stays unbound" negative gate all holding up under direct adversarial re-verification.
The one real gap: the SUMMARY's most load-bearing new *technical* claim — that a bound conflicting `saveLayout` write throws `PrismaClientUnknownRequestError` (not the `P2002`-shaped error the `tenders` area handles) — is asserted with unusual specificity ("am echten, generierten Prisma Client gemessen, nicht nur an rohem SQL") but that specific measurement was not committed anywhere reproducible: `rls-scratch-check.mjs`'s check 5 only exercises raw SQL via `$executeRaw`, and no unit test exercises `saveLayout`'s `catch (error)` branch. The production code itself (`instanceof Prisma.PrismaClientUnknownRequestError`) is plausible and well-reasoned, and the raw-SQL measurement it's grounded in did reproduce live with SQLSTATE 42501 — but the exact claim made in the document goes beyond what's in the repository, and nothing would catch a regression if the catch clause were later changed or removed.
**This looks like a real, if narrow, evidence gap rather than intentional scope creep** — the fix is small (either extend the scratch tool to call the real `.upsert()` and log the observed error class, or add one unit test asserting `saveLayout` throws `ConflictException` when the bound client's `upsert` rejects with a `Prisma.PrismaClientUnknownRequestError`). To accept the current state as-is instead of requiring that follow-up, add to this file's frontmatter:
```yaml
overrides:
- must_have: "Die gemessene Konfliktbehandlung in saveLayout ist im committeten Werkzeug reproduzierbar belegt und durch einen Testfall abgesichert."
reason: "Die Codepfad-Logik ist inhaltlich plausibel und durch eine verwandte (rohe SQL) Messung gestuetzt; die fehlende .upsert()-spezifische Messung/der fehlende Unit-Test werden als Nachtrag statt als Blocker akzeptiert."
accepted_by: "<name>"
accepted_at: "<ISO timestamp>"
```
---
*Verified: 2026-09-11T09:15:00Z*
*Verifier: Claude (gsd-verifier)*
+373
View File
@@ -2393,6 +2393,378 @@ async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Aufgabe 1 (260910-krx) — misst die dreizehn im Plan genannten
* Verhaltensweisen des Bereichs `dashboard` unter der Rolle ohne BYPASSRLS,
* mit den drei Policies fuer "DashboardLayout", "WidgetInstance" und
* "SearchProvider" WORTGLEICH aus der ausgelieferten
* *_rls_remaining_tenant_tables-Migration geschnitten (nicht im Werkzeug
* nachgetippt). Findet die Extraktion eine der drei nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Regel weiterzumessen — wie alle vorherigen Abschnitte.
*
* Legt die Tabellen "DashboardLayout" und "WidgetInstance" selbst neu an
* (bisher von keinem Abschnitt gebraucht). Die Tabelle "SearchProvider"
* dagegen wird von runSearchProviderAreaChecks() bereits angelegt, samt
* eingeschaltetem und erzwungenem Zeilenschutz, der unveraendert strengen
* Regel und der einen mandantenlosen Zeile 'search-tenantless' — dieser
* Abschnitt legt sie NICHT ein zweites Mal an, sondern ERGAENZT nur weitere
* Zeilen. Muss deshalb NACH runSearchProviderAreaChecks() laufen (in main()
* bereits der Fall: runSearchProviderAreaChecks() steht deutlich frueher in
* der Aufrufkette) und VOR runTransactionShapeMeasurement(), das weiterhin
* auf der von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt —
* dieser Abschnitt aendert daran nichts. Die bestehende Pruefung
* `searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar`
* und ihre Testzeile ('search-tenantless') bleiben unveraendert; alle hier
* neu vergebenen Kennungen sind eigene, damit sie nicht kollidieren.
*
* "DashboardLayout" bildet die Eindeutigkeitsbedingung des Schemas
* (`"userId" text UNIQUE`) nach, weil Pruefung 5 (die Konfliktmessung) ohne
* sie nicht stattfinden kann.
*/
async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const dashboardLayoutPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DashboardLayout')
: null;
const widgetInstancePolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'WidgetInstance')
: null;
const searchProviderPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'SearchProvider')
: null;
if (!dashboardLayoutPolicy || !widgetInstancePolicy || !searchProviderPolicy) {
report(
results,
'dashboard-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "DashboardLayout", "WidgetInstance" und/oder "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "DashboardLayout" (
id text PRIMARY KEY,
"userId" text NOT NULL UNIQUE,
"tenantId" text NOT NULL,
layouts jsonb NOT NULL DEFAULT '{}'::jsonb,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "WidgetInstance" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"widgetType" text NOT NULL
);
`);
for (const table of ['DashboardLayout', 'WidgetInstance']) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(dashboardLayoutPolicy);
await db.$executeRawUnsafe(widgetInstancePolicy);
for (const table of ['DashboardLayout', 'WidgetInstance']) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
// Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen (Befund
// G: die Regel kennt keine Benutzerdimension) plus eine Zeile unter
// TENANT-B fuer die Mandantengrenze.
await db.$executeRawUnsafe(`
INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES
('layout-a1', 'user-a1', 'TENANT-A', '{}'::jsonb),
('layout-a2', 'user-a2', 'TENANT-A', '{}'::jsonb),
('layout-b1', 'user-b1', 'TENANT-B', '{}'::jsonb);
`);
// Physisch vorhandene, unter TENANT-A unsichtbare Zeile fuer Pruefung 5
// (die Konfliktmessung) — ueber die Wartungsrolle angelegt, weil sich
// eine mandantenfremde Zeile unter der Anwendungsrolle ohnehin nicht
// schreiben liesse.
await db.$executeRawUnsafe(`
INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES
('layout-conflict-target', 'user-conflict', 'TENANT-B', '{}'::jsonb);
`);
await db.$executeRawUnsafe(`
INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES
('widget-a1', 'user-a1', 'TENANT-A', 'clock'),
('widget-a2', 'user-a2', 'TENANT-A', 'search'),
('widget-b1', 'user-b1', 'TENANT-B', 'clock');
`);
// Weitere Zeilen auf der bereits vorhandenen Tabelle "SearchProvider"
// (runSearchProviderAreaChecks) — eigene Kennungen, die bestehende Zeile
// 'search-tenantless' bleibt unberuehrt.
await db.$executeRawUnsafe(`
INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES
('search-a1', 'user-a1', 'TENANT-A', 'Interne Suche A1'),
('search-a2', 'user-a2', 'TENANT-A', 'Interne Suche A2'),
('search-b1', 'user-b1', 'TENANT-B', 'Interne Suche B1');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1 + 3: dashboardlayout-gebunden-nur-eigener-mandant UND
// dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar —
// eine Abfrage, zwei Aussagen. Die Pruefung 3 bestehen zu lassen IST das
// erwartete Ergebnis: die Regel kennt keine Benutzerdimension.
const layoutRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "DashboardLayout" ORDER BY id`,
);
report(
results,
'dashboardlayout-gebunden-nur-eigener-mandant',
layoutRowsForA.length === 2 && layoutRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${layoutRowsForA.length} Zeile(n): ${JSON.stringify(layoutRowsForA.map((r) => r.id))}`,
);
const secondUserVisible = layoutRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
secondUserVisible,
`forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible} — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten`,
);
// 2: dashboardlayout-ungebunden-null-zeilen — die Belegzeile dieses
// Abschnitts.
const unboundLayoutRows = await prisma.$queryRaw`SELECT "tenantId" FROM "DashboardLayout"`;
report(
results,
'dashboardlayout-ungebunden-null-zeilen',
unboundLayoutRows.length === 0,
`ungebundener SELECT auf "DashboardLayout" liefert ${unboundLayoutRows.length} Zeile(n), tatsaechlich vorhanden sind 4`,
);
// 4 + 13: widgetinstance-ungebunden-null-zeilen und
// widgetinstance-gebunden-nur-eigener-mandant / -fremder-nutzer-...
const unboundWidgetRows = await prisma.$queryRaw`SELECT "tenantId" FROM "WidgetInstance"`;
report(
results,
'widgetinstance-ungebunden-null-zeilen',
unboundWidgetRows.length === 0,
`ungebundener SELECT auf "WidgetInstance" liefert ${unboundWidgetRows.length} Zeile(n), tatsaechlich vorhanden sind 3`,
);
const widgetRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`,
);
report(
results,
'widgetinstance-gebunden-nur-eigener-mandant',
widgetRowsForA.length === 2 && widgetRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${widgetRowsForA.length} Zeile(n): ${JSON.stringify(widgetRowsForA.map((r) => r.id))}`,
);
const widgetSecondUserVisible = widgetRowsForA.some((r) => r.userId === 'user-a2');
report(
results,
'widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
widgetSecondUserVisible,
`forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${widgetSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs`,
);
// 5: dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-
// zeile-scheitert-laut — MISST, was ein gebundenes Einfuegen mit
// Konfliktbehandlung tut, wenn es auf eine physisch vorhandene, unter
// dem laufenden Mandanten unsichtbare Zeile trifft ('layout-conflict-
// target', TENANT-B). Bestanden ist diese Pruefung genau dann, wenn ein
// HARTER, benannter Fehler zurueckkommt — NICHT, wenn der Vorgang still
// gelingt, eine fremde Zeile aendert oder eine Dublette erzeugt. Das
// Ergebnis wird NICHT vorweggenommen: es haengt am Zusammenspiel von
// Eindeutigkeitsindex und Regel.
let conflictRejectedLoudly = false;
let conflictDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES ('layout-conflict-attempt', 'user-conflict', 'TENANT-A', '{}'::jsonb) ON CONFLICT ("userId") DO UPDATE SET layouts = EXCLUDED.layouts`,
);
conflictDetail =
'gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen — still gelungen oder eine Dublette erzeugt';
} catch (err) {
conflictRejectedLoudly = true;
const sqlState = sqlStateOf(err);
conflictDetail = `gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE ${sqlState ?? 'unbekannt'}: ${err.message.trim()}`;
}
report(
results,
'dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut',
conflictRejectedLoudly,
conflictDetail,
);
// 5b: dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown
//
// Pruefung 5 misst mit Roh-SQL, WAS die Datenbank tut (42501). Der
// Anwendungscode in `saveLayout` faengt aber nicht SQLSTATEs, sondern die
// Fehlerklasse, die der GENERIERTE Prisma-Client daraus macht — und die
// ist nicht dieselbe wie beim P2002-Fall der Bereiche `tenders`/`user`
// (`PrismaClientKnownRequestError`), sondern
// `PrismaClientUnknownRequestError`, weil die Regel den Schreibzugriff
// abweist, bevor eine Eindeutigkeit ueberhaupt geprueft wird. Genau
// DIESE Klasse muss `saveLayout` uebersetzen; eine Uebersetzung der
// falschen Klasse spraenge nie an.
//
// Diese Messung fehlte in der ersten Lieferung von 260910-krx — die
// Zusammenfassung berief sich auf eine nicht committete Ad-hoc-Messung.
// Vom Verifizierer gefunden, hier nachgereicht: derselbe Vorgang wie in
// Pruefung 5, aber ueber `bound.dashboardLayout.upsert(...)` auf dem
// gebundenen generierten Client, und gepruegt wird der KONSTRUKTORNAME
// des geworfenen Fehlers.
let upsertThrewUnknown = false;
let upsertDetail = '';
try {
const bound = buildInlineExtendedClient(prisma, 'TENANT-A');
await bound.dashboardLayout.upsert({
where: { userId: 'user-conflict' },
update: { layouts: {} },
create: {
id: 'layout-conflict-attempt-upsert',
userId: 'user-conflict',
tenantId: 'TENANT-A',
layouts: {},
},
});
upsertDetail =
'gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen';
} catch (err) {
const ctor = err?.constructor?.name ?? 'unbekannt';
upsertThrewUnknown = ctor === 'PrismaClientUnknownRequestError';
upsertDetail = `gebundenes dashboardLayout.upsert unter TENANT-A auf die unter TENANT-B unsichtbare Zeile wirft ${ctor}${err?.code ? ` (code ${err.code})` : ''} — saveLayout uebersetzt genau diese Klasse; ${upsertThrewUnknown ? 'stimmt mit dem Anwendungscode ueberein' : 'STIMMT NICHT mit dem Anwendungscode ueberein, die Uebersetzung in saveLayout spraenge nie an'}`;
}
report(
results,
'dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown',
upsertThrewUnknown,
upsertDetail,
);
// 6: widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt
let foreignWidgetInsertRejected = false;
let foreignWidgetInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES ('widget-rejected', 'user-a1', 'TENANT-B', 'clock')`,
);
foreignWidgetInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
foreignWidgetInsertRejected = sqlState === '42501';
foreignWidgetInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignWidgetInsertRejected,
foreignWidgetInsertDetail,
);
// 7: widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile
// — die Datenbankseite von Befund D: ein gebundenes DELETE ueber die
// Kennung einer fremden Zeile (widget-b1, TENANT-B) entfernt nichts und
// meldet keinen Fehler.
const foreignWidgetDeleteAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`DELETE FROM "WidgetInstance" WHERE id = 'widget-b1'`,
);
report(
results,
'widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile',
foreignWidgetDeleteAffected === 0,
`gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft ${foreignWidgetDeleteAffected} Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten`,
);
// 8 + 11: searchprovider-ungebunden-null-zeilen und
// searchprovider-gebunden-nur-eigener-mandant /
// -fremder-nutzer-desselben-mandanten-gebunden-sichtbar — gemessen
// gegen die neu hinzugefuegten Zeilen (search-a1/search-a2/search-b1),
// NICHT gegen 'search-tenantless' (bereits durch die bestehende
// Pruefung abgedeckt).
const actualSearchProviderCount = await withAdminPrisma(
urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(),
async (db) => {
const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "SearchProvider"`;
return row.n;
},
);
const unboundSearchProviderRows = await prisma.$queryRaw`SELECT "tenantId" FROM "SearchProvider"`;
report(
results,
'searchprovider-ungebunden-null-zeilen',
unboundSearchProviderRows.length === 0,
`ungebundener SELECT auf "SearchProvider" liefert ${unboundSearchProviderRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualSearchProviderCount}`,
);
const searchProviderRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "SearchProvider" WHERE id IN ('search-a1', 'search-a2', 'search-b1') ORDER BY id`,
);
report(
results,
'searchprovider-gebunden-nur-eigener-mandant',
searchProviderRowsForA.length === 2 &&
searchProviderRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${searchProviderRowsForA.length} Zeile(n) aus den neu hinzugefuegten: ${JSON.stringify(searchProviderRowsForA.map((r) => r.id))}`,
);
const searchProviderSecondUserVisible = searchProviderRowsForA.some(
(r) => r.userId === 'user-a2',
);
report(
results,
'searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar',
searchProviderSecondUserVisible,
`forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${searchProviderSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)`,
);
// 12: searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt — die
// Verteidigung der widerlegten Praemisse aus Befund F: selbst wenn ein
// kuenftiger Schreibweg es versuchte, kaeme er unter der
// Anwendungsrolle nicht durch, weil die Regel ohne eigene WITH-CHECK-
// Klausel die USING-Klausel dafuer wiederverwendet.
let searchProviderNoTenantInsertRejected = false;
let searchProviderNoTenantInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES ('search-rejected-no-tenant', 'user-a1', NULL, 'Sollte abgewiesen werden')`,
);
searchProviderNoTenantInsertDetail =
'gebundenes INSERT unter TENANT-A mit tenantId=NULL ist NICHT fehlgeschlagen';
} catch (err) {
const sqlState = sqlStateOf(err);
searchProviderNoTenantInsertRejected = sqlState === '42501';
searchProviderNoTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`;
}
report(
results,
'searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt',
searchProviderNoTenantInsertRejected,
searchProviderNoTenantInsertDetail,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -2628,6 +3000,7 @@ async function main() {
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
await runUserAreaChecks(adminUrl, scratchRoleUrlString, results);
await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {
+10 -10
View File
@@ -56,8 +56,8 @@ export class DashboardController {
@Get('layout')
async getLayout(@Req() req: Request) {
const { userId } = this.extractContext(req);
return this.dashboardService.getLayout(userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getLayout(userId, tenantId);
}
@Put('layout')
@@ -85,8 +85,8 @@ export class DashboardController {
@Req() req: Request,
@Body() dto: UpdateWidgetConfigDto,
) {
const { userId } = this.extractContext(req);
return this.dashboardService.updateWidgetConfig(id, userId, dto);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.updateWidgetConfig(id, userId, tenantId, dto);
}
@Delete('widgets/:id')
@@ -94,16 +94,16 @@ export class DashboardController {
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.dashboardService.removeWidget(id, userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeWidget(id, userId, tenantId);
}
// --- Search Providers (05-02, D-15) ---
@Get('search-providers')
async getSearchProviders(@Req() req: Request) {
const { userId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.getSearchProviders(userId, tenantId);
}
@Post('search-providers')
@@ -120,7 +120,7 @@ export class DashboardController {
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId);
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.removeSearchProvider(id, userId, tenantId);
}
}
@@ -17,22 +17,73 @@ vi.mock('./widget-module-map', () => ({
getModuleSlugForWidgetType: (widgetType: string) => mockMap[widgetType],
}));
/**
* Bindung an forTenant() (260910-krx, Aufgabe 2). Dasselbe Muster wie
* `module-access.service.spec.ts` (260910-exd): der gebundene Klient ist
* ein ZWEITES, von `prisma` unterscheidbares Objekt über DEMSELBEN
* Speicher, das protokolliert, welche Aufrufe über ihn liefen (Modellname,
* Methodenname, Mandantenkennung). Ein reiner Identitäts-Mock
* (`forTenant: vi.fn((p) => p)`) könnte einen vergessenen Bindungsaufruf
* nicht von einem ungebundenen Aufruf unterscheiden.
*
* `module` wird NICHT gewrappt — der Katalogzugriff läuft bewusst über den
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
* Bindung wäre heute wirkungslos.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
import { ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service';
/** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
* ergaenzt seit Aufgabe 3 (260910-krx). */
const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider'];
function makeWidget(
overrides: Partial<{
id: string;
userId: string;
tenantId: string;
widgetType: string;
config: Record<string, unknown>;
createdAt: Date;
}> = {},
) {
return {
id: overrides.id ?? 'w1',
userId: overrides.userId ?? 'user-1',
tenantId: 'tenant-1',
tenantId: overrides.tenantId ?? 'tenant-1',
widgetType: overrides.widgetType ?? 'clock',
config: {},
config: overrides.config ?? {},
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
};
}
function makeSearchProvider(
overrides: Partial<{
id: string;
userId: string | null;
tenantId: string | null;
name: string;
urlTemplate: string;
isDefault: boolean;
createdAt: Date;
}> = {},
) {
return {
id: overrides.id ?? 'sp1',
userId: overrides.userId ?? 'user-1',
tenantId: overrides.tenantId ?? 'tenant-1',
name: overrides.name ?? 'Eigene Suche',
urlTemplate: overrides.urlTemplate ?? 'https://example.test/?q={query}',
isDefault: overrides.isDefault ?? false,
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
};
}
@@ -41,12 +92,31 @@ function makeFakePrisma(
opts: {
widgets?: ReturnType<typeof makeWidget>[];
modules?: { id: string; slug: string }[];
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[];
} = {},
) {
const widgets = opts.widgets ?? [];
const modules = opts.modules ?? [];
let layoutRow = opts.layout ?? null;
const searchProviders = opts.searchProviders ?? [];
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
return {
const fake: any = {
dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => {
if (layoutRow && layoutRow.userId === where.userId) return layoutRow;
return null;
}),
upsert: vi.fn(async ({ where, update, create }: any) => {
if (layoutRow && layoutRow.userId === where.userId) {
layoutRow = { ...layoutRow, layouts: update.layouts };
return layoutRow;
}
layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts };
return layoutRow;
}),
},
widgetInstance: {
findMany: vi.fn(async ({ where }: any) => {
return widgets
@@ -54,7 +124,26 @@ function makeFakePrisma(
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}),
delete: vi.fn(),
create: vi.fn(async ({ data }: any) => {
const created = makeWidget({ id: `new-${widgets.length + 1}`, ...data });
widgets.push(created);
return created;
}),
findUnique: vi.fn(async ({ where }: any) => {
return widgets.find((w) => w.id === where.id) ?? null;
}),
update: vi.fn(async ({ where, data }: any) => {
const widget = widgets.find((w) => w.id === where.id);
if (!widget) return null;
Object.assign(widget, data);
return widget;
}),
delete: vi.fn(async ({ where }: any) => {
const idx = widgets.findIndex((w) => w.id === where.id);
if (idx === -1) return null;
const [removed] = widgets.splice(idx, 1);
return removed;
}),
},
module: {
findMany: vi.fn(async ({ where }: any) => {
@@ -62,7 +151,48 @@ function makeFakePrisma(
return modules.filter((m) => slugs.includes(m.slug));
}),
},
searchProvider: {
findMany: vi.fn(async ({ where }: any) => {
return searchProviders
.filter((p) => p.userId === where.userId)
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}),
create: vi.fn(async ({ data }: any) => {
const created = makeSearchProvider({ id: `new-sp-${searchProviders.length + 1}`, ...data });
searchProviders.push(created);
return created;
}),
findUnique: vi.fn(async ({ where }: any) => {
return searchProviders.find((p) => p.id === where.id) ?? null;
}),
delete: vi.fn(async ({ where }: any) => {
const idx = searchProviders.findIndex((p) => p.id === where.id);
if (idx === -1) return null;
const [removed] = searchProviders.splice(idx, 1);
return removed;
}),
},
// --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) --------------
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
const bound: any = { __isBoundClient: true, __tenantId: tenantId };
for (const modelName of BOUND_MODEL_NAMES) {
const model = fake[modelName];
const wrapped: any = {};
for (const method of Object.keys(model)) {
wrapped[method] = async (...args: any[]) => {
boundCallLog.push({ tenantId, model: modelName, method });
return model[method](...args);
};
}
bound[modelName] = wrapped;
}
return bound;
},
};
return fake;
}
function makeFakeModuleAccessService(accessibleIds: Set<string>) {
@@ -72,13 +202,38 @@ function makeFakeModuleAccessService(accessibleIds: Set<string>) {
}
/**
* DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden
* Fall aus 15-05-PLAN.md <behavior> inklusive der Edge-Probe-Kategorien
* adjacency/empty/ordering/idempotency ab.
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
* lief über den gebundenen Client (nicht über den rohen, ungebundenen
* Fake). Ein vergessener `forTenant()`-Aufruf hinterlässt hier KEINEN
* Eintrag und lässt den Test fehlschlagen.
*/
function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) {
const found = prisma.__boundCallLog.some(
(c: any) => c.tenantId === tenantId && c.model === model && c.method === method,
);
expect(
found,
`erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(true);
}
/**
* Wachhund-Gegenprobe: ein Modell darf im Bindungsprotokoll gar nicht
* vorkommen — das ist der Testfall, der jemanden erwischt, der den bewusst
* ungebundenen Katalogzugriff später versehentlich bindet.
*/
function expectNeverBound(prisma: any, model: string) {
const found = prisma.__boundCallLog.some((c: any) => c.model === model);
expect(
found,
`Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`,
).toBe(false);
}
describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
beforeEach(() => {
for (const key of Object.keys(mockMap)) delete mockMap[key];
vi.mocked(forTenant).mockClear();
});
it('empty/PERM-07: liefert bei leerer Zuordnungstabelle exakt die Prisma-Menge, ohne Zugriffs-Lookup', async () => {
@@ -213,3 +368,334 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
expect(result).toEqual([]);
});
});
// --- Bindung an forTenant() (260910-krx, Aufgabe 2) -------------------------
describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (260910-krx, Aufgabe 2)', () => {
beforeEach(() => {
for (const key of Object.keys(mockMap)) delete mockMap[key];
vi.mocked(forTenant).mockClear();
});
it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => {
const prisma = makeFakePrisma({
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } },
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getLayout('user-1', 'tenant-1');
expect(result).toEqual({ lg: [{ i: 'w1' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
});
it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => {
const prisma = makeFakePrisma({ layout: null });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getLayout('user-1', 'tenant-1');
expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] });
});
it('saveLayout: der Schreibzugriff läuft über den gebundenen Klienten mit derselben Mandantenkennung', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
});
/**
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist
* plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile
* unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen
* Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler
* (`PrismaClientKnownRequestError`), sondern als
* `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den
* Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit
* ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs`
* (`dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown`).
*
* Dieser Test fehlte in der ersten Lieferung — die Zusammenfassung berief
* sich auf eine nicht committete Ad-hoc-Messung. Vom Verifizierer gefunden.
*/
describe('saveLayout: Konflikt auf unsichtbare Zeile (w4)', () => {
it('uebersetzt PrismaClientUnknownRequestError in eine ConflictException statt in einen 500', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const unknown = new Prisma.PrismaClientUnknownRequestError(
'Error occurred during query execution: ConnectorError(... new row violates row-level security policy ...)',
{ clientVersion: 'test' },
);
prisma.dashboardLayout.upsert = vi.fn(async () => {
throw unknown;
});
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
).rejects.toBeInstanceOf(ConflictException);
});
it('reicht einen bekannten Prisma-Fehler (z.B. P2002) unveraendert durch — der ist hier NICHT der gemessene Fall', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const known = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
code: 'P2002',
clientVersion: 'test',
});
prisma.dashboardLayout.upsert = vi.fn(async () => {
throw known;
});
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
).rejects.toBe(known);
});
});
it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => {
const prisma = makeFakePrisma({
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getLayout('user-1', 'tenant-1');
await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any);
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
});
it('Widgets lesen: der Widget-Lesezugriff läuft gebunden, der Katalogzugriff NICHT', async () => {
const prisma = makeFakePrisma({ widgets: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
expect(result).toEqual([]);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
expectNeverBound(prisma, 'module');
});
it('Widget anlegen: gebunden, mit der übergebenen Mandantenkennung', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create');
});
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({ widgets: [widget] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { foo: 'bar' } } as any);
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'update');
});
it('Widget entfernen: ebenso, beide Abfragen über denselben Klienten', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({ widgets: [widget] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.removeWidget('w1', 'user-1', 'tenant-1');
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'delete');
});
it('Besitzprüfung bleibt wirksam beim Ändern: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException — die Bindung ERGÄNZT die Prüfung über die Benutzerkennung, sie ersetzt sie nicht', async () => {
const widget = makeWidget({ id: 'w1', userId: 'other-user' });
const prisma = makeFakePrisma({ widgets: [widget] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(
service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
).rejects.toThrow("Widget with id 'w1' not found");
});
it('Besitzprüfung bleibt wirksam beim Entfernen: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException', async () => {
const widget = makeWidget({ id: 'w1', userId: 'other-user' });
const prisma = makeFakePrisma({ widgets: [widget] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).rejects.toThrow(
"Widget with id 'w1' not found",
);
});
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
const prisma = makeFakePrisma({
widgets: [widget],
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [
() => service.getLayout('user-1', 'tenant-1'),
() => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any),
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any),
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any),
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
() => service.removeWidget('w1', 'user-1', 'tenant-1'),
]) {
vi.mocked(forTenant).mockClear();
await call().catch(() => undefined);
expect(
vi.mocked(forTenant).mock.calls.length,
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
).toBe(1);
}
});
it('Kein Widget vorhanden: der Rückgabewert ist eine leere Liste, kein Fehler — Deutung von Leere als Abwesenheit, heutiges Verhalten', async () => {
const prisma = makeFakePrisma({ widgets: [] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
expect(result).toEqual([]);
});
});
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
beforeEach(() => {
for (const key of Object.keys(mockMap)) delete mockMap[key];
vi.mocked(forTenant).mockClear();
});
it('Suchmaschinen lesen: der Lesezugriff läuft gebunden, die drei Vorgabe-Suchmaschinen aus der Konstante werden UNVERÄNDERT vorangestellt', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const result = await service.getSearchProviders('user-1', 'tenant-1');
expect(result.map((p: any) => p.id)).toEqual(['google', 'bing', 'ddg', 'sp1']);
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findMany');
});
it('Suchmaschine anlegen: gebunden, mit der übergebenen Mandantenkennung, die Mandantenkennung bleibt Pflichtangabe — wird rot, sobald ein Schreibweg ohne Mandantenkennung eingeführt wird', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
const created = await service.addSearchProvider('user-1', 'tenant-1', {
name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}',
} as any);
expect(created.tenantId).toBe('tenant-1');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'create');
expect(prisma.searchProvider.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1' }) }),
);
});
it('Suchmaschine entfernen: BEIDE Abfragen über DENSELBEN gebundenen Klienten', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.removeSearchProvider('sp1', 'user-1', 'tenant-1');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findUnique');
expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'delete');
});
it('Besitzprüfung bleibt wirksam: die Suchmaschine eines anderen Benutzers führt weiterhin zu NotFoundException', async () => {
const foreign = makeSearchProvider({ id: 'sp1', userId: 'other-user' });
const prisma = makeFakePrisma({ searchProviders: [foreign] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.removeSearchProvider('sp1', 'user-1', 'tenant-1')).rejects.toThrow(
"Search provider with id 'sp1' not found",
);
});
it('eine der drei Vorgabe-Suchmaschinen lässt sich weiterhin nicht entfernen (userId null fällt in den Nicht-gefunden-Zweig)', async () => {
const prisma = makeFakePrisma({});
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
await expect(service.removeSearchProvider('google', 'user-1', 'tenant-1')).rejects.toThrow(
"Search provider with id 'google' not found",
);
});
it('Wachhund: der Modulkatalog taucht im Bindungsprotokoll nie auf, auch nicht nach der Suchmaschinen-Bindung — und der Katalogpfad wird tatsächlich durchlaufen, nicht nur theoretisch geprüft', async () => {
mockMap['tender-radar'] = 'tender-radar';
const boundWidget = makeWidget({ id: 'w1', widgetType: 'tender-radar' });
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({
searchProviders: [own],
widgets: [boundWidget],
modules: [{ id: 'mod-1', slug: 'tender-radar' }],
});
const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1']));
const service = new DashboardService(prisma as any, moduleAccessService as any);
await service.getSearchProviders('user-1', 'tenant-1');
await service.addSearchProvider('user-1', 'tenant-1', {
name: 'Intranet',
urlTemplate: 'https://intranet.test/?q={query}',
} as any);
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
// Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
// koennte).
expect(prisma.module.findMany).toHaveBeenCalled();
expectNeverBound(prisma, 'module');
});
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf — auch fuer die drei Suchmaschinen-Methoden', async () => {
const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' });
const prisma = makeFakePrisma({ searchProviders: [own] });
const moduleAccessService = makeFakeModuleAccessService(new Set());
const service = new DashboardService(prisma as any, moduleAccessService as any);
for (const call of [
() => service.getSearchProviders('user-1', 'tenant-1'),
() =>
service.addSearchProvider('user-1', 'tenant-1', {
name: 'x',
urlTemplate: 'https://x.test/?q={query}',
} as any),
() => service.removeSearchProvider('sp1', 'user-1', 'tenant-1'),
]) {
vi.mocked(forTenant).mockClear();
await call().catch(() => undefined);
expect(
vi.mocked(forTenant).mock.calls.length,
`Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`,
).toBe(1);
}
});
});
+103 -21
View File
@@ -1,9 +1,11 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, Role } from '@prisma/client';
import { ModuleAccessService } from '../module-registry/module-access.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
@@ -52,7 +54,16 @@ const DEFAULT_SEARCH_PROVIDERS = [
* Layout (position/size) and widget config are stored in separate models
* to avoid unnecessary saves when only one changes (RESEARCH anti-pattern).
*
* All operations are scoped by userId for security (T-05-01, T-05-02).
* All operations are scoped by userId for security (T-05-01, T-05-02) — the
* three ownership checks in this file (`updateWidgetConfig`, `removeWidget`,
* `removeSearchProvider`) compare against the user id from the session proof
* and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance`
* and `SearchProvider` know only the tenant dimension, not the user dimension
* (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped
* (WINDOWS #18) they remain the only actually effective protection against
* cross-reading/cross-deleting between two users of the SAME tenant, and the
* `forTenant()` binding below ADDS a tenant boundary on top of them, it never
* replaces them.
*/
@Injectable()
export class DashboardService {
@@ -65,8 +76,9 @@ export class DashboardService {
* Returns the user's saved layout, or a default empty layout
* with all breakpoint arrays initialized.
*/
async getLayout(userId: string) {
const record = await this.prisma.dashboardLayout.findUnique({
async getLayout(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const record = await tenantPrisma.dashboardLayout.findUnique({
where: { userId },
});
@@ -80,9 +92,25 @@ export class DashboardService {
/**
* Upserts the user's dashboard layout.
* Creates a new record if none exists, updates if it does.
*
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
* whose user id was, by hand, moved off its actually-visible row could hit
* an `upsert` conflict on a row it cannot see under RLS. Measured
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
* error that the `tenders` area's translation pattern catches — this is a
* different Prisma error class, `.code`/`.meta` are `undefined`, the only
* signal is the raw `.message` text). Translated below into an
* understandable German message instead of a raw 500, same intent as
* `tender-notification-pref.service.ts`, different detection. Not
* reachable via any application path today (a user's tenant id never
* changes after creation) — the honest fix is a schema change and is
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
*/
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
return this.prisma.dashboardLayout.upsert({
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
return await tenantPrisma.dashboardLayout.upsert({
where: { userId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: {
@@ -91,6 +119,14 @@ export class DashboardService {
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
throw new ConflictException(
'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
}
throw error;
}
}
/**
@@ -108,7 +144,8 @@ export class DashboardService {
* betroffene Widget entfernt (Fail-Closed).
*/
async getWidgets(userId: string, tenantId: string, role: Role) {
const widgets = await this.prisma.widgetInstance.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId);
const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
@@ -125,12 +162,16 @@ export class DashboardService {
return widgets;
}
// getAccessibleModuleIds() already binds internally (260910-exd,
// module-access.service.ts) — do NOT wrap it a second time here.
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
tenantId,
userId,
role,
);
// Module catalogue: deliberately left UNBOUND — see the reasoning at
// the bottom of this file (260910-krx, Aufgabe 3).
const modules = await this.prisma.module.findMany({
where: { slug: { in: boundSlugs } },
select: { id: true, slug: true },
@@ -154,7 +195,8 @@ export class DashboardService {
* Creates a new widget instance for the user.
*/
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
return this.prisma.widgetInstance.create({
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.widgetInstance.create({
data: {
userId,
tenantId,
@@ -166,14 +208,23 @@ export class DashboardService {
/**
* Updates the config of a widget instance.
* Verifies ownership by userId before updating (T-05-01).
* Verifies ownership by userId before updating (T-05-01) — REAL, not
* decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that
* produced this effort's first two vulnerabilities): `widget.userId !==
* userId` genuinely compares against the session-sourced user id and
* subsumes the tenant dimension. Both queries below run over the SAME
* bound client and the same tenant id — reading and writing are never
* split across the binding, or the check could pass on a row the write no
* longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D).
*/
async updateWidgetConfig(
id: string,
userId: string,
tenantId: string,
dto: UpdateWidgetConfigDto,
) {
const widget = await this.prisma.widgetInstance.findUnique({
const tenantPrisma = forTenant(this.prisma, tenantId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
@@ -189,7 +240,7 @@ export class DashboardService {
...dto.config,
};
return this.prisma.widgetInstance.update({
return tenantPrisma.widgetInstance.update({
where: { id },
data: { config: mergedConfig as unknown as Prisma.InputJsonValue },
});
@@ -197,10 +248,13 @@ export class DashboardService {
/**
* Removes a widget instance.
* Verifies ownership by userId before deleting (T-05-01).
* Verifies ownership by userId before deleting (T-05-01) — same real
* ownership check as `updateWidgetConfig` above, same reasoning: both
* queries run over the SAME bound client and tenant id.
*/
async removeWidget(id: string, userId: string) {
const widget = await this.prisma.widgetInstance.findUnique({
async removeWidget(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
@@ -210,7 +264,7 @@ export class DashboardService {
);
}
return this.prisma.widgetInstance.delete({
return tenantPrisma.widgetInstance.delete({
where: { id },
});
}
@@ -220,9 +274,13 @@ export class DashboardService {
/**
* Returns the three default providers merged with any user-custom providers.
* Defaults are always returned even with an empty DB (no seed migration needed).
* The three defaults come from the TypeScript constant above (decision
* 05-02), never from the database — they are unaffected by the binding
* below and are always prepended unchanged.
*/
async getSearchProviders(userId: string) {
const custom = await this.prisma.searchProvider.findMany({
async getSearchProviders(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const custom = await tenantPrisma.searchProvider.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
@@ -231,14 +289,19 @@ export class DashboardService {
}
/**
* Creates a user-custom search provider.
* Creates a user-custom search provider. `tenantId` stays a required
* parameter of this method — the only write path this model has (260910-krx,
* Aufgabe 1, Befund F, WINDOWS #19): no application path exists that
* creates a tenant-less row, which is why the RLS rule on `SearchProvider`
* was deliberately left unchanged/strict in migration 20260910120000.
*/
async addSearchProvider(
userId: string,
tenantId: string,
dto: CreateSearchProviderDto,
) {
return this.prisma.searchProvider.create({
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.searchProvider.create({
data: {
userId,
tenantId,
@@ -251,11 +314,14 @@ export class DashboardService {
/**
* Removes a user-custom search provider.
* Verifies ownership — default providers (userId null) cannot be deleted (T-05-07).
* Verifies ownership — default providers (userId null) cannot be deleted
* (T-05-07) — REAL, same reasoning as `updateWidgetConfig`/`removeWidget`
* above: both queries run over the SAME bound client and tenant id.
*/
async removeSearchProvider(id: string, userId: string) {
async removeSearchProvider(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
// Default providers have hardcoded IDs that won't exist in DB
const provider = await this.prisma.searchProvider.findUnique({
const provider = await tenantPrisma.searchProvider.findUnique({
where: { id },
});
@@ -265,8 +331,24 @@ export class DashboardService {
);
}
return this.prisma.searchProvider.delete({
return tenantPrisma.searchProvider.delete({
where: { id },
});
}
}
// --- Modulkatalog: bewusst ungebunden (260910-krx, Aufgabe 3) --------------
//
// Der eine verbleibende ungebundene Modellzugriff dieser Datei (das
// `module`-Modell in `getWidgets`, ueber den ungebundenen Basisclient)
// betrifft den plattformweiten Modulkatalog (`Module`).
// MESSUNG (rls-scratch-check.mjs, Pruefung `module-tabelle-traegt-keinen-
// zeilenschutz`, uebernommen aus dem Bereich `module-registry`, 260910-exd
// Befund E): die Tabelle traegt heute KEINEN Zeilenschutz — `pg_class.
// relrowsecurity` ist `false`, eine Bindung waere heute WIRKUNGSLOS, nicht
// katastrophal. BEDINGUNG: sie wuerde katastrophal, WENN Etappe 3 dieser
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer jeden
// Mandanten. Die Katalogaufloesung, die dieser Dienst fuer den Widget-
// Modulfilter aufruft (`ModuleAccessService.getAccessibleModuleIds`), bindet
// bereits seit 260910-exd in ihrem eigenen Dienst — dieser Zugriff wird hier
// NICHT ein zweites Mal gebunden.
@@ -1611,6 +1611,21 @@ nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der
begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und
`.planning/WINDOWS.md`.
**Nachtrag (260910-krx):** der oben in Befund E festgehaltene Befund zur
Reihenfolge — der Bereich `dashboard` erbt die Bindung der
Modul-Zugriffsauflösung, ohne dass eine Datei unter
`apps/api/src/module-registry` dafür angefasst werden muss — ist mit
Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: `dashboard.service.ts`
ruft `ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
in `getWidgets` unverändert auf, diese Auflösung bindet seit 260910-exd
bereits über `forTenant()`, und der Filter ist damit gebunden. Nachgeprüft
mit `grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/
module-access.service.ts` (ein Treffer) und mit einem Wachhund-Testfall in
`dashboard.service.spec.ts`, der den Modulkatalog aus dem
Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx
NICHT ein zweites Mal gebunden — keine Datei unter
`apps/api/src/module-registry` ist Teil dieses Plans.
## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19
Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab,
@@ -1727,6 +1742,264 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F:
Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der
lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen.
## Bereich dashboard
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dashboard`
(Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen
Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet
hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die
umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie
ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe
(w3).
### (w1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen neunten
Abschnitt (`runDashboardAreaChecks`) erweitert, unmittelbar nach
`runModuleRegistryAreaChecks` und vor `runTransactionShapeMeasurement`
aufgerufen. Er legt die beiden Wegwerf-Tabellen `DashboardLayout` und
`WidgetInstance` selbst neu an und benutzt die von
`runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider`
WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit
`extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration
`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am
Regelstand NACH der Migration `20260910120000_rls_widen_membership_grant_and_platform_read`**
(die drei Regeln dieses Bereichs sind von jener Migration unverändert
gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem
aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich
beobachtete Ausgabe dieses Laufs (2026-09-11, gegen `tessera-ctl-db-1`,
Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen dieses Abschnitts sowie
die abschließende Summenzeile):
```
dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"]
dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten
dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3
widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"]
widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs
dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout"
widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance")
widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten
searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4
searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"]
searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)
searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider")
Alle 87 Pruefungen bestanden.
```
Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans
namentlich vorgezeichnet — die dreizehnte
(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`)
wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen
dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für
`DashboardLayout` und `SearchProvider` einen entsprechenden Testfall
vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf
87 (74 + 13).
**Die tragende Belegzeile ist `dashboardlayout-ungebunden-null-zeilen`:**
der IDENTISCHE `SELECT "tenantId" FROM "DashboardLayout"` ohne vorheriges
`set_config` liefert **0 Zeilen**, nicht die 4 tatsächlich vorhandenen —
an der echten, ausgelieferten Policy gemessen. `widgetinstance-ungebunden-
null-zeilen` misst dieselbe Unsichtbarkeit für die Widget-Tabelle.
**Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine
Vermutung.** Ein gebundenes `INSERT ... ON CONFLICT ("userId") DO UPDATE`
unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter
TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE `42501`
("new row violates row-level security policy (USING expression) for table
\"DashboardLayout\""), nicht mit einem Eindeutigkeitsfehler (`23505`) und
nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma
Client gemessen (nicht nur an rohem SQL), weil `saveLayout` in Wahrheit
`prisma.dashboardLayout.upsert()` aufruft, nicht `$executeRaw`: gegen eine
eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz
(`id`, `userId`, `tenantId`, `layouts`, `createdAt`, `updatedAt`) und einer
eigenen Wegwerf-Rolle ohne `BYPASSRLS` liefert derselbe Konfliktfall über
`tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create })`
einen **`PrismaClientUnknownRequestError`** — nicht den bekannten
`PrismaClientKnownRequestError` mit `.code === 'P2002'`, den der Bereich
`tenders` für seinen Eindeutigkeitsfall abfängt. `.code` und `.meta` sind
bei diesem Fehlertyp `undefined`; die einzige verlässliche Information
steht im rohen `.message`-Text, der den PostgreSQL-Fehler eingebettet
enthält (`code: "42501"`, `message: "new row violates row-level security
policy (USING expression) for table \"DashboardLayout\""`). **Das ist die
zentrale Abweichung von der Annahme, das `tenders`-P2002-Muster ließe sich
wörtlich übernehmen** — es lässt sich nicht, weil dieser Fehler eine andere
Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb
`Prisma.PrismaClientUnknownRequestError` ab (Prüfung auf die Fehlerklasse,
nicht auf `.code`) und übersetzt ihn in eine verständliche deutsche
Meldung — siehe (w4) für die Grenze dieser Behandlung.
**Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F,
WINDOWS #19).** Nachgeprüft mit derselben Anweisung wie zur Planungszeit,
diesmal gegen den aktuellen Quelltext (2026-09-11):
`grep -rn "searchProvider\|SearchProvider" apps packages prisma
--include=*.ts --include=*.mjs --include=*.js --include=*.sql
--include=*.json` (ohne `node_modules`, `dist/`, `.next/`). Ergebnis
unverändert: der einzige Schreibweg ist
`dashboard.service.ts:241` (`addSearchProvider` → `create`) mit
`tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode; keine
Seed-Datei (`apps/api/prisma/` enthält ausschließlich `migrations` und
`schema.prisma`), kein Skript, kein weiterer Schreibweg. Reichweite der
Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über
einen dynamisch gebildeten Modellnamen und deckt keine manuelle
Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad
erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich
datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne-
mandant-abgelehnt` weist ein gebundenes Einfügen mit `tenantId = NULL`
laut mit SQLSTATE `42501` ab, weil die Regel ohne eigene `WITH CHECK`-
Klausel ihre `USING`-Klausel dafür wiederverwendet.
### (w2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `DashboardService.getLayout` | Der gebundene Lesezugriff liefert `null` statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" | Der Rückgabepunkt liefert die Vorgabeanordnung `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette |
| `DashboardService.saveLayout` | Der gebundene Schreibzugriff trifft im `update`-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel `userId` — Konfliktmessung, siehe (w1) | `PUT /dashboard/layout` liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze |
| `DashboardService.getWidgets`, Widget-Lesezugriff | Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets | Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler |
| `DashboardService.addWidget` | Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung | `POST /dashboard/widgets` liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (`ForbiddenException` bereits im Controller) |
| `DashboardService.updateWidgetConfig`/`removeWidget`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4) |
| `DashboardService.getSearchProviders` | Der gebundene Lesezugriff auf `custom` liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt | Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J |
| `DashboardService.addSearchProvider` | Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter | — |
| `DashboardService.removeSearchProvider`, Besitzprüfung | Wie bei Widgets: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß |
### (w3) Welcher Code Leere als Abwesenheit deutet
**Backend, eine Stelle:** `DashboardService.getLayout` —
`if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`. Kein
Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe
Deutung wie bei `getWidgets` (leere Liste) und `getSearchProviders` (nur die
drei Konstanten).
**Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut
nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan
ändert an KEINER der beiden Dateien etwas:**
1. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt
`layouts` und `widgets` genau auf das, was `api.fetchLayout()` und
`api.fetchWidgets()` liefern. Der `catch`-Zweig
(`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk-
oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen
Fehlerzustand.
2. `apps/web/src/lib/stores/dashboard-store.ts`, `setEditMode`:
`if (prev && !mode && get().isDirty) { get().saveLayout(); }` — der
Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch
zurückgeschrieben, ohne dass jemand auf "Speichern" klickt.
3. `apps/web/src/components/dashboard/widgets/search-widget.tsx`: die
Rückfallprüfung `if (!cancelled && data.length > 0)` greift NIE, weil
`getSearchProviders` die drei Vorgaben immer voranstellt — `data` ist
nie leer, selbst wenn `custom` (die eigenen Suchmaschinen) nach dem
Scharfschalten leer geblieben wäre. Der `.catch()`-Rückfallzweig auf
`DEFAULT_PROVIDERS` feuert deshalb ebenfalls nie in diesem Fall.
**Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I):**
leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des
Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu
auf, `addWidget` legt echte neue `WidgetInstance`-Zeilen an (keine
Eindeutigkeitsbedingung über `(userId, widgetType)`, Dubletten häufen sich
also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus
schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer
"Speichern" geklickt hat → die `layouts`-Spalte der ursprünglichen Zeile
ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung
ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der
Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das
Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb
keinen Fehler — derselbe Mechanismus, der auch in `module-registry` (m3)
und `ldap` beschrieben ist, hier aber mit einer zusätzlichen, aktiven
Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem
der beiden anderen Bereiche gibt.
**Das Verhalten der Suchleiste (Befund J), präzise statt "still"
formuliert:** verschwinden die eigenen Suchmaschinen des Nutzers aus
`custom`, bleibt die Auswahlliste (`<select>`) dennoch gefüllt — mit den
drei Vorgaben. Sichtbar wird das nur, wenn der Nutzer VORHER eine eigene
Suchmaschine ausgewählt hatte: React setzt `value={selectedProviderId}`
auf dem `<select>`, aber `selectedProviderId` referenziert nach dem
Verschwinden keinen vorhandenen `<option>`-Wert mehr — der Browser zeigt in
diesem Fall (kein `<option>` mit passendem `value`) den ERSTEN Eintrag der
Liste an, also "Google", OHNE dass der interne React-State
`selectedProviderId` sich ändert oder irgendeine Meldung erscheint. Klickt
der Nutzer danach Suchen, greift `handleSearch`:
`providers.find((p) => p.id === selectedProviderId) ?? providers[0]` — der
`find` schlägt fehl (die eigene Suchmaschine ist nicht mehr in `providers`),
der Rückfall auf `providers[0]` (Google) greift, und eine Anfrage, die für
ein internes Werkzeug gedacht war, geht an eine externe Suchmaschine. Für
einen Nutzer, der die Anzeige "Google" im Dropdown nicht bewusst als
Abweichung von seiner eigenen Auswahl liest, bleibt das unbemerkt.
### (w4) Was dieser Durchlauf bewusst nicht löst
- **Die plattformweite Eindeutigkeit von `DashboardLayout.userId`
(Befund K).** `userId` trägt `@unique` ohne Mandantenanteil — strukturell
dieselbe Kette wie WINDOWS #22 im Bereich `user`: unsichtbare Zeile,
falsches "frei", harter Eindeutigkeitsfehler. Anders als bei #22 ist der
Fehler hier aber GEMESSEN statt angenommen (siehe (w1), Prüfung 5) und
wird in Aufgabe 2 BEDINGT auf das gemessene Ergebnis behandelt: eine
verständliche deutsche Meldung statt eines rohen 500ers, indem
`Prisma.PrismaClientUnknownRequestError` abgefangen wird — NICHT
`.code === 'P2002'` wie im Bereich `tenders`, weil der gemessene Fehler
eine andere Prisma-Fehlerklasse ist. Die ehrliche Reparatur wäre eine
Schemaänderung (Eindeutigkeit mit Mandantendimension) und ist als
Produktentscheidung für Etappe 3 vorgemerkt, wie #22 es für denselben
Fall bereits tut.
- **Die fehlende Unterscheidbarkeit von "noch keine Anordnung gespeichert"
und "Anordnung nicht sichtbar".** Beide liefern identisch die
Vorgabeanordnung, Status 200, keinen Protokolleintrag. Die konkrete
Vorabprüfung für Etappe 4 (`rls-preflight.mjs`): eine physisch
vorhandene `DashboardLayout`-Zeile für einen bekannten Benutzer, aber der
gebundene Lesezugriff für dessen Mandanten liefert `null` — das
unterscheidet den echten Erstbenutzer-Fall (keine Zeile vorhanden) vom
Trennungsfehler (Zeile vorhanden, aber unsichtbar).
- **Eine Laufzeitwarnung an `getLayout`/`getWidgets`/`getSearchProviders`
wurde erwogen und VERWORFEN**, mit derselben Begründung wie bei
`getAllActiveConfigs` im Bereich `ldap`: eine leere Anordnung, eine leere
Widget-Liste oder keine eigene Suchmaschine sind auf einer frischen
Installation oder für einen neuen Benutzer der NORMALZUSTAND — eine
Warnung an dieser Stelle wäre Dauerlärm und verlöre ihr Signal, bevor sie
gebraucht wird. Das gilt hier NOCH ausgeprägter als in `ldap`, weil
praktisch jeder neue Benutzer diesen Zustand beim ersten Login durchläuft.
- Der offene Ledger-Eintrag zur beweisvernichtenden Schleife (angelegt in
Aufgabe 3, siehe `.planning/WINDOWS.md`) ist an dieselbe Bedingung
gebunden wie #18 — er wird erst
nach dem Scharfschalten beobachtbar.
### (w5) Was dieser Durchlauf bewusst nicht anfasst
- **Das Frontend** — geprüft (Befund I/J, (w3) oben) und bewusst gelassen,
keine Datei dieses Plans. `dashboard-store.ts` und `search-widget.tsx`
werden NICHT geändert.
- **Der Bereich `favorites`.** `FavoriteLink` hängt per Fremdschlüssel an
`WidgetInstance` (`apps/api/prisma/schema.prisma`,
`favoriteLinks FavoriteLink[]` auf `WidgetInstance`), ist aber ein
eigener Bereich mit eigener Umstellung (`apps/api/src/favorites/
favorites.service.ts`, ungebunden, sieben Rohtreffer laut
Klassifikationsdokument) — nicht Teil dieses Plans.
- **Der Bereich `module-registry`** samt der geerbten Entlastung aus
Befund E: `dashboard.service.ts` ruft
`ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter
auf, und diese Auflösung bindet bereits seit 260910-exd
(`const tenantPrisma = forTenant(this.prisma, tenantId)` in
`module-access.service.ts`). Der Filter ist damit gebunden, OHNE dass
dieser Plan eine Datei unter `apps/api/src/module-registry` anfasst —
nachgeprüft mit `grep -n "const tenantPrisma = forTenant" apps/api/src/
module-registry/module-access.service.ts`, ein Treffer. Der eine
Katalogzugriff in `dashboard.service.ts` (`getWidgets`, `this.prisma.
module`) bleibt bewusst ungebunden — siehe die Begründung in der
Klassifikationstabelle, die Messung und Bedingung trennt.
- **Die Ungenauigkeit in `apps/api/src/groups/groups.service.ts` (Befund
L), zur Ausführungszeit gegengelesen und WÖRTLICH bestätigt, NICHT
behoben.** Der Kopfkommentar um Zeile 24 begründet die dortige
Join-Filterung mit "nach dem Ownership-Check-Muster aus
DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu:
`removeWidget` filtert NICHT zusätzlich in der Datenbankabfrage, sondern
vergleicht NACH dem Laden im JavaScript (`widget.userId !== userId`),
und dieser Vergleich läuft über die BENUTZER-, nicht die
Mandantenkennung. Die Datei `apps/api/src/groups/groups.service.ts`
wird von diesem Plan NICHT angefasst — diese Feststellung steht hier als
Richtigstellung, nicht als Änderung an der fremden Datei.
- Schema und Migrationen — geprüft und bewusst gelassen, keine
Schemaänderung in dieser Etappe.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
@@ -122,13 +122,13 @@ autoritative Quelle.
| dkv | 1 | 22 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung |
| user | 8 | 14 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 13 | 0 | unverändert |
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 8 | 5 | unverändert gegenüber dem in Etappe 1 (260909-eor) gemessenen Stand |
| calendar | 12 | 0 | unverändert |
| tenant | 8 | 0 | unverändert |
| favorites | 7 | 0 | unverändert |
| settings | 4 | 0 | unverändert |
| **Summe** | **107** | **135** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), jetzt 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), jetzt 135 nach 260910-jab (zusätzlich 1 in `tenders`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
| **Summe** | **95** | **147** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), jetzt 95 nach 260910-krx (`dashboard` 13→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), jetzt 147 nach 260910-krx (zusätzlich 12 in `dashboard`). Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 63 Paare)
@@ -166,6 +166,18 @@ Erstanlage des Administrators bereits bekannt, nicht strukturell fehlend).
Alle drei Zahlen sind der Ausgabe von `rls-access-inventory.spec.ts`
entnommen, nicht geschaetzt.
**Stand 260910-krx (Aufgabe 3): unveraendert, ausdruecklich festgehalten
statt uebersprungen.** Weiterhin 63 Paare, keine Klasse verschoben sich.
Die vier Paare des Bereichs `dashboard`
(`dashboard.service.ts`/`dashboardLayout`, `/module`, `/searchProvider`,
`/widgetInstance`) waren bereits vor diesem Durchlauf korrekt klassifiziert
(drei `muss-mandantengebunden`, eines `keine-mandantengebundene-tabelle`) —
dieser Plan aendert nur ihre `Stand`-Spalte (`ungebunden` auf `gebunden`
fuer drei der vier Paare), keine ihrer Klassen. Eine unveraenderte Tabelle
ohne diesen Vermerk waere von einer vergessenen Nachziehung nicht zu
unterscheiden — deshalb steht die Abwesenheit einer Aenderung hier
ausdruecklich, statt stillschweigend uebersprungen zu werden.
| Klasse | Anzahl Paare |
|---|---|
| muss-mandantengebunden | 31 |
@@ -281,6 +293,17 @@ der fünf oben geführten Fälle (übergreifend LESEN über alle Mandanten, dann
je Mandant BINDEN) — sie ist deshalb kein Kandidat für diese Liste. Dieser
Satz hält die Abwesenheit fest, damit sie nicht wie ein Übersehen aussieht.
**Stand 260910-krx — auch der Bereich `dashboard` fügt diesem Abschnitt
keinen sechsten Fall hinzu, gemessen statt angenommen.** Anweisung (260910-krx,
Aufgabe 1): `grep -rn "onModuleInit\|onApplicationBootstrap\|@Cron\|setInterval\|Scheduler" apps/api/src/dashboard --include=*.ts`
liefert null Treffer außerhalb von Testdateien — der einzige Dienst dieses
Bereichs, `dashboard.service.ts`, enthält keinen Hintergrunddienst, keinen
Planer und keinen Start-Hook. Jede seiner neun mandantengebundenen Methoden
wird ausschließlich synchron aus einer Anfrage eines einzelnen, bereits
bekannten Nutzers heraus aufgerufen — die Bauform dieses Abschnitts
(übergreifend LESEN über alle Mandanten, dann je Mandant BINDEN) kommt in
diesem Bereich an keiner Stelle vor.
## Bestandsaufnahme
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
@@ -294,10 +317,10 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
| apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gemischt | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | ungebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | ungebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | ungebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell: lokal gemessen null Zeilen mit `tenantId = NULL` insgesamt, dieser Schreibweg verlangt die Mandantenkennung als Pflichtparameter; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. Die Regel auf `SearchProvider` bleibt deshalb unverändert streng. |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | ungebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
@@ -366,8 +389,11 @@ verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
hat sich für denselben dienst-internen Weg entschieden — jede Methode in
`groups.service.ts` und `module-grants.service.ts` erzeugt ihren eigenen
`forTenant()`- bzw. `withTenantTransaction()`-Aufruf, gebundene Clients
werden nicht zwischen Methoden weitergereicht. Die Frage bleibt für alle
übrigen Bereiche der Etappe 2 offen.
werden nicht zwischen Methoden weitergereicht. Der Bereich `dashboard`
(260910-krx) hat sich für denselben dienst-internen Weg entschieden — jede
der neun umgestellten Methoden in `dashboard.service.ts` erzeugt ihren
eigenen `forTenant()`-Aufruf, wie alle sieben Bereiche vor ihm. Die Frage
bleibt für alle übrigen Bereiche der Etappe 2 offen.
- ~~Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
muss.~~ Aufgelöst (260910-jab): `TenderRssFeedSource` bekommt vier nach