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
|
* Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen
|
||||||
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
* den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte
|
||||||
@@ -1350,6 +1622,7 @@ async function main() {
|
|||||||
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runLdapAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runGroupsAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
|
await runTendersAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
|
await runDkvAreaChecks(adminUrl, scratchRoleUrlString, results);
|
||||||
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
await runTransactionShapeMeasurement(scratchRoleUrlString, results);
|
||||||
await runConcurrencyProbe(scratchRoleUrlString, results);
|
await runConcurrencyProbe(scratchRoleUrlString, results);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -617,6 +617,301 @@ nennen — die Datei selbst steht ohnehin auf der Nicht-Anfassen-Liste, hier
|
|||||||
nur festgehalten, damit niemand sie beim Nachziehen der Begründungen
|
nur festgehalten, damit niemand sie beim Nachziehen der Begründungen
|
||||||
"freundlich kommentiert" und den Lauf rot macht.
|
"freundlich kommentiert" und den Lauf rot macht.
|
||||||
|
|
||||||
|
## Bereich dkv
|
||||||
|
|
||||||
|
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `dkv`
|
||||||
|
(Quick-Task 260909-mir) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
||||||
|
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
||||||
|
beantwortet sie erneut, für einen Bereich mit einer dritten, eigenen
|
||||||
|
Fehlerform: nicht "eine Liste ist leer" (wie bei `ldap`/`groups`) und nicht
|
||||||
|
"ein Benachrichtigungsweg handelt gar nicht" (wie bei `tenders`), sondern
|
||||||
|
"ein einzelnes Objekt wird `null`, und `null` hat an dieser Stelle bereits
|
||||||
|
eine gültige, harmlose Bedeutung". Ein eingerichtetes Modul sieht danach
|
||||||
|
aus wie ein nie eingerichtetes.
|
||||||
|
|
||||||
|
### (d1) Die Messung
|
||||||
|
|
||||||
|
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen sechsten
|
||||||
|
Abschnitt (`runDkvAreaChecks`) erweitert, mit den drei Policies für
|
||||||
|
`DkvInvoiceHistory`, `DkvModuleConfig` und `DkvVehicleMaster` (alle aus der
|
||||||
|
ausgelieferten Migration `20260909140000_rls_remaining_tenant_tables`)
|
||||||
|
WORTGLEICH extrahiert, nicht im Werkzeug nachgetippt. Tatsächlich
|
||||||
|
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
|
||||||
|
Adresse `172.19.0.2`):
|
||||||
|
|
||||||
|
```
|
||||||
|
dkvmoduleconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||||||
|
dkvmoduleconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "DkvModuleConfig" liefert 0 Zeile(n)
|
||||||
|
dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile: bestanden — ungebundenes SELECT ... LIMIT 1 ohne jede Bedingung liefert 0 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: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
||||||
|
dkvvehiclemaster-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
|
||||||
|
dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: ERROR: new row violates row-level security policy for table "DkvVehicleMaster"
|
||||||
|
dkvvehiclemaster-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'veh-b1' (gehoert TENANT-B) allein betrifft 0 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: bestanden — 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
|
||||||
|
dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext: bestanden — TENANT-A: pid=286680, t="TENANT-A", rows=1; TENANT-B: pid=286679, t="TENANT-B", rows=1 — Nebenlaeufigkeitsform von getHistory(): zwei ueber Promise.all gleichzeitig gestartete gebundene Einzelabfragen ueber denselben Klienten, jede unter ihrem eigenen Kontext
|
||||||
|
Alle 41 Pruefungen bestanden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Belegzeile, die diesen Abschnitt der Kritikschrift trägt, ist
|
||||||
|
`dkvmoduleconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId"
|
||||||
|
FROM "DkvModuleConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**,
|
||||||
|
nicht etwa die 2 tatsächlich vorhandenen — an der echten, ausgelieferten
|
||||||
|
Policy gemessen, nicht an einer im Werkzeug nachgebauten Hilfstabelle.
|
||||||
|
|
||||||
|
Daneben trägt `dkvmoduleconfig-ungebundene-einzelabfrage-liefert-keine-zeile`
|
||||||
|
diesen Abschnitt zusätzlich, weil dieser Bereich an der entscheidenden
|
||||||
|
Stelle kein Mengenergebnis liest, sondern ein Einzelobjekt: dieselbe
|
||||||
|
Tabelle, dasselbe fehlende `set_config`, aber diesmal ein `SELECT ...
|
||||||
|
LIMIT 1` ohne jede Bedingung — exakt die Form, die `loadConfig()` ohne
|
||||||
|
Mandant (der Planer-Startpfad) heute über `findFirst()` benutzt. Auch
|
||||||
|
diese Abfrage liefert **0 Zeilen**, obwohl 2 existieren. Der Unterschied
|
||||||
|
zur ersten Belegzeile ist nicht die Zahl (beide sind 0), sondern die
|
||||||
|
Lesart: ein leeres `findMany`-Ergebnis ist im aufrufenden Code sichtbar
|
||||||
|
leer, ein leeres `findFirst`-Ergebnis wird zu `null`, und `null` hat in
|
||||||
|
`loadConfig()`/`onModuleInit()` bereits eine gültige, harmlose Bedeutung
|
||||||
|
("kein aktives Modul konfiguriert") — siehe (d3).
|
||||||
|
|
||||||
|
TEIL 2 hat zusätzlich die Nebenläufigkeitsform gemessen, auf die sich
|
||||||
|
`getHistory()` stützt (Befund C): zwei über `Promise.all` gleichzeitig
|
||||||
|
gestartete gebundene Einzelabfragen über DENSELBEN Klienten, hier für zwei
|
||||||
|
verschiedene Mandanten nachgebaut
|
||||||
|
(`dkv-zwei-parallele-gebundene-einzelabfragen-je-eigener-kontext`). Jede
|
||||||
|
Abfrage sah den Kontext, unter dem sie gestartet wurde (`TENANT-A`/
|
||||||
|
`TENANT-B`), und jede lieferte die richtige Zeilenzahl (je 1) — keine
|
||||||
|
Verletzung, kein Abbruch.
|
||||||
|
|
||||||
|
TEIL 3 hat nachgemessen, dass dieser Bereich keine mandantengebundene
|
||||||
|
Transaktion enthält (Befund C):
|
||||||
|
`grep -rn '\$transaction(' apps/api/src/dkv --include=*.ts | grep -v spec`
|
||||||
|
liefert **null Treffer** (Rückgabewert 1, keine Ausgabe). Der im Kopf von
|
||||||
|
`prisma-tenant.extension.ts` verlangte erneute Test vor jedem neuen
|
||||||
|
`forTenant()`-Fall mit eigener Transaktion ist damit für diesen Bereich
|
||||||
|
beantwortet: es fällt kein neuer Fall an, `withTenantTransaction()` wird
|
||||||
|
hier nicht gebraucht und in Aufgabe 2/3 nicht eingeführt. Die beiden
|
||||||
|
mehrschrittigen Stellen des Bereichs — der Ersetzen-Modus des
|
||||||
|
Fahrzeug-Imports (`deleteMany` gefolgt von `createMany`) und die
|
||||||
|
Zugangsdaten-Erhaltung in `saveConfig` (lesen, entschlüsseln, neu
|
||||||
|
verschlüsseln, schreiben) — bleiben deshalb so unatomar wie heute; sie in
|
||||||
|
eine Transaktion zu heben wäre eine Verhaltensänderung jenseits dieses
|
||||||
|
Auftrags, siehe (d5).
|
||||||
|
|
||||||
|
Zwei Messungen dieses Laufs tragen eine Entscheidung, die kein bisheriger
|
||||||
|
Bereich in dieser Form brauchte:
|
||||||
|
|
||||||
|
- `dkvvehiclemaster-gebundenes-einfuegen-fremder-mandant-abgelehnt`
|
||||||
|
(Befund I) — dieselbe Frage wie bei `TenderRssFeedSource` in `tenders`,
|
||||||
|
hier mit demselben Ergebnis: die ausgelieferten Policies dieses Bereichs
|
||||||
|
tragen keine eigene `WITH CHECK`-Klausel, also verwendet PostgreSQL
|
||||||
|
denselben `USING`-Ausdruck auch für neu geschriebene Zeilen — ein
|
||||||
|
gebundenes `INSERT` unter TENANT-A mit `tenantId = TENANT-B` wird
|
||||||
|
abgewiesen. Was PostgreSQL daraus für ein `INSERT` ableitet, ist eine
|
||||||
|
Eigenschaft der Datenbank, keine des Policy-Textes, deshalb gemessen und
|
||||||
|
nicht aus dem Text geschlossen.
|
||||||
|
- `dkvvehiclemaster-schluessel-traegt-mandant-keine-fremdkollision`
|
||||||
|
(Befund H) — der Gegenbefund zu `tenders`-Befund F: weil
|
||||||
|
`DkvVehicleMaster` die zusammengesetzte Eindeutigkeit `@@unique([tenantId,
|
||||||
|
kennzeichen])` trägt, gibt es hier KEINE Kollision auf einer unsichtbaren
|
||||||
|
fremden Zeile. Ein gebundenes `INSERT` unter TENANT-A auf ein
|
||||||
|
Kennzeichen, das unter TENANT-B bereits existiert, GELINGT — das
|
||||||
|
bestandene Ergebnis ist das Gelingen, nicht die Abweisung. Aufgabe 2/3
|
||||||
|
bauen deshalb keine P2002-Übersetzung für diesen Bereich; anders als bei
|
||||||
|
`TenderTriage` in `tenders` ist hier keine gebaut, weil keine gebraucht
|
||||||
|
wird — nachgemessen statt unterstellt.
|
||||||
|
|
||||||
|
### (d2) Signaltabelle je umgestelltem Pfad
|
||||||
|
|
||||||
|
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|
||||||
|
|---|---|---|
|
||||||
|
| `DkvService.getConfigForApi` | Der gebundene erste Lesezugriff (`loadConfig(tenantId)`) liefert `null` statt der eigenen Konfiguration | `GET /dkv/config` liefert `404 DKV module not yet configured`; die Oberfläche zeigt das Einrichtungsformular für ein Modul, das tatsächlich eingerichtet ist |
|
||||||
|
| `DkvService.getConfigForApi`, der zweite (rohe) Lesezugriff auf die Zugangsdaten | Läuft dieser gebundene `findUnique` leer (während der erste — sicher ausgewählte — noch träfe), bleibt `raw` `null`, der `try`-Block liefert `hasPassword=false` | Die Oberfläche meldet "kein Passwort hinterlegt" für ein Modul mit tatsächlich hinterlegtem Passwort — ohne Fehlermeldung, siehe (d3) Stelle 4 |
|
||||||
|
| `DkvService.saveConfig`, die Zugangsdaten-Erhaltung | Der gebundene erhaltende Lesezugriff liefert `null` statt der bestehenden Zeile, der `try/catch` schluckt das | Ein gespeichertes Passwort wird mit dem LEEREN Wert neu verschlüsselt — die zerstoerende Stelle, siehe (d3) Stelle 5 und T-MIR-07 |
|
||||||
|
| `DkvService.testConnection`, der Rückgriff auf gespeicherte Zugangsdaten | Der gebundene Lesezugriff liefert `null` statt der bestehenden Zeile | Der Verbindungstest schlägt mit einem Anmeldefehler des Postfachs fehl — die Meldung zeigt auf das Postfach, nicht auf die Datenbank |
|
||||||
|
| `DkvService._runPipeline`, der Konfigurations-Lesezugriff | Der gebundene `findUnique` liefert `null` statt der bestehenden Konfiguration | `_runPipeline` protokolliert `no config for tenant ...` als Warnung und `return`et — die Rechnungsverarbeitung stellt für diesen Mandanten die Arbeit ein, ohne Fehlermeldung |
|
||||||
|
| `DkvService.listVehicles`/`createVehicle` | Der gebundene Zugriff liefert 0 Zeilen statt der tatsächlich vorhandenen bzw. schreibt nicht | Die Fahrzeugliste ist leer für einen Mandanten mit tatsächlich vorhandenen Fahrzeugen |
|
||||||
|
| `DkvService.updateVehicle`/`deleteVehicle`, die Besitzprüfung | Der gebundene `findFirst` liefert `null` statt der eigenen Zeile | `PUT`/`DELETE /dkv/vehicles/:id` scheitert mit der vorhandenen `NotFoundException`, obwohl das Fahrzeug existiert — siehe (d2)-Zeile zum neuen Riegel unten für die spiegelbildliche Fehlerform |
|
||||||
|
| `DkvService.importVehiclesCsv` (Ersetzen-Modus) | Der gebundene `deleteMany` löscht 0 Zeilen statt der tatsächlich vorhandenen (harmlos: dann bleiben Alt-Fahrzeuge stehen, `createMany` legt zusätzlich an) | Nach einem Ersetzen-Import bestehen alte UND neue Fahrzeugzeilen nebeneinander — kein Datenverlust, aber ein Zustand, der als "Ersetzen" nicht mehr stimmt |
|
||||||
|
| `DkvService.getHistory` | Beide gebundenen Parallelabfragen liefern 0 Zeilen bzw. Zählung 0 statt der tatsächlich vorhandenen | Die Rechnungshistorie-Tabelle ist leer für einen Mandanten mit tatsächlich vorhandener Historie |
|
||||||
|
| `DkvService._buildExportRows`, der gebündelte Lesezugriff auf die Fahrzeugstammdaten | Der gebundene `findMany` liefert 0 Zeilen statt der tatsächlich vorhandenen | Eine vollständige Ausfuhrdatei OHNE einen einzigen Fahrer entsteht — kein Fehler, keine Warnung, eine Datei, die plausibel aussieht und falsch ist, siehe (d3) Stelle 7 |
|
||||||
|
| `DkvService.getExportFile`, neuer Riegel (Aufgabe 3, Befund E) | Der gebundene Lesezugriff auf `DkvInvoiceHistory.exportFilename` liefert keinen Treffer, obwohl die Datei existiert und das Namensmuster besteht | `GET /dkv/exports/:filename` liefert `404`, obwohl die Datei auf der Platte liegt — die Absicht der Umstellung: Fehlen und Fremdbesitz kollabieren bewusst zur selben Antwort |
|
||||||
|
| `loadConfig(tenantId)` ohne Mandant (bewusst ungebunden, Planer-Startpfad) | Betrifft nicht die Bindung selbst — die Methode bindet niemals. Nach dem Scharfschalten liefert dieselbe Abfrage `null` statt einer beliebigen Zeile | `onModuleInit()` protokolliert `DKV scheduler: no active config found — cron job not registered` und richtet für JEDEN Mandanten nichts ein — siehe (d4) |
|
||||||
|
|
||||||
|
### (d3) Welcher Code Leere als Abwesenheit deutet
|
||||||
|
|
||||||
|
Die Form, die diesen Bereich von `ldap`, `groups` und `tenders`
|
||||||
|
unterscheidet: nicht "eine Liste ist leer" und nicht "ein
|
||||||
|
Benachrichtigungsweg handelt gar nicht", sondern "ein einzelnes Objekt
|
||||||
|
wird `null`, und `null` hat an dieser Stelle bereits eine gültige,
|
||||||
|
harmlose Bedeutung". Ein eingerichtetes Modul sieht danach aus wie ein nie
|
||||||
|
eingerichtetes: ein leeres Formular, eine unauffällige Protokollzeile,
|
||||||
|
kein Alarm.
|
||||||
|
|
||||||
|
**Zerstörend (eine Stelle, der gefährlichste Punkt des Bereichs):**
|
||||||
|
|
||||||
|
5. `DkvService.saveConfig`, die Erhaltung der nicht ausgefüllten
|
||||||
|
Zugangsdaten — liest die bestehende Zeile, um Benutzername oder
|
||||||
|
Passwort zu übernehmen, wenn das Formularfeld leer gelassen wurde.
|
||||||
|
Läuft dieser Lesezugriff nach dem Scharfschalten leer (weil ungebunden
|
||||||
|
oder unter falschem Kontext gebunden), wird das Feld mit dem LEEREN
|
||||||
|
Wert neu verschlüsselt: aus einem gespeicherten Passwort wird ein
|
||||||
|
leeres. Die Stelle liegt hinter einem `try/catch`, das ausdrücklich
|
||||||
|
sagt, dass es Fehler ignoriert und mit dem Übergebenen überschreibt —
|
||||||
|
T-MIR-07 im Bedrohungsregister dieses Plans. Aufgabe 2 bindet Lese- UND
|
||||||
|
Schreibzugriff dieser Methode gemeinsam an denselben Mandanten, sodass
|
||||||
|
ein leerer Lesezugriff nicht mit einem erfolgreichen Schreibzugriff
|
||||||
|
unter einem anderen Kontext kombiniert werden kann.
|
||||||
|
|
||||||
|
**Lautlos (drei Stellen, die Rechnungsverarbeitung bekommt eigenen
|
||||||
|
Raum):**
|
||||||
|
|
||||||
|
1. `DkvSchedulerService.onModuleInit()`, gefolgt von
|
||||||
|
`config?.isActive && config.tenantId` — `null` heißt "kein aktives
|
||||||
|
Modul konfiguriert", der Planer richtet nichts ein und protokolliert
|
||||||
|
das als Normalfall (`DKV scheduler: no active config found — cron job
|
||||||
|
not registered`). Kein Fehler, keine Warnung, keine sichtbare
|
||||||
|
Änderung — siehe (d4) für die volle Begründung, warum dieser Pfad
|
||||||
|
bewusst ungebunden bleibt.
|
||||||
|
2. `DkvService._runPipeline`, `if (!config) { warn; return; }` — `null`
|
||||||
|
heißt "dieser Mandant hat DKV nicht eingerichtet". Die
|
||||||
|
Rechnungsverarbeitung stellt die Arbeit ein: Rechnungen laufen im
|
||||||
|
Postfach weiter auf, es entsteht keine Historienzeile, keine
|
||||||
|
Ausfuhrdatei, kein Versand — und keine Fehlermeldung. Anders als beim
|
||||||
|
Planer-Startpfad ist dieser Lesezugriff in Aufgabe 2 vollständig
|
||||||
|
gebunden; die Stelle bleibt hier festgehalten, weil sie die Folge einer
|
||||||
|
still verschwundenen Konfiguration ist, nicht weil sie ungebunden
|
||||||
|
bliebe.
|
||||||
|
7. `DkvService._buildExportRows`, der gebündelte Lesezugriff auf die
|
||||||
|
Fahrzeugstammdaten — ein fehlender Treffer je Kennzeichen ist nach D-13
|
||||||
|
bereits ein GÜLTIGER Zustand (unbekanntes Kennzeichen, leeres
|
||||||
|
Fahrerfeld). Läuft der Lesezugriff selbst ganz leer (0 Fahrzeuge statt
|
||||||
|
der tatsächlich vorhandenen), entsteht eine vollständige Ausfuhrdatei
|
||||||
|
OHNE einen einzigen Fahrer — kein Fehler, keine Warnung, eine Datei,
|
||||||
|
die plausibel aussieht und falsch ist. Diese Stelle ist der Grund,
|
||||||
|
warum es nicht genügt, nur die Anzeigepfade zu binden.
|
||||||
|
|
||||||
|
**Irreführend (drei Stellen, die auf die falsche Ursache zeigen oder eine
|
||||||
|
falsche Vergangenheit nahelegen):**
|
||||||
|
|
||||||
|
3. `DkvService.getConfigForApi`, `if (!safe) return null` — die
|
||||||
|
Oberfläche zeigt daraufhin ein leeres Einrichtungsformular. Ein
|
||||||
|
Administrator sieht "noch nicht eingerichtet" für ein Modul, das
|
||||||
|
eingerichtet IST — und würde beim Neu-Ausfüllen die vorhandenen
|
||||||
|
Zugangsdaten überschreiben. Diese Stelle bleibt bewusst unter
|
||||||
|
"irreführend" und nicht unter "zerstörend": die Zerstörung selbst
|
||||||
|
passiert erst in `saveConfig` (Stelle 5), falls der Administrator
|
||||||
|
tatsächlich neu ausfüllt und speichert — hier liegt nur die
|
||||||
|
irreführende Voraussetzung dafür.
|
||||||
|
4. `DkvService.getConfigForApi`, der `try/catch` um die Entschlüsselung —
|
||||||
|
fängt heute Entschlüsselungsfehler ab und liefert einen leeren
|
||||||
|
Benutzernamen. Nach dem Scharfschalten fällt der Lesezugriff selbst
|
||||||
|
leer aus, `raw` ist `null`, und der Zweig läuft ohne Fehler durch:
|
||||||
|
`hasPassword` bleibt `false`. Die Oberfläche meldet "kein Passwort
|
||||||
|
hinterlegt" für ein hinterlegtes Passwort.
|
||||||
|
6. `DkvService.testConnection`, der Rückgriff auf das gespeicherte
|
||||||
|
Passwort — läuft leer, der Test schlägt mit einem Anmeldefehler des
|
||||||
|
Postfachs fehl. Harmlos in der Richtung, aber irreführend: die Meldung
|
||||||
|
zeigt auf das Postfach, nicht auf die Datenbank.
|
||||||
|
|
||||||
|
**Gegenrichtung, ebenfalls nachgesehen statt geschlossen behauptet:** die
|
||||||
|
laut werfenden Stellen sind `updateVehicle`/`deleteVehicle`
|
||||||
|
(`NotFoundException` bei Leere), `importVehiclesCsv` (wirft bei leerem CSV)
|
||||||
|
und `getExportFile` (wirft bei fehlender Datei, jetzt zusätzlich bei
|
||||||
|
fehlendem gebundenen Historientreffer, Aufgabe 3). Diese Stellen sind
|
||||||
|
harmlos, weil ein zu kleines Ergebnis dort bereits heute einen Fehler
|
||||||
|
auslöst, der nicht mit dem Scharfschalten neu entsteht.
|
||||||
|
|
||||||
|
### (d4) Was dieser Durchlauf bewusst nicht löst
|
||||||
|
|
||||||
|
**Der Planer-Startpfad — die eigentliche Aufgabe dieses Plans, ausgeschrieben statt still getroffen.**
|
||||||
|
`DkvSchedulerService.onModuleInit()` ruft `DkvService.loadConfig()` ohne
|
||||||
|
Mandant auf und übernimmt `config.tenantId` als den einen Mandanten, den
|
||||||
|
der eine benannte Cron-Auftrag `dkv-inbox-poll` fortan bedient
|
||||||
|
(`this.dkvService.loadConfig()` → `findFirst()` ganz ohne Bedingung). Zwei
|
||||||
|
Zustände, beide gehören benannt, sonst liest sich die Markierung wie eine
|
||||||
|
Entwarnung:
|
||||||
|
|
||||||
|
- **Heute** ist die Abfrage bereits FALSCH, nicht nur ungenau: bei mehreren
|
||||||
|
Mandanten bedient sie einen BELIEBIGEN und die übrigen NIE. Die Prüfung
|
||||||
|
`config?.isActive && config.tenantId` verschärft das — ist ausgerechnet
|
||||||
|
die gezogene beliebige Zeile inaktiv, registriert der Planer gar nichts,
|
||||||
|
obwohl ein zweiter Mandant aktiv wäre.
|
||||||
|
- **Nach dem Scharfschalten** verstummt sie zusätzlich: dieselbe Abfrage
|
||||||
|
liefert `null`, der Planer protokolliert `DKV scheduler: no active
|
||||||
|
config found — cron job not registered` und richtet für JEDEN Mandanten
|
||||||
|
nichts ein — eine Zeile, die auf einer frischen Installation der
|
||||||
|
Normalfall ist und deshalb niemanden alarmiert.
|
||||||
|
|
||||||
|
Von den drei im Auftrag genannten Formen wurde geprüft:
|
||||||
|
|
||||||
|
- **(a) An einen konkret aufgelösten Mandanten binden** — nicht möglich.
|
||||||
|
`onModuleInit()` hat keine Anfrage, keinen Sitzungsnachweis und keinen
|
||||||
|
Konfigurationswert, aus dem ein Mandant käme. Einen einzuführen wäre eine
|
||||||
|
neue Einstellung, also eine Funktionsänderung.
|
||||||
|
- **(b) Umbau auf einmal-abfragen-viele-bedienen** — abgelehnt, mit
|
||||||
|
Begründung. Das ist genau die Mehrmandanten-Planung, die 07-04
|
||||||
|
zurückgestellt hat: alle aktiven Konfigurationen lesen, je Mandant einen
|
||||||
|
Auftrag führen, deren Lebenszyklus bei jeder Konfigurationsänderung
|
||||||
|
nachziehen (heute verwaltet `setInterval` GENAU EINEN Auftrag unter
|
||||||
|
einem festen Namen), und entscheiden, was bei unterschiedlichen
|
||||||
|
Intervallen je Mandant gilt. Das ist eine Funktion, kein Bindungsumbau.
|
||||||
|
- **(c) Als benannte Altlast weiterführen, mit Markierung** — GEWÄHLT. Der
|
||||||
|
unmittelbare Präzedenzfall ist `getAllActiveConfigs` im Bereich `ldap`
|
||||||
|
(260909-ipc, Befund B): ein bewusst übergreifender Planer-Lesezugriff,
|
||||||
|
der ungebunden bleibt, einen eigenen Kopfkommentar trägt, und dessen
|
||||||
|
Verstummen nach dem Scharfschalten an die Vorabprüfung von Etappe 4
|
||||||
|
übergeben wird.
|
||||||
|
|
||||||
|
**Die Unsymmetrie, die dieser Präzedenzfall NICHT deckt:**
|
||||||
|
`getAllActiveConfigs` ist HEUTE korrekt und verstummt erst später. Der
|
||||||
|
DKV-Planer ist HEUTE bereits falsch — er bedient bei mehreren Mandanten
|
||||||
|
einen beliebigen und die übrigen nie — und verstummt zusätzlich später.
|
||||||
|
Die Markierung in Aufgabe 2 sagt beides, sonst läse sie sich wie eine
|
||||||
|
Entwarnung. Die gewählte Form hat drei Teile, alle umgesetzt: die
|
||||||
|
übergreifende Abfrage ist eine EIGENE, benannte Methode (kein Zweig hinter
|
||||||
|
einem optionalen Parameter), sie und der Planer tragen einen Kopfkommentar,
|
||||||
|
der beide Zustände benennt, und die Altlast steht als offener Eintrag im
|
||||||
|
Broken-Windows-Register (siehe SUMMARY dieses Plans für die genaue
|
||||||
|
Eintragskennung). Das Signal für das Verstummen gehört in die
|
||||||
|
Vorabprüfung von Etappe 4 (`apps/api/scripts/rls-preflight.mjs`), NICHT in
|
||||||
|
diesen Durchlauf.
|
||||||
|
|
||||||
|
**Die gemeinsame Ablage der Ausfuhrdateien samt Verdrängung über
|
||||||
|
Mandantengrenzen (Befund F).** `DkvExportService.writeAndPrune` behält die
|
||||||
|
letzten zehn Dateien des GEMEINSAMEN Verzeichnisses `user-files/`.
|
||||||
|
Verarbeitet ein Mandant zehn Rechnungen, verdrängt er damit die Dateien
|
||||||
|
aller anderen; deren Historienzeilen nennen dann einen Dateinamen, der
|
||||||
|
nicht mehr existiert. Das ist keine Bindungsfrage — es ist die
|
||||||
|
Ablagestruktur, und sie zu ändern (Unterverzeichnisse je Mandant, Umzug der
|
||||||
|
Bestandsdateien) ist ein eigener Auftrag. Siehe auch (d5) und T-MIR-08.
|
||||||
|
|
||||||
|
**Die Übergaben in die noch nicht umgestellten Bereiche.**
|
||||||
|
`dkv-mail.service.ts` hängt an `SettingsService.getDecryptedSmtpConfig`
|
||||||
|
(`settings.service.ts` ist noch vollständig unbound, siehe
|
||||||
|
`docs/mandantentrennung-zugriffsklassifikation.md`) — dieselbe
|
||||||
|
Reihenfolgebedingung, die der `tenders`-Durchlauf für dieselbe Abhängigkeit
|
||||||
|
als Befund K festhielt. `dkv.seed.ts` hängt an `module-registry`, ebenfalls
|
||||||
|
noch unbound. Nach dem Scharfschalten fände die ungebundene SMTP-Abfrage
|
||||||
|
keine Zeile mehr — Ergebnis: kein Versand für niemanden, mit Wiederholung
|
||||||
|
bei jedem Lauf (die Datei bleibt lokal verfügbar, D-16). Reihenfolgebedingung
|
||||||
|
für Etappe 4, hier festgehalten, nicht gelöst.
|
||||||
|
|
||||||
|
**Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich `dkv`
|
||||||
|
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
|
||||||
|
`tenders` es vormachen.
|
||||||
|
|
||||||
|
### (d5) Was dieser Durchlauf bewusst nicht anfasst
|
||||||
|
|
||||||
|
- **Die beiden mehrschrittigen Stellen bleiben unatomar (Befund C, TEIL
|
||||||
|
3).** Der Ersetzen-Modus des Fahrzeug-Imports (`deleteMany` gefolgt von
|
||||||
|
`createMany`) und die Zugangsdaten-Erhaltung in `saveConfig` (lesen,
|
||||||
|
entschlüsseln, neu verschlüsseln, schreiben) werden NICHT in eine
|
||||||
|
Transaktion gehoben — geprüft und bewusst gelassen, nicht übersehen. Der
|
||||||
|
im Kopf von `prisma-tenant.extension.ts` verlangte erneute Test ist mit
|
||||||
|
TEIL 3 dieser Aufgabe beantwortet: kein neuer Transaktionsfall,
|
||||||
|
`withTenantTransaction()` bleibt für diesen Bereich ungenutzt.
|
||||||
|
- **Die Ablagestruktur der Ausfuhrdateien bleibt unverändert (Befund F).**
|
||||||
|
Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je
|
||||||
|
Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).
|
||||||
|
|
||||||
## Verweis
|
## Verweis
|
||||||
|
|
||||||
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
||||||
|
|||||||
Reference in New Issue
Block a user