diff --git a/apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql b/apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql new file mode 100644 index 0000000..3ce4f90 --- /dev/null +++ b/apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql @@ -0,0 +1,94 @@ +-- 260914-eym, Etappe 3c — der benannte Systemkontext fuer die +-- Hintergrunddienste. Sechs Stellen lesen absichtlich ueber ALLE Mandanten +-- (docs/mandantentrennung-zugriffsklassifikation.md, Abschnitt "Der +-- Hintergrunddienst als Falle"); ohne diese Migration saehen sie nach dem +-- Scharfschalten NULL Zeilen und wuerden stumm die Arbeit einstellen +-- (zu-wenig-statt-zu-viel, docs/mandantentrennung-etappe2-fehlerrichtung.md). +-- +-- Die betroffenen Dateien (20260618112133_rls_policies fuer LdapConfig und +-- LdapFieldMapping, 20260909140000_rls_remaining_tenant_tables fuer +-- DkvModuleConfig und TenderMatch, 20260911120000_rls_user_dimension_ +-- personal_tables fuer TenderSavedSearch) bleiben UNVERAENDERT stehen — +-- Prisma fuehrt ihre Pruefsumme, eine Aenderung braechte "prisma migrate +-- deploy" zum Abbruch. Kopfform: 20260911120000_rls_user_dimension_personal_tables. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (siehe +-- 20260909130000_rls_app_role und docs/mandantentrennung-datenbankrolle.md). +-- Die Verbindung ist zum Zeitpunkt dieser Migration weiterhin NICHT +-- umgestellt — `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera` +-- (BYPASSRLS). Der Schalter bleibt AUS: diese Regeln sind fuer jeden +-- heutigen Aufrufer wirkungslos, bis Etappe 4 scharfschaltet. + +-- Dritte Sitzungsvariable `app.system_context`. Der Helfer `forSystem()` +-- (apps/api/src/prisma/prisma-tenant.extension.ts) setzt sie auf 'true' +-- und die beiden anderen Variablen AUSDRUECKLICH auf den Leerstring; +-- `forTenant()` setzt sie umgekehrt ausdruecklich auf den Leerstring. +-- +-- COALESCE ist Pflicht: `current_setting(..., true)` liefert ohne gesetzte +-- Variable NULL, und `NULL = 'true'` waere NULL, nicht FALSE. Eine Regel +-- mit USING (NULL) laesst zwar keine Zeile durch, aber die Funktion soll +-- fuer jeden Aufrufer eine klare Antwort liefern: ohne Variable, mit +-- Leerstring und mit jedem anderen Wert als 'true' ist sie FALSE. Damit +-- bleibt die Vorher-Pruefung `ohne-kontext-leer` in rls-preflight.mjs +-- gueltig (ohne Variable sieht niemand etwas). Kein GRANT EXECUTE noetig — +-- wie bei current_tenant_id() und current_user_id(): PostgreSQL vergibt +-- EXECUTE auf Funktionen standardmaessig an PUBLIC. +CREATE OR REPLACE FUNCTION is_system_context() RETURNS BOOLEAN AS $$ + SELECT COALESCE(current_setting('app.system_context', true) = 'true', false); +$$ LANGUAGE sql STABLE; + +-- Je betroffener Tabelle EINE zusaetzliche PERMISSIVE Regel, NUR FOR SELECT. +-- Permissive Regeln werden ODER-verknuepft: fuer SELECT gilt danach +-- (Mandantenregel ODER Systemregel), fuer INSERT/UPDATE/DELETE gilt weiter +-- NUR die bestehende `tenant_isolation_policy` — unter Systemkontext ist +-- `current_tenant_id()` der Leerstring, kein Mandant passt, jedes Schreiben +-- faellt durch (gemessen: INSERT -> SQLSTATE 42501, updateMany/deleteMany +-- -> count 0, update per id -> P2025). Kein DROP POLICY, keine Aenderung an +-- bestehenden Regeln. Genau die fuenf Tabellen, die die Systemkontext-Leser +-- tatsaechlich lesen (gezaehlt in Aufrufe und include/select hinein): + +-- DkvModuleConfig — DkvService.loadActiveConfigsForScheduler() liest beim +-- Start des Planers ALLE aktiven Konfigurationen und registriert je Mandant +-- einen eigenen Cron-Auftrag (WINDOWS #21). +CREATE POLICY system_read_policy ON "DkvModuleConfig" + FOR SELECT USING (is_system_context()); + +-- LdapConfig — LdapConfigService.getAllActiveConfigs() (Sync-Planer) und +-- LdapConfigService.onApplicationBootstrap() (Nachverschluesselung alter +-- Klartext-Kennwoerter, liest ueber alle, schreibt je Zeile gebunden). +CREATE POLICY system_read_policy ON "LdapConfig" + FOR SELECT USING (is_system_context()); + +-- LdapFieldMapping — dieselbe Methode getAllActiveConfigs() ueber +-- `include: { fieldMappings: true }` (die WINDOWS-#27-Form: ein Relationsziel +-- wird ueber den Klienten der Elternabfrage gelesen und braucht dieselbe +-- Oeffnung). +CREATE POLICY system_read_policy ON "LdapFieldMapping" + FOR SELECT USING (is_system_context()); + +-- TenderMatch — TenderDigestScheduler.runDigest(), Kandidatenabfrage +-- (unbenachrichtigte Treffer aller Mandanten, danach je Kandidat gebunden). +CREATE POLICY system_read_policy ON "TenderMatch" + FOR SELECT USING (is_system_context()); + +-- TenderSavedSearch — TenderMatchingService.matchDelta(), alle gespeicherten +-- Suchprofile aller Mandanten (Treffer-Anlage danach je Profil gebunden). +CREATE POLICY system_read_policy ON "TenderSavedSearch" + FOR SELECT USING (is_system_context()); + +-- Was diese Migration bewusst NICHT tut: +-- +-- - Keine Regel auf SmtpConfig: der Startpfad des Mailmoduls (findFirst() +-- beim Boot, WINDOWS #30) wird in 260914-eym nicht auf den Systemkontext +-- umgestellt, sondern ENTFERNT — MailService baut je Versand einen +-- Transport aus der SmtpConfig des Empfaenger-Mandanten (gebunden). Der +-- sechste Fall der Hintergrunddienst-Falle existiert damit nicht mehr. +-- - Keine Regel auf Tenant und Tender: beide Tabellen tragen in KEINER +-- Migration ENABLE ROW LEVEL SECURITY — es gibt nichts zu oeffnen +-- (admin-seed.service.ts liest nur Tenant; der Katalog-Lesezugriff in +-- tender-matching.service.ts bleibt nach D-03 bewusst ungebunden). +-- - Keine Schreibregel unter Systemkontext: Schreiben bleibt je Mandant +-- ueber forTenant() — der Systemkontext liest, er handelt nicht. +-- - Keine Aenderung am Schalter: DATABASE_URL, Compose- und +-- Umgebungsdateien bleiben unangetastet. diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 3c45e37..9b4a09a 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -164,6 +164,27 @@ async function setupScratchDatabase(adminUrl) { await db.$executeRawUnsafe( `GRANT EXECUTE ON FUNCTION current_user_id() TO ${SCRATCH_ROLE_NAME}`, ); + + // Systemkontext (Etappe 3c, 260914-eym): is_system_context() wird aus + // der Migration 20260914120000_rls_system_context_read GESCHNITTEN, + // nicht getippt (T-EYM-07) — fehlt Migration oder Funktion, bricht das + // Werkzeug hier ab, statt mit einem geratenen Funktionstext zu messen. + const systemContextMigrationSql = readRlsSystemContextMigrationSql(); + if (!systemContextMigrationSql) { + fail( + 'Migration "_rls_system_context_read" nicht gefunden — is_system_context() kann nicht geschnitten werden.', + ); + } + const isSystemContextFunctionSql = extractIsSystemContextFunctionSql(systemContextMigrationSql); + if (!isSystemContextFunctionSql) { + fail( + 'CREATE OR REPLACE FUNCTION is_system_context() nicht in der Systemkontext-Migration gefunden.', + ); + } + await db.$executeRawUnsafe(isSystemContextFunctionSql); + await db.$executeRawUnsafe( + `GRANT EXECUTE ON FUNCTION is_system_context() TO ${SCRATCH_ROLE_NAME}`, + ); }); } @@ -190,7 +211,20 @@ function report(results, kennung, passed, detail) { * gemeinsamen Verbindung. */ async function forTenantQuery(prisma, tenantId, queryFn, userId) { - const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; + const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`; + const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]); + return result; +} + +/** + * Spiegelbildlich zu `forSystem()` in apps/api/src/prisma/prisma-tenant.extension.ts + * (Etappe 3c, 260914-eym) — bei jeder Aenderung dort HIER nachziehen: EINE + * getaggte Anweisung setzt `app.system_context = 'true'` und AUSDRUECKLICH + * `app.current_tenant = ''` und `app.current_user = ''`, alle drei als + * Literale; danach die Abfrage in derselben Array-Transaktion. + */ +async function forSystemQuery(prisma, queryFn) { + const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`; const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]); return result; } @@ -512,6 +546,42 @@ function extractCurrentUserIdFunctionSql(migrationSql) { return match ? match[0] : null; } +/** + * Liest die Migration des Systemkontexts (Etappe 3c, 260914-eym, Dateiname + * endet auf "_rls_system_context_read"). Die Funktion `is_system_context()` + * und die fuenf `system_read_policy`-Regeln MUESSEN aus dieser Datei + * geschnitten werden, nicht getippt (T-EYM-07, Muster + * readRlsUserDimensionMigrationSql). + */ +function readRlsSystemContextMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.endsWith('_rls_system_context_read')) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Schneidet die Definition von `is_system_context()` wortgleich aus der + * Systemkontext-Migration. `null`, wenn nichts gefunden wird — der Aufrufer + * bricht dann ab, statt die Funktion selbst zu tippen. + */ +function extractIsSystemContextFunctionSql(migrationSql) { + const re = /CREATE OR REPLACE FUNCTION is_system_context\(\)[\s\S]*?LANGUAGE sql STABLE;/; + const match = migrationSql.match(re); + return match ? match[0] : null; +} + +/** + * Schneidet die `system_read_policy` EINER Tabelle wortgleich aus der + * Systemkontext-Migration (Muster extractPolicySql, anderer Regelname). + */ +function extractSystemReadPolicySql(migrationSql, tableName) { + const re = new RegExp(`CREATE POLICY system_read_policy ON "${tableName}"[\\s\\S]*?;`); + const match = migrationSql.match(re); + return match ? match[0] : null; +} + /** * Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen * des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies @@ -5271,6 +5341,290 @@ async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) { * Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits * angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat. */ +/** + * Systemkontext (Etappe 3c, 260914-eym) — innere Routine je Tabelle, Muster + * `runSingleRulePersonalTableCheck`: Wegwerf-Tabelle mit ALLEN skalaren + * Spalten (Spaltenvergleich gegen schema.prisma als erste Pruefung mit + * Abbruch, 260910-krx-Lehre), Mandantenregel WORTGLEICH aus ihrer + * jeweiligen Migration, Systemregel WORTGLEICH aus der neuen Migration, + * Messung ueber den GENERIERTEN Client. Neun Kennungen je Tabelle: + * + * -wegwerftabelle-deckt-alle-spalten-des-generierten-clients + * -ungebunden-null-zeilen (roher Client: 0 Zeilen) + * -systemkontext-sieht-beide-mandanten (zu-wenig-Richtung, T-EYM-04) + * -systemkontext-insert-abgewiesen-42501 (zu-viel-Richtung, T-EYM-02) + * -systemkontext-updatemany-count-0 + * -systemkontext-deletemany-count-0 + * -fortenant-a-nach-systemkontext-nur-a (kein Erben, T-EYM-03) + * -is-system-context-unter-fortenant-false + * -pg-policies-genau-eine-system-read-policy-select + * + * Der Aufrufer reicht `tenantPolicySql` bereits geschnitten herein (jede + * Tabelle hat ihre eigene Quellmigration); `dropTable=false` laesst eine + * Elterntabelle stehen, auf die eine Folgetabelle per Join zeigt. + */ +async function runSystemContextTableCheck(config) { + const { + adminUrl, + scratchRoleUrl, + results, + slug, + tableName, + modelName, + tenantPolicySql, + systemContextMigrationSql, + createTableSql, + seedSql, + tenantOfRow, + createAttemptData, + updateManyData, + } = config; + + const systemPolicySql = extractSystemReadPolicySql(systemContextMigrationSql, tableName); + if (!systemPolicySql) { + report( + results, + `${slug}-system-read-policy-aus-migration-gefunden`, + false, + `CREATE POLICY system_read_policy ON "${tableName}" nicht in der Systemkontext-Migration (20260914120000) gefunden`, + ); + return; + } + if (!tenantPolicySql) { + report( + results, + `${slug}-tenant-isolation-policy-aus-migration-gefunden`, + false, + `CREATE POLICY tenant_isolation_policy ON "${tableName}" nicht in der zugehoerigen Migration gefunden`, + ); + return; + } + + const scratchAdminUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(); + await withAdminPrisma(scratchAdminUrl, async (db) => { + await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`); + await db.$executeRawUnsafe(createTableSql); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(tenantPolicySql); + await db.$executeRawUnsafe(systemPolicySql); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(seedSql); + }); + + const schemaFields = readSchemaModelScalarFieldNames(modelName); + const tableColumns = await withAdminPrisma(scratchAdminUrl, async (db) => { + const rows = await db.$queryRawUnsafe( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`, + ); + return rows.map((r) => r.column_name).sort(); + }); + const schemaFieldsSorted = [...schemaFields].sort(); + const columnsMatch = + schemaFieldsSorted.length > 0 && + schemaFieldsSorted.length === tableColumns.length && + schemaFieldsSorted.every((f, i) => f === tableColumns[i]); + report( + results, + `${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`, + columnsMatch, + `Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`, + ); + if (!columnsMatch) { + return; + } + + const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1); + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + // 2: roher Client ohne jede Variable — 0 Zeilen (die Regel oeffnet + // nichts, solange app.system_context nicht 'true' ist). + const unboundRows = await prisma[modelAccessor].findMany(); + report( + results, + `${slug}-ungebunden-null-zeilen`, + unboundRows.length === 0, + `roher Client ${modelAccessor}.findMany() ohne Kontext liefert ${unboundRows.length} Zeile(n)`, + ); + + // 3: Systemkontext sieht beide Mandanten (zu-wenig-Richtung). + const systemClient = buildInlineSystemClient(prisma); + const systemRows = await systemClient[modelAccessor].findMany(); + const seenTenants = [...new Set(systemRows.map((r) => tenantOfRow(r)))].sort(); + report( + results, + `${slug}-systemkontext-sieht-beide-mandanten`, + seenTenants.length === 2 && seenTenants[0] === 'TENANT-A' && seenTenants[1] === 'TENANT-B', + `system.${modelAccessor}.findMany() liefert ${systemRows.length} Zeile(n) aus Mandanten ${JSON.stringify(seenTenants)}`, + ); + + // 4: INSERT unter Systemkontext — die Regel ist FOR SELECT, das + // Schreiben faellt an der Mandantenregel durch (SQLSTATE 42501). + let insertRejected = false; + let insertDetail = ''; + try { + const created = await systemClient[modelAccessor].create({ data: createAttemptData }); + insertDetail = `system.${modelAccessor}.create(${JSON.stringify(createAttemptData)}) ist NICHT fehlgeschlagen — angelegt: ${JSON.stringify(created?.id)}`; + } catch (err) { + const sqlState = sqlStateOf(err); + const ctor = err?.constructor?.name ?? 'unbekannt'; + insertRejected = sqlState === '42501'; + insertDetail = `system.${modelAccessor}.create wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim().split('\n').slice(-1)[0]}`; + } + report(results, `${slug}-systemkontext-insert-abgewiesen-42501`, insertRejected, insertDetail); + + // 5: updateMany unter Systemkontext — count 0 (kein Mandant passt). + const updated = await systemClient[modelAccessor].updateMany({ where: {}, data: updateManyData }); + report( + results, + `${slug}-systemkontext-updatemany-count-0`, + updated.count === 0, + `system.${modelAccessor}.updateMany({ where: {}, data: ${JSON.stringify(updateManyData)} }) liefert count=${updated.count}`, + ); + + // 6: deleteMany unter Systemkontext — count 0. + const deleted = await systemClient[modelAccessor].deleteMany({ where: {} }); + const rowsAfterDelete = await withAdminPrisma(scratchAdminUrl, async (db) => { + const rows = await db.$queryRawUnsafe(`SELECT count(*)::int AS c FROM "${tableName}"`); + return rows[0].c; + }); + report( + results, + `${slug}-systemkontext-deletemany-count-0`, + deleted.count === 0 && rowsAfterDelete === 2, + `system.${modelAccessor}.deleteMany({}) liefert count=${deleted.count}; Zeilen danach (Wartungsrolle): ${rowsAfterDelete}`, + ); + + // 7: forTenant(A) unmittelbar nach dem Systemkontext auf DEMSELBEN + // Client — nur A (kein Erben, T-EYM-03). + await systemClient[modelAccessor].findMany(); + const boundA = buildInlineExtendedClient(prisma, 'TENANT-A'); + const rowsA = await boundA[modelAccessor].findMany(); + const tenantsA = [...new Set(rowsA.map((r) => tenantOfRow(r)))]; + report( + results, + `${slug}-fortenant-a-nach-systemkontext-nur-a`, + rowsA.length === 1 && tenantsA.length === 1 && tenantsA[0] === 'TENANT-A', + `bound(TENANT-A).${modelAccessor}.findMany() unmittelbar nach system.findMany() auf demselben Client liefert ${rowsA.length} Zeile(n) aus ${JSON.stringify(tenantsA)}`, + ); + + // 8: is_system_context() innerhalb der forTenant-Transaktion — false. + const [, isSystemRows] = await prisma.$transaction([ + prisma.$executeRaw`SELECT set_config('app.current_tenant', 'TENANT-A', true), set_config('app.current_user', '', true), set_config('app.system_context', '', true)`, + prisma.$queryRaw`SELECT is_system_context() AS v, current_setting('app.system_context', true) AS raw`, + ]); + report( + results, + `${slug}-is-system-context-unter-fortenant-false`, + isSystemRows[0].v === false, + `is_system_context() innerhalb der forTenant(TENANT-A)-Transaktion = ${JSON.stringify(isSystemRows[0].v)} (Rohwert ${JSON.stringify(isSystemRows[0].raw)})`, + ); + + // 9: pg_policies unter der Wegwerf-Rolle — genau eine system_read_policy, SELECT. + const policyRows = await prisma.$queryRaw`SELECT policyname, cmd, permissive, qual FROM pg_policies WHERE schemaname = 'public' AND tablename = ${tableName} AND policyname = 'system_read_policy'`; + report( + results, + `${slug}-pg-policies-genau-eine-system-read-policy-select`, + policyRows.length === 1 && policyRows[0].cmd === 'SELECT' && policyRows[0].permissive === 'PERMISSIVE', + `pg_policies fuer "${tableName}" (system_read_policy): ${JSON.stringify(policyRows)}`, + ); + } finally { + await prisma.$disconnect(); + } +} + +/** + * Systemkontext (Etappe 3c, 260914-eym) — misst zuerst die vier + * Funktionsfaelle von `is_system_context()` unter der Wegwerf-Rolle, dann + * je betroffener Tabelle die neun Wahrheiten ueber die innere Routine. + * Laeuft NACH runUserDimensionChecks() und VOR runConcurrencyProbe() (siehe + * Aufrufkette in main()); legt seine Wegwerf-Tabellen selbst neu an und + * setzt auf keiner Tabelle eines anderen Abschnitts auf. + */ +async function runSystemContextChecks(adminUrl, scratchRoleUrl, results) { + const systemContextMigrationSql = readRlsSystemContextMigrationSql(); + if (!systemContextMigrationSql) { + report(results, 'system-context-migration-gefunden', false, 'Migration "_rls_system_context_read" nicht gefunden'); + return; + } + const remainingTablesMigrationSql = readRemainingTenantTablesMigrationSql(); + if (!remainingTablesMigrationSql) { + report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden'); + return; + } + + // Vier Funktionsfaelle, je in einer eigenen Transaktion. + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const unsetRows = await prisma.$queryRaw`SELECT is_system_context() AS v, current_setting('app.system_context', true) AS raw`; + report( + results, + 'is-system-context-ungesetzt-false', + unsetRows[0].v === false, + `ohne gesetzte Variable: is_system_context() = ${JSON.stringify(unsetRows[0].v)} (Rohwert ${JSON.stringify(unsetRows[0].raw)}) — die Vorher-Pruefung ohne-kontext-leer in rls-preflight.mjs bleibt gueltig`, + ); + + const probeValue = async (value) => { + const [, rows] = await prisma.$transaction([ + prisma.$executeRaw`SELECT set_config('app.system_context', ${value}, true)`, + prisma.$queryRaw`SELECT is_system_context() AS v`, + ]); + return rows[0].v; + }; + const emptyValue = await probeValue(''); + report(results, 'is-system-context-leer-false', emptyValue === false, `nach set_config('app.system_context', '', true): ${JSON.stringify(emptyValue)}`); + const trueValue = await probeValue('true'); + report(results, 'is-system-context-true-true', trueValue === true, `nach set_config('app.system_context', 'true', true): ${JSON.stringify(trueValue)}`); + const foreignValue = await probeValue('yes'); + report(results, 'is-system-context-fremdwert-false', foreignValue === false, `nach set_config('app.system_context', 'yes', true): ${JSON.stringify(foreignValue)}`); + } finally { + await prisma.$disconnect(); + } + + // DkvModuleConfig — Mandantenregel aus 20260909140000_rls_remaining_tenant_tables, + // alle 16 skalaren Spalten des Modells. + await runSystemContextTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'dkvmoduleconfig', + tableName: 'DkvModuleConfig', + modelName: 'DkvModuleConfig', + tenantPolicySql: extractPolicySql(remainingTablesMigrationSql, 'DkvModuleConfig'), + systemContextMigrationSql, + createTableSql: ` + CREATE TABLE "DkvModuleConfig" ( + id text PRIMARY KEY, + "tenantId" text NOT NULL UNIQUE, + protocol text NOT NULL DEFAULT 'imap', + host text, + port integer, + encryption text NOT NULL DEFAULT 'ssl-tls', + folder text NOT NULL DEFAULT 'INBOX', + "senderFilter" text, + "pollIntervalMin" integer NOT NULL DEFAULT 60, + "isActive" boolean NOT NULL DEFAULT false, + "exportRecipient" text, + "vehicleFormatString" text NOT NULL DEFAULT '{Marke}/{Modell}/{Kennzeichen}', + domain text, + "encryptedInboxCreds" text, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `, + seedSql: ` + INSERT INTO "DkvModuleConfig" (id, "tenantId", "isActive", "pollIntervalMin") VALUES + ('cfg-a', 'TENANT-A', true, 15), + ('cfg-b', 'TENANT-B', true, 60); + `, + tenantOfRow: (row) => row.tenantId, + createAttemptData: { id: 'cfg-system-schreibversuch', tenantId: 'TENANT-A', isActive: true }, + updateManyData: { folder: 'SYSTEM-SCHREIBVERSUCH' }, + }); +} + /** * Spiegelbildlich zu `forTenant()` in apps/api/src/prisma/prisma-tenant.extension.ts * — bei jeder Aenderung dort HIER nachziehen. Seit Etappe 3b (260911-nke) @@ -5282,7 +5636,25 @@ function buildInlineExtendedClient(prisma, tenantId, userId) { return prisma.$extends({ query: { $allOperations({ args, query }) { - const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; + const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`; + return prisma.$transaction([setContext, query(args)]).then((res) => res[1]); + }, + }, + }); +} + +/** + * Spiegelbildlich zu `forSystem()` in apps/api/src/prisma/prisma-tenant.extension.ts + * (Etappe 3c, 260914-eym) — bei jeder Aenderung dort HIER nachziehen. Der + * Systemkontext ueber den GENERIERTEN Client: `app.system_context = 'true'`, + * die beiden anderen Variablen ausdruecklich leer, alles Literale, Array-Form + * von $transaction. + */ +function buildInlineSystemClient(prisma) { + return prisma.$extends({ + query: { + $allOperations({ args, query }) { + const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`; return prisma.$transaction([setContext, query(args)]).then((res) => res[1]); }, }, @@ -5510,6 +5882,7 @@ async function main() { await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); await runUserDimensionChecks(adminUrl, scratchRoleUrlString, results); + await runSystemContextChecks(adminUrl, scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); diff --git a/apps/api/src/dkv/dkv-scheduler.service.spec.ts b/apps/api/src/dkv/dkv-scheduler.service.spec.ts new file mode 100644 index 0000000..4f352db --- /dev/null +++ b/apps/api/src/dkv/dkv-scheduler.service.spec.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DkvSchedulerService } from './dkv-scheduler.service'; + +/** + * DkvSchedulerService.spec (Etappe 3c, 260914-eym, WINDOWS #21) — der + * Planer fuehrt seit diesem Durchlauf EINEN Cron-Auftrag JE aktivem + * Mandanten (`dkv-inbox-poll:`). Diese Tests nageln fest: + * + * - IDENTITAET MIT EINEM MANDANTEN (morgen alpha, ein Mandant): genau ein + * Auftrag, dieselbe Cron-Expression wie bisher, der Tick ruft + * `processInbox` mit dieser tenantId, eine inaktive/fehlende Config + * registriert nichts und protokolliert dieselbe Zeile wie bisher. + * - INVARIANTE MIT ZWEI MANDANTEN (assumption-delta "promote"): zwei + * Auftraege; die Aenderung des einen laesst den anderen unberuehrt. + * - FEHLERTOLERANZ: ein werfender Startpfad blockiert den Start nicht. + * + * Fake-Registry (Map-basiert, `getCronJob` wirft bei Unbekannt wie + * @nestjs/schedule), Fake-DkvService, ECHTES `cron` (liegt unter + * apps/api/node_modules als Peer von @nestjs/schedule) — `cronTime.source` + * und `fireOnTick()` sind die beobachtbaren Eigenschaften eines Auftrags. + */ + +function makeFakeRegistry() { + const jobs = new Map(); + return { + __jobs: jobs, + addCronJob: vi.fn((name: string, job: any) => { + if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`); + jobs.set(name, job); + }), + getCronJob: vi.fn((name: string) => { + const job = jobs.get(name); + if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); + return job; + }), + deleteCronJob: vi.fn((name: string) => { + const job = jobs.get(name); + if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); + jobs.delete(name); + }), + getCronJobs: vi.fn(() => jobs), + }; +} + +function makeFakeDkvService(configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error) { + return { + loadActiveConfigsForScheduler: vi.fn(async () => { + if (configs instanceof Error) throw configs; + return configs.filter((c) => c.isActive); + }), + processInbox: vi.fn(async (_tenantId: string) => undefined), + }; +} + +function makeScheduler( + configs: Array<{ tenantId: string; pollIntervalMin: number; isActive: boolean }> | Error, +) { + const registry = makeFakeRegistry(); + const dkvService = makeFakeDkvService(configs); + const scheduler = new DkvSchedulerService(registry as any, dkvService as any); + const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined); + return { registry, dkvService, scheduler, logSpy, errorSpy }; +} + +describe('DkvSchedulerService — ein Auftrag je Mandant (260914-eym, WINDOWS #21)', () => { + const registries: ReturnType[] = []; + + afterEach(() => { + // Jeden registrierten (echten) Cron-Auftrag stoppen, sonst haelt ein + // laufender Timer den Testprozess offen. + for (const registry of registries) { + for (const job of registry.__jobs.values()) job.stop(); + registry.__jobs.clear(); + } + registries.length = 0; + vi.restoreAllMocks(); + }); + + it('Test 1: EIN aktiver Mandant, pollIntervalMin 15 -> genau ein Auftrag dkv-inbox-poll: mit cronTime.source "*/15 * * * *" (Identitaet zu heute)', async () => { + const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]); + registries.push(registry); + + await scheduler.onModuleInit(); + + expect(dkvService.loadActiveConfigsForScheduler).toHaveBeenCalledTimes(1); + expect([...registry.__jobs.keys()]).toEqual(['dkv-inbox-poll:t1']); + expect(scheduler.registeredTenantIds()).toEqual(['t1']); + const job = registry.__jobs.get('dkv-inbox-poll:t1'); + expect(job.cronTime.source).toBe('*/15 * * * *'); + expect(job.isActive).toBe(true); + }); + + it('Test 2: pollIntervalMin 120 -> "0 */2 * * *" (Stundenfeld, unveraenderte Berechnung)', async () => { + const { registry, scheduler } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 120, isActive: true }]); + registries.push(registry); + + await scheduler.onModuleInit(); + + expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('0 */2 * * *'); + }); + + it('Test 3: fireOnTick() ruft processInbox genau mit dieser tenantId', async () => { + const { registry, scheduler, dkvService } = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: true }]); + registries.push(registry); + + await scheduler.onModuleInit(); + registry.__jobs.get('dkv-inbox-poll:t1').fireOnTick(); + await new Promise((r) => setImmediate(r)); + + expect(dkvService.processInbox).toHaveBeenCalledTimes(1); + expect(dkvService.processInbox).toHaveBeenCalledWith('t1'); + }); + + it('Test 4: inaktive oder keine Config -> kein Auftrag, Protokollzeile "no active config found"', async () => { + const inactive = makeScheduler([{ tenantId: 't1', pollIntervalMin: 15, isActive: false }]); + registries.push(inactive.registry); + await inactive.scheduler.onModuleInit(); + expect(inactive.registry.__jobs.size).toBe(0); + expect(inactive.scheduler.registeredTenantIds()).toEqual([]); + expect(inactive.logSpy).toHaveBeenCalledWith( + 'DKV scheduler: no active config found — cron job not registered', + ); + + const none = makeScheduler([]); + registries.push(none.registry); + await none.scheduler.onModuleInit(); + expect(none.registry.__jobs.size).toBe(0); + expect(none.logSpy).toHaveBeenCalledWith( + 'DKV scheduler: no active config found — cron job not registered', + ); + }); + + it('Test 5 (Invariante): ZWEI Mandanten -> zwei Auftraege; setInterval(30, t2) ersetzt nur t2, stopJob(t1) entfernt nur t1', async () => { + const { registry, scheduler, dkvService } = makeScheduler([ + { tenantId: 't1', pollIntervalMin: 15, isActive: true }, + { tenantId: 't2', pollIntervalMin: 60, isActive: true }, + ]); + registries.push(registry); + + await scheduler.onModuleInit(); + expect(scheduler.registeredTenantIds().sort()).toEqual(['t1', 't2']); + expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *'); + expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('0 */1 * * *'); + const t1JobBefore = registry.__jobs.get('dkv-inbox-poll:t1'); + + scheduler.setInterval(30, 't2'); + expect(registry.__jobs.get('dkv-inbox-poll:t2').cronTime.source).toBe('*/30 * * * *'); + expect(registry.__jobs.get('dkv-inbox-poll:t1')).toBe(t1JobBefore); + expect(registry.__jobs.get('dkv-inbox-poll:t1').cronTime.source).toBe('*/15 * * * *'); + + // Der Tick von t2 ruft weiterhin nur t2. + registry.__jobs.get('dkv-inbox-poll:t2').fireOnTick(); + await new Promise((r) => setImmediate(r)); + expect(dkvService.processInbox).toHaveBeenCalledWith('t2'); + expect(dkvService.processInbox).not.toHaveBeenCalledWith('t1'); + + scheduler.stopJob('t1'); + expect(scheduler.registeredTenantIds()).toEqual(['t2']); + expect(t1JobBefore.isActive).toBe(false); + expect(registry.__jobs.get('dkv-inbox-poll:t2').isActive).toBe(true); + }); + + it('Test 6: loadActiveConfigsForScheduler wirft -> Fehler gefangen und protokolliert, kein Auftrag, Start nicht blockiert', async () => { + const { registry, scheduler, errorSpy } = makeScheduler(new Error('db down')); + registries.push(registry); + + await expect(scheduler.onModuleInit()).resolves.toBeUndefined(); + + expect(registry.__jobs.size).toBe(0); + expect(errorSpy).toHaveBeenCalledWith('DKV scheduler init failed: db down'); + }); + + it('Test 7: stopJob fuer einen nicht registrierten Mandanten ist ein No-Op (kein Throw)', () => { + const { registry, scheduler } = makeScheduler([]); + registries.push(registry); + + expect(() => scheduler.stopJob('unbekannt')).not.toThrow(); + expect(registry.__jobs.size).toBe(0); + }); +}); diff --git a/apps/api/src/dkv/dkv-scheduler.service.ts b/apps/api/src/dkv/dkv-scheduler.service.ts index 503c546..54d60e7 100644 --- a/apps/api/src/dkv/dkv-scheduler.service.ts +++ b/apps/api/src/dkv/dkv-scheduler.service.ts @@ -21,77 +21,70 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi * module config. (Research Pattern 7: Dynamic Cron Job; Pitfall 4: ScheduleModule * must be registered in AppModule — done in Plan 01.) * - * Multi-tenant note (v1): On init, the scheduler loads config via - * `DkvService.loadAnyActiveConfigForScheduler()`, which pulls the first - * active DkvModuleConfig row via findFirst() — same underlying query as - * before, now split into its own named method (260909-mir). + * AUFTRAG JE MANDANT (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN): * - * BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D — - * volle Begruendung im Kopfkommentar von - * `DkvService.loadAnyActiveConfigForScheduler()` und im Abschnitt - * "Bereich dkv" von docs/mandantentrennung-etappe2-fehlerrichtung.md). - * Zwei Zustaende, beide gehoeren genannt: + * Einmal-abfragen-viele-bedienen. Beim Start laedt der Planer ueber + * `DkvService.loadActiveConfigsForScheduler()` (systemgebunden, `forSystem()`, + * nur lesend) ALLE aktiven Konfigurationen und registriert je aktivem + * Mandanten einen EIGENEN Cron-Auftrag unter dem Registry-Namen + * `dkv-inbox-poll:`. Der Tick eines Auftrags ruft + * `processInbox(tenantId)` fuer GENAU diesen Mandanten — der Tick selbst + * bleibt wie er ist (je Mandant gebunden, 260909-mir). * - * - HEUTE bereits falsch, nicht nur ungenau: bei mehreren Mandanten wird - * EIN beliebiger bedient, die uebrigen NIE — und ist ausgerechnet die - * gezogene Zeile inaktiv, registriert der Planer gar nichts, obwohl ein - * zweiter Mandant aktiv waere. - * - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe - * Abfrage zusaetzlich: sie liefert dann `null`, und die Protokollzeile - * unten ("no active config found") ist auf einer frischen Installation - * der Normalfall — sie alarmiert deshalb niemanden, obwohl ein - * tatsaechlich eingerichteter Mandant nicht bedient wird. + * Die Vorgaengerform hielt EIN Auftrag-Feld (`activeTenantId`) und EINEN + * Registry-Namen: bei mehreren Mandanten wurde ein beliebiger bedient, die + * uebrigen nie; `setInterval()` eines zweiten Mandanten ersetzte still den + * Auftrag des ersten. Das Einzahl-Feld ist ERSATZLOS entfernt (Entscheidung + * "promote", nicht "add-alongside": zwei Wahrheiten ueber denselben Zustand + * waren genau die Form, die #21 falsch machte). * - * Fuer single-tenant deployments (heute der einzige produktive Fall) ist - * dieselbe Abfrage stets die korrekte Config. Multi-tenant scheduling - * (poll-once-fan-out-many, ein Cron-Auftrag je aktivem Mandanten) ist die - * in 07-04 zurueckgestellte Mehrmandanten-Planung und bleibt eine - * Funktionsaenderung fuer eine kuenftige Phase, kein Bindungsumbau dieses - * Plans. + * Was mit EINEM Mandanten identisch bleibt (dkv-scheduler.service.spec.ts, + * je Aussage ein Test): genau ein Auftrag, dieselbe Cron-Expression wie + * bisher (`*\/15 * * * *` bzw. `0 *\/1 * * *`), der Tick ruft `processInbox` + * mit dieser tenantId, eine inaktive oder fehlende Konfiguration registriert + * nichts und protokolliert 'no active config found'. * - * The DkvController calls `setInterval()` after saving config so the cron job - * reflects any admin change immediately — without a service restart. + * `setInterval(intervalMin, tenantId)` (tenantId PFLICHT) und + * `stopJob(tenantId)` ersetzen bzw. entfernen NUR den Auftrag dieses + * Mandanten. The DkvController calls `setInterval()` after saving config so + * the cron job reflects any admin change immediately — without a restart. */ @Injectable() export class DkvSchedulerService implements OnModuleInit { private readonly logger = new Logger(DkvSchedulerService.name); - /** Name of the managed cron job in the SchedulerRegistry. */ - private readonly JOB_NAME = 'dkv-inbox-poll'; - - /** - * The tenantId this scheduler is currently serving. - * Updated when setInterval() is called with a new tenantId. - */ - private activeTenantId: string | null = null; + /** Praefix der Registry-Namen; der volle Name ist `:`. */ + private readonly JOB_NAME_PREFIX = 'dkv-inbox-poll'; constructor( private readonly schedulerRegistry: SchedulerRegistry, private readonly dkvService: DkvService, ) {} + private jobNameFor(tenantId: string): string { + return `${this.JOB_NAME_PREFIX}:${tenantId}`; + } + /** - * On application startup: load the first active DkvModuleConfig and - * register the cron job if the module is active. + * On application startup: load ALL active DkvModuleConfig rows (system + * context) and register one cron job per active tenant. * * Errors are caught and logged (not re-thrown) so a missing or broken * config does not prevent the rest of the application from starting. + * Eine LEERE Liste fuehrt zu "nichts tun" — kein Auftrag, nichts geloescht + * oder deaktiviert (Etappe-3c-Frage "Leere als Abwesenheit": nein). */ async onModuleInit(): Promise { try { - // Bewusst uebergreifender Planer-Startpfad (WINDOWS #21) — siehe - // Kopfkommentar dieser Klasse und von - // DkvService.loadAnyActiveConfigForScheduler(). - const config = await this.dkvService.loadAnyActiveConfigForScheduler(); - if (config?.isActive && config.tenantId) { - this.activeTenantId = config.tenantId; - this.setInterval(config.pollIntervalMin, config.tenantId); - this.logger.log( - `DKV scheduler initialized: every ${config.pollIntervalMin} min for tenant ${config.tenantId}`, - ); - } else { + const configs = await this.dkvService.loadActiveConfigsForScheduler(); + if (!configs || configs.length === 0) { this.logger.log('DKV scheduler: no active config found — cron job not registered'); + return; } + for (const config of configs) { + this.setInterval(config.pollIntervalMin, config.tenantId); + } + this.logger.log(`DKV scheduler initialized: ${configs.length} tenant(s)`); } catch (err) { this.logger.error( `DKV scheduler init failed: ${(err as Error).message}`, @@ -100,28 +93,22 @@ export class DkvSchedulerService implements OnModuleInit { } /** - * Create (or replace) the inbox polling cron job. + * Create (or replace) the inbox polling cron job of ONE tenant. * - * Replaces any existing job with the new interval. Called on module init - * and by DkvController.saveConfig() after the admin updates the config. + * Replaces only the job registered under this tenant's name. Called on + * module init (once per active tenant) and by DkvController.saveConfig() + * after the admin updates the config. * * @param intervalMin - Poll interval in minutes (e.g. 60 = every hour) - * @param tenantId - Tenant to process on each tick + * @param tenantId - Tenant to process on each tick (Pflicht) */ - setInterval(intervalMin: number, tenantId?: string): void { - if (tenantId) this.activeTenantId = tenantId; + setInterval(intervalMin: number, tenantId: string): void { + const jobName = this.jobNameFor(tenantId); - if (!this.activeTenantId) { - this.logger.warn('DKV scheduler: no active tenantId — cron job not created'); - return; - } - - const tenant = this.activeTenantId; - - // Remove existing job if registered + // Remove existing job of THIS tenant if registered try { - this.schedulerRegistry.getCronJob(this.JOB_NAME).stop(); - this.schedulerRegistry.deleteCronJob(this.JOB_NAME); + this.schedulerRegistry.getCronJob(jobName).stop(); + this.schedulerRegistry.deleteCronJob(jobName); } catch { /* Job not yet registered — this is expected on first call */ } @@ -136,9 +123,9 @@ export class DkvSchedulerService implements OnModuleInit { cronExpr = `0 */${hours} * * *`; // e.g. 0 */2 * * * } const job = new CronJobClass(cronExpr, () => { - this.dkvService.processInbox(tenant).catch((err) => + this.dkvService.processInbox(tenantId).catch((err) => this.logger.error( - `DKV inbox poll failed for tenant ${tenant}: ${(err as Error).message}`, + `DKV inbox poll failed for tenant ${tenantId}: ${(err as Error).message}`, ), ); }); @@ -146,25 +133,37 @@ export class DkvSchedulerService implements OnModuleInit { // Cast required: our minimal CronJob type doesn't match cron's full type signature. // At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it. // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any); + this.schedulerRegistry.addCronJob(jobName, job as any); job.start(); this.logger.log( - `DKV cron job registered: every ${intervalMin} minutes for tenant ${tenant}`, + `DKV cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`, ); } /** - * Stop and remove the inbox polling cron job. + * Stop and remove the inbox polling cron job of ONE tenant. * Called by DkvController when admin sets isActive=false in config. */ - stopJob(): void { + stopJob(tenantId: string): void { + const jobName = this.jobNameFor(tenantId); try { - this.schedulerRegistry.getCronJob(this.JOB_NAME).stop(); - this.schedulerRegistry.deleteCronJob(this.JOB_NAME); - this.logger.log('DKV cron job stopped and removed'); + this.schedulerRegistry.getCronJob(jobName).stop(); + this.schedulerRegistry.deleteCronJob(jobName); + this.logger.log(`DKV cron job stopped and removed for tenant ${tenantId}`); } catch { /* Not registered — no-op */ } } + + /** + * Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der + * Registry abgeleitet (nicht aus einem eigenen Feld), fuer Tests und + * Diagnose. + */ + registeredTenantIds(): string[] { + const prefix = `${this.JOB_NAME_PREFIX}:`; + const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[]; + return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)); + } } diff --git a/apps/api/src/dkv/dkv.controller.ts b/apps/api/src/dkv/dkv.controller.ts index fce940c..4cc460d 100644 --- a/apps/api/src/dkv/dkv.controller.ts +++ b/apps/api/src/dkv/dkv.controller.ts @@ -83,7 +83,7 @@ export class DkvController { if (dto.isActive && dto.pollIntervalMin) { this.dkvScheduler.setInterval(dto.pollIntervalMin, tenantId); } else if (dto.isActive === false) { - this.dkvScheduler.stopJob(); + this.dkvScheduler.stopJob(tenantId); } return result; diff --git a/apps/api/src/dkv/dkv.service.spec.ts b/apps/api/src/dkv/dkv.service.spec.ts index 58ce938..eed6f73 100644 --- a/apps/api/src/dkv/dkv.service.spec.ts +++ b/apps/api/src/dkv/dkv.service.spec.ts @@ -22,6 +22,8 @@ import { DkvService } from './dkv.service'; vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), + // Systemkontext (260914-eym): der Planer-Startpfad liest ueber forSystem(). + forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()), })); // `import * as fs from 'fs'` under ESM has a non-configurable module @@ -45,6 +47,7 @@ function _applySelect(row: any, select: Record | undefined) { function makeFakePrisma() { const configs = new Map(); // key: tenantId const boundCallLog: { tenantId: string; model: string; method: string }[] = []; + const systemCallLog: { model: string; method: string }[] = []; const dkvModuleConfig = { findFirst: vi.fn(async ({ select }: { select?: Record } = {}) => { @@ -215,6 +218,7 @@ function makeFakePrisma() { dkvVehicleMaster, dkvInvoiceHistory, __boundCallLog: boundCallLog, + __systemCallLog: systemCallLog, __seedConfig(tenantId: string, row: Record) { configs.set(tenantId, { tenantId, ...row }); }, @@ -231,6 +235,40 @@ function makeFakePrisma() { ...row, }); }, + /** + * Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog, + * NICHT in __boundCallLog. dkvModuleConfig.findMany filtert ueber die + * Map nach where.isActive und liefert nach tenantId sortiert. + */ + __makeSystemClient() { + return { + dkvModuleConfig: { + findMany: async ({ + where, + select, + orderBy, + }: { + where?: { isActive?: boolean }; + select?: Record; + orderBy?: { tenantId?: 'asc' | 'desc' }; + } = {}) => { + systemCallLog.push({ model: 'dkvModuleConfig', method: 'findMany' }); + let rows = Array.from(configs.values()); + if (where && typeof where.isActive === 'boolean') { + rows = rows.filter((r) => r.isActive === where.isActive); + } + if (orderBy?.tenantId) { + rows = rows.sort((a, b) => + orderBy.tenantId === 'asc' + ? a.tenantId.localeCompare(b.tenantId) + : b.tenantId.localeCompare(a.tenantId), + ); + } + return rows.map((r) => _applySelect(r, select)); + }, + }, + }; + }, __makeBoundClient(tenantId: string) { const wrapModel = (model: Record, modelName: string, methods: string[]) => { const wrapped: any = {}; @@ -409,33 +447,45 @@ describe('DkvService — Bindung an forTenant() (260909-mir)', () => { expect(result.status).toBe('ok'); }); - it('Test 6: der bewusst uebergreifende Planer-Startpfad steht NICHT im Bindungsprotokoll — Fehlen der Bindung ist hier die bestandene Erwartung, NICHT spaeter "reparieren"', async () => { + it('Test 6 (umgedreht, 260914-eym): der Planer-Startpfad erzeugt GENAU EINEN System-Aufruf (dkvModuleConfig.findMany) und KEINEN gebundenen — WINDOWS #21 geschlossen', async () => { const prisma = makeFakePrisma(); prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, encryptedInboxCreds: 'enc(egal)' }); const { service } = makeDkvService(prisma); - await service.loadAnyActiveConfigForScheduler(); + await service.loadActiveConfigsForScheduler(); + expect(prisma.__systemCallLog).toEqual([{ model: 'dkvModuleConfig', method: 'findMany' }]); expect( prisma.__boundCallLog.length, `der Planer-Startpfad darf KEINEN gebundenen Aufruf erzeugen, gefunden: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(0); }); - it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit (Befund D — Entlastung wird festgeschrieben, nicht geglaubt)', async () => { + it('Test 7: der Planer-Startpfad liefert die verschluesselten Zugangsdaten NICHT mit und genau die aktiven Mandanten, nach tenantId sortiert (260914-eym)', async () => { const prisma = makeFakePrisma(); + prisma.__seedConfig('t2', { + id: 'cfg-2', + protocol: 'imap', + isActive: true, + pollIntervalMin: 30, + encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)', + }); + prisma.__seedConfig('t3', { id: 'cfg-3', protocol: 'imap', isActive: false, pollIntervalMin: 60 }); prisma.__seedConfig('t1', { id: 'cfg-1', protocol: 'imap', isActive: true, + pollIntervalMin: 15, encryptedInboxCreds: 'enc(sollte-nie-hier-auftauchen)', }); const { service } = makeDkvService(prisma); - const result = await service.loadAnyActiveConfigForScheduler(); + const result = await service.loadActiveConfigsForScheduler(); - expect(result).not.toBeNull(); - expect((result as any).encryptedInboxCreds).toBeUndefined(); + expect(result.map((r: any) => r.tenantId)).toEqual(['t1', 't2']); + for (const row of result) { + expect((row as any).encryptedInboxCreds).toBeUndefined(); + } }); // ─── Aufgabe 3 (260909-mir): dkvVehicleMaster / dkvInvoiceHistory / getExportFile ─── diff --git a/apps/api/src/dkv/dkv.service.ts b/apps/api/src/dkv/dkv.service.ts index e7c153c..088be71 100644 --- a/apps/api/src/dkv/dkv.service.ts +++ b/apps/api/src/dkv/dkv.service.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { PrismaService } from '../prisma/prisma.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { DkvExportService } from './dkv-export.service'; import { DkvMailService } from './dkv-mail.service'; import { DkvParserService } from './dkv-parser.service'; @@ -56,13 +56,17 @@ const CONFIG_SAFE_SELECT = { * - T-07-09: Export filename validated against safe pattern before reading (traversal guard) * - Single-flight guard: prevents concurrent inbox processing (Pitfall 7) * - * Multi-tenant note (v1): The scheduler loads its startup config via - * loadAnyActiveConfigForScheduler(), which stays bewusst UNGEBUNDEN - * (WINDOWS #21, see that method's own doc comment). Each processInbox(tenantId) - * call is per-tenant and fully forTenant()-bound (260909-mir). The Controller - * scopes all operations to req.tenantId. Full per-tenant scheduling (one cron - * per active tenant) is deferred to a future plan — v1 covers single-tenant - * deployments. + * Multi-tenant note (seit 260914-eym, Etappe 3c): Der Planer laedt seinen + * Startpfad ueber `loadActiveConfigsForScheduler()` — SYSTEMGEBUNDEN + * (`forSystem()`, liest ALLE aktiven Konfigurationen ueber alle Mandanten, + * nur lesend) — und registriert je aktivem Mandanten einen eigenen + * Cron-Auftrag (einmal-abfragen-viele-bedienen, WINDOWS #21 geschlossen). + * Each processInbox(tenantId) call is per-tenant and fully forTenant()-bound + * (260909-mir). The Controller scopes all operations to req.tenantId. + * + * Bewusst NICHT angefasst (260914-eym): der Single-Flight-Riegel + * `processing` ist EIN prozessweites Boolean, nicht je Mandant — siehe + * Kommentar am Feld und WINDOWS-Eintrag (Ledger). */ @Injectable() export class DkvService { @@ -72,6 +76,14 @@ export class DkvService { * Single-flight guard: if processing is already in progress, any concurrent * call to processInbox() returns early without starting a second pipeline * run (Pitfall 7 — prevents the prune race condition and duplicate records). + * + * PROZESSWEIT, nicht je Mandant (260914-eym, bewusst unangetastet): seit + * je aktivem Mandanten ein eigener Cron-Auftrag laeuft, koennen sich zwei + * Ticks verschiedener Mandanten ueberschneiden — der zweite bricht dann + * still ab und wartet bis zum naechsten Intervall (Verzoegerung, kein + * Datenverlust; mit EINEM Mandanten unveraendert). Loesungsweg: Riegel je + * Mandant (Set) — als Ledger-Eintrag in .planning/WINDOWS.md + * gefuehrt, nicht in diesem Durchlauf gebaut (Auftrag: Tick unangetastet). */ private processing = false; @@ -102,7 +114,8 @@ export class DkvService { * optionalen Parameter, hinter dem der eine Zweig gebunden werden MUSSTE * und der andere gebunden werden DURFTE NICHT — genau die Form, die * dieser Umbau aufloest. Der uebergreifende Zweig ist jetzt eine eigene, - * benannte Methode: `loadAnyActiveConfigForScheduler()` unten. + * benannte Methode: `loadActiveConfigsForScheduler()` unten (seit + * 260914-eym systemgebunden, eine Zeile je aktivem Mandanten). */ async loadConfig(tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; @@ -113,40 +126,39 @@ export class DkvService { } /** - * Pull the DKV module config for a single, ARBITRARY tenant that has one - * configured — used EXCLUSIVELY by DkvSchedulerService.onModuleInit() to - * seed the one (v1, single-tenant) cron job at boot time. + * Alle AKTIVEN DKV-Konfigurationen ueber ALLE Mandanten — verwendet + * AUSSCHLIESSLICH von DkvSchedulerService.onModuleInit(), das je Zeile + * einen eigenen Cron-Auftrag `dkv-inbox-poll:` registriert. * - * BLEIBT bewusst UNGEBUNDEN (WINDOWS #21 Etappe 2, 260909-mir, Befund D - * — siehe .planning/WINDOWS.md und den Abschnitt "Bereich dkv" in - * docs/mandantentrennung-etappe2-fehlerrichtung.md fuer die vollstaendige - * Begruendung, hier nur die Kurzfassung): + * SYSTEMGEBUNDEN (Etappe 3c, 260914-eym, WINDOWS #21 GESCHLOSSEN): liest + * ueber `forSystem()` (Sitzungsvariable `app.system_context = 'true'`, + * Regel `system_read_policy ... FOR SELECT` auf "DkvModuleConfig", + * Migration 20260914120000). Warum VIELE statt EINER: * - * - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede - * Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient - * die uebrigen NIE. Ist ausgerechnet die gezogene Zeile inaktiv, - * registriert der Planer gar nichts, obwohl ein zweiter Mandant aktiv - * waere. - * - NACH DEM SCHARFSCHALTEN (Etappe 4, WINDOWS #18) verstummt dieselbe - * Abfrage zusaetzlich: sie liefert dann `null` statt einer beliebigen - * Zeile, der Planer protokolliert das als Normalfall und richtet fuer - * JEDEN Mandanten nichts ein — ohne Fehler, ohne Alarm. - * - Binden wuerde diesen Pfad garantiert leer laufen lassen (es gibt beim - * Boot strukturell keinen Mandantenkontext). Umbau auf - * einmal-abfragen-viele-bedienen ist die in 07-04 zurueckgestellte - * Mehrmandanten-Planung — eine Funktionsaenderung, kein Bindungsumbau, - * und deshalb hier NICHT vorgenommen. - * - Praezedenzfall: `LdapConfigService.getAllActiveConfigs()` - * (260909-ipc, Befund B) — mit der einen Unsymmetrie, die dieser - * Praezedenzfall NICHT deckt: `getAllActiveConfigs` ist heute korrekt - * und verstummt erst spaeter, dieser Pfad ist HEUTE bereits falsch UND - * verstummt zusaetzlich spaeter. + * - Die Vorgaengerform `findFirst()` ohne Bedingung zog bei mehreren + * Mandanten EINEN beliebigen und bediente die uebrigen NIE — HEUTE + * schon falsch (260909-mir, Befund D). `findMany({ where: { isActive: + * true } })` liefert jeden aktiven Mandanten genau einmal, sortiert nach + * tenantId (deterministische Reihenfolge der Auftraege). + * - Das VERSTUMMEN nach dem Scharfschalten (Etappe 4) ist strukturell + * ausgeschlossen: ohne Systemkontext saehe dieser Pfad unter einer Rolle + * ohne BYPASSRLS NULL Zeilen; `system_read_policy` oeffnet genau diese + * Tabelle fuer genau diesen Kontext, nur lesend (Werkzeugbeleg + * `dkvmoduleconfig-systemkontext-sieht-beide-mandanten`). + * - Mit EINEM Mandanten ist das Ergebnis beobachtbar identisch zur + * Vorgaengerform: eine Zeile, derselbe Auftrag, dieselbe Cron-Expression + * (dkv-scheduler.service.spec.ts, Test 1). * - * Das Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 - * (`apps/api/scripts/rls-preflight.mjs`), NICHT in diesen Durchlauf. + * `CONFIG_SAFE_SELECT`: die verschluesselten Zugangsdaten bleiben draussen + * (T-07-12) — der Planer braucht nur tenantId und pollIntervalMin. */ - async loadAnyActiveConfigForScheduler() { - return this.prisma.dkvModuleConfig.findFirst({ select: CONFIG_SAFE_SELECT }); + async loadActiveConfigsForScheduler() { + const systemPrisma = forSystem(this.prisma) as any; + return systemPrisma.dkvModuleConfig.findMany({ + where: { isActive: true }, + select: CONFIG_SAFE_SELECT, + orderBy: { tenantId: 'asc' }, + }); } /** diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts index 2aaf4a2..082c406 100644 --- a/apps/api/src/groups/migration-sql.spec.ts +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -255,6 +255,67 @@ describe('rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nk }); }); +describe('rls_system_context_read migration.sql (Etappe 3c, 260914-eym)', () => { + const sql = readMigrationSql('_rls_system_context_read'); + const SYSTEM_READ_TABLES = ['DkvModuleConfig', 'LdapConfig', 'LdapFieldMapping', 'TenderMatch', 'TenderSavedSearch']; + const NOT_OPENED_TABLES = ['SmtpConfig', 'Tenant', 'Tender']; + + function nonCommentLines(source: string): string { + return source + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + } + + function policyStatements(source: string): string[] { + return (nonCommentLines(source).match(/CREATE POLICY [\w]+ ON "[A-Za-z]+"[\s\S]*?;/g) ?? []).map((stmt) => + stmt.replace(/\s+/g, ' '), + ); + } + + it('legt is_system_context() mit COALESCE an (ohne Variable FALSE, nicht NULL)', () => { + expect(sql).toContain('CREATE OR REPLACE FUNCTION is_system_context() RETURNS BOOLEAN AS $$'); + expect(sql).toContain("COALESCE(current_setting('app.system_context', true) = 'true', false)"); + expect(sql).toContain('LANGUAGE sql STABLE'); + }); + + it('legt genau fuenf CREATE POLICY system_read_policy an, je eine fuer die fuenf Tabellen', () => { + const stmts = policyStatements(sql); + expect(stmts).toHaveLength(5); + for (const table of SYSTEM_READ_TABLES) { + const forTable = stmts.filter((stmt) => stmt.startsWith(`CREATE POLICY system_read_policy ON "${table}"`)); + expect(forTable, table).toHaveLength(1); + } + }); + + it('jede system_read_policy ist FOR SELECT mit USING (is_system_context())', () => { + const stmts = policyStatements(sql); + expect(stmts).toHaveLength(5); + for (const stmt of stmts) { + expect(stmt).toContain('FOR SELECT'); + expect(stmt).toContain('USING (is_system_context())'); + expect(stmt).not.toContain('WITH CHECK'); + } + }); + + it('enthaelt kein DROP POLICY (bestehende Regeln bleiben unveraendert)', () => { + expect(nonCommentLines(sql)).not.toContain('DROP POLICY'); + }); + + it('enthaelt KEINE Anweisung auf SmtpConfig/Tenant/Tender ausserhalb von Kommentaren', () => { + const codeOnly = nonCommentLines(sql); + expect(codeOnly).not.toContain('SmtpConfig'); + for (const table of NOT_OPENED_TABLES) { + expect(codeOnly).not.toContain(`"${table}"`); + } + }); + + it('nennt SmtpConfig im Kopf als bewusst nicht enthalten (Startpfad entfernt, nicht umgestellt)', () => { + expect(sql).toContain('Keine Regel auf SmtpConfig'); + expect(sql).toContain('ENTFERNT'); + }); +}); + describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => { const sql = readMigrationSql('_add_group_internal_name_and_object_guid'); diff --git a/apps/api/src/prisma/prisma-tenant.extension.spec.ts b/apps/api/src/prisma/prisma-tenant.extension.spec.ts index 1df7883..02f74e3 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.spec.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { forTenant, withTenantTransaction } from './prisma-tenant.extension'; +import { forSystem, forTenant, withTenantTransaction } from './prisma-tenant.extension'; /** * Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit @@ -199,6 +199,80 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => { expect(transactionCalls).toHaveLength(1); expect((transactionCalls[0] as unknown[]).length).toBe(2); }); + + // Systemkontext (Etappe 3c, 260914-eym): forTenant() setzt app.system_context + // AUSDRUECKLICH auf den Leerstring — als Literal im Template-Text, nicht als + // Parameter (die Parameterliste bleibt [tenantId, userId ?? '']). + it('setzt app.system_context im Template-Text ausdruecklich auf den Leerstring (kein Erben aus einem Systemkontext, 260914-eym)', async () => { + const fakePrisma: any = { + $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), + $extends: (config: any) => ({ + async __invoke(args: unknown, query: (args: unknown) => unknown) { + return config.query.$allOperations({ args, query }); + }, + }), + $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join(''); + expect(text).toContain("set_config('app.system_context', '', true)"); + expect(values).toEqual(['tenant-a', '']); + return 'set-config-promise'; + }), + }; + + const scoped = forTenant(fakePrisma, 'tenant-a') as any; + await scoped.__invoke({}, () => 'query-result'); + + expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); + }); +}); + +describe('forSystem() — Systemkontext fuer Hintergrunddienste (Etappe 3c, 260914-eym)', () => { + it("setzt alle drei Variablen als Literale im Template-Text ('true'/''/''), values leer, $transaction-Feld mit genau zwei Eintraegen", async () => { + const transactionCalls: unknown[] = []; + const fakeQueryResult = [{ id: 'row-1' }]; + const fakePrisma: any = { + $transaction: vi.fn((arg: unknown) => { + transactionCalls.push(arg); + return Promise.resolve(['set-config-result', fakeQueryResult]); + }), + $extends: (config: any) => ({ + async __invoke(args: unknown, query: (args: unknown) => unknown) { + return config.query.$allOperations({ args, query }); + }, + }), + $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join(''); + expect(text).toContain("set_config('app.system_context', 'true', true)"); + expect(text).toContain("set_config('app.current_tenant', '', true)"); + expect(text).toContain("set_config('app.current_user', '', true)"); + expect(values).toEqual([]); + return 'set-config-promise'; + }), + }; + + const system = forSystem(fakePrisma) as any; + let queryCallCount = 0; + const result = await system.__invoke({ where: { isActive: true } }, () => { + queryCallCount += 1; + return fakeQueryResult; + }); + + expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); + expect(transactionCalls).toHaveLength(1); + expect(Array.isArray(transactionCalls[0])).toBe(true); + expect((transactionCalls[0] as unknown[]).length).toBe(2); + expect(result).toBe(fakeQueryResult); + expect(queryCallCount).toBe(1); + }); + + it('nutzt im tatsaechlichen Code die Array-Form von $transaction — innerhalb von forSystem() selbst (WINDOWS-#20-Bauart)', () => { + const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8')); + const forSystemSource = extractFunctionSource(source, 'forSystem'); + expect(forSystemSource).not.toBe(''); + expect(forSystemSource).toMatch(/\$transaction\(\s*\[/); + expect(forSystemSource).not.toMatch(/\$transaction\(\s*async/); + expect(forSystemSource).not.toContain('$executeRawUnsafe'); + }); }); describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => { @@ -273,6 +347,24 @@ describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebund await withTenantTransaction(fakePrisma, "tenant-with-quote-' OR 1=1", async () => 'ok'); + expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1); + }); + it('setzt app.system_context im Template-Text auf tx ausdruecklich auf den Leerstring (260914-eym)', async () => { + const fakeTx: any = { + $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + const text = strings.join(''); + expect(text).toContain("set_config('app.current_tenant', "); + expect(text).toContain("set_config('app.system_context', '', true)"); + expect(values).toEqual(['tenant-a']); + return Promise.resolve(1); + }), + }; + const fakePrisma: any = { + $transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)), + }; + + await withTenantTransaction(fakePrisma, 'tenant-a', async () => 'ok'); + expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/api/src/prisma/prisma-tenant.extension.ts b/apps/api/src/prisma/prisma-tenant.extension.ts index 09aa665..4a9266c 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.ts @@ -146,13 +146,83 @@ import { PrismaClient } from '@prisma/client'; * KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion * (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere * Spekulation ohne heutigen Aufrufer. + * + * SYSTEMKONTEXT (Etappe 3c, 260914-eym): + * + * `forSystem(prisma)` ist ein SCHWESTERHELFER von `forTenant()`, kein + * vierter Parameter — die UMKEHRUNG der 3b-Begruendung oben, ausdruecklich + * so gewollt: der Systemkontext ist eine EIGENE Zugriffsklasse (liest ueber + * ALLE Mandanten), und genau deshalb bekommt der Detektor der + * Bestandsaufnahme (`rls-access-inventory.spec.ts`) fuer ihn eine EIGENE, + * fuenfte Erkennungsform (`const X = forSystem(`) mit dem Stand + * `system-gebunden`. Ein vierter Parameter an `forTenant()` haette diese + * Klasse fuer den Detektor UNSICHTBAR gemacht — ein ueber alle Mandanten + * lesender Zugriff waere als `gebunden` gezaehlt worden. + * + * Alle DREI Sitzungsvariablen werden in JEDER Form gesetzt: + * `forSystem()` setzt `app.system_context = 'true'` und AUSDRUECKLICH + * `app.current_tenant = ''` und `app.current_user = ''`; `forTenant()` und + * `withTenantTransaction()` setzen umgekehrt AUSDRUECKLICH + * `app.system_context = ''`. Kein Kontext darf vom anderen erben. + * `set_config(..., true)` (transaktionslokal) ist das ERSTE Netz — deshalb + * sieht `forTenant(A)` unmittelbar nach `forSystem` auf demselben Client + * nur A (gemessen im Werkzeug: `-fortenant-a-nach-systemkontext-nur-a`, + * `-is-system-context-unter-fortenant-false`). Der ausdrueckliche + * Reset ist das ZWEITE Netz fuer eine hypothetische `local=false`-Aenderung + * — durch Rueckbau falsifiziert (Reset entfernt UND local=false -> rot). + * Alle Werte von `forSystem()` stehen als LITERALE im Template-Text (es + * fliesst nichts Variables ein); in `forTenant()` bleibt die Parameterliste + * `[tenantId, userId ?? '']` unveraendert. + * + * Unter Systemkontext kann NUR GELESEN werden: die Regel + * `system_read_policy` (Migration 20260914120000_rls_system_context_read) + * ist `FOR SELECT`; permissive Regeln werden ODER-verknuepft, fuer + * INSERT/UPDATE/DELETE gilt weiter NUR die Mandantenregel, und unter + * Systemkontext ist `current_tenant_id()` der Leerstring — kein Mandant + * passt. Gemessen: INSERT -> SQLSTATE 42501, `updateMany`/`deleteMany` -> + * count 0, `update` per id -> P2025. + * + * Wer `forSystem()` rufen darf: AUSSCHLIESSLICH die in + * `FORSYSTEM_ALLOWED_CALL_SITES` (rls-access-inventory.spec.ts) genannten + * Stellen mit der dort genannten EXAKTEN Zahl je Datei. Jeder weitere + * Aufruf — in einer fremden Datei oder als zweiter in einer erlaubten — + * macht die Spec rot. Ein Anfrageweg darf diesen Helfer NIE rufen. */ export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) { return prisma.$extends({ query: { $allOperations({ args, query }: { args: any; query: (args: any) => any }) { const setContext = (prisma as any) - .$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; + .$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`; + + return (prisma as any) + .$transaction([setContext, query(args)]) + .then((results: any[]) => results[1]); + }, + }, + }); +} + +/** + * Systemkontext (Etappe 3c, 260914-eym): ein Client, der ueber ALLE + * Mandanten LIEST — fuer die Hintergrunddienste, die einmal ueber alles + * lesen und dann je Mandant gebunden handeln (DKV-Planer, ldap, + * tender-digest, tender-matching). Gleiche Array-Form-`$transaction`-Bauart + * wie `forTenant()` (Kontext und Abfrage auf EINER Verbindung, WINDOWS #20). + * + * EINE getaggte Anweisung setzt `app.system_context = 'true'` und + * AUSDRUECKLICH `app.current_tenant = ''` und `app.current_user = ''` — + * alle drei als Literale im Template-Text, es fliesst nichts Variables ein. + * Nur Lesen ist geoeffnet (`system_read_policy ... FOR SELECT`); jedes + * Schreiben scheitert an der Mandantenregel. Aufrufer: ausschliesslich die + * Stellen aus `FORSYSTEM_ALLOWED_CALL_SITES` (siehe Kopfkommentar). + */ +export function forSystem(prisma: PrismaClient) { + return prisma.$extends({ + query: { + $allOperations({ args, query }: { args: any; query: (args: any) => any }) { + const setContext = (prisma as any) + .$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`; return (prisma as any) .$transaction([setContext, query(args)]) @@ -186,7 +256,7 @@ export function withTenantTransaction( fn: (tx: any) => Promise, ): Promise { return (prisma as any).$transaction(async (tx: any) => { - await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; + await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.system_context', '', true)`; return fn(tx); }); } diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 916eecd..b107389 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -50,6 +50,23 @@ import { describe, expect, it } from 'vitest'; * ganzen kommentarfreien Quelltext gegen die innerhalb erkannter Aufrufe * gezaehlte Zahl) haelt die Grenze der Erkennung laut, nicht still — siehe * `RELATION_SPEC_EXCEPTIONS` unten. + * + * Erweitert in 260914-eym (Etappe 3c, Systemkontext): die FUENFTE Erkennung + * sammelt je Datei die Zuweisungen der Form `const = forSystem(` und + * sucht danach `.` — das ist die eigene Zugriffsklasse + * "liest ueber ALLE Mandanten" (Stand `system-gebunden`), die der + * Schwesterhelfer `forSystem()` aus `prisma-tenant.extension.ts` bildet. + * Relationsziele ueber `include`/`select` auf einem System-Klienten landen + * ebenfalls in `systemModels` (die vierte Erkennung bekommt dafuer die + * Zielmenge direkt statt eines `isBound`-Flags). Vorrang der Staende je + * Paar (Datei, Modell): ungebunden vorhanden UND anderes -> `gemischt`; + * nur ungebunden -> `ungebunden`; Systemkontext vorhanden und KEIN + * ungebundener Zugriff -> `system-gebunden` (auch wenn daneben + * mandantengebundene Zugriffe stehen — die Begruendungsspalte nennt sie); + * nur mandantengebunden -> `gebunden`. Der Wachhund + * `FORSYSTEM_ALLOWED_CALL_SITES` unten nennt je Datei die EXAKTE Zahl der + * `forSystem(`-Aufrufe — ein Anfrageweg, der `forSystem` ruft, laese an + * JEDER Mandantenregel vorbei (T-EYM-01). */ const API_SRC_DIR = join(__dirname, '..'); @@ -109,15 +126,33 @@ const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set([]); */ const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill-tender-source.ts']); -const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const; +/** + * Erlaubnisliste fuer `forSystem(` (Etappe 3c, 260914-eym, T-EYM-01): + * Datei -> EXAKTE Zahl der `forSystem(`-Aufrufe. Der Systemkontext liest an + * JEDER Mandantenregel vorbei; ein Anfrageweg darf ihn nie rufen. Deshalb + * ist die Liste kein "mindestens", sondern ein "genau": jede Datei mit + * `forSystem(` ausserhalb der Liste, jede Abweichung der Zahl (auch ein + * ZWEITER Aufruf in einer erlaubten Datei) und jeder veraltete Eintrag + * (Datei weg oder Zahl gesunken) machen die Spec rot. Aufgabe 1 traegt den + * ersten Aufrufer (DKV-Planer-Startpfad); Aufgabe 2 erweitert auf die vier + * Dateien der sechs Hintergrunddienst-Faelle. + */ +const FORSYSTEM_ALLOWED_CALL_SITES = new Map([ + ['apps/api/src/dkv/dkv.service.ts', 1], +]); + +const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt', 'system-gebunden'] as const; type Stand = (typeof STAND_TOKENS)[number]; interface FileAnalysis { file: string; unboundModels: Set; boundModels: Set; + systemModels: Set; totalForTenantCalls: number; assignmentFormCalls: number; + totalForSystemCalls: number; + systemAssignmentFormCalls: number; rawInteractiveTransactionCount: number; matchedInteractiveTransactionCount: number; rawRelationSpecCount: number; @@ -303,9 +338,7 @@ interface RelationScanFrame { function scanRelationKeys( region: string, initialContext: string, - isBound: boolean, - unboundModels: Set, - boundModels: Set, + targetModels: Set, ): void { const stack: RelationScanFrame[] = [{ context: initialContext, enteringKey: null }]; let pendingContext: string | null = null; @@ -333,7 +366,7 @@ function scanRelationKeys( const relTarget = keyName ? SCHEMA_RELATIONS.get(currentContext)?.get(keyName) : undefined; if (keyName && relTarget) { const clientName = lowerFirst(relTarget); - (isBound ? boundModels : unboundModels).add(clientName); + targetModels.add(clientName); pendingContext = relTarget; pendingKey = keyName; } else if (keyName === '_count') { @@ -344,7 +377,7 @@ function scanRelationKeys( const relations = SCHEMA_RELATIONS.get(currentContext); if (relations) { for (const target of relations.values()) { - (isBound ? boundModels : unboundModels).add(lowerFirst(target)); + targetModels.add(lowerFirst(target)); } } } @@ -383,6 +416,20 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis { // die Definition ist kein Aufruf und braucht keine Zuweisungsform. const totalForTenantCalls = [...source.matchAll(/(? = + // forSystem(` und danach `.` — die Klasse "liest ueber ALLE + // Mandanten". Gezaehlt wie bei forTenant: Aufrufe, nicht die Definition. + const systemAssignmentMatches = [...source.matchAll(/const\s+(\w+)\s*=\s*forSystem\(/g)]; + const systemNames = new Set(systemAssignmentMatches.map((m) => m[1]).filter(Boolean) as string[]); + const systemModels = new Set(); + for (const name of systemNames) { + const re = new RegExp(`\\b${name}\\.([a-zA-Z]+)`, 'g'); + for (const m of source.matchAll(re)) { + if (m[1]) systemModels.add(m[1]); + } + } + const totalForSystemCalls = [...source.matchAll(/(?.$transaction(async" im Quelltext), danach die @@ -460,6 +507,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis { const allReceiverNames = new Set([ 'this.prisma', ...boundNames, + ...systemNames, ...txBoundParams, ...txUnboundParams, ]); @@ -478,7 +526,13 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis { const modelClientName = m[2]; if (!receiver || !modelClientName || m.index === undefined) continue; - const isBound = boundReceiverNames.has(receiver); + // Zielmenge nach dem Empfaenger des Ankers: System-Klient -> systemModels, + // gebundener Klient/Transaktionsparameter -> boundModels, sonst unboundModels. + const targetModels = systemNames.has(receiver) + ? systemModels + : boundReceiverNames.has(receiver) + ? boundModels + : unboundModels; const openIndex = m.index + m[0].length - 1; const closeIndex = findMatchingBracket(blank, openIndex, '(', ')'); if (closeIndex === -1) continue; @@ -502,7 +556,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis { const initialContext = CLIENT_NAME_TO_MODEL.get(modelClientName); if (initialContext) { - scanRelationKeys(region, initialContext, isBound, unboundModels, boundModels); + scanRelationKeys(region, initialContext, targetModels); } } @@ -515,8 +569,11 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis { file: relPath, unboundModels, boundModels, + systemModels, totalForTenantCalls, assignmentFormCalls: assignmentMatches.length, + totalForSystemCalls, + systemAssignmentFormCalls: systemAssignmentMatches.length, rawInteractiveTransactionCount, matchedInteractiveTransactionCount: directInteractiveMatches.length, rawRelationSpecCount, @@ -548,7 +605,7 @@ interface AccessSite { function findAccessSites(analyses: FileAnalysis[]): AccessSite[] { const sites: AccessSite[] = []; for (const a of analyses) { - const allModels = new Set([...a.unboundModels, ...a.boundModels]); + const allModels = new Set([...a.unboundModels, ...a.boundModels, ...a.systemModels]); for (const model of allModels) { sites.push({ file: a.file, model }); } @@ -559,11 +616,19 @@ function findAccessSites(analyses: FileAnalysis[]): AccessSite[] { function computeStandByKey(analyses: FileAnalysis[]): Map { const standByKey = new Map(); for (const a of analyses) { - const allModels = new Set([...a.unboundModels, ...a.boundModels]); + const allModels = new Set([...a.unboundModels, ...a.boundModels, ...a.systemModels]); for (const model of allModels) { const isBound = a.boundModels.has(model); const isUnbound = a.unboundModels.has(model); - const stand: Stand = isBound && isUnbound ? 'gemischt' : isBound ? 'gebunden' : 'ungebunden'; + const isSystem = a.systemModels.has(model); + // Vorrang (260914-eym): ungebunden + anderes -> gemischt; nur ungebunden + // -> ungebunden; system ohne ungebunden -> system-gebunden (auch neben + // gebundenen Zugriffen); sonst gebunden. + let stand: Stand; + if (isUnbound && (isBound || isSystem)) stand = 'gemischt'; + else if (isUnbound) stand = 'ungebunden'; + else if (isSystem) stand = 'system-gebunden'; + else stand = 'gebunden'; standByKey.set(`${a.file}::${model}`, stand); } } @@ -634,7 +699,7 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst expect(invalid, JSON.stringify(invalid)).toEqual([]); }); - it('jeder Eintrag traegt einen der drei gueltigen Stand-Werte', () => { + it('jeder Eintrag traegt einen der vier gueltigen Stand-Werte (gebunden, ungebunden, gemischt, system-gebunden)', () => { const invalid = docEntries.filter((e) => !STAND_TOKENS.includes(e.stand as Stand)); expect(invalid, JSON.stringify(invalid)).toEqual([]); }); @@ -700,6 +765,53 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst ).toEqual([]); }); + it('FORSYSTEM_ALLOWED_CALL_SITES: jede Datei mit forSystem(-Aufrufen steht in der Erlaubnisliste und die Zahl stimmt EXAKT (260914-eym, T-EYM-01)', () => { + const violations: string[] = []; + for (const a of analyses) { + if (a.totalForSystemCalls === 0) continue; + const allowed = FORSYSTEM_ALLOWED_CALL_SITES.get(a.file); + if (allowed === undefined) { + violations.push( + `${a.file}: ${a.totalForSystemCalls} forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen`, + ); + } else if (allowed !== a.totalForSystemCalls) { + violations.push( + `${a.file}: gemessen ${a.totalForSystemCalls} forSystem(-Aufruf(e), erlaubt sind genau ${allowed}`, + ); + } + } + expect(violations, violations.join('\n')).toEqual([]); + }); + + it('keine veraltete FORSYSTEM_ALLOWED_CALL_SITES: jede Datei existiert und traegt genau die genannte Zahl forSystem(-Aufrufe (260914-eym)', () => { + const staleEntries: string[] = []; + const analysesByFile = new Map(analyses.map((a) => [a.file, a])); + for (const [file, allowed] of FORSYSTEM_ALLOWED_CALL_SITES) { + if (!existsSync(join(REPO_ROOT, file))) { + staleEntries.push(`${file}: Datei existiert nicht mehr`); + continue; + } + const measured = analysesByFile.get(file)?.totalForSystemCalls ?? 0; + if (measured !== allowed) { + staleEntries.push( + `${file}: Erlaubnisliste nennt ${allowed}, gemessen ${measured} — der Eintrag ist ueberholt`, + ); + } + } + expect(staleEntries, staleEntries.join('\n')).toEqual([]); + }); + + it('jedes forSystem(-Vorkommen folgt der Zuweisungsform `const X = forSystem(` — ohne Ausnahmeliste (260914-eym)', () => { + const violations: string[] = []; + for (const a of analyses) { + const unmatched = a.totalForSystemCalls - a.systemAssignmentFormCalls; + if (unmatched > 0) { + violations.push(`${a.file}: ${unmatched} forSystem(-Aufruf(e) ausserhalb der Zuweisungsform`); + } + } + expect(violations, violations.join('\n')).toEqual([]); + }); + it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => { const violations: string[] = []; for (const a of analyses) { @@ -911,4 +1023,78 @@ class ProbeService { expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1); expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT'); }); + it('Probe C (260914-eym, Systemkontext): `include: { fieldMappings: true }` auf einem forSystem(-Klienten liefert systemModels mit ldapConfig UND ldapFieldMapping, beide weder in bound noch unbound, Stand system-gebunden', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async getAllActiveConfigs() { + const systemPrisma = forSystem(this.prisma) as any; + return systemPrisma.ldapConfig.findMany({ + where: { isActive: true }, + include: { fieldMappings: true }, + }); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-c.service.ts'); + expect([...result.systemModels].sort()).toEqual(['ldapConfig', 'ldapFieldMapping']); + expect(result.boundModels.size).toBe(0); + expect(result.unboundModels.size).toBe(0); + expect(result.totalForSystemCalls).toBe(1); + expect(result.systemAssignmentFormCalls).toBe(1); + const stand = computeStandByKey([result]); + expect(stand.get('apps/api/src/probe/probe-c.service.ts::ldapConfig')).toBe('system-gebunden'); + expect(stand.get('apps/api/src/probe/probe-c.service.ts::ldapFieldMapping')).toBe('system-gebunden'); + }); + + it('Probe D (260914-eym, Vorrang): system + forTenant auf demselben Modell bleibt system-gebunden; system + this.prisma auf demselben Modell wird gemischt', () => { + const systemPlusBound = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async readAll() { + const systemPrisma = forSystem(this.prisma) as any; + return systemPrisma.ldapConfig.findMany(); + } + async writeOne(tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.ldapConfig.update({ where: { id: 'x' }, data: {} }); + } +} +`; + const r1 = analyzeSource(systemPlusBound, 'apps/api/src/probe/probe-d1.service.ts'); + expect(computeStandByKey([r1]).get('apps/api/src/probe/probe-d1.service.ts::ldapConfig')).toBe( + 'system-gebunden', + ); + + const systemPlusUnbound = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async readAll() { + const systemPrisma = forSystem(this.prisma) as any; + return systemPrisma.ldapConfig.findMany(); + } + async readRaw() { + return this.prisma.ldapConfig.findMany(); + } +} +`; + const r2 = analyzeSource(systemPlusUnbound, 'apps/api/src/probe/probe-d2.service.ts'); + expect(computeStandByKey([r2]).get('apps/api/src/probe/probe-d2.service.ts::ldapConfig')).toBe( + 'gemischt', + ); + }); + + it('Probe E (260914-eym, Zuweisungsform): `forSystem(this.prisma).x.findMany()` ohne Zuweisung zaehlt totalForSystemCalls 1, systemAssignmentFormCalls 0', () => { + const probe = ` +class ProbeService { + constructor(private readonly prisma: any) {} + async run() { + return forSystem(this.prisma).ldapConfig.findMany(); + } +} +`; + const result = analyzeSource(probe, 'apps/api/src/probe/probe-e.service.ts'); + expect(result.totalForSystemCalls).toBe(1); + expect(result.systemAssignmentFormCalls).toBe(0); + }); }); diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index cc1085d..72474c8 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -534,9 +534,23 @@ geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.` oder — seit 260909-ipc, Befund G — in `.` -verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit +oder — seit 260914-eym — in `.` verwendet), Klasse, +Stand (`gebunden`/`ungebunden`/`gemischt`/`system-gebunden`, seit 260909-ipc maschinell gegen den Quelltext geprüft), Begründung. +**Fünfte Erkennungsform und vierter Stand-Wert (260914-eym, Etappe 3c):** +`const = forSystem(` und danach `.` — der benannte +Systemkontext der Hintergrunddienste (liest über ALLE Mandanten, nur lesend, +Regel `system_read_policy … FOR SELECT`). Relationsziele über `include`/ +`select` auf einem System-Klienten zählen ebenfalls als system-gebunden. +Vorrang der Stände je Paar: ungebunden vorhanden UND anderes → `gemischt`; +nur ungebunden → `ungebunden`; Systemkontext vorhanden und KEIN ungebundener +Zugriff → `system-gebunden` (auch wenn daneben mandantengebundene Zugriffe +stehen — die Begründungsspalte nennt sie); nur mandantengebunden → +`gebunden`. Wer `forSystem(` rufen darf, steht mit EXAKTER Zahl je Datei in +`FORSYSTEM_ALLOWED_CALL_SITES` (Detektor) — jede Fremddatei, jede Abweichung +der Zahl und jeder veraltete Eintrag machen die Spec rot. + **Erkennungslücke GESCHLOSSEN (260911-mkj, WINDOWS #27):** bis 260911-e2s sah die Bestandsaufnahme ausschließlich (Datei, Modell)-Paare über `this.prisma.` bzw. `.` — eine @@ -553,8 +567,9 @@ true`, Relationsfilter in `where:`, `orderBy:` über Relationen und verschachtelte Schreibzugriffe in `data:`. Bewusst NICHT gesehen, und wie das begrenzt ist: ein Empfänger außerhalb der -vier Erkennungsformen (`this.prisma`, eine `const X = forTenant(`-Zuweisung, -ein Transaktionsparameter, `withTenantTransaction(`) — begrenzt durch den +fünf Erkennungsformen (`this.prisma`, eine `const X = forTenant(`-Zuweisung, +ein Transaktionsparameter, `withTenantTransaction(`, seit 260914-eym eine +`const X = forSystem(`-Zuweisung) — begrenzt durch den Waechter Rohzahl (`include|select|_count` über den gesamten kommentarfreien Quelltext) gegen die innerhalb erkannter Aufrufe gezählte Zahl, mit begründeter Ausnahmeliste `RELATION_SPEC_EXCEPTIONS`; ein `include:`/ @@ -587,7 +602,7 @@ werden. | apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). | | apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. | -| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. | +| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). | | apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). | | apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |