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

69 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260910-krx 01 execute 1
true
WINDOWS-18
ETAPPE-2-DASHBOARD
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
tokens raw_tokens tasks confidence
185000 185000 3 low
truths artifacts key_links
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.
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
`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.
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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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

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

Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — an den Regeln, wie sie nach der Migration 20260910120000 stehen 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. apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md 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 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. 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; }; } 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.

Aufgabe 2: Anordnung und Widgets binden — erst die Testlage, dann die Umstellung, Lesen und Schreiben nie getrennt 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/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 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. 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. 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; }; } 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.

Aufgabe 3: Die Suchmaschinen binden, den Katalog begruendet ungebunden lassen und beide Dokumente samt allen Handzaehlungen nachziehen 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 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 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. 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). 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.tslaufen alle zwoelf mandantengebundenen Zugriffe ueberforTenant()unter dem NamentenantPrisma, 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.mdsind 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 AbschnittWas 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.

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

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.

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

Create `.planning/quick/260910-krx-mandantentrennung-etappe-2-bereich-dashb/260910-krx-SUMMARY.md` when done