From 6744918a013384270e684ada0ba8c2a1cf44725d Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 08:46:46 +0200 Subject: [PATCH] feat(quick-260910-krx): Fehlerrichtung fuer Bereich dashboard gemessen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufgabe 1 — misst die umgekehrte Fehlerrichtung des Bereichs dashboard an den Regeln nach Migration 20260910120000, VOR der Umstellung: - rls-scratch-check.mjs bekommt einen neunten Abschnitt (runDashboardAreaChecks) mit 13 neuen, namentlich benannten Pruefungen gegen die aus der ausgelieferten Migration geschnittenen Regeln fuer DashboardLayout, WidgetInstance und SearchProvider. Alle 87 Pruefungen bestehen (74 bisherige + 13 neue). - Die Konfliktmessung (Befund K) ist gemessen, nicht angenommen: ein gebundenes INSERT ... ON CONFLICT auf eine unter dem Mandanten unsichtbare Zeile scheitert laut mit SQLSTATE 42501. Zusaetzlich am echten generierten Prisma Client gemessen: prisma.dashboardLayout.upsert() wirft PrismaClientUnknownRequestError (nicht P2002) — das tenders-Muster laesst sich deshalb nicht woertlich uebernehmen. - Die widerlegte Praemisse zu SearchProvider (WINDOWS #19) ist in diesem Durchlauf eigenstaendig nachgeprueft, mit benannter Suchreichweite. - docs/mandantentrennung-etappe2-fehlerrichtung.md bekommt den Abschnitt "## Bereich dashboard" (w1)-(w5) samt der beweisvernichtenden Schleife (leeres Dashboard -> Neuaufbau -> automatisches Zurueckschreiben -> ueberschriebene Anordnung) und einen Nachtrag im Abschnitt "## Bereich module-registry" zur geerbten Bindungsentlastung. Baseline gehalten: 839 Tests / 56 Dateien gruen, Typpruefung sauber. Schalter bleibt aus. --- apps/api/scripts/rls-scratch-check.mjs | 325 ++++++++++++++++++ ...andantentrennung-etappe2-fehlerrichtung.md | 273 +++++++++++++++ 2 files changed, 598 insertions(+) diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index e3f46b1..7f9d90e 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -2393,6 +2393,330 @@ async function runModuleRegistryAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Aufgabe 1 (260910-krx) — misst die dreizehn im Plan genannten + * Verhaltensweisen des Bereichs `dashboard` unter der Rolle ohne BYPASSRLS, + * mit den drei Policies fuer "DashboardLayout", "WidgetInstance" und + * "SearchProvider" WORTGLEICH aus der ausgelieferten + * *_rls_remaining_tenant_tables-Migration geschnitten (nicht im Werkzeug + * nachgetippt). Findet die Extraktion eine der drei nicht, meldet dieser + * Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer + * geratenen Regel weiterzumessen — wie alle vorherigen Abschnitte. + * + * Legt die Tabellen "DashboardLayout" und "WidgetInstance" selbst neu an + * (bisher von keinem Abschnitt gebraucht). Die Tabelle "SearchProvider" + * dagegen wird von runSearchProviderAreaChecks() bereits angelegt, samt + * eingeschaltetem und erzwungenem Zeilenschutz, der unveraendert strengen + * Regel und der einen mandantenlosen Zeile 'search-tenantless' — dieser + * Abschnitt legt sie NICHT ein zweites Mal an, sondern ERGAENZT nur weitere + * Zeilen. Muss deshalb NACH runSearchProviderAreaChecks() laufen (in main() + * bereits der Fall: runSearchProviderAreaChecks() steht deutlich frueher in + * der Aufrufkette) und VOR runTransactionShapeMeasurement(), das weiterhin + * auf der von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — + * dieser Abschnitt aendert daran nichts. Die bestehende Pruefung + * `searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar` + * und ihre Testzeile ('search-tenantless') bleiben unveraendert; alle hier + * neu vergebenen Kennungen sind eigene, damit sie nicht kollidieren. + * + * "DashboardLayout" bildet die Eindeutigkeitsbedingung des Schemas + * (`"userId" text UNIQUE`) nach, weil Pruefung 5 (die Konfliktmessung) ohne + * sie nicht stattfinden kann. + */ +async function runDashboardAreaChecks(adminUrl, scratchRoleUrl, results) { + const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); + + const dashboardLayoutPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'DashboardLayout') + : null; + const widgetInstancePolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'WidgetInstance') + : null; + const searchProviderPolicy = remainingMigrationSql + ? extractPolicySql(remainingMigrationSql, 'SearchProvider') + : null; + + if (!dashboardLayoutPolicy || !widgetInstancePolicy || !searchProviderPolicy) { + report( + results, + 'dashboard-policies-aus-migration-gefunden', + false, + 'CREATE POLICY fuer "DashboardLayout", "WidgetInstance" und/oder "SearchProvider" nicht in der ausgelieferten *_rls_remaining_tenant_tables-Migration gefunden', + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(` + CREATE TABLE "DashboardLayout" ( + id text PRIMARY KEY, + "userId" text NOT NULL UNIQUE, + "tenantId" text NOT NULL, + layouts jsonb NOT NULL DEFAULT '{}'::jsonb + ); + `); + await db.$executeRawUnsafe(` + CREATE TABLE "WidgetInstance" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + "widgetType" text NOT NULL + ); + `); + + for (const table of ['DashboardLayout', 'WidgetInstance']) { + await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`); + } + await db.$executeRawUnsafe(dashboardLayoutPolicy); + await db.$executeRawUnsafe(widgetInstancePolicy); + + for (const table of ['DashboardLayout', 'WidgetInstance']) { + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`, + ); + } + + // Zwei Zeilen unter TENANT-A mit VERSCHIEDENEN Benutzerkennungen (Befund + // G: die Regel kennt keine Benutzerdimension) plus eine Zeile unter + // TENANT-B fuer die Mandantengrenze. + await db.$executeRawUnsafe(` + INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES + ('layout-a1', 'user-a1', 'TENANT-A', '{}'::jsonb), + ('layout-a2', 'user-a2', 'TENANT-A', '{}'::jsonb), + ('layout-b1', 'user-b1', 'TENANT-B', '{}'::jsonb); + `); + // Physisch vorhandene, unter TENANT-A unsichtbare Zeile fuer Pruefung 5 + // (die Konfliktmessung) — ueber die Wartungsrolle angelegt, weil sich + // eine mandantenfremde Zeile unter der Anwendungsrolle ohnehin nicht + // schreiben liesse. + await db.$executeRawUnsafe(` + INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES + ('layout-conflict-target', 'user-conflict', 'TENANT-B', '{}'::jsonb); + `); + + await db.$executeRawUnsafe(` + INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES + ('widget-a1', 'user-a1', 'TENANT-A', 'clock'), + ('widget-a2', 'user-a2', 'TENANT-A', 'search'), + ('widget-b1', 'user-b1', 'TENANT-B', 'clock'); + `); + + // Weitere Zeilen auf der bereits vorhandenen Tabelle "SearchProvider" + // (runSearchProviderAreaChecks) — eigene Kennungen, die bestehende Zeile + // 'search-tenantless' bleibt unberuehrt. + await db.$executeRawUnsafe(` + INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES + ('search-a1', 'user-a1', 'TENANT-A', 'Interne Suche A1'), + ('search-a2', 'user-a2', 'TENANT-A', 'Interne Suche A2'), + ('search-b1', 'user-b1', 'TENANT-B', 'Interne Suche B1'); + `); + }); + + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 1 + 3: dashboardlayout-gebunden-nur-eigener-mandant UND + // dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar — + // eine Abfrage, zwei Aussagen. Die Pruefung 3 bestehen zu lassen IST das + // erwartete Ergebnis: die Regel kennt keine Benutzerdimension. + const layoutRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "DashboardLayout" ORDER BY id`, + ); + report( + results, + 'dashboardlayout-gebunden-nur-eigener-mandant', + layoutRowsForA.length === 2 && layoutRowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${layoutRowsForA.length} Zeile(n): ${JSON.stringify(layoutRowsForA.map((r) => r.id))}`, + ); + const secondUserVisible = layoutRowsForA.some((r) => r.userId === 'user-a2'); + report( + results, + 'dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + secondUserVisible, + `forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${secondUserVisible} — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten`, + ); + + // 2: dashboardlayout-ungebunden-null-zeilen — die Belegzeile dieses + // Abschnitts. + const unboundLayoutRows = await prisma.$queryRaw`SELECT "tenantId" FROM "DashboardLayout"`; + report( + results, + 'dashboardlayout-ungebunden-null-zeilen', + unboundLayoutRows.length === 0, + `ungebundener SELECT auf "DashboardLayout" liefert ${unboundLayoutRows.length} Zeile(n), tatsaechlich vorhanden sind 4`, + ); + + // 4 + 13: widgetinstance-ungebunden-null-zeilen und + // widgetinstance-gebunden-nur-eigener-mandant / -fremder-nutzer-... + const unboundWidgetRows = await prisma.$queryRaw`SELECT "tenantId" FROM "WidgetInstance"`; + report( + results, + 'widgetinstance-ungebunden-null-zeilen', + unboundWidgetRows.length === 0, + `ungebundener SELECT auf "WidgetInstance" liefert ${unboundWidgetRows.length} Zeile(n), tatsaechlich vorhanden sind 3`, + ); + + const widgetRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "WidgetInstance" ORDER BY id`, + ); + report( + results, + 'widgetinstance-gebunden-nur-eigener-mandant', + widgetRowsForA.length === 2 && widgetRowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${widgetRowsForA.length} Zeile(n): ${JSON.stringify(widgetRowsForA.map((r) => r.id))}`, + ); + const widgetSecondUserVisible = widgetRowsForA.some((r) => r.userId === 'user-a2'); + report( + results, + 'widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + widgetSecondUserVisible, + `forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${widgetSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs`, + ); + + // 5: dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare- + // zeile-scheitert-laut — MISST, was ein gebundenes Einfuegen mit + // Konfliktbehandlung tut, wenn es auf eine physisch vorhandene, unter + // dem laufenden Mandanten unsichtbare Zeile trifft ('layout-conflict- + // target', TENANT-B). Bestanden ist diese Pruefung genau dann, wenn ein + // HARTER, benannter Fehler zurueckkommt — NICHT, wenn der Vorgang still + // gelingt, eine fremde Zeile aendert oder eine Dublette erzeugt. Das + // Ergebnis wird NICHT vorweggenommen: es haengt am Zusammenspiel von + // Eindeutigkeitsindex und Regel. + let conflictRejectedLoudly = false; + let conflictDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "DashboardLayout" (id, "userId", "tenantId", layouts) VALUES ('layout-conflict-attempt', 'user-conflict', 'TENANT-A', '{}'::jsonb) ON CONFLICT ("userId") DO UPDATE SET layouts = EXCLUDED.layouts`, + ); + conflictDetail = + 'gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) ist NICHT fehlgeschlagen — still gelungen oder eine Dublette erzeugt'; + } catch (err) { + conflictRejectedLoudly = true; + const sqlState = sqlStateOf(err); + conflictDetail = `gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE ${sqlState ?? 'unbekannt'}: ${err.message.trim()}`; + } + report( + results, + 'dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut', + conflictRejectedLoudly, + conflictDetail, + ); + + // 6: widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt + let foreignWidgetInsertRejected = false; + let foreignWidgetInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "WidgetInstance" (id, "userId", "tenantId", "widgetType") VALUES ('widget-rejected', 'user-a1', 'TENANT-B', 'clock')`, + ); + foreignWidgetInsertDetail = + 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen'; + } catch (err) { + const sqlState = sqlStateOf(err); + foreignWidgetInsertRejected = sqlState === '42501'; + foreignWidgetInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`; + } + report( + results, + 'widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt', + foreignWidgetInsertRejected, + foreignWidgetInsertDetail, + ); + + // 7: widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile + // — die Datenbankseite von Befund D: ein gebundenes DELETE ueber die + // Kennung einer fremden Zeile (widget-b1, TENANT-B) entfernt nichts und + // meldet keinen Fehler. + const foreignWidgetDeleteAffected = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$executeRaw`DELETE FROM "WidgetInstance" WHERE id = 'widget-b1'`, + ); + report( + results, + 'widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile', + foreignWidgetDeleteAffected === 0, + `gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft ${foreignWidgetDeleteAffected} Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten`, + ); + + // 8 + 11: searchprovider-ungebunden-null-zeilen und + // searchprovider-gebunden-nur-eigener-mandant / + // -fremder-nutzer-desselben-mandanten-gebunden-sichtbar — gemessen + // gegen die neu hinzugefuegten Zeilen (search-a1/search-a2/search-b1), + // NICHT gegen 'search-tenantless' (bereits durch die bestehende + // Pruefung abgedeckt). + const actualSearchProviderCount = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const [row] = await db.$queryRaw`SELECT count(*)::int AS n FROM "SearchProvider"`; + return row.n; + }, + ); + const unboundSearchProviderRows = await prisma.$queryRaw`SELECT "tenantId" FROM "SearchProvider"`; + report( + results, + 'searchprovider-ungebunden-null-zeilen', + unboundSearchProviderRows.length === 0, + `ungebundener SELECT auf "SearchProvider" liefert ${unboundSearchProviderRows.length} Zeile(n), tatsaechlich vorhanden sind ${actualSearchProviderCount}`, + ); + + const searchProviderRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => + tx.$queryRaw`SELECT id, "userId", "tenantId" FROM "SearchProvider" WHERE id IN ('search-a1', 'search-a2', 'search-b1') ORDER BY id`, + ); + report( + results, + 'searchprovider-gebunden-nur-eigener-mandant', + searchProviderRowsForA.length === 2 && + searchProviderRowsForA.every((r) => r.tenantId === 'TENANT-A'), + `forTenant(TENANT-A) liefert ${searchProviderRowsForA.length} Zeile(n) aus den neu hinzugefuegten: ${JSON.stringify(searchProviderRowsForA.map((r) => r.id))}`, + ); + const searchProviderSecondUserVisible = searchProviderRowsForA.some( + (r) => r.userId === 'user-a2', + ); + report( + results, + 'searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar', + searchProviderSecondUserVisible, + `forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: ${searchProviderSecondUserVisible} — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G)`, + ); + + // 12: searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt — die + // Verteidigung der widerlegten Praemisse aus Befund F: selbst wenn ein + // kuenftiger Schreibweg es versuchte, kaeme er unter der + // Anwendungsrolle nicht durch, weil die Regel ohne eigene WITH-CHECK- + // Klausel die USING-Klausel dafuer wiederverwendet. + let searchProviderNoTenantInsertRejected = false; + let searchProviderNoTenantInsertDetail = ''; + try { + await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => + tx.$executeRaw`INSERT INTO "SearchProvider" (id, "userId", "tenantId", name) VALUES ('search-rejected-no-tenant', 'user-a1', NULL, 'Sollte abgewiesen werden')`, + ); + searchProviderNoTenantInsertDetail = + 'gebundenes INSERT unter TENANT-A mit tenantId=NULL ist NICHT fehlgeschlagen'; + } catch (err) { + const sqlState = sqlStateOf(err); + searchProviderNoTenantInsertRejected = sqlState === '42501'; + searchProviderNoTenantInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE ${sqlState ?? 'unbekannt'} (${err.message.trim()})`; + } + report( + results, + 'searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt', + searchProviderNoTenantInsertRejected, + searchProviderNoTenantInsertDetail, + ); + } finally { + await prisma.$disconnect(); + } +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -2628,6 +2952,7 @@ async function main() { await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results); await runUserAreaChecks(adminUrl, scratchRoleUrlString, results); await runModuleRegistryAreaChecks(adminUrl, scratchRoleUrlString, results); + await runDashboardAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results); } finally { diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index b81cfc1..c33ad8d 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -1611,6 +1611,21 @@ nichts gefunden" unterscheidet, mit der Vorabprüfung für Etappe 4 und der begründeten Verwerfung einer Laufzeitwarnung — siehe (m3) oben und `.planning/WINDOWS.md`. +**Nachtrag (260910-krx):** der oben in Befund E festgehaltene Befund zur +Reihenfolge — der Bereich `dashboard` erbt die Bindung der +Modul-Zugriffsauflösung, ohne dass eine Datei unter +`apps/api/src/module-registry` dafür angefasst werden muss — ist mit +Quick-Task 260910-krx EINGELÖST und NACHGEPRÜFT: `dashboard.service.ts` +ruft `ModuleAccessService.getAccessibleModuleIds` für den Widget-Modulfilter +in `getWidgets` unverändert auf, diese Auflösung bindet seit 260910-exd +bereits über `forTenant()`, und der Filter ist damit gebunden. Nachgeprüft +mit `grep -n "const tenantPrisma = forTenant" apps/api/src/module-registry/ +module-access.service.ts` (ein Treffer) und mit einem Wachhund-Testfall in +`dashboard.service.spec.ts`, der den Modulkatalog aus dem +Bindungsprotokoll heraushält. Der Widget-Modulfilter wurde von 260910-krx +NICHT ein zweites Mal gebunden — keine Datei unter +`apps/api/src/module-registry` ist Teil dieses Plans. + ## Regelschluss T-JTS-02, T-JTS-03 und WINDOWS #19 Dieser Abschnitt weicht bewusst von der geplanten Reihenfolge ab (260910-jab, @@ -1727,6 +1742,264 @@ Tabellen, inklusive der NEUEN Stelle aus Befund F: Regeln sind heute wirkungslos; das Wegwerf-Werkzeug und die Regelliste der lebenden Datenbank sind die einzigen Zeugen dafür, dass sie greifen. +## Bereich dashboard + +Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dashboard` +(Quick-Task 260910-krx), den achten Bereich der Etappe und den einzigen +Dienst, der ausschließlich hält, was ein Nutzer sich selbst eingerichtet +hat: die Anordnung seiner Widgets und seine eigenen Suchmaschinen. Die +umgekehrte Fehlerrichtung sieht hier nicht wie ein Fehler aus, sondern wie +ein Zurücksetzen — und sie ist in diesem Bereich beweisvernichtend, siehe +(w3). + +### (w1) Die Messung + +Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen neunten +Abschnitt (`runDashboardAreaChecks`) erweitert, unmittelbar nach +`runModuleRegistryAreaChecks` und vor `runTransactionShapeMeasurement` +aufgerufen. Er legt die beiden Wegwerf-Tabellen `DashboardLayout` und +`WidgetInstance` selbst neu an und benutzt die von +`runSearchProviderAreaChecks` bereits angelegte Tabelle `SearchProvider` +WEITER (eigene Kennungen, keine zweite Anlage) — alle drei Policies mit +`extractPolicySql()` WORTGLEICH aus der ausgelieferten Migration +`20260909140000_rls_remaining_tenant_tables` geschnitten, **gemessen am +Regelstand NACH der Migration `20260910120000_rls_widen_membership_grant_and_platform_read`** +(die drei Regeln dieses Bereichs sind von jener Migration unverändert +gelassen worden, siehe deren Abschnitt (4) — die Messung gilt trotzdem dem +aktuellen, lebenden Regelstand, nicht einem veralteten). Tatsächlich +beobachtete Ausgabe dieses Laufs (2026-09-11, gegen `tessera-ctl-db-1`, +Adresse `172.19.0.2`, nur die dreizehn neuen Zeilen dieses Abschnitts sowie +die abschließende Summenzeile): + +``` +dashboardlayout-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["layout-a1","layout-a2"] +dashboardlayout-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Anordnung von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — die Regel auf "DashboardLayout" kennt keine Benutzerdimension, die anwendungsseitige Pruefung ueber die Benutzerkennung bleibt deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten +dashboardlayout-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DashboardLayout" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4 +widgetinstance-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "WidgetInstance" liefert 0 Zeile(n), tatsaechlich vorhanden sind 3 +widgetinstance-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["widget-a1","widget-a2"] +widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert das Widget von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "SearchProvider" (Befund G), der dritte der drei Faelle dieses Bereichs +dashboardlayout-gebundener-konfliktschreibvorgang-auf-unsichtbare-zeile-scheitert-laut: bestanden — gebundenes INSERT ... ON CONFLICT ("userId") DO UPDATE unter TENANT-A auf die unter TENANT-B physisch vorhandene, unsichtbare Zeile (user-conflict) scheitert LAUT mit SQLSTATE 42501: ERROR: new row violates row-level security policy (USING expression) for table "DashboardLayout" +widgetinstance-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "WidgetInstance") +widgetinstance-gebundenes-loeschen-fremder-zeile-trifft-keine-zeile: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'widget-b1' (gehoert TENANT-B) trifft 0 Zeile(n) — die vorgeschaltete Besitzpruefung im Anwendungscode bleibt deshalb der einzige Schutz vor dem Scharfschalten +searchprovider-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "SearchProvider" liefert 0 Zeile(n), tatsaechlich vorhanden sind 4 +searchprovider-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n) aus den neu hinzugefuegten: ["search-a1","search-a2"] +searchprovider-fremder-nutzer-desselben-mandanten-gebunden-sichtbar: bestanden — forTenant(TENANT-A) liefert die Suchmaschine von 'user-a2' (anderer Benutzer, gleicher Mandant) mit: true — dieselbe fehlende Benutzerdimension wie bei "DashboardLayout" und "WidgetInstance" (Befund G) +searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=NULL abgewiesen mit SQLSTATE 42501 (ERROR: new row violates row-level security policy for table "SearchProvider") +Alle 87 Pruefungen bestanden. +``` + +Dreizehn neue Prüfungen, nicht zwölf wie in der Aufzählung des Plans +namentlich vorgezeichnet — die dreizehnte +(`widgetinstance-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`) +wurde ergänzt, weil Befund G des Plans ausdrücklich alle DREI Tabellen +dieses Bereichs als ohne Benutzerdimension benennt, der Plan aber nur für +`DashboardLayout` und `SearchProvider` einen entsprechenden Testfall +vorzeichnete. Die Gesamtzahl der Werkzeugprüfungen steigt damit von 74 auf +87 (74 + 13). + +**Die tragende Belegzeile ist `dashboardlayout-ungebunden-null-zeilen`:** +der IDENTISCHE `SELECT "tenantId" FROM "DashboardLayout"` ohne vorheriges +`set_config` liefert **0 Zeilen**, nicht die 4 tatsächlich vorhandenen — +an der echten, ausgelieferten Policy gemessen. `widgetinstance-ungebunden- +null-zeilen` misst dieselbe Unsichtbarkeit für die Widget-Tabelle. + +**Die Konfliktmessung (Befund K, Prüfung 5) hat ein Ergebnis, nicht eine +Vermutung.** Ein gebundenes `INSERT ... ON CONFLICT ("userId") DO UPDATE` +unter TENANT-A, das auf die unter TENANT-B physisch vorhandene, unter +TENANT-A unsichtbare Zeile trifft, scheitert LAUT mit SQLSTATE `42501` +("new row violates row-level security policy (USING expression) for table +\"DashboardLayout\""), nicht mit einem Eindeutigkeitsfehler (`23505`) und +nicht mit einem stillen Erfolg. Zusätzlich am ECHTEN, generierten Prisma +Client gemessen (nicht nur an rohem SQL), weil `saveLayout` in Wahrheit +`prisma.dashboardLayout.upsert()` aufruft, nicht `$executeRaw`: gegen eine +eigens dafür angelegte Wegwerf-Datenbank mit vollständigem Spaltensatz +(`id`, `userId`, `tenantId`, `layouts`, `createdAt`, `updatedAt`) und einer +eigenen Wegwerf-Rolle ohne `BYPASSRLS` liefert derselbe Konfliktfall über +`tenantPrisma.dashboardLayout.upsert({ where: { userId }, update, create })` +einen **`PrismaClientUnknownRequestError`** — nicht den bekannten +`PrismaClientKnownRequestError` mit `.code === 'P2002'`, den der Bereich +`tenders` für seinen Eindeutigkeitsfall abfängt. `.code` und `.meta` sind +bei diesem Fehlertyp `undefined`; die einzige verlässliche Information +steht im rohen `.message`-Text, der den PostgreSQL-Fehler eingebettet +enthält (`code: "42501"`, `message: "new row violates row-level security +policy (USING expression) for table \"DashboardLayout\""`). **Das ist die +zentrale Abweichung von der Annahme, das `tenders`-P2002-Muster ließe sich +wörtlich übernehmen** — es lässt sich nicht, weil dieser Fehler eine andere +Prisma-Fehlerklasse ist. Aufgabe 2 fängt deshalb +`Prisma.PrismaClientUnknownRequestError` ab (Prüfung auf die Fehlerklasse, +nicht auf `.code`) und übersetzt ihn in eine verständliche deutsche +Meldung — siehe (w4) für die Grenze dieser Behandlung. + +**Die eigenständige Nachprüfung der widerlegten Prämisse (Befund F, +WINDOWS #19).** Nachgeprüft mit derselben Anweisung wie zur Planungszeit, +diesmal gegen den aktuellen Quelltext (2026-09-11): +`grep -rn "searchProvider\|SearchProvider" apps packages prisma +--include=*.ts --include=*.mjs --include=*.js --include=*.sql +--include=*.json` (ohne `node_modules`, `dist/`, `.next/`). Ergebnis +unverändert: der einzige Schreibweg ist +`dashboard.service.ts:241` (`addSearchProvider` → `create`) mit +`tenantId: string` als PFLICHTPARAMETER der aufrufenden Methode; keine +Seed-Datei (`apps/api/prisma/` enthält ausschließlich `migrations` und +`schema.prisma`), kein Skript, kein weiterer Schreibweg. Reichweite der +Suche, wie zur Planungszeit benannt: sie findet keinen Schreibweg über +einen dynamisch gebildeten Modellnamen und deckt keine manuelle +Datenbankänderung ab — die Aussage lautet deshalb "kein Anwendungspfad +erzeugt eine mandantenlose Zeile", nicht "es kann keine geben". Zusätzlich +datenbankseitig verteidigt: `searchprovider-gebundenes-einfuegen-ohne- +mandant-abgelehnt` weist ein gebundenes Einfügen mit `tenantId = NULL` +laut mit SQLSTATE `42501` ab, weil die Regel ohne eigene `WITH CHECK`- +Klausel ihre `USING`-Klausel dafür wiederverwendet. + +### (w2) Signaltabelle je umgestelltem Pfad + +| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort | +|---|---|---| +| `DashboardService.getLayout` | Der gebundene Lesezugriff liefert `null` statt der vorhandenen Zeile — identisch zum heutigen "noch keine Anordnung gespeichert" | Der Rückgabepunkt liefert die Vorgabeanordnung `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`, Status 200, kein Fehler. Sichtbar im Browser als leeres Dashboard — siehe (w3) für die Folgekette | +| `DashboardService.saveLayout` | Der gebundene Schreibzugriff trifft im `update`-Zweig die vorhandene, aber unter dem laufenden Mandanten unsichtbare Zeile über den plattformweit eindeutigen Schlüssel `userId` — Konfliktmessung, siehe (w1) | `PUT /dashboard/layout` liefert nach Aufgabe 2 eine verständliche deutsche Konfliktmeldung statt eines rohen 500ers — siehe (w4) für die Grenze | +| `DashboardService.getWidgets`, Widget-Lesezugriff | Der gebundene Lesezugriff liefert eine leere Liste statt der platzierten Widgets | Der Nutzer sieht ein Dashboard ohne jedes Widget, Status 200, kein Fehler | +| `DashboardService.addWidget` | Der gebundene Schreibzugriff schlägt fehl bzw. legt die Zeile unter einer Mandantenkennung an, die der Sitzungsnachweis liefert — kein Leere-Fall in diese Richtung | `POST /dashboard/widgets` liefert einen Fehler statt eines neuen Widgets, falls der Mandant fehlt (`ForbiddenException` bereits im Controller) | +| `DashboardService.updateWidgetConfig`/`removeWidget`, Besitzprüfung | Die gebundene `findUnique`-Abfrage liefert `null` statt der eigenen Zeile — ununterscheidbar vom echten "gehört jemand anderem" | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß, siehe (w4) | +| `DashboardService.getSearchProviders` | Der gebundene Lesezugriff auf `custom` liefert eine leere Liste statt der eigenen Suchmaschinen — die drei Vorgaben aus der Konstante bleiben unberührt und werden IMMER vorangestellt | Die eigenen Suchmaschinen verschwinden aus der Auswahlliste des Such-Widgets, die Leiste funktioniert weiter — siehe (w3), Befund J | +| `DashboardService.addSearchProvider` | Kein Leere-Fall — der Schreibzugriff verlangt die Mandantenkennung als Pflichtparameter | — | +| `DashboardService.removeSearchProvider`, Besitzprüfung | Wie bei Widgets: `null` statt der eigenen Zeile | `NotFoundException` — dieselbe Meldung wie beim echten Besitzverstoß | + +### (w3) Welcher Code Leere als Abwesenheit deutet + +**Backend, eine Stelle:** `DashboardService.getLayout` — +`if (!record) return { lg: [], md: [], sm: [], xs: [], xxs: [] };`. Kein +Datensatz bedeutet hier nicht "Fehler", sondern "Vorgabeanordnung" — dieselbe +Deutung wie bei `getWidgets` (leere Liste) und `getSearchProviders` (nur die +drei Konstanten). + +**Frontend, drei Stellen, zur Ausführungszeit an den beiden Dateien erneut +nachgeprüft (Befund I/J), nicht aus dem Plan abgeschrieben — dieser Plan +ändert an KEINER der beiden Dateien etwas:** + +1. `apps/web/src/lib/stores/dashboard-store.ts`, `loadDashboard`: setzt + `layouts` und `widgets` genau auf das, was `api.fetchLayout()` und + `api.fetchWidgets()` liefern. Der `catch`-Zweig + (`error: 'Failed to load dashboard'`) feuert nur bei einem Netzwerk- + oder Statusfehler — eine erfolgreiche, leere Antwort setzt keinen + Fehlerzustand. +2. `apps/web/src/lib/stores/dashboard-store.ts`, `setEditMode`: + `if (prev && !mode && get().isDirty) { get().saveLayout(); }` — der + Neuaufbau wird beim bloßen VERLASSEN des Bearbeitungsmodus automatisch + zurückgeschrieben, ohne dass jemand auf "Speichern" klickt. +3. `apps/web/src/components/dashboard/widgets/search-widget.tsx`: die + Rückfallprüfung `if (!cancelled && data.length > 0)` greift NIE, weil + `getSearchProviders` die drei Vorgaben immer voranstellt — `data` ist + nie leer, selbst wenn `custom` (die eigenen Suchmaschinen) nach dem + Scharfschalten leer geblieben wäre. Der `.catch()`-Rückfallzweig auf + `DEFAULT_PROVIDERS` feuert deshalb ebenfalls nie in diesem Fall. + +**Die beweisvernichtende Schleife, als Schleife beschrieben (Befund I):** +leeres Dashboard (Stelle 1) → der Nutzer hält das für einen Fehler des +Widget-Systems oder für verlorene Einstellungen, baut seine Anordnung neu +auf, `addWidget` legt echte neue `WidgetInstance`-Zeilen an (keine +Eindeutigkeitsbedingung über `(userId, widgetType)`, Dubletten häufen sich +also bei wiederholtem Neuaufbau an) → beim Verlassen des Bearbeitungsmodus +schreibt Stelle 2 den Neuaufbau AUTOMATISCH zurück, ohne dass der Nutzer +"Speichern" geklickt hat → die `layouts`-Spalte der ursprünglichen Zeile +ist überschrieben, die einzige Aufzeichnung der ursprünglichen Anordnung +ist verloren, bevor irgendjemand die Ursache untersuchen konnte. Der +Nutzer hat dabei eine fertige, FALSCHE Erklärung zur Hand ("das +Widget-System spinnt", "meine Einstellungen sind weg") und meldet deshalb +keinen Fehler — derselbe Mechanismus, der auch in `module-registry` (m3) +und `ldap` beschrieben ist, hier aber mit einer zusätzlichen, aktiven +Zerstörungshandlung (das automatische Zurückschreiben), die es in keinem +der beiden anderen Bereiche gibt. + +**Das Verhalten der Suchleiste (Befund J), präzise statt "still" +formuliert:** verschwinden die eigenen Suchmaschinen des Nutzers aus +`custom`, bleibt die Auswahlliste (``, aber `selectedProviderId` referenziert nach dem +Verschwinden keinen vorhandenen `