feat(quick-260910-krx): Fehlerrichtung fuer Bereich dashboard gemessen

Aufgabe 1 — misst die umgekehrte Fehlerrichtung des Bereichs dashboard an
den Regeln nach Migration 20260910120000, VOR der Umstellung:

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

Baseline gehalten: 839 Tests / 56 Dateien gruen, Typpruefung sauber.
Schalter bleibt aus.
This commit is contained in:
2026-09-11 08:46:46 +02:00
parent 89fb02797a
commit 6744918a01
2 changed files with 598 additions and 0 deletions
+325
View File
@@ -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 {