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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user