diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 0ec2411..9ae7911 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -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 { diff --git a/docs/mandantentrennung-etappe2-fehlerrichtung.md b/docs/mandantentrennung-etappe2-fehlerrichtung.md index ebda997..367136f 100644 --- a/docs/mandantentrennung-etappe2-fehlerrichtung.md +++ b/docs/mandantentrennung-etappe2-fehlerrichtung.md @@ -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 "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 Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang