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
+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...`);