Files
tessera-ctl/.planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-PLAN.md
T
schalli c7d93f235c docs(quick-260910-krx): Plan fuer Etappe 2, Bereich dashboard
Dreizehn Zugriffe, alle in dashboard.service.ts: zwoelf werden gebunden,
einer (der plattformweite Modulkatalog) bleibt begruendet ungebunden.

Zur Planungszeit gemessen statt angenommen:
- Die Zahl 13 haelt beim Hineinschauen.
- Die Besitzpruefungen sind ECHT (Pruefung auf die Benutzerkennung nach
  dem Laden), anders als die gleich geformte Luecke im Bereich ldap.
- Die widerlegte Praemisse zu SearchProvider haelt einer eigenstaendigen
  Suche stand; die Reichweite der Suche ist benannt, damit sie
  widerlegbar bleibt.
- Die Modul-Zugriffsaufloesung ist bereits gebunden — die geerbte
  Entlastung wird nachgeprueft, nicht ein zweites Mal repariert.
- Alle drei Regeln kennen keine Benutzerdimension; die
  anwendungsseitigen Pruefungen bleiben der einzige Quer-Lese-Schutz.
- Die beweisvernichtende Schleife ist an den beiden Web-Dateien belegt:
  leeres Dashboard, Neuaufbau, automatisches Zurueckschreiben beim
  Verlassen des Bearbeitungsmodus, ueberschriebene Aufzeichnung.

Umfang als Erlaubnisliste gegen den Ausgangsstand f205120 gegatet statt
als Verbotsliste; die Zaehlgates der Klassifikation leiten ihre Werte aus
den dokumenteigenen Messanweisungen ab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 15:16:58 +02:00

740 lines
69 KiB
Markdown

---
phase: quick-260910-krx
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [WINDOWS-18, ETAPPE-2-DASHBOARD]
files_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
estimate:
tokens: 185000
raw_tokens: 185000
tasks: 3
confidence: low
must_haves:
truths:
- "Alle dreizehn Datenbankzugriffe dieses Bereichs sind entschieden: zwoelf laufen gebunden unter dem woertlichen Namen `tenantPrisma`, einer (der plattformweite Modulkatalog) bleibt begruendet ungebunden. Keiner bleibt unentschieden, und keine Methode erzeugt mehr als einen gebundenen Klienten."
- "Lesen und Schreiben derselben Tabelle sind nirgends auf gebunden und ungebunden aufgeteilt. `getLayout` und `saveLayout` sind GEMEINSAM gebunden, und die drei Besitzpruefungen (Widget aendern, Widget entfernen, Suchmaschine entfernen) fuehren ihre beiden Abfragen ueber DENSELBEN gebundenen Klienten. Das ist als Testfall festgenagelt, nicht behauptet."
- "Die umgekehrte Fehlerrichtung dieses Bereichs ist an den ECHTEN, ausgelieferten Regeln gemessen — und zwar an dem Stand, den die Datenbank NACH der Migration 20260910120000 hat, nicht an dem, den aeltere Migrationen beschreiben. Je umgestelltem Pfad steht das konkrete Signal, an dem man ein zu kleines Ergebnis erkennen wuerde."
- "Die beweisvernichtende Schleife dieses Bereichs ist ausdruecklich benannt: ein zu kleines Leseergebnis zeigt kein Fehlerbild, sondern ein LEERES Dashboard beziehungsweise die Vorgabeanordnung. Der Nutzer haelt das fuer einen Fehler des Widget-Systems oder fuer verlorene Einstellungen, baut seine Anordnung neu auf — und die Anwendung schreibt diesen Neuaufbau beim Verlassen des Bearbeitungsmodus von selbst zurueck, womit die einzige Aufzeichnung der urspruenglichen Anordnung ueberschrieben ist. Festgehalten in der Kritikschrift UND als offener Eintrag im Broken-Windows-Register mit der konkreten Vorabpruefung fuer Etappe 4."
- "Die widerlegte Praemisse zu `SearchProvider` (WINDOWS #19, geschlossen in 260910-jab) ist in DIESEM Durchlauf eigenstaendig nachgeprueft, nicht aus dem Vorlauf abgeschrieben — mit einer Suche, deren Reichweite benannt ist, damit die Aussage widerlegbar bleibt. Faende sich doch ein Schreibweg fuer eine mandantenlose Zeile, waere die strenge Regel ein offenes Loch und der Befund wuerde umgedreht statt uebergangen."
- "Die Entlastung, die dieser Bereich geerbt hat, ist nachgeprueft und NICHT ein zweites Mal repariert: die Modul-Zugriffsaufloesung ist bereits im Dienst gebunden (260910-exd), der Widget-Modulfilter ist damit ohne Zutun dieses Bereichs gebunden, und keine Datei unter `apps/api/src/module-registry` wird angefasst."
- "Der plattformweite Modulkatalog bleibt ungebunden, und MESSUNG und BEDINGUNG bleiben getrennt: die Tabelle traegt heute ueberhaupt keinen Zeilenschutz, eine Bindung waere heute WIRKUNGSLOS und nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Messung dafuer wird beim Namen aus dem bestehenden Werkzeuglauf uebernommen, nicht neu behauptet."
- "Die Testlage steht VOR der Umstellung: die vorhandene Testdatei deckt heute ausschliesslich `getWidgets` ab und kennt kein Bindungshilfsmittel. Sie bekommt den Zwei-Klienten-Nachweis und Faelle fuer die Pfade, die heute ueberhaupt keinen Test haben. Ein vergessener Bindungsaufruf wird dadurch rot, statt aus einem anderen Grund zu scheitern."
- "Die Regeln dieses Bereichs kennen KEINE Benutzerdimension — zwei Nutzer desselben Mandanten sind fuereinander auf Datenbankebene vollstaendig sichtbar. Das ist gemessen, und die anwendungsseitigen Besitzpruefungen ueber die Benutzerkennung bleiben deshalb unveraendert bestehen; sie werden durch die Bindung ERGAENZT, nie ersetzt."
- "Baseline gehalten am Ende JEDER Aufgabe, nicht nur am Ende des Plans: mindestens 839 Tests gruen (mindestens 56 Dateien), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden."
- "Der Schalter bleibt AUS: `DATABASE_URL` zeigt weiterhin auf die Rolle `tessera`, Schema und Migrationen sind unveraendert, keine Compose- oder Umgebungsdatei wird angefasst."
artifacts:
- "apps/api/scripts/rls-scratch-check.mjs — ein neunter Abschnitt `runDashboardAreaChecks` mit dreizehn namentlich benannten Pruefungen gegen die aus den ausgelieferten Migrationen geschnittenen Regeln fuer `DashboardLayout`, `WidgetInstance` und `SearchProvider`"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich dashboard` mit (w1) Messung, (w2) Signaltabelle, (w3) Leere-als-Abwesenheit samt der beweisvernichtenden Schleife und dem Verhalten der Suchleiste, (w4) bewusst nicht geloest, (w5) bewusst nicht angefasst, plus Nachtrag im Abschnitt `## Bereich module-registry`"
- "apps/api/src/dashboard/dashboard.service.ts — die zwoelf mandantengebundenen Zugriffe gebunden, der eine Katalogzugriff begruendet ungebunden"
- "apps/api/src/dashboard/dashboard.controller.ts — die fuenf Handler, die den bereits aufgeloesten Mandanten heute verwerfen, reichen ihn durch"
- "apps/api/src/dashboard/dashboard.service.spec.ts — Zwei-Klienten-Nachweis ueber `__makeBoundClient`, alle acht bestehenden Faelle erhalten, neue Faelle fuer die heute ungetesteten Pfade"
- "docs/mandantentrennung-zugriffsklassifikation.md — vier Bestandsaufnahme-Zeilen, Uebersichtszeile, Summenzeile, Klassen-Verteilung, Abschnitt zur Hintergrunddienst-Falle, Abschnitt `Was diese Etappe NICHT entscheidet`"
- ".planning/WINDOWS.md — offener Eintrag zur beweisvernichtenden Schleife, in Tabelle UND JSON-Block"
key_links:
- "`getLayout` und `saveLayout` greifen ueber einen PLATTFORMWEIT eindeutigen Schluessel (`userId`, ohne Mandantenanteil) auf dieselbe Zeile zu. Wird eine der beiden gebunden und die andere nicht, wird aus einem zu kleinen Leseergebnis ein UEBERSCHREIBEN der tatsaechlichen Anordnung — dieselbe Familie wie WINDOWS #22 im Bereich `user`."
- "Der Controller loest den Mandanten bereits in `extractContext` auf, fuenf Handler verwerfen ihn nur. Die Umstellung ist ein Durchreichen, keine neue Vertrauensquelle — die Mandantenkennung kommt weiterhin ausschliesslich aus dem Sitzungsnachweis."
- "`dashboard.service.ts` ruft die bereits gebundene Modul-Zugriffsaufloesung auf. Der Widget-Modulfilter ist dadurch gebunden, ohne dass eine Datei dieses Bereichs ihn bindet; eine zweite Bindung hier erzeugte zwei Klienten fuer dieselbe Aufloesung."
- "Die drei Regeln dieses Bereichs lauten schlicht `\"tenantId\" = current_tenant_id()` — ohne Benutzerdimension. Die Besitzpruefungen ueber die Benutzerkennung sind und bleiben der einzige Schutz gegen Quer-Lesen und Quer-Loeschen zwischen Nutzern DESSELBEN Mandanten."
---
<objective>
Etappe 2 der Mandantentrennung, achter Bereich: `dashboard`. Die dreizehn
Datenbankzugriffe des einzigen Dienstes dieses Bereichs werden dort auf
`forTenant()` umgestellt, wo sie auf Rechnung genau eines Mandanten laufen, und
dort begruendet ungebunden gelassen, wo sie den plattformweiten Modulkatalog
betreffen.
Zweck: dieser Bereich haelt, 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
Zuruecksetzen: nach dem Scharfschalten meldet eine ungebunden gebliebene
Abfrage keinen Fehler, sondern liefert ein leeres Dashboard beziehungsweise die
Vorgabeanordnung. Wer sein sorgfaeltig eingerichtetes Dashboard leer vorfindet,
vermutet einen Fehler im Widget-System oder verlorene Einstellungen, baut es
neu auf — und die Anwendung schreibt diesen Neuaufbau beim Verlassen des
Bearbeitungsmodus von selbst zurueck. Damit ist die einzige Aufzeichnung der
urspruenglichen Anordnung ueberschrieben, bevor irgendjemand die Ursache
untersuchen konnte. Das ist die beweisvernichtende Auspraegung der umgekehrten
Fehlerrichtung, und sie ist der eigentliche Grund, warum dieser Bereich seine
Kritikschrift VOR der Umstellung braucht.
Ergebnis: zwoelf gebundene Zugriffe, ein begruendet ungebundener Katalogzugriff,
eine gemessene Kritikschrift, eine Testlage, die einen vergessenen
Bindungsaufruf rot macht, und zwei Dokumente, die am Ende nachweislich mit dem
Quelltext uebereinstimmen.
**Der Schalter bleibt AUS.** `DATABASE_URL` zeigt weiterhin auf die Rolle
`tessera` mit `BYPASSRLS`. Das Scharfschalten ist Etappe 4 und findet hier
NICHT statt. Schema und Migrationen werden NICHT angefasst — die Regelarbeit
ist einen Commit zuvor abgeschlossen und verifiziert worden.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@docs/mandantentrennung-zugriffsklassifikation.md
@docs/mandantentrennung-etappe2-fehlerrichtung.md
@apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/prisma/rls-access-inventory.spec.ts
@apps/api/scripts/rls-scratch-check.mjs
@apps/api/src/dashboard/dashboard.service.ts
@apps/api/src/dashboard/dashboard.service.spec.ts
@apps/api/src/dashboard/dashboard.controller.ts
@apps/api/src/dashboard/widget-module-map.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/api/src/module-registry/module-access.service.spec.ts
@apps/api/src/tenders/tender-rss-feed.service.ts
@.planning/quick/260910-exd-mandantentrennung-etappe-2-bereich-modul/260910-exd-PLAN.md
</context>
<planning_time_findings>
Alle Zahlen unten sind zur Planungszeit am 2026-09-10 gegen HEAD `f205120`
GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung,
sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung
erneut gelesen, und jede Zahl wird zur Ausfuehrungszeit erneut gemessen.
**Befund A — die Zahl haelt, und der Bereich ist eine einzige Datei.** Gemessen
mit `grep -rnoE "this\.prisma\.[a-zA-Z]+" apps/api/src/dashboard --include=*.ts | grep -v spec`:
dreizehn Treffer, alle in `dashboard.service.ts`. Aufteilung nach Modell:
`dashboardLayout` zwei (Zeilen 69, 85), `widgetInstance` sieben (111, 157, 176,
192, 203, 213), `module` einer (134), `searchProvider` vier (225, 241, 258,
268). `dashboard.controller.ts`, `dashboard.module.ts` und
`widget-module-map.ts` halten null Zugriffe. Nach `dkv` (21), `user` (17) und
`module-registry` (17) der vierte Bereich in Folge, in dem beim Hineinschauen
nichts schrumpft. Auch die vier (Datei, Modell)-Paare der Bestandsaufnahme
stimmen mit dem Quelltext ueberein — anders als im Bereich `user` (eine
sachlich falsche Zeile) und im Bereich `dkv` (ein ganz fehlendes Paar) ist hier
keine Korrektur der PAARE noetig. Die BEGRUENDUNGEN von drei der vier Zeilen
sind trotzdem nachzuziehen, siehe Befunde E und F.
Zaehlkontrolle: 2 + 7 + 1 + 4 = 14, nicht 13 — die Liste oben nennt fuer
`widgetInstance` sechs Zeilennummern fuer sieben Treffer, weil in einer Zeile
zwei Vorkommen liegen koennen. Genau deshalb ist die ZAHL zur Ausfuehrungszeit
neu zu messen und nicht aus dieser Aufzaehlung abzuschreiben. Erwartet wird
dreizehn; weicht die Messung ab, ist die Abweichung im SUMMARY zu benennen,
nicht stillschweigend zu uebernehmen.
**Befund B — keine Transaktion, kein Roh-SQL in diesem Bereich.** Gemessen mit
`grep -rn '\$transaction(\|\$queryRaw\|\$executeRaw' apps/api/src/dashboard --include=*.ts`:
null Treffer. Der im Kopfkommentar von `prisma-tenant.extension.ts`
ausdruecklich verlangte erneute Test vor jedem neuen `forTenant()`-Fall mit
eigener Transaktion ist damit fuer diesen Bereich beantwortet: es faellt kein
neuer Fall an, `withTenantTransaction()` wird hier nicht gebraucht und nicht
eingefuehrt.
**Befund C — die Testdatei existiert, deckt aber nur EINEN von neun Pfaden ab,
und sie hat die `groups`/`tenders`-Form.** `dashboard.service.spec.ts` (215
Zeilen, acht Faelle) testet ausschliesslich `getWidgets` und den
Modulfilter aus 15-05. Ihr handgerollter Prisma-Nachbau (`makeFakePrisma`)
kennt genau zwei Modelle (`widgetInstance`, `module`) und KEINE Attrappe fuer
das Bindungshilfsmittel. Nach der Umstellung riefe das echte `forTenant()` eine
`$extends`-Methode auf einem schlichten Objekt auf, das keine hat — die Tests
wuerden scheitern, aber aus dem falschen Grund, und ein vergessener
Bindungsaufruf waere von einem vorhandenen nicht zu unterscheiden. Acht der
neun Dienstmethoden (`getLayout`, `saveLayout`, `addWidget`,
`updateWidgetConfig`, `removeWidget`, `getSearchProviders`,
`addSearchProvider`, `removeSearchProvider`) haben heute UEBERHAUPT keinen
Test. Die Zahl acht ist zur Ausfuehrungszeit nachzuzaehlen.
**Befund D — die Besitzpruefung ist ECHT, anders als im Bereich `ldap`.** Die
Form ist dieselbe, die in diesem Vorhaben die erste echte Luecke erzeugt hat:
`findUnique` auf die Kennung, dann `delete`/`update` auf dieselbe Kennung. Aber
zwischen beiden steht hier tatsaechlich eine Pruefung, und zwar auf der
BENUTZER-Dimension, die die Mandanten-Dimension einschliesst: `removeWidget`
und `updateWidgetConfig` pruefen `widget.userId !== userId`,
`removeSearchProvider` prueft `provider.userId !== userId`, jeweils mit einer
Benutzerkennung aus dem Sitzungsnachweis, und werfen sonst `NotFoundException`.
Ein Administrator des Mandanten A kann ein Widget des Mandanten B daher heute
NICHT entfernen — er muesste es besitzen. Die Vorgabe-Suchmaschinen tragen
`userId = null` und fallen deshalb ebenfalls in den Nicht-gefunden-Zweig, wie
es der Kopfkommentar an dieser Methode behauptet. **Diese Entlastung ist zur
Ausfuehrungszeit erneut zu pruefen, bevor sie im SUMMARY behauptet wird** — sie
ist der Kern der Sicherheitsaussage dieses Bereichs.
Was daraus NICHT folgt: die beiden Abfragen jeder Besitzpruefung muessen nach
der Umstellung ueber DENSELBEN gebundenen Klienten laufen. Waere die
Lesehaelfte gebunden und die Schreibhaelfte nicht, ginge die Pruefung auf einer
Zeile auf, die der anschliessende Schreibvorgang gar nicht mehr sieht — und
umgekehrt.
**Befund E — die Entlastung, die dieser Bereich geerbt hat.**
`apps/api/src/module-registry/module-access.service.ts` bindet
`getAccessibleModuleIds` seit 260910-exd (`const tenantPrisma = forTenant(this.prisma, tenantId)`),
und `dashboard.service.ts` ruft genau diese Aufloesung fuer den
Widget-Modulfilter auf. Der Filter ist damit bereits gebunden, ohne dass eine
Datei dieses Bereichs angefasst worden waere. Dieser Plan darf ihn NICHT ein
zweites Mal binden. Zusatzbefund, ebenfalls gemessen:
`apps/api/src/dashboard/widget-module-map.ts` fuehrt `WIDGET_MODULE_MAP` als
leeres Objekt, weshalb `getWidgets` heute vor der Aufloesung zurueckkehrt und
sie im Normalbetrieb gar nicht erreicht — der Pfad ruht, er fehlt nicht.
**Befund F — die widerlegte Praemisse zu `SearchProvider` haelt der eigenen
Nachpruefung stand.** Die Migration 20260910120000 hat `SearchProvider`
ausdruecklich UNVERAENDERT gelassen, mit der Begruendung, es gebe keinen
Codeweg, der eine mandantenlose Zeile erzeugt. Zur Planungszeit eigenstaendig
nachgeprueft mit einer Suche ueber `apps/`, `packages/` und `prisma/` nach
`searchProvider`/`SearchProvider` in `*.ts`, `*.mjs`, `*.js`, `*.sql`, `*.json`
(ohne `node_modules`): der einzige Schreibweg ist `dashboard.service.ts:241`
(`create`) mit `tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode,
es existiert keine Seed-Datei (`apps/api/prisma/` enthaelt nur `migrations` und
`schema.prisma`), kein Skript und kein weiterer Schreibweg. Die drei
Vorgabe-Suchmaschinen sind eine TypeScript-Konstante im selben Dienst
(Entscheidung 05-02) mit fest verdrahteten Kennungen `google`/`bing`/`ddg`, die
in keiner Datenbankzeile vorkommen.
**Grenze dieser Suche, ausdruecklich benannt (Fehler 6 dieses Vorhabens):**
sie findet keinen Schreibweg, der das Modell ueber einen dynamisch gebildeten
Namen anspricht, und sie deckt keine manuelle Datenbankaenderung ab. Die
Aussage lautet deshalb nicht "es kann keine mandantenlose Zeile geben", sondern
"kein Anwendungspfad erzeugt eine". Aufgabe 1 haelt beide Haelften fest: die
Codeaussage samt Reichweite UND die Datenbankmessung, dass ein gebundenes
Einfuegen ohne Mandant von der unveraenderten Regel abgewiesen wird — die
strenge Regel verteidigt die widerlegte Praemisse also zusaetzlich.
**Befund G — die drei Regeln dieses Bereichs kennen keine Benutzerdimension.**
Gemessen in `20260909140000_rls_remaining_tenant_tables/migration.sql`: die
Policies auf `DashboardLayout` (Zeile 67), `SearchProvider` (99) und
`WidgetInstance` (155) lauten alle drei wortgleich
`USING ("tenantId" = current_tenant_id())`. Zwei Nutzer DESSELBEN Mandanten
sind fuereinander auf Datenbankebene vollstaendig sichtbar — wortgleich derselbe
Fall wie `tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar` im
Bereich `tenders`. Die anwendungsseitigen Besitzpruefungen aus Befund D bleiben
deshalb der EINZIGE Schutz gegen Quer-Lesen und Quer-Loeschen zwischen Nutzern
und werden bei der Umstellung ausdruecklich NICHT entfernt.
**Befund H — der Modulkatalog traegt weiterhin ueberhaupt keinen Zeilenschutz.**
Gemessen mit `grep -n 'ENABLE ROW LEVEL SECURITY' apps/api/prisma/migrations/*/migration.sql`:
`"Module"` ist nicht darunter, und das Modell traegt im Schema kein `tenantId`.
Das ist exakt Befund E aus 260910-exd, dort bereits als Werkzeugpruefung
`module-tabelle-traegt-keinen-zeilenschutz` hinterlegt. Dieser Plan MISST das
nicht neu — er beruft sich beim Namen auf die bestehende Pruefung und
uebernimmt die dort etablierte Trennung: eine Bindung waere HEUTE wirkungslos,
nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle
eine Regel gibt. Die Bestandsaufnahme-Zeile fuer `dashboard.service.ts`/`module`
begruendet die Nichtbindung heute mit "Modulkatalog ist plattformweit, kein
`tenantId`" — richtig, aber unvollstaendig; sie nennt die BEDINGUNG nicht.
**Befund I — die beweisvernichtende Schleife, gemessen im Frontend statt aus
dem Backend geschlossen.** Drei Stellen greifen ineinander:
1. `dashboard.service.ts`, `getLayout`: `if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`
— kein Datensatz heisst hier nicht "Fehler", sondern "Vorgabeanordnung".
2. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt
`layouts` und `widgets` genau auf das, was die beiden Abfragen liefern. Ein
zu kleines Ergebnis erzeugt keinen Fehlerzustand — der `catch`-Zweig
(`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk- oder
Statusfehler, nicht bei einer erfolgreichen, leeren Antwort.
3. Derselbe Store, `setEditMode(false)`: `if (prev && !mode && get().isDirty) { get().saveLayout(); }`
— der Neuaufbau wird beim blossen VERLASSEN des Bearbeitungsmodus
automatisch zurueckgeschrieben, ohne dass jemand auf "Speichern" klickt.
Die Folge, wenn nach dem Scharfschalten ein Lesepfad zu wenig liefert: leeres
Dashboard, Nutzer baut neu auf, `addWidget` legt echte neue
`WidgetInstance`-Zeilen an (es gibt keine Eindeutigkeitsbedingung ueber
`(userId, widgetType)`, Dubletten haeufen sich also an), und das automatische
Zurueckschreiben ueberschreibt die `layouts`-Spalte der urspruenglichen Zeile.
Die urspruengliche Anordnung ist danach nicht mehr rekonstruierbar. **Dieser
Ablauf ist zur Ausfuehrungszeit an den beiden Web-Dateien erneut zu pruefen,
bevor er in der Kritikschrift behauptet wird** — dieser Plan aendert am
Frontend NICHTS, er beschreibt es nur.
**Befund J — was mit der Suchleiste passiert, gemessen.**
`apps/web/src/components/dashboard/widgets/search-widget.tsx` uebernimmt die
Antwort nur, wenn sie nicht leer ist (`if (!cancelled && data.length > 0)`),
und faellt sonst auf drei fest verdrahtete Vorgaben zurueck. Weil
`getSearchProviders` die drei Vorgaben IMMER voranstellt, ist die Antwort nie
leer — der Rueckfallzweig feuert nie, und die eigenen Suchmaschinen des
Nutzers verschwinden schlicht aus der Auswahlliste, waehrend die Leiste
weiterhin funktioniert. Zusaetzlich waehlt `handleSearch` bei unbekannter
Auswahl `providers[0]`, also Google: eine Suchanfrage, die fuer eine interne
Suchmaschine gedacht war, ginge dann an eine externe. Sichtbar bleibt das
insofern, als die Auswahlliste den ersten Eintrag anzeigt — **wie sichtbar
genau, ist zur Ausfuehrungszeit an der Datei nachzusehen und praezise zu
formulieren, statt "still" zu behaupten.**
**Befund K — die plattformweite Eindeutigkeit von `DashboardLayout.userId`.**
Im Schema traegt `userId` ein `@unique` OHNE Mandantenanteil. Damit ist
strukturell dieselbe Kette moeglich wie im Bereich `user` (WINDOWS #22):
unsichtbare Zeile — falsches "frei" — harter Eindeutigkeitsfehler. Gemessen
wurde zusaetzlich, ob diese Kette ueber einen Anwendungspfad ueberhaupt
erreichbar ist: `apps/api/src/user/dto/update-user.dto.ts` kennt kein
`tenantId`, und keine Stelle schreibt `tenantId` auf einen bestehenden
Benutzer fort — die Mandantenkennung eines Benutzers aendert sich nach der
Anlage nicht. Die Kette ist damit heute nur ueber eine Aenderung an der
Datenbank von Hand erreichbar. Der `update`-Zweig von `saveLayout` schreibt
`tenantId` allerdings NICHT mit, weshalb eine einmal abweichende
Mandantenkennung dauerhaft stehen bliebe. Aufgabe 1 misst, WAS in diesem Fall
tatsaechlich passiert; Aufgabe 2 behandelt das Ergebnis. **Ein Schemawechsel
ist ausdruecklich NICHT Teil dieses Plans** — er waere die ehrliche Reparatur
und gehoert als Produktentscheidung nach Etappe 3, wie #22 es fuer denselben
Fall bereits vormerkt.
**Befund L — eine Fremdaussage ueber diesen Bereich, die nicht ganz stimmt.**
`apps/api/src/groups/groups.service.ts` (Kopfkommentar, um Zeile 24) begruendet
die dortige Regel "jede Lookup-Query mit einer Gruppen-ID filtert zusaetzlich
auf tenantId" mit dem Zusatz "nach dem Ownership-Check-Muster aus
DashboardService.removeWidget". Das trifft in zwei Punkten nicht zu:
`removeWidget` filtert NICHT zusaetzlich in der Abfrage, sondern vergleicht
nach dem Laden im JavaScript, und der Vergleich laeuft ueber die BENUTZER-,
nicht die Mandantenkennung. Die Aussage in
`apps/api/src/groups/module-grants.service.ts` (um Zeile 41, "betreffen nur
direktes Eigentum, nicht eine zweite Mandantengrenze ueber eine Relation") ist
dagegen zutreffend. **Der Bereich `groups` wird von diesem Plan NICHT
angefasst** — die Ungenauigkeit wird in (w5) festgehalten, nicht behoben. Zur
Ausfuehrungszeit erneut gegenzulesen, bevor sie im Dokument behauptet wird.
</planning_time_findings>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — an den Regeln, wie sie nach der Migration 20260910120000 stehen</name>
<precondition>Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen.</precondition>
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<read_first>apps/api/scripts/rls-scratch-check.mjs, apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql, apps/api/prisma/migrations/20260910120000_rls_widen_membership_grant_and_platform_read/migration.sql, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/components/dashboard/widgets/search-widget.tsx, apps/api/prisma/schema.prisma, docs/mandantentrennung-etappe2-fehlerrichtung.md</read_first>
<action>
TEIL 1 — die Messung. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um
einen neunten Abschnitt `runDashboardAreaChecks(adminUrl, scratchRoleUrl, results)`
und rufe ihn in `main()` NACH `runModuleRegistryAreaChecks` und VOR
`runTransactionShapeMeasurement` auf. Der Abschnitt legt die beiden
Wegwerf-Tabellen `DashboardLayout` und `WidgetInstance` selbst an und benutzt
die von `runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider`
WEITER, statt sie ein zweites Mal anzulegen — halte diese Reihenfolgebedingung
im Kopfkommentar des neuen Abschnitts fest, so wie `runDkvAreaChecks` es fuer
seine Bedingung vormacht. Die bestehende Pruefung
`searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar` und
ihre Testzeile bleiben UNVERAENDERT bestehen; neue Zeilen bekommen eigene
Kennungen, damit sie nicht kollidieren.
Alle drei Policies werden mit `extractPolicySql()` WORTGLEICH aus
`20260909140000_rls_remaining_tenant_tables` geschnitten, nicht im Werkzeug
nachgetippt. Findet die Extraktion eine der drei nicht, meldet der Abschnitt
eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer geratenen Regel
weiterzumessen — genau wie die bestehenden Abschnitte. Die Wegwerf-Tabelle
`DashboardLayout` bildet die Eindeutigkeitsbedingung des Schemas nach
(`"userId" text UNIQUE`), weil ohne sie die Messung aus Pruefung 5 unten gar
nicht stattfinden kann.
Dreizehn namentlich benannte Pruefungen, jede mit einer Belegausgabe, die die
beobachteten Werte nennt:
1. `dashboardlayout-gebunden-nur-eigener-mandant` — ein gebundener Lesezugriff
fuer TENANT-A liefert ausschliesslich A-Zeilen.
2. `dashboardlayout-ungebunden-null-zeilen` — die tragende Belegzeile: der
IDENTISCHE Lesezugriff ohne vorheriges Setzen des Mandantenkontexts liefert
null Zeilen, nicht etwa alle vorhandenen.
3. `dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` — das
GELINGEN ist hier das bestandene Ergebnis: die Regel kennt keine
Benutzerdimension, ein zweiter Nutzer desselben Mandanten ist gebunden
sichtbar. Die Belegausgabe muss ausdruecklich sagen, dass die
anwendungsseitige Pruefung ueber die Benutzerkennung deshalb der einzige
Schutz gegen Quer-Lesen bleibt und nicht entfallen darf.
4. `widgetinstance-ungebunden-null-zeilen` und
`widgetinstance-gebunden-nur-eigener-mandant` — dasselbe Paar fuer die
Widget-Tabelle.
5. `dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut`
— MISST, was passiert, wenn ein gebundenes Einfuegen mit
Konfliktbehandlung (`INSERT ... ON CONFLICT ("userId") DO UPDATE ...`) auf
eine physisch vorhandene, unter dem laufenden Mandanten aber unsichtbare
Zeile trifft. Bestanden ist diese Pruefung genau dann, wenn ein HARTER,
benannter Fehler zurueckkommt; sie ist NICHT bestanden, wenn der Vorgang
still gelingt, eine fremde Zeile aendert oder eine Dublette erzeugt. Die
Belegausgabe nennt den beobachteten SQLSTATE und die Meldung woertlich,
damit Aufgabe 2 daran anknuepfen kann. Rate das Ergebnis NICHT vorweg — es
haengt am Zusammenspiel von Eindeutigkeitsindex und Regel und ist genau
deshalb eine Messung.
6. `widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt` — ein
gebundenes Einfuegen mit fremder Mandantenkennung wird abgewiesen.
7. `widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile` — ein
gebundenes Loeschen ueber die Kennung einer fremden Zeile entfernt nichts
und meldet keinen Fehler. Das ist die Datenbankseite von Befund D.
8. `searchprovider-ungebunden-null-zeilen` und
`searchprovider-gebunden-nur-eigener-mandant` — dasselbe Paar fuer die
Suchmaschinentabelle, gegen die UNVERAENDERTE strenge Regel.
9. `searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar` —
wie Pruefung 3, fuer die Suchmaschinen.
10. `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` — ein
gebundenes Einfuegen ohne Mandantenkennung wird von der unveraenderten
Regel abgewiesen. Das ist die Verteidigung der widerlegten Praemisse aus
Befund F: selbst wenn ein kuenftiger Schreibweg es versuchte, kaeme er
unter der Anwendungsrolle nicht durch.
Fuelle die Liste auf dreizehn auf, wenn beim Lesen der Migration weitere
Eigenschaften auffallen, die dieser Bereich braucht; nenne im SUMMARY die
tatsaechliche Zahl, nicht diese. Ergaenzt wird nur, gestrichen wird nicht.
TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die drei
Nachpruefungen aus Befund D, F und I/J tatsaechlich aus und notiere jeweils die
Anweisung, mit der du gesucht hast, damit die Aussage widerlegbar bleibt:
die Echtheit der drei Besitzpruefungen, die Vollstaendigkeit der Suche nach
einem Schreibweg fuer eine mandantenlose Suchmaschinenzeile, und den Ablauf im
Frontend (Vorgabeanordnung bei fehlendem Datensatz, automatisches
Zurueckschreiben beim Verlassen des Bearbeitungsmodus, Verhalten der
Suchleiste). Miss ausserdem, ob dieser Bereich einen Hintergrunddienst enthaelt
(Anweisung angeben), damit der entsprechende Abschnitt der Klassifikation in
Aufgabe 3 mit einer Messung statt einer Annahme fortgeschrieben werden kann.
Faellt eine dieser Nachpruefungen ANDERS aus als in den Planungsbefunden
beschrieben, gilt die Messung und nicht der Plan — schreibe dann die Messung
auf und benenne die Abweichung ausdruecklich.
TEIL 3 — die Kritikschrift. Erweitere
`docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt
`## Bereich dashboard` mit fuenf Unterabschnitten, in der Form der sechs
bereits vorhandenen Bereichsabschnitte:
- `### (w1) Die Messung` — die tatsaechlich beobachtete Ausgabe des
Werkzeuglaufs woertlich eingeruckt, die tragende Belegzeile benannt, dazu die
eigenstaendige Nachpruefung der widerlegten Praemisse aus Befund F samt der
Reichweite der Suche. Halte ausdruecklich fest, dass hier an dem Regelstand
NACH der Migration 20260910120000 gemessen wurde.
- `### (w2) Signaltabelle je umgestelltem Pfad` — je Dienstmethode eine Zeile:
Verhalten bei zu wenig Ergebnis, und das konkrete Signal, an dem man es
saehe. Die Zeile zu `getLayout` muss das Zuruecksetzen auf die
Vorgabeanordnung benennen, die Zeile zu `getSearchProviders` das Verschwinden
der eigenen Suchmaschinen bei weiterhin funktionierender Leiste.
- `### (w3) Welcher Code Leere als Abwesenheit deutet` — namentlich, mit
Dateiname und Stelle. Trenne dabei die Stellen im Backend von den drei
Stellen im Frontend aus Befund I und beschreibe die Schleife als Schleife:
leeres Dashboard, Neuaufbau, automatisches Zurueckschreiben, ueberschriebene
Aufzeichnung, angehaeufte Widget-Dubletten. Nenne ausdruecklich, dass der
Nutzer eine fertige, falsche Erklaerung zur Hand hat ("das Widget-System
spinnt", "meine Einstellungen sind weg") und deshalb keinen Fehler meldet.
Das Verhalten der Suchleiste aus Befund J gehoert ebenfalls hierhin.
- `### (w4) Was dieser Durchlauf bewusst nicht loest` — die plattformweite
Eindeutigkeit aus Befund K mit dem Verweis auf WINDOWS #22 als
gleichgelagerten Fall, und die fehlende Unterscheidbarkeit von "noch keine
Anordnung gespeichert" und "Anordnung nicht sichtbar". Halte fest, welche
Vorabpruefung fuer Etappe 4 (`rls-preflight.mjs`) das aufdecken wuerde. Eine
Laufzeitwarnung ist zu erwaegen und, wenn verworfen, mit derselben
Begruendung zu verwerfen wie bei `getAllActiveConfigs` im Bereich `ldap` —
begruende die Entscheidung, uebernimm sie nicht.
- `### (w5) Was dieser Durchlauf bewusst nicht anfasst` — das Frontend, der
Bereich `favorites` (dessen Tabelle an der Widget-Tabelle haengt, aber ein
eigener Bereich mit eigener Umstellung ist), der Bereich `module-registry`
samt der geerbten Entlastung aus Befund E, und die Ungenauigkeit aus Befund L
woertlich zitiert und richtiggestellt — ohne die fremde Datei zu aendern.
Setze ausserdem im bereits vorhandenen Abschnitt `## Bereich module-registry`
einen `**Nachtrag (260910-krx):**`, der festhaelt, dass der dortige Befund zur
Reihenfolge (der Bereich `dashboard` erbt die Bindung der
Modul-Zugriffsaufloesung) mit diesem Durchlauf eingeloest und nachgeprueft ist
— und dass der Widget-Modulfilter NICHT ein zweites Mal gebunden wurde. Der
urspruengliche Text bleibt unveraendert stehen; ein Nachtrag ergaenzt, er
ersetzt nicht.
Aendere in dieser Aufgabe KEINE Datei unter `apps/api/src` und KEINE Datei
unter `apps/api/prisma`.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in dashboardlayout-gebunden-nur-eigener-mandant dashboardlayout-ungebunden-null-zeilen dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut widgetinstance-ungebunden-null-zeilen widgetinstance-gebunden-nur-eigener-mandant widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile searchprovider-ungebunden-null-zeilen searchprovider-gebunden-nur-eigener-mandant searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar module-tabelle-traegt-keinen-zeilenschutz; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && echo "$OUT" | grep -qE '^Alle [0-9]+ Pruefungen bestanden\.$' && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test "$N" -ge 86 || { echo "PRUEFUNGSZAHL: $N, erwartet mindestens 86 (74 bisherige plus mindestens 12 neue)"; exit 1; }; } && grep -q '^## Bereich dashboard$' docs/mandantentrennung-etappe2-fehlerrichtung.md && for S in w1 w2 w3 w4 w5; do grep -qE "^### \($S\) " docs/mandantentrennung-etappe2-fehlerrichtung.md || { echo "FEHLENDER UNTERABSCHNITT: ($S)"; exit 1; }; done && grep -q 'Nachtrag (260910-krx' docs/mandantentrennung-etappe2-fehlerrichtung.md && awk '/^## Bereich dashboard$/{f=1; next} /^## /{f=0} f && /dashboard-store|search-widget/{m++} END{ if(m+0 < 2){print "ABSCHNITT (w3): die beiden gemessenen Frontend-Dateien sind nicht namentlich genannt"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && npm --prefix apps/api run test && npm --prefix apps/api run type-check && git rev-parse --verify f205120 >/dev/null && PRISMA_CHANGED=$(git diff --name-only f205120 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f205120) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`apps/api/scripts/rls-scratch-check.mjs` hat einen neunten Abschnitt mit mindestens zwoelf neuen, namentlich benannten Pruefungen gegen die aus den ausgelieferten Migrationen geschnittenen Regeln; alle Pruefungen des Werkzeugs bestehen, einschliesslich der bereits vorhandenen Suchmaschinen-Pruefung und der geliehenen Katalogmessung aus dem Bereich `module-registry`. `docs/mandantentrennung-etappe2-fehlerrichtung.md` hat einen Abschnitt `## Bereich dashboard` mit den fuenf Unterabschnitten (w1) bis (w5), die tatsaechlich beobachtete Werkzeugausgabe woertlich, die beweisvernichtende Schleife namentlich an den beiden gemessenen Frontend-Dateien beschrieben, und einen Nachtrag im Abschnitt `## Bereich module-registry`. Baseline gehalten: Tests gruen, Typpruefung sauber. Unter `apps/api/src`, `apps/api/prisma`, `apps/web` und den Compose-/Umgebungsdateien ist nichts geaendert.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Anordnung und Widgets binden — erst die Testlage, dann die Umstellung, Lesen und Schreiben nie getrennt</name>
<files>apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.controller.ts</files>
<read_first>apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/prisma/prisma-tenant.extension.ts</read_first>
<behavior>
Zuerst die Testlage in `dashboard.service.spec.ts`, DANN die Umstellung. Der
Nachbau bekommt das Muster aus `module-access.service.spec.ts` uebernommen:
`vi.mock` auf das Bindungshilfsmittel, das auf `prisma.__makeBoundClient(tenantId)`
umgeleitet wird, und ein Bindungsprotokoll, das je Aufruf Mandantenkennung,
Modell und Methode festhaelt. Der gebundene Klient veroeffentlicht AUSSCHLIESSLICH
die mandantengebundenen Modelle — den Modulkatalog bewusst nicht, damit ein
versehentlich gebundener Katalogzugriff im Test laut scheitert statt still
durchzugehen.
Alle acht bestehenden Faelle bleiben erhalten und gruen. Neu hinzu, jeder als
eigener Fall:
- Anordnung lesen: der Lesezugriff laeuft ueber den gebundenen Klienten, mit
der uebergebenen Mandantenkennung im Protokoll.
- Anordnung speichern: der Schreibzugriff laeuft ueber den gebundenen Klienten
mit derselben Mandantenkennung. Zusaetzlich ein Fall, der festnagelt, dass
Lesen und Speichern GEMEINSAM gebunden sind — er wird rot, sobald eine der
beiden Haelften ungebunden liefe.
- Widgets lesen: der Widget-Lesezugriff laeuft gebunden, der Katalogzugriff
NICHT — im Bindungsprotokoll steht kein Katalogeintrag, und der bestehende
Fall mit leerer Zuordnungstabelle bleibt unveraendert.
- Widget anlegen: gebunden, mit der uebergebenen Mandantenkennung.
- Widget-Konfiguration aendern: BEIDE Abfragen (Besitzpruefung und Aenderung)
laufen ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung.
- Widget entfernen: ebenso, beide Abfragen ueber denselben Klienten.
- Die Besitzpruefung bleibt wirksam: ein Widget, das einem anderen Benutzer
gehoert, fuehrt weiterhin zu `NotFoundException`, sowohl beim Aendern als
auch beim Entfernen. Diese beiden Faelle sind der Nachweis, dass die
Bindung die Pruefung ueber die Benutzerkennung ERGAENZT und nicht ersetzt —
sie muessen rot werden, wenn jemand die Pruefung entfernt.
- Ein Fall, der festnagelt, dass keine Methode dieses Bereichs mehr als EINEN
gebundenen Klienten erzeugt.
- Kein Widget vorhanden: der Rueckgabewert ist eine leere Liste, kein Fehler —
das ist die Deutung von Leere als Abwesenheit, hier ausdruecklich als
heutiges Verhalten festgehalten, damit eine spaetere Aenderung sichtbar wird.
Die Zahl der neu hinzugekommenen Faelle wird am Ende ABGEZAEHLT und im SUMMARY
mit der gezaehlten Zahl genannt, nicht mit der hier aufgelisteten — die Bereiche
`tenders` und `module-registry` haben genau an dieser Stelle je eine falsche
Zahl behauptet.
</behavior>
<action>
Stelle in `dashboard.service.ts` die neun Zugriffe auf `dashboardLayout` und
`widgetInstance` auf `forTenant()` um. Ein gebundener Klient JE METHODE, unter
dem woertlichen Namen `tenantPrisma`, wie in jedem bereits umgestellten
Bereich; gebundene Klienten werden nicht zwischen Methoden weitergereicht. Der
Katalogzugriff in `getWidgets` bleibt in dieser Aufgabe unveraendert auf
`this.prisma.module` — seine Begruendung folgt in Aufgabe 3. Die vier Zugriffe
auf die Suchmaschinen bleiben in dieser Aufgabe ebenfalls unveraendert; sie sind
Aufgabe 3.
Die bestehenden `where`-Filter und die drei Besitzpruefungen ueber die
Benutzerkennung bleiben ausnahmslos stehen. Begruende im Quelltext, warum sie
kein Beiwerk sind: die Regeln dieses Bereichs kennen keine Benutzerdimension
(gemessen in Aufgabe 1), und bis zum Scharfschalten ist die Anwendungspruefung
ohnehin der einzige tatsaechlich wirksame Schutz.
Die Modul-Zugriffsaufloesung wird NICHT angefasst — sie bindet bereits in ihrem
eigenen Dienst. Halte das im Quelltext an der Aufrufstelle fest, damit niemand
sie hier ein zweites Mal bindet.
Reiche in `dashboard.controller.ts` bei den Handlern, die den bereits
aufgeloesten Mandanten heute verwerfen, diesen an den Dienst durch. Die
Mandantenkennung stammt unveraendert aus `extractContext` und damit aus dem
Sitzungsnachweis — es entsteht KEINE neue Vertrauensquelle, und aus Rumpf oder
Pfadparametern wird nichts uebernommen. Halte die Parameterreihenfolge des
Dienstes durchgaengig: die Mandantenkennung steht unmittelbar hinter der
Benutzerkennung, wie es die bereits vorhandenen Methoden vormachen.
Behandle das in Aufgabe 1 gemessene Ergebnis der Konfliktmessung
(Pruefung 5) BEDINGT, nicht vorweggenommen: kommt dort ein harter Fehler
zurueck, uebersetze ihn beim Speichern der Anordnung in eine verstaendliche
deutsche Meldung statt eines rohen Serverfehlers — dem Muster folgend, das der
Bereich `tenders` fuer denselben Fall etabliert hat. Kommt etwas anderes
zurueck, setze KEINE Uebersetzung ein, sondern halte den tatsaechlichen Befund
im SUMMARY fest und trage ihn in Aufgabe 3 als offenen Punkt nach. Die
Entscheidung folgt der Messung, nicht diesem Absatz.
Aendere keine Datei ausserhalb der drei genannten. Fuehre am Ende dieser
Aufgabe einen Falsifizierungsnachweis durch: nimm probeweise EINEN
Bindungsaufruf zurueck, ueberzeuge dich, dass die Testlage rot wird, notiere
Testname und Fehlermeldung woertlich, und stelle den Zustand wieder her.
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && npm --prefix apps/api run test -- src/dashboard/dashboard.service.spec.ts && grep -q '__makeBoundClient' apps/api/src/dashboard/dashboard.service.spec.ts && grep -q "vi.mock('../prisma/prisma-tenant.extension'" apps/api/src/dashboard/dashboard.service.spec.ts && grep -q "from '../prisma/prisma-tenant.extension'" apps/api/src/dashboard/dashboard.service.ts && B=$(grep -o "tenantPrisma\.[a-zA-Z]*\." apps/api/src/dashboard/dashboard.service.ts | wc -l | tr -d ' ') && { test "$B" -ge 9 || { echo "BINDUNG: nur $B gebundene Modellzugriffe in dashboard.service.ts, erwartet mindestens 9"; exit 1; }; } && U=$(grep -o "this\.prisma\.[a-zA-Z]*" apps/api/src/dashboard/dashboard.service.ts | wc -l | tr -d ' ') && { test "$U" -eq 5 || { echo "REST: $U ungebundene Rohtreffer in dashboard.service.ts, erwartet genau 5 (Katalog plus die vier Suchmaschinenzugriffe aus Aufgabe 3)"; exit 1; }; } && { if grep -qE 'tenantPrisma\.module\.' apps/api/src/dashboard/dashboard.service.ts; then echo "KATALOG WURDE GEBUNDEN oder die Begruendung nennt die verbotene Zeichenfolge woertlich"; exit 1; fi; } && { if grep -qE 'tenantPrisma\.searchProvider\.' apps/api/src/dashboard/dashboard.service.ts; then echo "SUCHMASCHINEN GEHOEREN IN AUFGABE 3, nicht hierher"; exit 1; fi; } && C=$(grep -o 'forTenant(this\.prisma' apps/api/src/dashboard/dashboard.service.ts | wc -l | tr -d ' ') && { test "$C" -eq 6 || { echo "KLIENTEN: $C forTenant-Aufrufstellen in dashboard.service.ts, erwartet genau 6 (eine je umgestellter Methode, keine zweite in derselben Methode)"; exit 1; }; } && grep -q 'getAccessibleModuleIds' apps/api/src/module-registry/module-access.service.ts && grep -q 'const tenantPrisma = forTenant(this.prisma, tenantId)' apps/api/src/module-registry/module-access.service.ts && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/dashboard --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && git rev-parse --verify f205120 >/dev/null && PRISMA_CHANGED=$(git diff --name-only f205120 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f205120) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(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|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>`dashboard.service.spec.ts` nutzt den Zwei-Klienten-Nachweis, alle acht bisherigen Faelle sind erhalten und gruen, und die in `<behavior>` genannten Faelle sind abgedeckt — einschliesslich der beiden Faelle, die die Besitzpruefung ueber die Benutzerkennung festnageln, und des Falls, der Lesen und Speichern der Anordnung als gemeinsam gebunden festnagelt. In `dashboard.service.ts` laufen die neun Zugriffe auf Anordnung und Widgets ueber `forTenant()` unter dem Namen `tenantPrisma`, genau ein Klient je Methode; der Katalogzugriff und die vier Suchmaschinenzugriffe sind unveraendert. `dashboard.controller.ts` reicht den bereits aufgeloesten Mandanten durch, ohne eine neue Vertrauensquelle einzufuehren. Die Modul-Zugriffsaufloesung ist nachweislich unangetastet. Der Falsifizierungsnachweis ist durchgefuehrt, zurueckgenommen und mit Testname und Fehlermeldung notiert. Baseline gehalten.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Die Suchmaschinen binden, den Katalog begruendet ungebunden lassen und beide Dokumente samt allen Handzaehlungen nachziehen</name>
<files>apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.controller.ts, docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md</files>
<read_first>apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md</read_first>
<behavior>
Wieder erst die Testlage, dann die Umstellung. Der gebundene Klient des
Nachbaus veroeffentlicht zusaetzlich das Suchmaschinenmodell; der Modulkatalog
bleibt bewusst ausgeschlossen. Neu hinzu, jeder als eigener Fall:
- Suchmaschinen lesen: der Lesezugriff laeuft gebunden. Die drei
Vorgabe-Suchmaschinen aus der Konstanten werden UNVERAENDERT vorangestellt —
sie stammen nicht aus der Datenbank und sind von der Bindung nicht betroffen.
- Suchmaschine anlegen: gebunden, mit der uebergebenen Mandantenkennung, und
die Mandantenkennung wird weiterhin als Pflichtangabe geschrieben. Dieser Fall
ist die Testseite der widerlegten Praemisse: er wird rot, sobald jemand einen
Schreibweg ohne Mandantenkennung einfuehrt.
- Suchmaschine entfernen: BEIDE Abfragen ueber DENSELBEN gebundenen Klienten.
- Die Besitzpruefung bleibt wirksam: die Suchmaschine eines anderen Benutzers
fuehrt weiterhin zu `NotFoundException`, und eine der drei
Vorgabe-Suchmaschinen laesst sich weiterhin nicht entfernen.
- Ein Wachhund, der den Katalogzugriff aus dem Bindungsprotokoll heraushaelt:
er wird rot, sobald jemand den Modulkatalog bindet.
Auch hier gilt: die Zahl der neu hinzugekommenen Faelle wird ABGEZAEHLT und mit
der gezaehlten Zahl im SUMMARY genannt.
</behavior>
<action>
Stelle die vier Zugriffe auf die Suchmaschinen in `dashboard.service.ts` auf
`forTenant()` um, ein Klient je Methode unter dem Namen `tenantPrisma`, und
reiche in `dashboard.controller.ts` bei den beiden noch fehlenden Handlern den
Mandanten durch. Die Besitzpruefungen ueber die Benutzerkennung bleiben stehen.
Lass den EINEN Katalogzugriff ungebunden und schreibe die Begruendung an die
Stelle, mit MESSUNG und BEDINGUNG getrennt, so wie der Bereich
`module-registry` es etabliert hat: die Tabelle traegt heute keinen
Zeilenschutz, eine Bindung waere heute wirkungslos und nicht katastrophal; sie
wuerde katastrophal, WENN Etappe 3 dieser Tabelle eine Regel gibt. Berufe dich
dabei auf die bestehende Werkzeugpruefung beim Namen, statt die Messung neu zu
behaupten.
Ziehe danach `docs/mandantentrennung-zugriffsklassifikation.md` an ALLEN fuenf
handgepflegten Stellen nach — jede einzeln nachgesehen, keine ueberflogen:
1. Die vier Bestandsaufnahme-Zeilen dieses Bereichs: Spalte `Stand` auf den
tatsaechlich gemessenen Wert, und die Begruendungen fortgeschrieben. Die
Katalogzeile bekommt die Trennung von Messung und Bedingung; die
Suchmaschinenzeile bekommt den Hinweis, dass die widerlegte Praemisse in
diesem Durchlauf eigenstaendig nachgeprueft wurde, mit der Reichweite der
Suche.
2. Die Uebersichtszeile dieses Bereichs mit den NEU GEMESSENEN Zahlen, im
etablierten Stil mit dem Vermerk des vorherigen Standes.
3. Die Summenzeile derselben Tabelle.
4. Die Klassen-Verteilung samt der Zahl in ihrer Ueberschrift. Aendert sich
hier nichts, schreibe ausdruecklich hin, dass sich nichts aendert und warum
— eine unveraenderte Tabelle ohne Vermerk ist von einer vergessenen nicht zu
unterscheiden.
5. Den Abschnitt zur Hintergrunddienst-Falle: ergaenze einen PLAIN-Absatz (KEINEN
Aufzaehlungspunkt in der Form der bestehenden Faelle, sonst waere die Zahl in
der Ueberschrift falsch), der festhaelt, dass dieser Bereich keinen
Hintergrunddienst enthaelt, mit der Anweisung, mit der das in Aufgabe 1
gemessen wurde.
Ergaenze zusaetzlich den Abschnitt `Was diese Etappe NICHT entscheidet` um die
Entscheidung dieses Bereichs zur offenen Architekturfrage — er bindet
dienst-intern, wie alle sieben Bereiche vor ihm.
Lege in `.planning/WINDOWS.md` einen neuen OFFENEN Eintrag an, in Tabelle UND
JSON-Block, fuer die beweisvernichtende Schleife aus (w3): das Zusammenspiel
aus Vorgabeanordnung bei fehlendem Datensatz, Neuaufbau durch den Nutzer,
automatischem Zurueckschreiben beim Verlassen des Bearbeitungsmodus und
angehaeuften Widget-Dubletten, mit der konkreten Vorabpruefung fuer Etappe 4
und dem Vermerk, dass er an dieselbe Bedingung gebunden ist wie #18. Faellt
aus Aufgabe 2 ein weiterer offener Punkt an (die bedingte Behandlung des
Konfliktschreibvorgangs), gehoert er ebenfalls ins Register, nicht nur ins
SUMMARY.
Fuehre am Ende zwei weitere Falsifizierungsnachweise durch, jeder
zurueckgenommen und mit Testname beziehungsweise Meldung woertlich notiert:
den Katalogzugriff probeweise binden (der Wachhund muss rot werden) und eine
Bestandsaufnahme-Zeile probeweise auf einen falschen `Stand` setzen (die
maschinelle Bestandspruefung muss rot werden).
</action>
<verify>
<automated>DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs && npm --prefix apps/api run test && npm --prefix apps/api run type-check && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && npm --prefix apps/api run test -- src/dashboard/dashboard.service.spec.ts && U=$(grep -o "this\.prisma\.[a-zA-Z]*" apps/api/src/dashboard/dashboard.service.ts | wc -l | tr -d ' ') && { test "$U" -eq 1 || { echo "KATALOG: $U ungebundene Rohtreffer in dashboard.service.ts, erwartet genau 1 (nur der Katalogzugriff)"; exit 1; }; } && { if grep -qE 'tenantPrisma\.module\.' apps/api/src/dashboard/dashboard.service.ts; then echo "KATALOG WURDE GEBUNDEN oder die Begruendung nennt die verbotene Zeichenfolge woertlich"; exit 1; fi; } && C=$(grep -o 'forTenant(this\.prisma' apps/api/src/dashboard/dashboard.service.ts | wc -l | tr -d ' ') && { test "$C" -eq 9 || { echo "KLIENTEN: $C forTenant-Aufrufstellen, erwartet genau 9 (eine je Dienstmethode mit Datenbankzugriff)"; exit 1; }; } && grep -qE '^\| apps/api/src/dashboard/dashboard\.service\.ts \| dashboardLayout \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/dashboard/dashboard\.service\.ts \| widgetInstance \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/dashboard/dashboard\.service\.ts \| searchProvider \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/dashboard/dashboard\.service\.ts \| module \| keine-mandantengebundene-tabelle \| ungebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && DU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/dashboard | grep -v spec | wc -l | tr -d ' ') && DB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/dashboard | grep -v spec | wc -l | tr -d ' ') && { test "$DU" -lt 13 || { echo "UEBERSICHTSZEILE: ungebundene Rohtreffer in apps/api/src/dashboard sind $DU, also nicht gesunken — es wurde nichts umgestellt"; exit 1; }; } && { test "$DB" -gt 0 || { echo "UEBERSICHTSZEILE: gebundene Rohtreffer in apps/api/src/dashboard sind $DB"; exit 1; }; } && { grep -qE "^\| dashboard \| ${DU} \| ${DB} \| \*\*war 13/0\*\*" docs/mandantentrennung-zugriffsklassifikation.md || { echo "UEBERSICHTSZEILE dashboard nennt nicht die neu gemessenen Zahlen ${DU}/${DB} im etablierten Stil"; exit 1; }; } && awk -F'|' '$2 ~ /^ *[a-z][a-z-]* *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *[0-9]+ *$/ { su+=$3; sb+=$4; n++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 !~ /^ *$/ { g3=$3; g4=$4; gsub(/[^0-9]/,"",g3); gsub(/[^0-9]/,"",g4); ru=g3+0; rb=g4+0; seen=1 } END { if (!seen || n+0 != 12) { print "UEBERSICHTSTABELLE nicht erkannt, Bereichszeilen: " n; exit 1 } if (su+0 != ru || sb+0 != rb) { print "SUMMENZEILE stimmt nicht: Bereichszeilen ergeben " su "/" sb ", Summenzeile nennt " ru "/" rb; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk -F'|' '$2 ~ /^ *apps\/api\/src\// { k=$4; gsub(/^ +| +$/,"",k); cls[k]++; pairs++ } $2 ~ /^ *(muss-mandantengebunden|keine-mandantengebundene-tabelle|beides|bewusst-uebergreifend) *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *$/ { k=$2; gsub(/^ +| +$/,"",k); v=$3; gsub(/[^0-9]/,"",v); tab[k]=v+0; tn++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 ~ /^ *$/ { v=$3; gsub(/[^0-9]/,"",v); tsum=v+0; tseen=1 } /^## Klassen-Verteilung/ { h=$0; gsub(/[^0-9]/,"",h); hp=h+0; hseen=1 } END { if (tn+0 != 4 || !tseen || !hseen) { print "KLASSEN-VERTEILUNG nicht erkannt: Klassenzeilen " tn ", Summenzeile " tseen ", Ueberschrift " hseen; exit 1 } if (tsum != pairs+0) { print "KLASSEN-SUMME stimmt nicht: Bestandsaufnahme hat " pairs " Paare, Tabellensumme nennt " tsum; exit 1 } if (hp != pairs+0) { print "UEBERSCHRIFT der Klassen-Verteilung nennt " hp " Paare, Bestandsaufnahme hat " pairs; exit 1 } s=0; for (k in tab) { if (tab[k] != cls[k]+0) { print "KLASSE " k ": Tabelle nennt " tab[k] ", Bestandsaufnahme zaehlt " cls[k]+0; exit 1 } s+=tab[k] } if (s != pairs+0) { print "KLASSENZEILEN ergeben " s ", Bestandsaufnahme hat " pairs; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk 'BEGIN{split("ein zwei drei vier x sechs sieben acht neun",w," "); w[5]="fünf"} /^## Der Hintergrunddienst als Falle/{seen=1; head=$0; f=1; next} /^## /{f=0} f && /^- \*\*`/{n++} f && /^\*\*Der .* Fall, anderer Bauart/{n++} f && /dashboard/{m=1} END{ if(!seen){print "ABSCHNITT Hintergrunddienst nicht gefunden"; exit 1} want="## Der Hintergrunddienst als Falle — " w[n] " Fälle"; if(head != want){printf "HINTERGRUNDDIENST-UEBERSCHRIFT nennt \"%s\", gezaehlt wurden %d Faelle, erwartet \"%s\"\n", head, n, want; exit 1} if(!m){print "ABSCHNITT Hintergrunddienst nennt diesen Bereich nicht — die Abwesenheit eines sechsten Falls ist nicht belegt"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} f && /dashboard/{m=1} END{ if(!m){print "ABSCHNITT \"Was diese Etappe NICHT entscheidet\" nennt den Bereich dashboard nicht"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && python3 -c "
import re,sys
s=open('.planning/WINDOWS.md',encoding='utf-8').read()
rows=[l for l in s.splitlines() if re.match(r'^\| \d+ \|', l)]
ids=re.findall(r'\"id\": (\d+),', s)
if len(rows)!=len(ids): print('WINDOWS: %d Tabellenzeilen, %d JSON-Eintraege' % (len(rows), len(ids))); sys.exit(1)
if not any(re.match(r'^\| 25 \|', l) for l in rows): print('WINDOWS: Eintrag 25 fehlt in der Tabelle'); sys.exit(1)
if '25' not in ids: print('WINDOWS: Eintrag 25 fehlt im JSON-Block'); sys.exit(1)
if 'dashboard' not in s: print('WINDOWS: kein Eintrag nennt diesen Bereich'); sys.exit(1)
" && test 0 -eq "$(grep -rn '\$transaction(' apps/api/src/dashboard --include=*.ts | grep -v spec | wc -l | tr -d ' ')" && git rev-parse --verify f205120 >/dev/null && PRISMA_CHANGED=$(git diff --name-only f205120 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only f205120) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|docs/mandantentrennung-zugriffsklassifikation\.md|apps/api/src/dashboard/dashboard\.service\.ts|apps/api/src/dashboard/dashboard\.service\.spec\.ts|apps/api/src/dashboard/dashboard\.controller\.ts|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; }</automated>
</verify>
<done>In `dashboard.service.ts` laufen alle zwoelf mandantengebundenen Zugriffe ueber `forTenant()` unter dem Namen `tenantPrisma`, genau ein Klient je Dienstmethode, und der eine Katalogzugriff bleibt ungebunden mit einer Begruendung, die Messung und Bedingung trennt und sich auf die bestehende Werkzeugpruefung beim Namen beruft. Alle fuenf handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und maschinell gegatet: vier Bestandsaufnahme-Zeilen, Uebersichtszeile mit neu gemessenen Zahlen, Summenzeile, Klassen-Verteilung samt Ueberschrift, Hintergrunddienst-Abschnitt mit dem Messbeleg fuer die Abwesenheit eines sechsten Falls; dazu der Abschnitt `Was diese Etappe NICHT entscheidet`. `.planning/WINDOWS.md` traegt den neuen offenen Eintrag zur beweisvernichtenden Schleife in Tabelle und JSON-Block. Die maschinelle Bestandspruefung ist gruen. Beide weiteren Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und woertlich notiert. Baseline gehalten, Schalter unveraendert aus.</done>
</task>
</tasks>
<threat_model>
Konfiguriert: ASVS-Stufe 1, blockierend ab `high`.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser/Benutzer → Dashboard-API | Benutzerkennung und Mandantenkennung stammen ausschliesslich aus dem validierten Sitzungsnachweis (`extractContext` liest `request.user` beziehungsweise den vom Wächter gesetzten Mandanten), nie aus Rumpf oder Pfadparametern. Die Widget- und Suchmaschinenkennung im Pfad ist dagegen frei waehlbare Nutzereingabe. |
| Nutzer A → Anordnung, Widgets und Suchmaschinen von Nutzer B DESSELBEN Mandanten | Die Grenze, die die Datenbank in diesem Bereich NACHWEISLICH nicht zieht — alle drei Regeln kennen nur die Mandantendimension. Gezogen wird sie allein von den drei anwendungsseitigen Besitzpruefungen ueber die Benutzerkennung. |
| Mandant A → Zeilen des Mandanten B | Die Grenze, um die es in diesem Plan geht. Heute ausschliesslich von der Anwendung gezogen, nach diesem Plan zusaetzlich von der Datenbank — aber erst wirksam nach Etappe 4. |
| API → PostgreSQL | Die Zeilenschutz-Grenze. Heute wirkungslos, weil die Rolle das Umgehungsrecht traegt (WINDOWS #18) — dieser Plan bereitet sie vor, schaltet sie NICHT scharf. |
| API → plattformweiter Modulkatalog | Eine bewusst durchlaessige Grenze: der Katalog gehoert keinem Mandanten und traegt heute keinen Zeilenschutz. |
| Nutzer → externe Suchmaschine | Die Grenze, die das Such-Widget im Browser ueberschreitet. Welche Suchmaschine eine eingegebene Zeichenfolge erhaelt, haengt an der Liste, die dieser Bereich liefert. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-KRX-01 | Information Disclosure | `dashboard.service.ts`, `getLayout`/`getWidgets`/`getSearchProviders` | high | mitigate | Quer-Lesen der Anordnung, der platzierten Widgets oder der eigenen Suchmaschinen eines fremden Mandanten. Alle drei Lesepfade werden gebunden; die bestehende Filterung ueber die Benutzerkennung bleibt zusaetzlich stehen. Gemessen in Aufgabe 1: `dashboardlayout-gebunden-nur-eigener-mandant`, `widgetinstance-gebunden-nur-eigener-mandant`, `searchprovider-gebunden-nur-eigener-mandant`. |
| T-KRX-02 | Information Disclosure | Alle drei Regeln dieses Bereichs | high | accept | Quer-Lesen zwischen zwei Nutzern DESSELBEN Mandanten. Die Regeln kennen keine Benutzerdimension — gemessen in Aufgabe 1 (`*-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`, das Gelingen IST das Ergebnis). Bewusst akzeptiert und aufgezeichnet: der Schutz bleibt vollstaendig bei der anwendungsseitigen Filterung ueber die Benutzerkennung, die dieser Plan ausdruecklich NICHT entfernt und als Testfall festnagelt. Eine Benutzerdimension in der Regel waere eine Schemafrage und gehoert nach Etappe 3. |
| T-KRX-03 | Tampering | `dashboard.service.ts`, `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` | high | mitigate | Quer-Loeschen oder Quer-Aendern ueber die Kennung im Pfad — die Form, die im Bereich `ldap` die erste echte Luecke dieses Vorhabens war. Hier existiert die Besitzpruefung tatsaechlich (Befund D, zur Ausfuehrungszeit erneut nachzupruefen), sie wird durch die Bindung ERGAENZT, und beide Abfragen jedes Paares laufen ueber DENSELBEN gebundenen Klienten, damit die Pruefung nicht auf einer Zeile aufgeht, die der Schreibvorgang nicht mehr sieht. Als zwei Testfaelle je Pfad festgenagelt; datenbankseitig gemessen mit `widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile`. |
| T-KRX-04 | Denial of Service | `dashboard.service.ts`, `getLayout`, plus `dashboard-store.ts` im Web | high | mitigate | Die umgekehrte Fehlerrichtung mit Beweisvernichtung: ein zu kleines Leseergebnis liefert die Vorgabeanordnung statt eines Fehlers, der Nutzer baut neu auf, und das automatische Zurueckschreiben beim Verlassen des Bearbeitungsmodus ueberschreibt die urspruengliche Zeile, bevor jemand die Ursache untersuchen kann. Vollstaendige, gemeinsame Bindung von Lesen und Speichern; Signaltabelle und namentliche Liste in der Kritikschrift; offener Ledger-Eintrag mit der Vorabpruefung fuer Etappe 4. Das Frontend wird NICHT geaendert — die Kette wird beschrieben, nicht unterbrochen. |
| T-KRX-05 | Information Disclosure | `search-widget.tsx`, Rueckfall auf den ersten Eintrag | medium | accept | Verschwinden die eigenen Suchmaschinen aus der Liste, faellt die Auswahl auf den ersten Eintrag (eine externe Suchmaschine) zurueck — eine fuer ein internes Werkzeug gedachte Suchanfrage ginge dann nach draussen. Reichweite in Aufgabe 1 nachzumessen (wie sichtbar der Rueckfall in der Auswahlliste ist). Bewusst akzeptiert und aufgezeichnet statt hier behoben: das Frontend ist nicht Gegenstand dieses Plans, und der Fall wird durch die vollstaendige Bindung des Lesepfads gar nicht erst erreicht. Festgehalten in (w3) und in der Signaltabelle. |
| T-KRX-06 | Tampering | `DashboardLayout.userId`, plattformweit eindeutig | medium | accept | Die Kette aus den Bereichen `tenders` und `user`: unsichtbare Zeile, falsches "frei", harter Eindeutigkeitsfehler — hier strukturell moeglich, weil der Eindeutigkeitsschluessel keinen Mandantenanteil traegt. In Aufgabe 1 GEMESSEN statt angenommen (`dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut`), in Aufgabe 2 BEDINGT behandelt (verstaendliche Meldung statt rohem Serverfehler). Ueber einen Anwendungspfad heute nicht erreichbar (die Mandantenkennung eines Benutzers aendert sich nach der Anlage nicht, gemessen); die ehrliche Reparatur waere eine Schemaaenderung und gehoert als Produktentscheidung nach Etappe 3, wie WINDOWS #22 sie fuer denselben Fall bereits vormerkt. |
| T-KRX-07 | Elevation of Privilege | `dashboard.controller.ts`, Durchreichen des Mandanten | high | mitigate | Die Mandantenkennung koennte beim Umbau versehentlich aus Rumpf oder Pfad statt aus dem Sitzungsnachweis genommen werden. Die Handler nehmen sie unveraendert aus `extractContext`, das sie aus dem validierten Sitzungsnachweis liest und ohne Mandant mit `ForbiddenException` abbricht — es entsteht keine neue Vertrauensquelle. Als Negativ-Gate abgesichert: keine Datei ausserhalb der drei genannten wird geaendert. |
| T-KRX-08 | Information Disclosure | Modulkatalog (`Module`) | medium | mitigate | Der plattformweite Katalog wird versehentlich gebunden und verschwindet, sobald Etappe 3 ihm eine Regel gibt. Negativ-Gate in beiden Verifikationen plus ein Wachhund-Testfall, der jeden Katalogzugriff aus dem Bindungsprotokoll heraushaelt. |
| T-KRX-09 | Tampering | `SearchProvider`, nullbare Mandantenkennung | medium | mitigate | Eine kuenftige mandantenlose Zeile waere unter der strengen Regel fuer JEDEN Mandanten unsichtbar — die Praemisse von WINDOWS #19, fuer dieses Modell widerlegt. In diesem Durchlauf EIGENSTAENDIG nachgeprueft (Befund F, mit benannter Reichweite) statt aus 260910-jab abgeschrieben, und zusaetzlich datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt`. Faellt die Nachpruefung anders aus, wird der Befund umgedreht statt uebergangen. |
| T-KRX-10 | Repudiation | `getLayout`, `getWidgets`, `getSearchProviders` | medium | accept | Es gibt kein Signal, das "noch nichts eingerichtet" von "nicht sichtbar" unterscheidet — beide liefern eine leere Antwort mit Status 200 und keinen Protokolleintrag. Bewusst akzeptiert und AUFGEZEICHNET statt durch eine Laufzeitwarnung ueberdeckt, die auf einer frischen Installation Dauerlaerm waere (dieselbe Begruendung wie bei `getAllActiveConfigs` im Bereich `ldap`). Das Signal gehoert in die Vorabpruefung von Etappe 4, und als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein Signal einbaut. |
| T-KRX-11 | Spoofing | Sitzungsnachweis | low | accept | Mandanten- oder Benutzerkennung aus Rumpf oder Pfad statt aus dem Sitzungsnachweis. Bereits in Phase 05 und 15 behandelt (T-05-01/T-05-02, T-15-10); dieser Plan aendert daran nichts und schwaecht es nicht. |
| T-KRX-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt damit nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. |
</threat_model>
<verification>
Nach Abschluss aller drei Aufgaben:
1. `node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen
bestanden (74 bisherige plus die neuen), Rueckgabewert 0 — einschliesslich
der bereits vorhandenen Suchmaschinen-Pruefung, die unveraendert bestehen
bleibt.
2. `npm --prefix apps/api run test` meldet mindestens 839 Tests gruen in
mindestens 56 Dateien.
3. `npm --prefix apps/api run type-check` ist sauber.
4. `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts`
ist gruen — die Bestandsaufnahme stimmt mit dem Quelltext ueberein.
5. Alle fuenf handgepflegten Stellen der Klassifikation sind maschinell gegatet
und gruen, und die Zaehlgates LEITEN ihre Werte aus den im Dokument selbst
genannten Messanweisungen ab, statt sie fest einzutragen.
6. Der Umfang ist als ERLAUBNISLISTE gegatet, nicht als Verbotsliste: jede
Datei, die sich gegenueber dem Ausgangsstand `f205120` geaendert hat, muss
eine der sieben in `files_modified` genannten sein (oder unter
`.planning/` liegen). Damit faellt jede Streuaenderung auf — auch eine, an
die eine Verbotsliste nicht gedacht haette. Zusaetzlich ausdruecklich:
unter `apps/api/prisma` hat sich nichts geaendert. Beide Pruefungen laufen
gegen den Ausgangsstand statt gegen `HEAD` und gelten deshalb auch,
nachdem eine Aufgabe bereits eingecheckt wurde.
7. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`.
</verification>
<success_criteria>
- Die dreizehn Zugriffe des Bereichs sind vollstaendig entschieden: zwoelf
gebunden, einer begruendet ungebunden, keiner unentschieden.
- Keine Tabelle dieses Bereichs wird halb gebunden: Lesen und Schreiben
derselben Zeile laufen nie auf verschiedenen Seiten der Bindung, und keine
Methode erzeugt mehr als einen gebundenen Klienten.
- Die Begruendung des ungebundenen Zugriffs trennt Messung von Bedingung — sie
behauptet nichts, was heute nicht gilt, und verschweigt nicht, was morgen
gelten wuerde.
- Die umgekehrte Fehlerrichtung ist an den echten, ausgelieferten Regeln in
ihrem AKTUELLEN Stand gemessen und je Pfad mit ihrem konkreten Signal
beschrieben.
- Die beweisvernichtende Schleife ist benannt, an den tatsaechlichen Dateien
belegt und als offener Ledger-Eintrag mit einer konkreten Vorabpruefung
festgehalten — nicht als Sorge formuliert.
- Die widerlegte Praemisse zu den Suchmaschinen ist eigenstaendig nachgeprueft,
mit benannter Reichweite der Suche, und datenbankseitig zusaetzlich
verteidigt.
- Die geerbte Entlastung ist nachgeprueft und nicht ein zweites Mal repariert.
- Alle drei Falsifizierungsnachweise sind durchgefuehrt, zurueckgenommen und im
SUMMARY mit Testname und Fehlermeldung festgehalten — nicht als Behauptung.
- Jede im SUMMARY genannte Zahl (neue Werkzeugpruefungen, neue Testfaelle,
umgestellte Zugriffe) ist ABGEZAEHLT, nicht aus diesem Plan abgeschrieben.
- Baseline gehalten am Ende jeder Aufgabe, nicht nur am Ende des Plans.
- Der Schalter ist weiterhin AUS.
</success_criteria>
<output>
Create `.planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-SUMMARY.md` when done
</output>