feat(quick-260914-eym): forSystem(), is_system_context(), Systemleseregel auf fuenf Tabellen, DKV-Planer je Mandant — ein Pfad (WINDOWS #21)

- Helfer forSystem(prisma) in prisma-tenant.extension.ts (Array-Form,
  setzt app.system_context='true' und die beiden anderen Variablen
  ausdruecklich leer); forTenant()/withTenantTransaction() setzen
  app.system_context='' als Literal (4 neue Spec-Tests)
- Migration 20260914120000_rls_system_context_read: is_system_context()
  (COALESCE, STABLE) und system_read_policy FOR SELECT auf DkvModuleConfig,
  LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — lokal
  angewendet (36 Migrationen, pg_proc 1, 5 system_read_policy, 34 Regeln)
- migration-sql.spec.ts: describe-Block fuer die neue Migration (6 Tests)
- rls-scratch-check.mjs: Funktion aus der Migration geschnitten,
  forSystemQuery/buildInlineSystemClient, Reset in forTenantQuery/
  buildInlineExtendedClient, runSystemContextChecks (4 Funktionsfaelle +
  9 Kennungen DkvModuleConfig) -> Alle 216 Pruefungen bestanden
- rls-access-inventory.spec.ts: fuenfte Erkennungsform const X = forSystem(,
  Stand system-gebunden mit Vorrangregel, FORSYSTEM_ALLOWED_CALL_SITES
  (exakte Zahl je Datei, 3 Tests), Proben C/D/E
- DKV: loadActiveConfigsForScheduler() ueber forSystem (findMany isActive,
  CONFIG_SAFE_SELECT, orderBy tenantId); DkvSchedulerService mit Auftrag je
  Mandant dkv-inbox-poll:<tenantId>, activeTenantId ersatzlos entfernt,
  setInterval/stopJob je Mandant, registeredTenantIds(); Controller
  stopJob(tenantId); neue dkv-scheduler.service.spec.ts (7 Tests),
  dkv.service.spec.ts Tests 6/7 umgestellt
- Klassifikation: dkv.service.ts/dkvModuleConfig system-gebunden, Header
  mit fuenfter Erkennungsform und viertem Stand-Wert
- Baseline: 63 Dateien / 1051 Tests, tsc 0, Werkzeug 216

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 11:32:54 +02:00
parent 02016e19eb
commit 3d645674f0
12 changed files with 1272 additions and 139 deletions
@@ -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.
+375 -2
View File
@@ -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:
*
* <slug>-wegwerftabelle-deckt-alle-spalten-des-generierten-clients
* <slug>-ungebunden-null-zeilen (roher Client: 0 Zeilen)
* <slug>-systemkontext-sieht-beide-mandanten (zu-wenig-Richtung, T-EYM-04)
* <slug>-systemkontext-insert-abgewiesen-42501 (zu-viel-Richtung, T-EYM-02)
* <slug>-systemkontext-updatemany-count-0
* <slug>-systemkontext-deletemany-count-0
* <slug>-fortenant-a-nach-systemkontext-nur-a (kein Erben, T-EYM-03)
* <slug>-is-system-context-unter-fortenant-false
* <slug>-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...`);
@@ -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:<tenantId>`). 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<string, any>();
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<typeof makeFakeRegistry>[] = [];
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:<t> 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);
});
});
+71 -72
View File
@@ -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:<tenantId>`. 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 `<Praefix>:<tenantId>`. */
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<void> {
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));
}
}
+1 -1
View File
@@ -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;
+56 -6
View File
@@ -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<string, boolean> | undefined) {
function makeFakePrisma() {
const configs = new Map<string, any>(); // 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<string, boolean> } = {}) => {
@@ -215,6 +218,7 @@ function makeFakePrisma() {
dkvVehicleMaster,
dkvInvoiceHistory,
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
__seedConfig(tenantId: string, row: Record<string, unknown>) {
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<string, boolean>;
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<string, any>, 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 ───
+51 -39
View File
@@ -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<tenantId>) — 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:<tenantId>` 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' },
});
}
/**
+61
View File
@@ -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');
@@ -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);
});
});
+72 -2
View File
@@ -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: `<slug>-fortenant-a-nach-systemkontext-nur-a`,
* `<slug>-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<T>(
fn: (tx: any) => Promise<T>,
): Promise<T> {
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);
});
}
+198 -12
View File
@@ -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 <Name> = forSystem(` und
* sucht danach `<Name>.<Modell>` — 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<string>([]);
*/
const RELATION_SPEC_EXCEPTIONS = new Set<string>(['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<string, number>([
['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<string>;
boundModels: Set<string>;
systemModels: Set<string>;
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<string>,
boundModels: Set<string>,
targetModels: Set<string>,
): 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(/(?<!function )forTenant\(/g)].length;
// Fuenfte Erkennung (260914-eym, Etappe 3c): Zuweisungen `const <Name> =
// forSystem(` und danach `<Name>.<Modell>` — 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<string>();
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(/(?<!function )forSystem\(/g)].length;
// Dritte Erkennung (260909-jts, Befund B): Modellzugriffe ueber den
// Rueckgabeparameter einer interaktiven Transaktion. Rohzahl zuerst
// (jedes "<etwas>.$transaction(async" im Quelltext), danach die
@@ -460,6 +507,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
const allReceiverNames = new Set<string>([
'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<string, Stand> {
const standByKey = new Map<string, Stand>();
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);
});
});