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
+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' },
});
}
/**