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 `