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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user