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.