feat(quick-260909-mir): dkv-Fehlerform messen und Kritikschrift erweitern

- rls-scratch-check.mjs: runDkvAreaChecks() misst die drei ausgelieferten
  Policies (DkvInvoiceHistory/DkvModuleConfig/DkvVehicleMaster) wortgleich
  aus der Migration, plus die Nebenlaeufigkeitsform von getHistory()
  (Promise.all ueber zwei gebundene Einzelabfragen); alle 9 neuen plus
  32 bestehende Pruefungen bestehen (41 gesamt)
- Belegt Befund H (kein P2002-Fall, Mandant ist Teil des zusammengesetzten
  Schluessels) und Befund I (gebundenes INSERT mit fremder tenantId wird
  ohne eigene WITH-CHECK-Klausel trotzdem abgewiesen) an der echten
  Datenbank statt am Policy-Text
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
  "Bereich dkv" mit der dritten Fehlerform der Etappe (ein Einzelobjekt
  wird null, wo null bereits "nicht eingerichtet" bedeutet), der
  Signaltabelle je umzustellendem Pfad, den sieben Stellen aus Befund K
  (zerstoerend/lautlos/irrefuehrend) und der ausgeschriebenen
  Planer-Entscheidung (Form c, mit Unsymmetrie zum ldap-Praezedenzfall)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-09 16:38:57 +02:00
parent 748f0b513e
commit 761e5e2c36
2 changed files with 568 additions and 0 deletions
+273
View File
@@ -1119,6 +1119,278 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) {
}
}
/**
* Aufgabe 1 (260909-mir), TEIL 1 — misst die neun im Plan genannten
* Verhaltensweisen des Bereichs dkv unter der Rolle ohne BYPASSRLS, mit
* den drei Policies fuer "DkvInvoiceHistory", "DkvModuleConfig" und
* "DkvVehicleMaster" WORTGLEICH aus der ausgelieferten
* *_rls_remaining_tenant_tables-Migration extrahiert, nicht im Werkzeug
* nachgetippt. Findet die Extraktion eine der drei nicht, meldet dieser
* Abschnitt eine FEHLGESCHLAGENE Pruefung und bricht ab, statt mit einer
* geratenen Policy weiterzumessen.
*
* TEIL 2 (die Nebenlaeufigkeitsform aus Befund C, auf die sich
* getHistory() stuetzt) ist als eigene Pruefung
* `dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext` am Ende
* dieser Funktion mit untergebracht, nicht als eigener Abschnitt — sie
* braucht dieselben Tabellen und Testzeilen wie TEIL 1.
*
* Legt keine Tabelle an, auf der eine andere Pruefung dieses Werkzeugs
* aufsetzt — wie runTendersAreaChecks() ist dieser Abschnitt in der
* Aufrufkette ein Blatt: er muss NACH runTendersAreaChecks() und VOR
* runTransactionShapeMeasurement() laufen, weil Letztere weiterhin auf der
* von runGroupsAreaChecks() angelegten Tabelle "Group" aufsetzt — dieser
* Abschnitt aendert daran nichts.
*/
async function runDkvAreaChecks(adminUrl, scratchRoleUrl, results) {
const remainingMigrationSql = readRemainingTenantTablesMigrationSql();
const invoiceHistoryPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvInvoiceHistory')
: null;
const moduleConfigPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvModuleConfig')
: null;
const vehicleMasterPolicy = remainingMigrationSql
? extractPolicySql(remainingMigrationSql, 'DkvVehicleMaster')
: null;
if (!invoiceHistoryPolicy || !moduleConfigPolicy || !vehicleMasterPolicy) {
report(
results,
'dkv-policies-aus-migration-gefunden',
false,
'CREATE POLICY fuer "DkvInvoiceHistory", "DkvModuleConfig" und/oder "DkvVehicleMaster" 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 "DkvModuleConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL UNIQUE,
"isActive" boolean NOT NULL DEFAULT true
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "DkvVehicleMaster" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
kennzeichen text NOT NULL,
UNIQUE ("tenantId", kennzeichen)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE "DkvInvoiceHistory" (
id text PRIMARY KEY,
"tenantId" text NOT NULL,
"exportFilename" text
);
`);
for (const table of ['DkvModuleConfig', 'DkvVehicleMaster', 'DkvInvoiceHistory']) {
await db.$executeRawUnsafe(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY;`);
}
await db.$executeRawUnsafe(moduleConfigPolicy);
await db.$executeRawUnsafe(vehicleMasterPolicy);
await db.$executeRawUnsafe(invoiceHistoryPolicy);
for (const table of ['DkvModuleConfig', 'DkvVehicleMaster', 'DkvInvoiceHistory']) {
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON "${table}" TO ${SCRATCH_ROLE_NAME}`,
);
}
await db.$executeRawUnsafe(`
INSERT INTO "DkvModuleConfig" (id, "tenantId", "isActive") VALUES
('config-a', 'TENANT-A', true),
('config-b', 'TENANT-B', true);
`);
await db.$executeRawUnsafe(`
INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES
('veh-a1', 'TENANT-A', 'GEMEINSAM-1'),
('veh-a2', 'TENANT-A', 'A-ONLY-1'),
('veh-b1', 'TENANT-B', 'GEMEINSAM-1'),
('veh-b2', 'TENANT-B', 'B-ONLY-1');
`);
await db.$executeRawUnsafe(`
INSERT INTO "DkvInvoiceHistory" (id, "tenantId", "exportFilename") VALUES
('hist-a', 'TENANT-A', 'RG-DKV-TEST-A-260909.xlsx'),
('hist-b', 'TENANT-B', 'RG-DKV-TEST-B-260909.xlsx');
`);
});
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// dkvmoduleconfig-gebunden-nur-eigene-zeile
const configRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig" ORDER BY id`,
);
report(
results,
'dkvmoduleconfig-gebunden-nur-eigene-zeile',
configRowsForA.length === 1 && configRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${configRowsForA.length} Zeile(n): ${JSON.stringify(configRowsForA.map((r) => r.tenantId))}`,
);
// dkvmoduleconfig-ungebunden-null-zeilen — die Belegzeile, die den
// gesamten dkv-Abschnitt der Kritikschrift traegt: der IDENTISCHE
// SELECT ohne vorheriges set_config liefert null Zeilen, nicht die
// beiden tatsaechlich vorhandenen.
const unboundConfigRows = await prisma.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig"`;
report(
results,
'dkvmoduleconfig-ungebunden-null-zeilen',
unboundConfigRows.length === 0,
`ungebundener SELECT auf "DkvModuleConfig" liefert ${unboundConfigRows.length} Zeile(n)`,
);
// dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile — die
// eigene, diesem Bereich vorbehaltene Messung: die Form, die der
// Planer-Startpfad heute benutzt (loadConfig() ohne Mandant, also ein
// findFirst() ohne jede Bedingung). Bestanden, wenn KEINE Zeile
// zurueckkommt, obwohl zwei existieren.
const unboundSingleRow = await prisma.$queryRaw`SELECT "tenantId" FROM "DkvModuleConfig" LIMIT 1`;
report(
results,
'dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile',
unboundSingleRow.length === 0,
`ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert ${unboundSingleRow.length} Zeile(n), obwohl 2 existieren — die Form, die der Planer-Startpfad heute benutzt: aus einer beliebigen-aber-vorhandenen Zeile wird KEINE Zeile, und der aufrufende Code liest das als "dieses Modul ist nicht eingerichtet"`,
);
// dkvinvoicehistory-gebunden-nur-eigener-mandant
const historyRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvInvoiceHistory" ORDER BY id`,
);
report(
results,
'dkvinvoicehistory-gebunden-nur-eigener-mandant',
historyRowsForA.length === 1 && historyRowsForA[0].tenantId === 'TENANT-A',
`forTenant(TENANT-A) liefert ${historyRowsForA.length} Zeile(n): ${JSON.stringify(historyRowsForA.map((r) => r.tenantId))}`,
);
// dkvvehiclemaster-gebunden-nur-eigener-mandant
const vehicleRowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM "DkvVehicleMaster" ORDER BY id`,
);
report(
results,
'dkvvehiclemaster-gebunden-nur-eigener-mandant',
vehicleRowsForA.length === 2 && vehicleRowsForA.every((r) => r.tenantId === 'TENANT-A'),
`forTenant(TENANT-A) liefert ${vehicleRowsForA.length} Zeile(n): ${JSON.stringify(vehicleRowsForA.map((r) => r.tenantId))}`,
);
// dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt
// (Befund I): die ausgelieferten Policies tragen keine eigene
// WITH-CHECK-Klausel — was PostgreSQL daraus fuer ein INSERT ableitet,
// ist eine Eigenschaft der Datenbank, keine des Policy-Textes. Die
// Abweisung IST das bestandene Ergebnis.
let foreignInsertRejected = false;
let foreignInsertDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES ('veh-rejected', 'TENANT-B', 'REJECTED-PLATE')`,
);
foreignInsertDetail = 'gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B ist NICHT fehlgeschlagen';
} catch (err) {
foreignInsertRejected = true;
foreignInsertDetail = `gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ${err.message}`;
}
report(
results,
'dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt',
foreignInsertRejected,
foreignInsertDetail,
);
// dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen
// (Befund G): ein gebundenes UPDATE unter TENANT-A, das eine Zeile von
// TENANT-B allein ueber deren id anspricht — die Form, die
// updateVehicle()/deleteVehicle() im Schreibschritt nutzen. Bestanden,
// wenn null Zeilen betroffen sind: ein Schreibzugriff ueber die
// Kennung allein scheitert gebunden nicht laut, sondern trifft still
// nichts, deshalb bleibt die vorgeschaltete Besitzpruefung erhalten.
const foreignUpdateAffected = await forTenantQuery(
prisma,
'TENANT-A',
(tx) => tx.$executeRaw`UPDATE "DkvVehicleMaster" SET kennzeichen = 'UEBERSCHRIEBEN' WHERE id = 'veh-b1'`,
);
report(
results,
'dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen',
foreignUpdateAffected === 0,
`gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft ${foreignUpdateAffected} Zeile(n) — Folge fuer Aufgabe 3 (Befund G): die vorgeschaltete Besitzpruefung bleibt deshalb erhalten und wird nicht durch die Datenbank ersetzt`,
);
// dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision
// (Befund H, Gegenbefund zu tenders-Befund F): unter TENANT-A ein
// gebundenes INSERT auf ein Kennzeichen, das unter TENANT-B bereits
// existiert ('B-ONLY-1', veh-b2). Bestanden, wenn es GELINGT — weil der
// Mandant Teil des zusammengesetzten Schluessels (tenantId,
// kennzeichen) ist, gibt es hier keine Kollision auf einer
// unsichtbaren fremden Zeile und keine P2002-Uebersetzung zu bauen.
let noForeignCollision = false;
let noForeignCollisionDetail = '';
try {
await forTenantQuery(
prisma,
'TENANT-A',
(tx) =>
tx.$executeRaw`INSERT INTO "DkvVehicleMaster" (id, "tenantId", kennzeichen) VALUES ('veh-a-neu', 'TENANT-A', 'B-ONLY-1')`,
);
noForeignCollision = true;
noForeignCollisionDetail = `gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' gelingt — der Mandant ist Teil des zusammengesetzten Schluessels, keine Kollision auf einer unsichtbaren fremden Zeile, keine P2002-Uebersetzung noetig`;
} catch (err) {
noForeignCollisionDetail = `gebundenes INSERT unter TENANT-A auf das bereits unter TENANT-B vorhandene Kennzeichen 'B-ONLY-1' ist fehlgeschlagen: ${err.message}`;
}
report(
results,
'dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision',
noForeignCollision,
noForeignCollisionDetail,
);
// dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext (TEIL
// 2, Befund C): getHistory() fuehrt zwei Abfragen ueber Promise.all
// parallel aus — nach der Umstellung also zwei parallele
// Einzeloperationen auf EINEM gebundenen Klienten. Hier fuer ZWEI
// verschiedene Mandanten ueber DENSELBEN Klienten nachgebaut. Bestanden,
// wenn jede Abfrage den Kontext sieht, unter dem sie gestartet wurde,
// und jede die richtige Zeilenzahl liefert. Als Verletzung zaehlt
// beides: ein fremder oder fehlender Kontext und ein Abbruch.
const [parallelA, parallelB] = await Promise.all([
forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "DkvInvoiceHistory") AS rows`,
),
forTenantQuery(prisma, 'TENANT-B', (tx) =>
tx.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t, (SELECT count(*)::int FROM "DkvInvoiceHistory") AS rows`,
),
]);
const rowA = parallelA[0];
const rowB = parallelB[0];
const parallelOk =
Boolean(rowA) &&
Boolean(rowB) &&
rowA.t === 'TENANT-A' &&
rowB.t === 'TENANT-B' &&
rowA.rows === 1 &&
rowB.rows === 1;
report(
results,
'dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext',
parallelOk,
`TENANT-A: pid=${rowA?.pid}, t=${JSON.stringify(rowA?.t)}, rows=${rowA?.rows}; TENANT-B: pid=${rowB?.pid}, t=${JSON.stringify(rowB?.t)}, rows=${rowB?.rows} — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext`,
);
} finally {
await prisma.$disconnect();
}
}
/**
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
@@ -1350,6 +1622,7 @@ async function main() {
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
await runConcurrencyProbe(scratchRoleUrlString, results);
} finally {