4 Commits

Author SHA1 Message Date
schalli 939c8121a1 docs(quick-260914-eym): Etappe 3c abgeschlossen — Kritikschrift, Klassifikation, Auftrag, Datenbankrolle, WINDOWS #21/#30 geschlossen, Single-Flight-Riegel als Eintrag
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 53s
Tessera CI/CD / Build & Publish Images (push) Successful in 28s
- Kritikschrift: neuer Abschnitt "## Systemkontext (Etappe 3c, 260914-eym)"
  mit (y1) woertlicher Werkzeugausgabe und pg_policies der lebenden DB,
  (y2) Signaltabelle beider Fehlerrichtungen samt Rueckbau-Belegen (a)-(d),
  (y3) Leere-als-Abwesenheit je Pfad (kein Pfad loescht), (y4) bewusst
  nicht geloest, (y5) bewusst nicht angefasst; Nachtraege in (d4), (s4),
  (b4) und im Abschluss
- Klassifikation: Uebersichtstabelle mit dritter Spalte System, Werte
  nachgerechnet (61/179/5), Stand-Absatz 260914-eym (72 Paare, Klassen
  unveraendert, sieben Staende geaendert), sechs Regelschluesse im
  Hintergrunddienst-Abschnitt, admin-seed-Zeile mit 3c-Befund, 3c-Punkt
  unter "NICHT entscheidet" erledigt
- Auftrag: 3c als Erledigt vermerkt (3d64567/6e2a641), zwei neue Fallen
  unter "Werkzeuge und Fallen"
- Datenbankrolle: dritte Sitzungsvariable, Nachtrag zum Systemkontext und
  zur weiterhin gueltigen Vorher-Pruefung ohne-kontext-leer
- Ledger (ueber gsd-tools windows): #21 fixed, #30 fixed, #37 neu
  (prozessweiter Single-Flight-Riegel processInbox) — open 15 / waived 1 /
  fixed 21 / total 37, aus den Zeilen gezaehlt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 11:52:47 +02:00
schalli 6e2a641d76 feat(quick-260914-eym): Mail-Transport je Versand nach Mandant (WINDOWS #30), ldap/digest/matching ueber Systemkontext, vier Tabellen im Werkzeug, Erlaubnisliste vollstaendig
- mail: MailerModule-Fabrik und DB-Startpfad (findFirst beim Boot) ersatzlos
  entfernt; MailService baut je Versand einen nodemailer-Transport aus
  getDecryptedSmtpConfig(tenantId) des Empfaenger-Mandanten, Umgebungs-Kette
  (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) nur als Rueckfall; Fehler
  weiter verschluckt (T-02-12), close() im finally; neue mail.service.spec.ts
  (4 Tests, T-GWH-03 geschlossen)
- settings: Startpfad-Methode samt vier Spec-Tests geloescht;
  auth: requestPasswordReset reicht user.tenantId durch (Spec-Zusicherung)
- ldap: getAllActiveConfigs und Nachverschluesselung lesen ueber forSystem
  (zwei Zuweisungen), Schreibzeile je Altzeile ueber forTenant(config.tenantId);
  Tests 301/306 umgedreht, neuer Altzeilen-Test
- tender-digest: Kandidatenabfrage ueber forSystem, Schleife gebunden (+1 Test)
- tender-matching: Profilabfrage ueber forSystem, Katalog (D-03) ungebunden (+1 Test)
- tender-notifications.integration.spec: Mock um forSystem
- Werkzeug: LdapConfig (15 Spalten), LdapFieldMapping (6), TenderMatch (8),
  TenderSavedSearch (8) je neun Kennungen plus Relations-Kennung
  ldapconfig-systemkontext-include-fieldmappings-beider-mandanten
  -> Alle 253 Pruefungen bestanden
- Detektor: FORSYSTEM_ALLOWED_CALL_SITES auf 4 Dateien / 5 Aufrufe;
  Proben-Empfaenger sysPrisma (Gate-Zaehlung, Name nicht hartkodiert)
- Klassifikation: 6 Zeilen system-gebunden, settings/smtpConfig gebunden
- Falsifizierung durch Rueckbau ausgefuehrt und zurueckgenommen:
  (a) FOR SELECT bei TenderMatch entfernt -> 5 von 253 rot (Insert gelingt,
  cmd ALL); (b) Regel TenderSavedSearch aus der Datei entfernt -> 1 von 245
  rot (Extraktion), lebende DB bleibt bei 34; (c) local=false -> gruen, plus
  Reset entfernt -> 5 rot (Erben sichtbar); (d) Zahl 0 -> 2 rot, Fremddatei
  admin-seed -> 3 rot
- Baseline: 64 Dateien / 1054 Tests, tsc 0, Werkzeug 253

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 11:46:08 +02:00
schalli 3d645674f0 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
2026-09-14 11:32:54 +02:00
schalli 02016e19eb docs(quick-260914-eym): Plan fuer Etappe 3c, Systemkontext fuer die Hintergrunddienste (WINDOWS #21, #30)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-14 11:15:13 +02:00
31 changed files with 2803 additions and 501 deletions
+24 -10
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 16
open_count: 15
waived_count: 1
fixed_count: 19
total_count: 36
last_updated: 2026-09-14T08:38:12.619Z
fixed_count: 21
total_count: 37
last_updated: 2026-09-14T09:51:24.295Z
---
# Broken Windows Ledger
@@ -35,7 +35,7 @@ last_updated: 2026-09-14T08:38:12.619Z
| 18 | 2 | unmet-truth | docker-compose.yml | | Die Mandantentrennung auf Datenbankebene ist wirkungslos, weil die Anwendungsrolle sie umgeht. Die API verbindet laut docker-compose.yml:33 als Rolle 'tessera'; diese Rolle hat auf alpha rolsuper=t UND rolbypassrls=t. PostgreSQL wendet Row-Level-Security auf solche Rollen grundsaetzlich nicht an — auch FORCE ROW LEVEL SECURITY aendert daran nichts, das erzwingt nur die Anwendung auf den Tabelleneigentuemer, nicht auf BYPASSRLS-Rollen. Am 2026-09-09 praktisch gemessen: ohne gesetztes app.current_tenant liefert 'SELECT count(*) FROM "Group"' zwei Zeilen, waehrend die Policy USING ("tenantId" = current_tenant_id()) bei NULL-Kontext null Zeilen liefern muesste. Damit sind alle sieben bisher mit RLS ausgestatteten Tabellen (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant, PasswordResetToken) faktisch ungeschuetzt; die Trennung haengt allein am manuellen 'where tenantId' im Anwendungscode. Die Migration 20260804130918 nennt RLS ausdruecklich 'ein zweites Sicherheitsnetz' — dieses Netz existiert derzeit nicht. Reihenfolge der Behebung: ZUERST eine eigene Anwendungsrolle ohne Superuser- und BYPASSRLS-Recht einrichten und die Anwendung darauf umstellen, DANN greifen die vorhandenen Policies, und ERST DANN lohnt es, fehlende Tabellen zu ergaenzen. Vorher gebaute Policies waeren wirkungslos und wuerden eine Sicherheit vortaeuschen. Kein akutes Risiko, solange Tessera nur intern und einmandantig laeuft (ein einziger Mandant 'default'), aber vor jedem Kundeneinsatz zwingend. NACHTRAG (260909-eor, Aufgabe 3): dieser Plan hat den fuer die Umstellung noetigen Anwendungscode klassifiziert (docs/mandantentrennung-zugriffsklassifikation.md, 227 Fundstellen / 59 Datei-Modell-Paare) und zusaetzlich einen weiteren, beim Anlegen dieses Eintrags noch nicht bekannten Defekt gefunden und behoben: forTenant() setzte den Mandantenkontext auf einer anderen Datenbankverbindung als die eigentliche Abfrage lief (siehe #20). #20 bleibt trotz nachgewiesener Reparatur bewusst OPEN, an dieselbe Bedingung gebunden wie dieser Eintrag — die Wirkung unter der echten Rolle ist erst nach dem Scharfschalten beobachtbar. | open | | 2026-09-09T07:42:13.878Z | |
| 19 | 2 | unmet-truth | apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql | | Zwei der neuen Policies wuerden plattformweite Zeilen unsichtbar machen, sobald die Mandantentrennung scharf geschaltet wird. SearchProvider und TenderRssFeedSource haben ein nullable tenantId: Zeilen mit tenantId = NULL gelten fuer alle Mandanten (die von der Administration gepflegten Feeds und Suchanbieter). Die einfache Policy 'tenantId = current_tenant_id()' vergleicht NULL niemals gleich, diese Zeilen waeren nach der Aktivierung fuer JEDEN Mandanten weg — nicht nur fuer fremde. Heute ohne Wirkung, weil die Anwendung weiter als BYPASSRLS-Rolle verbindet (#18, Schalter bewusst aus). Beim Scharfschalten zwingend mitzuloesen, zusammen mit den 182 unskalierten Zugriffen: die Policy muss die plattformweiten Zeilen ausdruecklich einschliessen, etwa ueber 'tenantId IS NULL OR tenantId = current_tenant_id()' fuer den Lesezugriff, waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Beim Schreiben der Migration am 2026-09-09 aufgefallen und bewusst nicht eigenmaechtig anders geloest, weil die richtige Semantik eine Produktentscheidung ist. NACHTRAG (260909-eor, Aufgabe 3): docs/mandantentrennung-zugriffsklassifikation.md haelt diesen Befund im Abschnitt 'Zwei belegte Befunde' fest und benennt ihn als Blocker fuer Etappe 3. Die '182 unskalierten Zugriffe' sind ueberholt — die aktuelle, maschinell geprüfte Zahl ist 227 Fundstellen (59 Datei-Modell-Paare, siehe Klassifikationsdokument). NACHTRAG (260910-jab, Aufgabe 1/2): geschlossen durch Migration 20260910120000_rls_widen_membership_grant_and_platform_read — TenderRssFeedSource bekommt vier nach Befehl getrennte Regeln (tenant_platform_read_policy schliesst Zeilen ohne Mandant ausdruecklich ein, tenant_insert_policy/tenant_update_policy/tenant_delete_policy verlangen weiterhin einen Mandanten), lokal angewandt und am Systemkatalog der lebenden Datenbank gemessen. Belegt durch rls-scratch-check.mjs: tenderrssfeed-plattformzeile-gebunden-sichtbar, tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar, tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt, tenderrssfeed-gebundenes-aendern-der-plattformzeile-abgelehnt, tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt (alle bestanden). Die Haelfte zur Suchanbietertabelle (SearchProvider) schliesst NICHT als geloestes Problem, sondern als WIDERLEGTE PRAEMISSE: es gibt lokal gemessen keinen Codeweg, der eine mandantenlose SearchProvider-Zeile erzeugt (dashboard.service.ts verlangt die Mandantenkennung als Pflichtparameter, die Vorgabe-Suchmaschinen sind Konstanten, 05-02) — die Regel bleibt deshalb bewusst unveraendert streng, belegt durch searchprovider-mandantenlose-zeile-bleibt-unter-jedem-kontext-unsichtbar (bestanden). Anwendungsseitig ist genau ein Pfad mitgebunden worden: TenderRssFeedSourceService.listForUser (Befund F) — ungebunden haette die Reparatur ihn sonst still auf nur die plattformweiten Zeilen reduziert. Was diese Reparatur NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite Zeile weder anlegen noch entfernen, in der alten wie in der neuen Regel — siehe WINDOWS #24, das diesen Rest als eigenen offenen Punkt fuehrt und nicht mit diesem Eintrag verschwindet. | fixed | | 2026-09-09T08:08:19.293Z | 2026-09-10T12:35:40.000Z |
| 20 | 2 | unmet-truth | apps/api/src/prisma/prisma-tenant.extension.ts | | forTenant() setzt den Mandantenkontext auf einer anderen Verbindung als die Abfrage laeuft — die Mandantentrennung hat damit nie funktioniert, auch nicht dort, wo sie scheinbar benutzt wird. Die Erweiterung oeffnet prisma.$transaction, setzt app.current_tenant per set_config(..., true) auf tx, ruft dann aber query(args) auf, das ueber den AEUSSEREN Client dispatcht. set_config mit local=true gilt nur in der Transaktion und nur auf deren Verbindung. Am 2026-09-09 gegen die lokale Datenbank reproduziert: set_config landete auf Backend-PID 254999, die eigentliche Abfrage auf 255000, und dort war current_setting('app.current_tenant') NULL. Heute ohne sichtbare Folge, weil die Anwendungsrolle BYPASSRLS hat (#18) und deshalb ohnehin alles sieht. NACH dem Scharfschalten kehrt sich das um: die betroffenen Abfragen liefern dann NULL ZEILEN statt zu vieler. Besonders gefaehrlich in ldap.service.ts (Loeschzweig um Zeile 1559): der Sync deutet die Leere als 'Gruppe im Verzeichnis verschwunden' und loescht sie samt Mitgliedschaften und Modulfreigaben — aus einem stillen Trennungsfehler wuerde stiller Datenverlust. Zusatzbefund: von den 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare, die erklaeren, warum forTenant FEHLT; echte Aufrufstellen sind 6, echte mandantengebundene Abfragen 9, alle in ldap.service.ts. Ausserdem setzen tenant.middleware.ts:44 und tenant.guard.ts:41 ein req.tenantPrisma, das in apps/api/src von NIEMANDEM gelesen wird. Muss vor jedem weiteren Umbau repariert werden, sonst baut alles Weitere auf einem Helfer auf, der nicht traegt. NACHTRAG (260909-eor, Aufgabe 1/4): der beschriebene Verbindungsfehler ist behoben (Array-Form von $transaction, prisma-tenant.extension.ts) und gegen eine Wegwerf-Datenbank mit einer Rolle ohne BYPASSRLS live nachgewiesen (rls-scratch-check.mjs, 8/8 Pruefungen bestanden). Bleibt dennoch bewusst OPEN, nicht fixed: die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar — bis dahin bleibt #20 an dieselbe Bedingung gebunden wie #18 und #19. | open | | 2026-09-09T08:44:18.496Z | |
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | open | | 2026-09-09T14:41:07.256Z | |
| 21 | 2 | deviation | apps/api/src/dkv/dkv-scheduler.service.ts | | DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf. | fixed | | 2026-09-09T14:41:07.256Z | 2026-09-14T09:51:23.849Z |
| 22 | quick-260910-das | deviation | apps/api/src/user/user.service.ts | | Plattformweite Eindeutigkeit von username/email (kein tenantId-Anteil im Unique-Index): die gemessene Kette unsichtbare Zeile -> falsches 'frei' -> harter Eindeutigkeitsfehler (SQLSTATE 23505) ist in dieser Etappe im Anwendungscode entschaerft (Konfliktmeldung bei create/update, Startsperre in admin-seed.service.ts abgefangen), nicht an der Ursache geloest. Die ehrliche Reparatur waere eine Schemaaenderung (Eindeutigkeit mit Mandantendimension) und ist als Produktentscheidung fuer Etappe 3 vorgemerkt. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich user' (u1/u4). | open | | 2026-09-10T08:17:20.009Z | |
| 23 | quick-260910-exd | deviation | apps/api/src/module-registry/module-access.service.ts | | Es gibt heute KEIN Signal, das 'wirklich keine Freigabe' (USER hat tatsaechlich keinen Grant) von 'die Abfrage hat nichts gefunden' (z. B. eine nach dem Scharfschalten ungebunden gebliebene Abfrage) unterscheidet: dieselbe ForbiddenException-Meldung im Waechter, dieselbe leere Modulliste mit Status 200, kein Protokolleintrag. Nach dem Scharfschalten ist ein zu kleines Ergebnis in diesem Bereich TOTAL und lautlos -- JEDER Benutzer JEDES Mandanten verliert gleichzeitig jedes Modul, waehrend die Aktivierungs- und Freigabetabellen weiterhin Zeilen halten -- und sieht fuer den Betroffenen wie ein absichtlicher Rechteentzug aus, nicht wie ein Fehler (der Betroffene hat eine fertige, falsche Erklaerung zur Hand und meldet deshalb keinen Fehler). Konkrete Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): aktive Aktivierungszeilen vorhanden, aber die Aufloesung liefert fuer einen bekannten Administrator eine leere Menge. Eine Laufzeitwarnung an den betroffenen Stellen wurde erwogen und begruendet verworfen (Dauerlaerm auf einer frischen Installation, dieselbe Begruendung wie bei getAllActiveConfigs im Bereich ldap und den fuenf Stellen im Bereich tenders). In module.guard.spec.ts als Testfall festgenagelt, damit die Aufzeichnung rot wird, sobald jemand ein unterscheidendes Signal einbaut. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich module-registry' (m3). | open | | 2026-09-10T09:28:45.310Z | |
| 24 | quick-260910-jab | deviation | apps/api/src/tenders/tender-rss-feed.service.ts | | Was das Schliessen von WINDOWS #19 NICHT loest: unter der Anwendungsrolle laesst sich eine plattformweite RSS-Quelle (TenderRssFeedSource, tenantId NULL) weder anlegen noch entfernen — in der alten wie in der neuen Regel, weil jede Schreibregel (Einfuegen/Aendern/Entfernen) ausdruecklich einen Mandanten verlangt (tenant_insert_policy/tenant_update_policy/tenant_delete_policy, 20260910120000_rls_widen_membership_grant_and_platform_read). Betroffen sind zwei Pfade in TenderRssFeedSourceService: createPlatform() (setzt tenantId=NULL, ein gebundenes INSERT liefe in die WITH-CHECK-Klausel und wuerde abgewiesen) und remove() (deckt fuer Administratoren auch das Entfernen einer plattformweiten Zeile ab; ein gebundenes DELETE traefe sie nie). Beide bleiben deshalb bewusst ungebunden — das ist KEINE Folge dieser Reparatur, sondern bestand bereits vor 260910-jab identisch, weil die vom Ledger vorgegebene #19-Semantik Schreibzugriffe ausdruecklich an einen Mandanten bindet. Vorabpruefung fuer Etappe 4 (rls-preflight.mjs): ein Verwaltungsweg fuer plattformweite Zeilen (Anlegen/Entfernen unter der Anwendungsrolle) muss gebaut werden, BEVOR die Rolle umgeschaltet wird — sonst kann kein Administrator nach dem Scharfschalten mehr eine plattformweite Quelle pflegen. Eigener Eintrag, damit dieser Rest nicht mit #19 verschwindet. | open | | 2026-09-10T12:35:40.000Z | |
@@ -44,13 +44,14 @@ last_updated: 2026-09-14T08:38:12.619Z
| 27 | 2 | unmet-truth | apps/api/src/prisma/rls-access-inventory.spec.ts | | Die maschinelle Bestandsaufnahme (rls-access-inventory.spec.ts) ist fuer Relationszugriffe strukturell blind. Sie erkennt nur direkte Zugriffe der Form this.prisma.<Modell> bzw. <gebundener Client>.<Modell>. Ein Zugriff, der ueber include:/_count:/select: in eine ZWEITE Tabelle hineinreicht, ist fuer sie unsichtbar — obwohl Prisma daraus eine Unterabfrage auf diese zweite Tabelle macht, die unter DEREN Regel laeuft. Nachgewiesen in 260911-e2s: drei Zugriffe in tenant.controller.ts zaehlten ueber include: { _count: { select: { users } } } in die geschuetzte Tabelle User hinein (Prisma 6.19 rendert das als LEFT JOIN (SELECT tenantId, COUNT(*) FROM User ...)); nach dem Scharfschalten haette die Mandantenliste des Plattform-Administrators fuer jeden Mandanten 0 Benutzer gezeigt und der Loeschriegel T-02-09 waere vakuum geworden. Diese drei Stellen sind behoben (Fan-out je Mandant ueber gebundenen Client). Zur Planungszeit wurden alle 19 include:-Stellen und alle _count-Stellen in apps/api/src einzeln beurteilt, vom Orchestrator und vom Verifizierer unabhaengig gegengeprueft: nur diese drei waren gefaehrlich (tenders zaehlt auf dem plattformglobalen Katalog ohne Zeilenschutz, groups zaehlt ueber einen bereits gebundenen Client in eine Tabelle desselben Mandanten). OFFEN bleibt der MECHANISMUS: jede kuenftige include:/_count:-Stelle in eine fremd geschuetzte Tabelle bleibt fuer die Pruefung unsichtbar. Zu schliessen, indem der Detektor include:/select:/_count:-Bloecke auf Modellnamen durchsucht und die Zieltabelle als eigene Fundstelle fuehrt — oder durch eine Pruefung, die jede include:-Stelle einer expliziten Freigabeliste unterwirft. Gehoert vor das Scharfschalten (Etappe 4), weil die Vorabpruefung sich sonst auf eine Bestandsaufnahme stuetzt, die diese Form nicht sieht. | fixed | | 2026-09-11T09:08:00.435Z | 2026-09-11T14:48:15.447Z |
| 28 | quick-260911-fh9 | deviation | apps/web/src/components/layout/header.tsx | | Bereich auth: getMe liefert nach dem Scharfschalten null, der Controller antwortet 200 mit leerem Rumpf, fetchCurrentUser (auth-actions.ts) macht daraus null, header.tsx und account-settings-form.tsx tun bei null nichts — die Portalhuelle rendert ohne angemeldeten Benutzer; changePassword liest sich als networkError (nicht als falsches Kennwort); adminResetPassword als 'User not found' ohne UI-Aufrufer. 'nicht angemeldet' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert — an dieselbe Bedingung gebunden wie WINDOWS #18; Familie #23/#25/#26; Etappe-4-Vorabpruefung: bekannten Benutzer ueber die Wartungsrolle lesen und den gebundenen findUnique unter seinem Claim-Mandanten daneben halten. Das Frontend wird von 260911-fh9 NICHT geaendert. | open | | 2026-09-11T10:00:29.558Z | |
| 29 | quick-260911-fh9 | unmet-truth | apps/api/src/user/user.controller.ts | | Bereich auth: adminResetPassword schliesst die Rechteausweitung ADMIN -> SUPER_ADMIN im eigenen Handler (T-FH9-04), der Schwesterweg PATCH /users/:id tut das nicht. UserController.update (T-02-08) prueft nur, ob die Rolle SUPER_ADMIN NEU ZUGEWIESEN wird (dto.role === Role.SUPER_ADMIN), nicht ob das ZIEL diese Rolle bereits HAT — password/isActive gehen fuer ein bestehendes SUPER_ADMIN-Ziel ungeprueft durch; remove prueft ueberhaupt keine Rolle des Ziels, nur Selbstloeschung und Mandantengrenze. Kein Mandantenproblem, sondern Rechteausweitung INNERHALB des Mandanten. Reparatur in einem Satz: die Rolle des ZIELS pruefen (ist user.role === Role.SUPER_ADMIN und der Aufrufer nicht SUPER_ADMIN, ForbiddenException) — die Vorlage steht seit 260911-fh9 in AuthService.adminResetPassword. Ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen. | fixed | | 2026-09-11T10:00:38.418Z | 2026-09-14T08:37:53.307Z |
| 30 | quick-260911-gwh | deviation | apps/api/src/mail/mail.module.ts | | Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a). | open | | 2026-09-11T11:57:36.656Z | |
| 30 | quick-260911-gwh | deviation | apps/api/src/mail/mail.module.ts | | Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a). | fixed | | 2026-09-11T11:57:36.656Z | 2026-09-14T09:51:24.073Z |
| 31 | quick-260911-gwh | deviation | apps/web/src/components/dashboard/widgets/favorites-widget.tsx | | Bereich favorites: ein nach dem Scharfschalten (#18) zu klein gebliebenes Leseergebnis auf list() sieht aus wie 'Noch keine Favoriten.' (favorites-widget.tsx Zeile um 212, de.json favorites.empty) -- fetchFavorites (favorites-api.ts) reicht die leere Liste durch. 'Nie einen gespeichert' und 'Zeile unsichtbar' sind fuer das Frontend derselbe Wert []. Etappe-4-Vorabpruefung (f4)(d): fuer einen bekannten Nutzer/Widget die Favoritenzahl ueber die Wartungsrolle und ueber den gebundenen findMany daneben halten. Familie #23/#25/#26/#28. Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich favorites' (f3)/(f4). | open | | 2026-09-11T11:57:50.276Z | |
| 32 | quick-260911-gwh | deviation | apps/web/src/components/settings/smtp-settings-form.tsx | | Bereich settings: getSmtpConfig liefert nach dem Scharfschalten (#18) null, der Controller antwortet 200 mit leerem Rumpf, fetchSmtp (settings-api.ts) laeuft mit res.json() auf den leeren Rumpf und wirft, smtp-settings-form.tsx verschluckt das in .catch(() => {}) -- leeres Formular 'nicht eingerichtet', waehrend die Zugangsdaten physisch da sind. Ein erneutes Speichern unter der ungebundenen Form scheitert am Eindeutigkeitsindex SmtpConfig_tenantId_key (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 Pruefung 8) -- nach diesem Lauf ist saveSmtpConfig gebunden und trifft die eigene Zeile, dieser Rest bestand nur unter der ungebundenen Form vor dieser Aenderung. Dieselbe 200-leerer-Rumpf-Kette wie #28. Etappe-4-Vorabpruefung (s4)(e). Das Frontend wird von 260911-gwh NICHT geaendert. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s3)/(s4). | open | | 2026-09-11T11:57:50.484Z | |
| 33 | quick-260911-mkj | unmet-truth | apps/api/src/tenders/tenders.seed.ts | | Modellaufrufe auf Empfaengern, die weder this.prisma noch eine const X = forTenant(-Zuweisung noch ein Transaktionsparameter sind, sind fuer ALLE vier Erkennungsformen der Bestandsaufnahme unsichtbar. Gemessen 260911-mkj: tenders/tenders.seed.ts (Funktionsparameter prisma: PrismaService, tenderRssFeedSource.findFirst/create, kein Eintrag in der Bestandsaufnahme) und tenders/backfill-tender-source.ts (eigenstaendiges Skript mit new PrismaClient(), tender.findMany/update, durch RELATION_SPEC_EXCEPTIONS laut gehalten). Beide beruehren nur den plattformglobalen Katalog bzw. die plattformweite RSS-Verwaltung (WINDOWS #24), heute ungefaehrlich; OFFEN ist der Mechanismus (ein kuenftiger Dienst mit Parameter-Empfaenger auf einer geschuetzten Tabelle bliebe unsichtbar). Zu schliessen vor Etappe 4 durch eine Zaehlung ALLER <Kennung>.<Modell>.<Operation>(-Anker gegen die bekannte Empfaengermenge, Ueberschuss laut. | open | | 2026-09-11T14:48:09.723Z | |
| 34 | quick-260911-nke | deviation | apps/api/src/prisma/prisma-tenant.extension.ts | | Etappe 3b: ein Nutzer-CRUD-Aufrufer, der den Benutzer an forTenant() vergisst, sieht den ganzen Mandanten (IS-NULL-Form) — gleicher Stand wie vor 20260911120000, keine Verschlechterung, aber kein Netz. Die Bestandsaufnahme unterscheidet nur mandanten-gebunden/ungebunden, nicht benutzer-gebunden; ein Waechter, der jede Methode mit userId-Parameter auf das dritte Argument prueft, ist NICHT gebaut. Bis dahin sind die dreistelligen Spec-Zusicherungen je Dienst das einzige Netz. Vor dem Scharfschalten (Etappe 4, rls-preflight.mjs) zu entscheiden: Waechter bauen oder Rest benennen. | open | | 2026-09-11T15:46:08.295Z | |
| 35 | quick-260914-ebg | deviation | biome.json | | Biome ist im Bestand nicht lauffaehig: biome.json traegt den in Biome 2.5.0 unbekannten Schluessel organizeImports (gehoert unter assist), Biome bricht bei jedem Aufruf mit Konfigurationsfehler ab; zusaetzlich fehlt javascript.parser.unsafeParameterDecoratorsEnabled, ohne den jeder NestJS-Parameter-Dekorator ein Parse-Fehler ist (17 allein in user.controller.ts). Der CI-Schritt Lint ruft pnpm lint = turbo lint, keine App hat ein lint-Skript - der Schritt ist ein Leerlauf, der gruen meldet. CLAUDE.md und docs/anleitung-entwicklung.md beschreiben Biome als aktives Werkzeug. Gemessen 260914-ebg; das dortige Gate lief mit einer Ersatzkonfiguration im Scratchpad, relativ zur Baseline (0 Fehler, Warnungen je Datei 22/25/20, alle noExplicitAny-Familie; biome format ebenfalls unsauber, Anfuehrungszeichen-Stil). Zu entscheiden: biome.json reparieren (organizeImports nach assist, Parser-Schalter, quoteStyle single) und ein lint-Skript je App anlegen, dann die Warnungen in einem eigenen Durchlauf abbauen oder als Regelabschaltung begruenden. | open | | 2026-09-14T08:38:04.079Z | |
| 36 | quick-260914-ebg | deviation | apps/web/src/app/(portal)/admin/users/page.tsx | | handleSubmit und handleDelete pruefen nur res.ok ohne else-Zweig und fangen mit leerem catch - ein 403 der API fuehrt zu keiner sichtbaren Reaktion (Formular bleibt offen, Loeschdialog bleibt stehen, keine Meldung). Bestehendes Verhalten fuer alle 403-Wege (fremder Mandant, Selbstloeschung); seit 260914-ebg (WINDOWS #29) ist der Fall fuer einen ADMIN im Alltag erreichbar, weil die SUPER_ADMIN-Zeile in der eigenen Benutzerliste steht und Aendern/Loeschen darauf jetzt 403 liefert. Familie der still verschluckten Antworten (#28, #32). Frontend von 260914-ebg NICHT geaendert (ausserhalb der Erlaubnisliste). Zu schliessen: Fehlermeldung aus dem Antwortrumpf anzeigen und die Aktionsknoepfe fuer SUPER_ADMIN-Zeilen einem ADMIN gar nicht erst anbieten. | open | | 2026-09-14T08:38:12.619Z | |
| 37 | quick-260914-eym | deviation | apps/api/src/dkv/dkv.service.ts | | Der Single-Flight-Riegel processing in DkvService.processInbox ist EIN prozessweites Boolean, nicht je Mandant. Seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag (dkv-inbox-poll:<tenantId>); ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab (Warnzeile 'already processing') und der Mandant wartet bis zum naechsten Intervall - kein Datenverlust, Verzoegerung; mit EINEM Mandanten unveraendert. Der Tick blieb in 3c laut Auftrag unangetastet (T-EYM-09, accept mit Aufzeichnung). Zu schliessen: Riegel je Mandant (Set<tenantId>) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'. | open | | 2026-09-14T09:51:24.295Z | |
````json
[
@@ -301,10 +302,10 @@ last_updated: 2026-09-14T08:38:12.619Z
"file": "apps/api/src/dkv/dkv-scheduler.service.ts",
"line": null,
"description": "DKV-Planer-Startpfad (DkvSchedulerService.onModuleInit -> DkvService.loadAnyActiveConfigForScheduler, vormals loadConfig() ohne Mandant) bleibt bewusst UNGEBUNDEN, als benannte Altlast aus 07-04 (260909-mir, Befund D). Zwei Zustaende, beide gehoeren genannt: HEUTE bereits falsch -- findFirst() ohne jede Bedingung zieht bei mehreren Mandanten EINEN beliebigen und bedient die uebrigen NIE (isActive-Pruefung kann den Planer sogar ganz leer laufen lassen, wenn ausgerechnet die gezogene Zeile inaktiv ist, obwohl ein zweiter Mandant aktiv waere). NACH DEM SCHARFSCHALTEN (#18) verstummt sie zusaetzlich -- dieselbe Abfrage liefert dann null, der Planer protokolliert 'no active config found' und richtet fuer JEDEN Mandanten nichts ein, ohne Alarm. Drei erwogene Formen prufen: (a) an einen konkret aufgeloesten Mandanten binden -- nicht moeglich, onModuleInit() hat beim Boot strukturell keinen Mandantenkontext. (b) Umbau auf einmal-abfragen-viele-bedienen -- abgelehnt, das ist die in 07-04 zurueckgestellte Mehrmandanten-Planung (neue Auftragsverwaltung je Mandant statt des heutigen setInterval() mit GENAU EINEM Auftrag) und damit eine Funktionsaenderung, kein Bindungsumbau. (c) Als benannte Altlast weiterfuehren, mit Markierung -- GEWAEHLT, Praezedenzfall LdapConfigService.getAllActiveConfigs() (260909-ipc, Befund B). Die Unsymmetrie zu diesem Praezedenzfall: getAllActiveConfigs ist HEUTE korrekt und verstummt erst spaeter: der DKV-Planer ist HEUTE bereits falsch UND verstummt zusaetzlich spaeter. Markierung dreifach: eigene benannte Methode loadAnyActiveConfigForScheduler() mit Kopfkommentar (dkv.service.ts), fortgeschriebener Kopfkommentar in dkv-scheduler.service.ts, Abschnitt (d4) in docs/mandantentrennung-etappe2-fehlerrichtung.md. Signal fuer das Verstummen gehoert in die Vorabpruefung von Etappe 4 (rls-preflight.mjs), NICHT in diesen Durchlauf.",
"status": "open",
"status": "fixed",
"reason": "",
"recorded_at": "2026-09-09T14:41:07.256Z",
"resolved_at": null
"resolved_at": "2026-09-14T09:51:23.849Z"
},
{
"id": 22,
@@ -409,10 +410,10 @@ last_updated: 2026-09-14T08:38:12.619Z
"file": "apps/api/src/mail/mail.module.ts",
"line": null,
"description": "Startpfad des Mailmoduls (SettingsService.loadAnySmtpConfigForStartupTransport(), vormals getStartupSmtpConfig()) als SECHSTER Fall der Hintergrunddienst-Falle bleibt bewusst UNGEBUNDEN. HEUTE bereits falsch: findFirst() ohne Bedingung zieht bei mehreren Mandanten den SMTP-Server und Absender EINES beliebigen Mandanten fuer ALLE Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten (T-GWH-03, Nutzung fremder Zugangsdaten). NACH DEM SCHARFSCHALTEN (#18) liefert dieselbe Abfrage null, mail.module.ts faellt auf MAIL_*/TESSERA_SMTP_*/localhost:1025 zurueck, MailService faengt den Transportfehler (T-02-12) -- KEINE Protokollzeile, das Verstummen ist doppelt verdeckt (Unsymmetrie zu ldap.getAllActiveConfigs [heute korrekt] UND zu dkv WINDOWS #21 [verstummt mit Protokollzeile]). Drei erwogene Formen: an einen aufgeloesten Mandanten binden (unmoeglich, kein Kontext beim Start); Mehrmandanten-Versand (abgelehnt als Funktion -- Vorlage steht in DkvMailService/TenderMailService, Transport je Versand aus getDecryptedSmtpConfig(tenantId)); als benannte Altlast weiterfuehren mit Markierung (GEWAEHLT). Eigener Eintrag statt Anschluss an #21: andere Datei, andere Reparatur, andere Verdeckungsform. Signal fuer rls-preflight.mjs gehoert in Etappe 4. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt 'Bereich settings' (s4)(a).",
"status": "open",
"status": "fixed",
"reason": "",
"recorded_at": "2026-09-11T11:57:36.656Z",
"resolved_at": null
"resolved_at": "2026-09-14T09:51:24.073Z"
},
{
"id": 31,
@@ -487,6 +488,19 @@ last_updated: 2026-09-14T08:38:12.619Z
"recorded_at": "2026-09-14T08:38:12.619Z",
"resolved_at": null,
"milestone": "v1.2"
},
{
"id": 37,
"kind": "deviation",
"phase": "quick-260914-eym",
"file": "apps/api/src/dkv/dkv.service.ts",
"line": null,
"description": "Der Single-Flight-Riegel processing in DkvService.processInbox ist EIN prozessweites Boolean, nicht je Mandant. Seit 260914-eym laeuft je aktivem Mandanten ein eigener Cron-Auftrag (dkv-inbox-poll:<tenantId>); ueberschneiden sich zwei Ticks verschiedener Mandanten, bricht der zweite still ab (Warnzeile 'already processing') und der Mandant wartet bis zum naechsten Intervall - kein Datenverlust, Verzoegerung; mit EINEM Mandanten unveraendert. Der Tick blieb in 3c laut Auftrag unangetastet (T-EYM-09, accept mit Aufzeichnung). Zu schliessen: Riegel je Mandant (Set<tenantId>) mit Test 'zwei Mandanten gleichzeitig, beide werden bedient'.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-14T09:51:24.295Z",
"resolved_at": null,
"milestone": "v1.2"
}
]
````
File diff suppressed because one or more lines are too long
@@ -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.
+581 -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,496 @@ 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;
}
// Mandantenregeln je Tabelle aus IHRER Quellmigration (Aufgabe 2, 260914-eym):
// DkvModuleConfig/TenderMatch aus _rls_remaining_tenant_tables, LdapConfig/
// LdapFieldMapping aus _rls_policies, TenderSavedSearch aus
// _rls_user_dimension_personal_tables.
const remainingTablesMigrationSql = readRemainingTenantTablesMigrationSql();
if (!remainingTablesMigrationSql) {
report(results, 'system-context-remaining-tables-migration-gefunden', false, 'Migration "_rls_remaining_tenant_tables" nicht gefunden');
return;
}
const rlsPoliciesMigrationSql = readRlsPoliciesMigrationSql();
if (!rlsPoliciesMigrationSql) {
report(results, 'system-context-rls-policies-migration-gefunden', false, 'Migration "_rls_policies" nicht gefunden');
return;
}
const userDimensionMigrationSql = readRlsUserDimensionMigrationSql();
if (!userDimensionMigrationSql) {
report(results, 'system-context-user-dimension-migration-gefunden', false, 'Migration "_rls_user_dimension_personal_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' },
});
// Aufgabe 2 (260914-eym): die vier weiteren Tabellen ueber dieselbe Routine.
// LdapConfig — Mandantenregel aus 20260618112133_rls_policies, alle 15
// skalaren Spalten (text[]-Spalten mit DEFAULT '{}'). MUSS vor
// LdapFieldMapping laufen (deren Regel joint auf "LdapConfig").
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapconfig',
tableName: 'LdapConfig',
modelName: 'LdapConfig',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapConfig'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapConfig" (
id text PRIMARY KEY,
"tenantId" text NOT NULL UNIQUE,
"serverUrl" text NOT NULL,
"baseDn" text NOT NULL,
"bindDn" text,
"encryptedBindPassword" text,
"searchFilter" text NOT NULL DEFAULT '(objectClass=person)',
"syncIntervalMin" integer NOT NULL DEFAULT 0,
"isActive" boolean NOT NULL DEFAULT true,
"tlsRejectUnauthorized" boolean NOT NULL DEFAULT true,
"groupFilterDns" text[] NOT NULL DEFAULT '{}',
"userExcludeList" text[] NOT NULL DEFAULT '{}',
"lastSyncAt" timestamp(3),
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`,
seedSql: `
INSERT INTO "LdapConfig" (id, "tenantId", "serverUrl", "baseDn", "isActive") VALUES
('cfg-a', 'TENANT-A', 'ldap://a.example.invalid', 'dc=a', true),
('cfg-b', 'TENANT-B', 'ldap://b.example.invalid', 'dc=b', true);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'cfg-system-schreibversuch',
tenantId: 'TENANT-A',
serverUrl: 'ldap://x.example.invalid',
baseDn: 'dc=x',
},
updateManyData: { searchFilter: '(cn=SYSTEM-SCHREIBVERSUCH)' },
});
// LdapFieldMapping — Regel aus derselben Datei (Join auf LdapConfig), 6
// Spalten, ohne DROP der Elternzeilen (cfg-a/cfg-b bleiben stehen); der
// Mandant einer Zeile ergibt sich ueber ldapConfigId.
const tenantOfMapping = (row) => (row.ldapConfigId === 'cfg-a' ? 'TENANT-A' : row.ldapConfigId === 'cfg-b' ? 'TENANT-B' : row.ldapConfigId);
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'ldapfieldmapping',
tableName: 'LdapFieldMapping',
modelName: 'LdapFieldMapping',
tenantPolicySql: extractPolicySql(rlsPoliciesMigrationSql, 'LdapFieldMapping'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "LdapFieldMapping" (
id text PRIMARY KEY,
"ldapConfigId" text NOT NULL REFERENCES "LdapConfig"(id) ON DELETE CASCADE,
"ldapField" text NOT NULL,
"tesseraField" text NOT NULL,
"isDefault" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("ldapConfigId", "ldapField")
);
`,
seedSql: `
INSERT INTO "LdapFieldMapping" (id, "ldapConfigId", "ldapField", "tesseraField") VALUES
('fm-a', 'cfg-a', 'mail', 'email'),
('fm-b', 'cfg-b', 'mail', 'email');
`,
tenantOfRow: tenantOfMapping,
createAttemptData: {
id: 'fm-system-schreibversuch',
ldapConfigId: 'cfg-a',
ldapField: 'sn',
tesseraField: 'lastName',
},
updateManyData: { tesseraField: 'SYSTEM-SCHREIBVERSUCH' },
});
// Relations-Kennung: die #27-Form unter Systemkontext — ldapConfig.findMany
// mit include: { fieldMappings } liefert beide Mandanten und je genau eine
// Zuordnung (der Pfad von LdapConfigService.getAllActiveConfigs()).
{
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const rows = await buildInlineSystemClient(prisma).ldapConfig.findMany({
where: { isActive: true },
include: { fieldMappings: true },
orderBy: { tenantId: 'asc' },
});
const shape = rows.map((r) => `${r.tenantId}:${r.fieldMappings.length}`);
report(
results,
'ldapconfig-systemkontext-include-fieldmappings-beider-mandanten',
rows.length === 2 && shape.join(',') === 'TENANT-A:1,TENANT-B:1',
`system.ldapConfig.findMany({ where: { isActive: true }, include: { fieldMappings: true } }) liefert ${rows.length} Zeile(n): ${JSON.stringify(shape)} (Mandant:Anzahl Zuordnungen)`,
);
} finally {
await prisma.$disconnect();
}
}
// TenderMatch — Regel aus 20260909140000_rls_remaining_tenant_tables, 8
// Spalten, je Mandant eine Zeile mit notifiedAt NULL (die Kandidatenform
// des Digest). Keine Fremdschluessel in der Wegwerf-Tabelle — gemessen
// wird die Regel, nicht die Referenz.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendermatch',
tableName: 'TenderMatch',
modelName: 'TenderMatch',
tenantPolicySql: extractPolicySql(remainingTablesMigrationSql, 'TenderMatch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderMatch" (
id text PRIMARY KEY,
"tenderId" text NOT NULL,
"savedSearchId" text NOT NULL,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
"matchedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifiedAt" timestamp(3),
"notifiedChannel" text,
UNIQUE ("tenderId", "savedSearchId")
);
`,
seedSql: `
INSERT INTO "TenderMatch" (id, "tenderId", "savedSearchId", "userId", "tenantId", "notifiedAt") VALUES
('tm-a', 'tender-1', 'ss-a', 'user-a', 'TENANT-A', NULL),
('tm-b', 'tender-1', 'ss-b', 'user-b', 'TENANT-B', NULL);
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'tm-system-schreibversuch',
tenderId: 'tender-2',
savedSearchId: 'ss-a',
userId: 'user-a',
tenantId: 'TENANT-A',
},
updateManyData: { notifiedChannel: 'SYSTEM-SCHREIBVERSUCH' },
});
// TenderSavedSearch — Regel aus 20260911120000_rls_user_dimension_personal_tables
// (IS-NULL-OR-Form), 8 Spalten.
await runSystemContextTableCheck({
adminUrl,
scratchRoleUrl,
results,
slug: 'tendersavedsearch',
tableName: 'TenderSavedSearch',
modelName: 'TenderSavedSearch',
tenantPolicySql: extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch'),
systemContextMigrationSql,
createTableSql: `
CREATE TABLE "TenderSavedSearch" (
id text PRIMARY KEY,
"userId" text NOT NULL,
"tenantId" text NOT NULL,
name text NOT NULL,
filters jsonb NOT NULL DEFAULT '{}',
"instantAlert" boolean NOT NULL DEFAULT false,
"createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE ("userId", name)
);
`,
seedSql: `
INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) VALUES
('ss-a', 'user-a', 'TENANT-A', 'Profil A', '{}'),
('ss-b', 'user-b', 'TENANT-B', 'Profil B', '{}');
`,
tenantOfRow: (row) => row.tenantId,
createAttemptData: {
id: 'ss-system-schreibversuch',
userId: 'user-a',
tenantId: 'TENANT-A',
name: 'Schreibversuch',
filters: {},
},
updateManyData: { name: '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 +5842,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 +6088,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...`);
+3
View File
@@ -387,9 +387,12 @@ describe('AuthService.requestPasswordReset', () => {
expiresAt: expect.any(Date),
},
});
// Drittes Argument (260914-eym): die tenantId des Empfaengers (emailUser
// liegt unter 't1') — MailService baut daraus den Transport je Versand.
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com',
expect.any(String),
't1',
);
});
+4 -2
View File
@@ -242,8 +242,10 @@ export class AuthService {
},
});
// Send the reset email (fire-and-forget, errors logged by MailService)
await this.mailService.sendPasswordResetEmail(email, token);
// Send the reset email (fire-and-forget, errors logged by MailService).
// Der Mandant des Empfaengers entscheidet ueber den SMTP-Transport
// (260914-eym, WINDOWS #30) — er ist hier bereits bekannt.
await this.mailService.sendPasswordResetEmail(email, token, user.tenantId);
}
/**
@@ -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 ueber den
* Systemkontext-Helfer, 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');
+59 -5
View File
@@ -8,9 +8,12 @@ import { LdapConfigService } from './ldap-config.service';
// Implementierung auf ein zweites, unterscheidbares Client-Objekt um.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
// Systemkontext (260914-eym): liefert den in `__systemClient` hinterlegten
// Klienten, sonst denselben Client (Bestandstests).
forSystem: vi.fn((p: any) => p.__systemClient ?? p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
/**
* Das Bind-Passwort ist das einzige Zugangsdatum, das nicht gehasht werden
@@ -298,14 +301,65 @@ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
expect(prisma.ldapFieldMapping.delete).not.toHaveBeenCalled();
});
it('getAllActiveConfigs() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
await service.getAllActiveConfigs();
it('getAllActiveConfigs() liest ueber den Systemkontext: forSystem genau einmal, forTenant nie (260914-eym)', async () => {
const systemClient = {
ldapConfig: { findMany: vi.fn().mockResolvedValue([{ ...CONFIG_ROW }]) },
};
prisma.__systemClient = systemClient;
const result = await service.getAllActiveConfigs();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(forTenant).not.toHaveBeenCalled();
expect(systemClient.ldapConfig.findMany).toHaveBeenCalledWith({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
expect(prisma.ldapConfig.findMany).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('onApplicationBootstrap() bleibt bewusst uebergreifend — kein Mandantenkontext', async () => {
prisma.ldapConfig.findMany.mockResolvedValue([]);
it('onApplicationBootstrap() mit leerer Liste: forSystem einmal, forTenant nie, kein Update (Leere ist Nichtstun, 260914-eym)', async () => {
const systemClient = { ldapConfig: { findMany: vi.fn().mockResolvedValue([]) } };
prisma.__systemClient = systemClient;
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).not.toHaveBeenCalled();
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
});
it('onApplicationBootstrap() mit einer Altzeile (Klartext, t1): liest system, schreibt GEBUNDEN — forTenant genau einmal mit t1, update traegt das verschluesselte Kennwort (260914-eym)', async () => {
const systemClient = {
ldapConfig: {
findMany: vi.fn().mockResolvedValue([
{ id: 'alt', tenantId: 't1', encryptedBindPassword: 'klartext' },
]),
},
};
prisma.__systemClient = systemClient;
const boundClient = {
ldapConfig: { update: vi.fn((args: any) => Promise.resolve({ ...CONFIG_ROW, ...args.data })) },
};
vi.mocked(forTenant).mockImplementation(() => boundClient as any);
await service.onApplicationBootstrap();
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledTimes(1);
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(boundClient.ldapConfig.update).toHaveBeenCalledTimes(1);
const call = boundClient.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'alt' });
expect(call.data.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
);
// Der rohe Client schreibt NICHT.
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
// Implementierung zuruecksetzen (vi.clearAllMocks loescht nur Aufrufe).
vi.mocked(forTenant).mockImplementation((p: unknown) => p as any);
});
});
+35 -21
View File
@@ -2,7 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import {
CreateFieldMappingDto,
CreateLdapConfigDto,
@@ -53,17 +53,24 @@ export class LdapConfigService implements OnApplicationBootstrap {
* re-encrypted still authenticates, because the read path below tolerates a
* legacy plaintext value.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* dieser Durchlauf muss ALLE Konfigurationen ALLER Mandanten nachziehen,
* bevor je ein einzelner Mandantenkontext feststeht — beim Boot existiert
* strukturell noch keiner. Nach dem Scharfschalten (Etappe 4) sieht dieser
* Zugriff 0 Zeilen; die Nachverschluesselung wird dann stillschweigend zum
* Nichtstun statt zu einem Fehler. Die Loesung gehoert nach Etappe 3
* (Systemkontext), diese Umstellung entscheidet sie nicht.
* SYSTEMGEBUNDEN LESEN, JE ZEILE GEBUNDEN SCHREIBEN (Etappe 3c,
* 260914-eym; vorher bewusst ungebunden, 260909-ipc Befund B): dieser
* Durchlauf muss ALLE Konfigurationen ALLER Mandanten sehen, bevor je ein
* einzelner Mandantenkontext feststeht — beim Boot existiert strukturell
* noch keiner. Das Lesen laeuft deshalb ueber `forSystem()`
* (`system_read_policy ... FOR SELECT` auf "LdapConfig", Migration
* 20260914120000): das Verstummen nach dem Scharfschalten ist strukturell
* ausgeschlossen. Die Schreibzeile je Altzeile laeuft ueber
* `forTenant(this.prisma, config.tenantId)` — unter Systemkontext ist
* Schreiben abgewiesen (gemessen: `update` per id -> P2025, INSERT ->
* 42501), und der Mandant steht in der gelesenen Zeile. Eine LEERE Liste
* ist Nichtstun (kein Loeschen, kein Deaktivieren).
*/
async onApplicationBootstrap(): Promise<void> {
try {
const configs = await this.prisma.ldapConfig.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const configs: { id: string; tenantId: string; encryptedBindPassword: string | null }[] =
await systemPrisma.ldapConfig.findMany({
select: { id: true, tenantId: true, encryptedBindPassword: true },
});
@@ -75,7 +82,10 @@ export class LdapConfigService implements OnApplicationBootstrap {
if (legacy.length === 0) return;
for (const config of legacy) {
await this.prisma.ldapConfig.update({
// Schreiben je Altzeile GEBUNDEN an den Mandanten der Zeile — unter
// Systemkontext wuerde die Datenbank das Update abweisen (P2025).
const tenantPrisma = forTenant(this.prisma, config.tenantId) as any;
await tenantPrisma.ldapConfig.update({
where: { id: config.id },
data: {
encryptedBindPassword: this.crypto.encrypt(
@@ -295,21 +305,25 @@ export class LdapConfigService implements OnApplicationBootstrap {
* Get all active LDAP configs. Used by the scheduler to determine which
* tenants need auto-sync.
*
* BLEIBT bewusst UNGEBUNDEN (WINDOWS #20 Etappe 2, 260909-ipc, Befund B):
* der Planer braucht die Liste ALLER aktiven Konfigurationen ALLER
* Mandanten, um daraus je Mandant einen Sync-Lauf anzustossen — das ist
* die Aufgabe dieser Methode, nicht ein vergessener `forTenant()`-Aufruf.
* Nach dem Scharfschalten (Etappe 4) sieht dieser Zugriff 0 Zeilen: der
* LDAP-Abgleich stellt dann fuer JEDEN Mandanten ohne Fehlermeldung, ohne
* Protokolleintrag und ohne sichtbare Aenderung die Arbeit ein (Befund E,
* docs/mandantentrennung-etappe2-fehlerrichtung.md). Die Loesung
* (Systemkontext) gehoert nach Etappe 3.
* SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; vorher bewusst ungebunden,
* 260909-ipc Befund B): der Planer braucht die Liste ALLER aktiven
* Konfigurationen ALLER Mandanten, um daraus je Mandant einen gebundenen
* Sync-Lauf anzustossen — `forSystem()` liest sie ueber
* `system_read_policy ... FOR SELECT` (Migration 20260914120000).
* `LdapFieldMapping` wird ueber `include: { fieldMappings }` mitgelesen
* (WINDOWS-#27-Form) und traegt deshalb dieselbe Regel; `Tenant` traegt
* in keiner Migration eine Regel und braucht keine Oeffnung. Das
* Verstummen nach dem Scharfschalten (Befund E) ist damit strukturell
* ausgeschlossen; eine LEERE Liste startet keinen Sync-Lauf — der
* Loeschzweig in ldap.service.ts liegt INNERHALB eines gebundenen Laufs,
* den es dann nicht gibt.
*/
async getAllActiveConfigs() {
const configs = await this.prisma.ldapConfig.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const configs = await systemPrisma.ldapConfig.findMany({
where: { isActive: true },
include: { tenant: true, fieldMappings: true },
});
return configs.map((config) => this.withDecryptedPassword(config));
return configs.map((config: any) => this.withDecryptedPassword(config));
}
}
+13 -83
View File
@@ -1,102 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerModule } from '@nestjs-modules/mailer';
import { SettingsModule } from '../settings/settings.module';
import { SettingsService } from '../settings/settings.service';
import { MailService } from './mail.service';
/**
* MailModule — system email delivery (password reset, welcome emails).
*
* D-06: SMTP transport is now sourced from the DB SmtpConfig row (priority 1)
* with an env-var fallback (priority 2) when no DB row exists.
* KEIN STARTPFAD MEHR (Etappe 3c, 260914-eym, WINDOWS #30 GESCHLOSSEN):
* die Mailer-Fabrik (`MailerModule.forRootAsync`) und ihr Lesezugriff
* `findFirst()` auf SmtpConfig beim Boot sind ersatzlos entfernt.
* `MailService` baut je Versand einen nodemailer-Transport nach dem
* Mandanten des Empfaengers (siehe dessen Kopfkommentar). Der sechste Fall
* der Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md)
* EXISTIERT damit NICHT MEHR — deshalb traegt SmtpConfig keine
* `system_read_policy` (Migration 20260914120000).
*
* Transport priority:
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
* siehe deren Kopfkommentar in settings.service.ts fuer beide
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
* `null` und diese Rueckfallkette greift — WINDOWS #30)
* Transport-Prioritaet JE VERSAND:
* 1. SmtpConfig des Empfaenger-Mandanten (gebunden, `getDecryptedSmtpConfig(tenantId)`)
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
*
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
* from the DB. No circular import risk: MailModule → SettingsModule →
* CalendarModule (no reverse edges).
* No circular import risk: MailModule -> SettingsModule -> CalendarModule
* (no reverse edges). `@nestjs-modules/mailer` bleibt als Paket installiert,
* wird aber von keinem Modul mehr benutzt.
*/
@Module({
imports: [
SettingsModule,
MailerModule.forRootAsync({
imports: [SettingsModule],
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
if (db) {
// T-07-11: DB password used only to build transport; never logged
return {
transport: {
host: db.host,
port: db.port,
secure: db.secure,
requireTLS: db.requireTLS,
auth: db.username
? { user: db.username, pass: db.password ?? '' }
: undefined,
},
defaults: {
from: db.fromAddress,
},
};
}
// Priority 2: Env vars (new names first, legacy TESSERA_SMTP_* as secondary fallback)
const host =
configService.get<string>('MAIL_HOST') ??
configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
configService.get<number>('MAIL_PORT') ??
configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
configService.get<string>('MAIL_USER') ??
configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
configService.get<string>('MAIL_PASS') ??
configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
transport: {
host,
port,
secure,
auth: { user, pass },
},
defaults: { from },
};
},
inject: [SettingsService, ConfigService],
}),
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service';
/**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
* `mail` hatte VOR diesem Durchlauf KEINE Testdatei. Festgenagelt wird die
* Bauform "Transport je Versand nach Mandant des Empfaengers":
*
* 1. IDENTITAET FUER EINEN MANDANTEN MIT SmtpConfig: Transport aus GENAU
* dieser Config, `from` = deren fromAddress, `close()` gerufen.
* 2. Mandant OHNE SmtpConfig: die bisherige Umgebungs-Kette (MAIL_* vor
* TESSERA_SMTP_* vor localhost:1025), `from` aus TESSERA_SMTP_FROM bzw.
* Vorgabe.
* 3. ZWEI Mandanten nacheinander -> zwei verschiedene Transporte, keiner
* sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen).
* 4. `sendMail` wirft -> kein Throw nach aussen (T-02-12), Fehler
* protokolliert, `close()` trotzdem gerufen.
*
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`.
*/
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
const mockClose = vi.fn();
vi.mock('nodemailer', () => ({
createTransport: vi.fn(() => ({
sendMail: (...args: unknown[]) => (mockSendMail as any)(...args),
close: (...args: unknown[]) => (mockClose as any)(...args),
})),
}));
interface FakeDecrypted {
host: string;
port: number;
encryption: string;
username: string | null;
fromAddress: string;
decryptedPassword: string | null;
}
function makeFakeSettings(configsByTenant: Record<string, FakeDecrypted>) {
return {
getDecryptedSmtpConfig: vi.fn(async (tenantId: string) => configsByTenant[tenantId] ?? null),
};
}
function makeFakeConfig(values: Record<string, string | number | undefined>) {
return {
get: vi.fn((key: string, fallback?: unknown) => (values[key] !== undefined ? values[key] : fallback)),
};
}
const configA: FakeDecrypted = {
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
fromAddress: 'noreply@a.example.invalid',
decryptedPassword: 'geheim-a',
};
const configB: FakeDecrypted = {
host: 'smtp-b.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-b',
fromAddress: 'noreply@b.example.invalid',
decryptedPassword: 'geheim-b',
};
beforeEach(() => {
vi.clearAllMocks();
mockSendMail = vi.fn(async (_mail: unknown) => ({}));
});
describe('MailService — Transport je Versand nach Mandant des Empfaengers (260914-eym, WINDOWS #30)', () => {
it('Test 1: Mandant MIT SmtpConfig -> getDecryptedSmtpConfig genau einmal mit dieser tenantId, createTransport mit deren host/port/secure/requireTLS/auth, from = deren fromAddress, close() gerufen (Identitaet zu heute)', async () => {
const settings = makeFakeSettings({ t1: configA });
const config = makeFakeConfig({ MAIL_HOST: 'env-darf-nicht-greifen' });
const service = new MailService(settings as any, config as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1');
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledTimes(1);
expect(settings.getDecryptedSmtpConfig).toHaveBeenCalledWith('t1');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledTimes(1);
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenCalledWith({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
auth: { user: 'user-a', pass: 'geheim-a' },
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('alice@a.example.invalid');
expect(sent.text).toContain('/reset-password/tok-1');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 2: Mandant OHNE SmtpConfig -> Umgebungs-Kette: MAIL_* vor TESSERA_SMTP_* vor localhost:1025, from aus TESSERA_SMTP_FROM bzw. Vorgabe', async () => {
// (a) MAIL_* gesetzt -> gewinnt vor TESSERA_SMTP_*
const svcA = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
MAIL_HOST: 'mail.example.invalid',
MAIL_PORT: 2525,
MAIL_USER: 'mail-user',
MAIL_PASS: 'mail-pass',
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_FROM: 'Tessera <from@example.invalid>',
}) as any,
);
await svcA.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'mail.example.invalid',
port: 2525,
secure: false,
auth: { user: 'mail-user', pass: 'mail-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <from@example.invalid>');
// (b) nur TESSERA_SMTP_* gesetzt -> zweite Stufe
const svcB = new MailService(
makeFakeSettings({}) as any,
makeFakeConfig({
TESSERA_SMTP_HOST: 'legacy.example.invalid',
TESSERA_SMTP_PORT: 587,
TESSERA_SMTP_USER: 'legacy-user',
TESSERA_SMTP_PASSWORD: 'legacy-pass',
TESSERA_SMTP_SECURE: 'true',
}) as any,
);
await svcB.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'legacy.example.invalid',
port: 587,
secure: true,
auth: { user: 'legacy-user', pass: 'legacy-pass' },
});
expect((mockSendMail.mock.calls.at(-1)![0] as any).from).toBe('Tessera <tessera@tessera.local>');
// (c) nichts gesetzt -> localhost:1025
const svcC = new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any);
await svcC.sendPasswordResetEmail('x@example.invalid', 'tok', 't-ohne');
expect(vi.mocked(nodemailer.createTransport)).toHaveBeenLastCalledWith({
host: 'localhost',
port: 1025,
secure: false,
auth: { user: '', pass: '' },
});
expect(mockClose).toHaveBeenCalledTimes(3);
});
it('Test 3: zwei Mandanten nacheinander -> zwei verschiedene Transporte, keiner sieht die Zugangsdaten des anderen (T-GWH-03 geschlossen)', async () => {
const settings = makeFakeSettings({ t1: configA, t2: configB });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
expect(transports).toHaveLength(2);
expect(transports[0].host).toBe('smtp-a.example.invalid');
expect(transports[0].auth).toEqual({ user: 'user-a', pass: 'geheim-a' });
expect(transports[1].host).toBe('smtp-b.example.invalid');
expect(transports[1].requireTLS).toBe(true);
expect(transports[1].auth).toEqual({ user: 'user-b', pass: 'geheim-b' });
expect(JSON.stringify(transports[0])).not.toContain('geheim-b');
expect(JSON.stringify(transports[1])).not.toContain('geheim-a');
const sentMails = mockSendMail.mock.calls.map((c) => c[0] as any);
expect(sentMails[0].from).toBe('noreply@a.example.invalid');
expect(sentMails[1].from).toBe('noreply@b.example.invalid');
expect(sentMails[1].text).toContain('bob');
expect(mockClose).toHaveBeenCalledTimes(2);
});
it('Test 4: sendMail wirft -> kein Throw nach aussen (T-02-12), Fehler protokolliert ohne Kennwort, close() trotzdem gerufen', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(String(errorSpy.mock.calls[0][0])).toContain('Failed to send Password reset email to alice@a.example.invalid');
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
+156 -31
View File
@@ -1,6 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MailerService } from '@nestjs-modules/mailer';
import * as nodemailer from 'nodemailer';
import { SettingsService } from '../settings/settings.service';
/**
* MailService — Systemmails (Kennwort-Zuruecksetzung, Willkommensmail).
*
* TRANSPORT JE VERSAND NACH MANDANT DES EMPFAENGERS (Etappe 3c, 260914-eym,
* WINDOWS #30 GESCHLOSSEN):
*
* Vorher baute `mail.module.ts` beim Start EINEN Transport aus einer
* beliebigen SmtpConfig (`findFirst()` ohne Bedingung) und alle
* Systemmails aller Mandanten liefen ueber den SMTP-Server und die
* Absenderadresse DIESES einen Mandanten (T-GWH-03). Zwei Gruende, warum
* der Transport jetzt JE VERSAND entsteht:
*
* 1. Pitfall 3 (Research): ein Start-Transport kann nicht wechseln — eine
* Aenderung der SMTP-Einstellungen im UI griff erst nach einem Neustart.
* 2. Mandantentrennung: der Mandant des EMPFAENGERS entscheidet, welche
* Zugangsdaten benutzt werden — nie ein beliebiger. Der Mandant ist an
* der einzigen produktiven Versandstelle bekannt
* (`AuthService.requestPasswordReset`: `user.tenantId` steht eine Zeile
* vor dem Versand). Vorlage: `DkvMailService`/`TenderMailService`
* (`getDecryptedSmtpConfig(tenantId)`, gebunden, `nodemailer.createTransport`,
* `transport.close()` im `finally`).
*
* Die Umgebungs-Kette (MAIL_* -> TESSERA_SMTP_* -> localhost:1025) ist NUR
* noch der Rueckfall fuer Mandanten OHNE eigene SmtpConfig — nicht mehr
* der Ersatz fuer einen verstummten Startpfad. Es gibt keinen Startpfad
* mehr, deshalb braucht `SmtpConfig` auch keine `system_read_policy`.
*
* Was mit EINEM Mandanten identisch bleibt (mail.service.spec.ts): Mandant
* MIT SmtpConfig -> Transport aus GENAU dieser Config, `from` = deren
* fromAddress; Mandant OHNE -> dieselbe Umgebungs-Kette wie bisher;
* Transportfehler werden weiter verschluckt und protokolliert (T-02-12 —
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*/
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
from: string;
}
@Injectable()
export class MailService {
@@ -8,8 +54,8 @@ export class MailService {
private readonly appUrl: string;
constructor(
private mailerService: MailerService,
private configService: ConfigService,
private readonly settingsService: SettingsService,
private readonly configService: ConfigService,
) {
this.appUrl = this.configService.get<string>(
'TESSERA_APP_URL',
@@ -17,14 +63,113 @@ export class MailService {
);
}
/**
* Transport-Optionen fuer den Mandanten des Empfaengers: die SmtpConfig
* des Mandanten (gebunden ueber `getDecryptedSmtpConfig(tenantId)`),
* sonst die bisherige Umgebungs-Kette aus `mail.module.ts` unveraendert.
*/
private async resolveTransport(tenantId: string): Promise<ResolvedTransport> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) {
return {
source: 'tenant',
options: {
host: smtpConfig.host,
port: smtpConfig.port,
secure: smtpConfig.encryption === 'ssl-tls',
requireTLS: smtpConfig.encryption === 'starttls',
auth: smtpConfig.username
? {
user: smtpConfig.username,
// T-07-10/T-07-11: entschluesseltes Kennwort nur hier, nie protokolliert
pass: smtpConfig.decryptedPassword ?? '',
}
: undefined,
},
from: smtpConfig.fromAddress,
};
}
// Rueckfall: Umgebungsvariablen (neue Namen zuerst, TESSERA_SMTP_* als
// zweite Stufe, zuletzt localhost:1025 — Mailhog / dev default).
const host =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST') ??
'localhost';
const port =
this.configService.get<number>('MAIL_PORT') ??
this.configService.get<number>('TESSERA_SMTP_PORT') ??
1025;
const user =
this.configService.get<string>('MAIL_USER') ??
this.configService.get<string>('TESSERA_SMTP_USER') ??
'';
const pass =
this.configService.get<string>('MAIL_PASS') ??
this.configService.get<string>('TESSERA_SMTP_PASSWORD') ??
'';
const from =
this.configService.get<string>('TESSERA_SMTP_FROM') ??
'Tessera <tessera@tessera.local>';
const secure =
this.configService.get<string>('TESSERA_SMTP_SECURE', 'false') === 'true';
return {
source: 'env',
options: { host, port, secure, auth: { user, pass } },
from,
};
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
transport = nodemailer.createTransport(resolved.options as any);
await transport.sendMail({
from: resolved.from,
to: mail.to,
subject: mail.subject,
text: mail.text,
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
* (no email enumeration).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendPasswordResetEmail(
email: string,
token: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const resetLink = `${this.appUrl}/reset-password/${token}`;
@@ -66,28 +211,20 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Password reset email sent to ${email}`);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send password reset email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Password reset');
}
/**
* Send a welcome email to a newly created user (optional).
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
*/
async sendWelcomeEmail(
email: string,
username: string,
tenantId: string,
locale: string = 'de',
): Promise<void> {
const isGerman = locale === 'de';
@@ -117,18 +254,6 @@ export class MailService {
'The Tessera Team',
].join('\n');
try {
await this.mailerService.sendMail({
to: email,
subject,
text,
});
this.logger.log(`Welcome email sent to ${email}`);
} catch (error) {
this.logger.error(
`Failed to send welcome email to ${email}`,
error instanceof Error ? error.stack : String(error),
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
}
}
@@ -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);
});
}
+215 -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,50 @@ 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.
*
* Die sechs Faelle der Hintergrunddienst-Falle
* (docs/mandantentrennung-zugriffsklassifikation.md) und wo sie stehen:
* (1) DKV-Planer-Startpfad -> dkv.service.ts (1 Aufruf,
* `loadActiveConfigsForScheduler`); (2) Mailmodul-Startpfad -> NICHT in der
* Liste: der Startpfad ist ENTFERNT, `MailService` baut je Versand einen
* Transport gebunden ueber `getDecryptedSmtpConfig(tenantId)`
* (settings.service.ts/mail.service.ts rufen `forSystem` nie); (3) ldap ->
* ldap-config.service.ts (2 Aufrufe: `getAllActiveConfigs` und die
* Nachverschluesselung in `onApplicationBootstrap`, je eigene Methode);
* (4) tender-digest -> tender-digest.scheduler.ts (1, Kandidatenabfrage);
* (5) tender-matching -> tender-matching.service.ts (1, Profilabfrage);
* (6) admin-seed -> NICHT in der Liste: der einzige Lesezugriff ausserhalb
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
* Summe: 4 Dateien, 5 Aufrufe.
*/
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
['apps/api/src/tenders/tender-matching.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 +355,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 +383,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 +394,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 +433,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 +524,7 @@ function analyzeSource(rawSource: string, relPath: string): FileAnalysis {
const allReceiverNames = new Set<string>([
'this.prisma',
...boundNames,
...systemNames,
...txBoundParams,
...txUnboundParams,
]);
@@ -478,7 +543,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 +573,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 +586,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 +622,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 +633,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 +716,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 +782,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 +1040,78 @@ class ProbeService {
expect(unresolvedResult.unresolvedRelationSpecValues).toHaveLength(1);
expect(unresolvedResult.unresolvedRelationSpecValues[0]).toContain('IMPORTED_SELECT');
});
it('Probe C (260914-eym, Systemkontext, Empfaengername absichtlich nicht systemPrisma): `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 sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.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 sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.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 sysPrisma = forSystem(this.prisma) as any;
return sysPrisma.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);
});
});
+7 -87
View File
@@ -7,11 +7,13 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR
* diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit
* einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) —
* KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet
* AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener
* Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg
* ("X is not a function" statt eines stillen Fallbacks).
* bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` — KEIN `findUnique`,
* KEIN `upsert`; der GEBUNDENE Klient bietet AUSSCHLIESSLICH
* `findUnique`/`upsert` — KEIN `findFirst`. Ein ungebundener Anfrageweg
* scheitert damit hart ("X is not a function" statt eines stillen
* Fallbacks). Der ungebundene Startpfad des Mailmoduls (findFirst beim
* Boot) und sein describe-Block sind seit 260914-eym (WINDOWS #30)
* GELOESCHT — der ungebundene Nachbau bleibt als Falsifizierungsform stehen.
*
* `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal
* gibt es keinen `mailhog`).
@@ -416,88 +418,6 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
});
});
describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => {
it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 465,
encryption: 'ssl-tls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
},
]);
const crypto = makeFakeCrypto();
const service = new SettingsService(prisma as any, crypto as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toEqual({
host: 'smtp-a.example.invalid',
port: 465,
secure: true,
requireTLS: false,
username: 'user-a',
password: 'geheim',
fromAddress: 'a@example.invalid',
});
});
it('requireTLS bei starttls', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result?.secure).toBe(false);
expect(result?.requireTLS).toBe(true);
});
it('leerer Nachbau -> null', async () => {
const prisma = makeFakePrisma([]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.loadAnySmtpConfigForStartupTransport();
expect(result).toBeNull();
});
it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => {
const prisma = makeFakePrisma([
{
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: null,
encryptedPassword: null,
fromAddress: 'a@example.invalid',
},
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
await service.loadAnySmtpConfigForStartupTransport();
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('Wachhund je Anfrageweg', () => {
const storedRow: FakeSmtpRow = {
id: 'smtp-a',
+4 -73
View File
@@ -91,8 +91,10 @@ export class SettingsService {
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService to build a nodemailer transport
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von
* MailService (Systemmails, Transport je Versand nach Mandant des
* Empfaengers, WINDOWS #30) — to build a nodemailer transport at send
* time — the ONLY send path (Befund K, 260909-laa/260909-mir).
* NEVER log the decrypted password (T-07-10 / T-05-13).
*
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
@@ -190,75 +192,4 @@ export class SettingsService {
return { success: false };
}
}
/**
* Tenant-agnostic startup accessor for the MailModule factory.
*
* BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der
* Hintergrunddienst-Falle — gleicher Bauart wie
* `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe
* dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren
* genannt:
*
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
* Bedingung zieht bei mehreren Mandanten den SMTP-Server und die
* Absenderadresse EINES beliebigen Mandanten fuer ALLE
* Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten
* (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
* - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage
* `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt
* `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport
* statt einer Meldung; `MailService` faengt jeden Transportfehler
* (T-02-12) und der Controller antwortet `200`. Das Verstummen ist
* damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch
* das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap`
* (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu
* `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT
* Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE
* Protokollzeile.
*
* Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start
* gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport
* je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
* `DkvMailService`/`TenderMailService` bereits haben, `MailService`
* muesste den Mandanten nur von `requestPasswordReset` entgegennehmen —
* ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau,
* NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt
* Anschluss an #21 (andere Datei, andere Reparatur, andere
* Verdeckungsform) — siehe WINDOWS #30 und
* `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
* "## Bereich settings", (s4)(a).
*
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
* T-07-11: Decrypted password is used only to build the transport — never logged.
*/
async loadAnySmtpConfigForStartupTransport(): Promise<{
host: string;
port: number;
secure: boolean;
requireTLS: boolean;
username: string | null;
password: string | null;
fromAddress: string;
} | null> {
const config = await this.prisma.smtpConfig.findFirst();
if (!config) return null;
let password: string | null = null;
if (config.encryptedPassword) {
// T-07-11: Used only to build transport at startup; never logged
password = this.crypto.decrypt(config.encryptedPassword);
}
return {
host: config.host,
port: config.port,
secure: config.encryption === 'ssl-tls',
requireTLS: config.encryption === 'starttls',
username: config.username,
password,
fromAddress: config.fromAddress,
};
}
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderDigestScheduler } from './tender-digest.scheduler';
/**
@@ -29,6 +29,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Kandidatenabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const MONDAY = new Date('2026-07-27T10:00:00Z');
@@ -84,6 +86,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, tenderNotificationPref, user };
const systemCallLog: { model: string; method: string }[] = [];
return {
tenderMatch,
@@ -91,6 +94,19 @@ function makeFakePrisma(opts: {
user,
__store: { matches, prefs, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog,
// tenderMatch.findMany unveraendert (dieselbe Fake-Implementierung).
__makeSystemClient() {
return {
tenderMatch: {
findMany: async (args: any) => {
systemCallLog.push({ model: 'tenderMatch', method: 'findMany' });
return tenderMatch.findMany(args);
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -380,6 +396,26 @@ describe('TenderDigestScheduler — Bindung an forTenant() (260909-laa)', () =>
expect(forTenant).toHaveBeenCalledTimes(1);
});
it('die Kandidatenabfrage laeuft ueber den System-Klienten: __systemCallLog enthaelt genau tenderMatch.findMany, nichts aus der Schleife (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
const matches = [makeMatch({ userId: 'user-1', tenantId: 'tenant-1' })];
const prisma = makeFakePrisma({ matches, prefs, users });
const mail = { sendDigest: vi.fn().mockResolvedValue(true) };
const scheduler = new TenderDigestScheduler(makeSchedulerRegistry() as any, prisma as any, mail as any);
await scheduler.runDigest(TUESDAY);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderMatch', method: 'findMany' }]);
// Die Schleife (Praeferenz, Treffer, Benutzer, Stempelung) lief gebunden,
// nicht ueber den System-Klienten.
expect(prisma.__boundCallLog.length).toBeGreaterThan(0);
expect(mail.sendDigest).toHaveBeenCalledTimes(1);
});
it('die Zugriffe je Kandidatenzeile binden an den Mandanten DIESER Zeile — Zugriffe innerhalb der Schleife laufen auf dem gebundenen Client', async () => {
const users = new Map([['user-1', { id: 'user-1', email: 'a@tenant.de', tenantId: 'tenant-1' }]]);
const prefs = new Map([['user-1', { userId: 'user-1', digestInterval: 'daily' }]]);
@@ -1,7 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailItem, TenderMailService } from './tender-mail.service';
/**
@@ -105,9 +105,13 @@ export class TenderDigestScheduler implements OnModuleInit {
async runDigest(now: Date = new Date()): Promise<void> {
// Candidate users: distinct userId with at least one un-notified match,
// across ALL tenants — a single findMany, never a per-tenant iteration.
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — der bewusste Fan-out
// über alle Mandanten dieses Bereichs; Etappe-3-Uebergabe (Systemkontext
// für Hintergrundläufe wird dort entschieden, hier NICHT vorweggenommen).
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): `forSystem()` liest TenderMatch
// ALLER Mandanten NUR lesend (`system_read_policy ... FOR SELECT`,
// Migration 20260914120000) — ohne diese Regel saehe der Digest nach dem
// Scharfschalten 0 Kandidaten und wuerde stumm. Die Schleife unten
// bleibt je Kandidatenzeile GEBUNDEN (bewusst ohne Benutzer, wie in 3b).
// Eine LEERE Kandidatenliste ist Nichtstun: `notifiedAt` bleibt NULL.
//
// Zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
// ausgewaehlt (nicht Teil von `distinct`), damit die Schleife unten
@@ -117,7 +121,8 @@ export class TenderDigestScheduler implements OnModuleInit {
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
const candidates = await this.prisma.tenderMatch.findMany({
const systemPrisma = forSystem(this.prisma) as any;
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
where: { notifiedAt: null },
select: { userId: true, tenantId: true },
distinct: ['userId'],
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMatchingService } from './tender-matching.service';
/**
@@ -24,6 +24,8 @@ import { TenderMatchingService } from './tender-matching.service';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): die Profilabfrage laeuft ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
const PROFILE_A = {
@@ -125,6 +127,7 @@ function makeFakePrisma(opts: {
};
const modelsByName: Record<string, any> = { tenderMatch, user };
const systemCallLog: { model: string; method: string }[] = [];
const prisma = {
tenderSavedSearch,
@@ -133,6 +136,20 @@ function makeFakePrisma(opts: {
user,
__store: { matches, savedSearches, tenders, users },
__boundCallLog: boundCallLog,
__systemCallLog: systemCallLog,
// Systemkontext-Klient (260914-eym): protokolliert in __systemCallLog;
// nur tenderSavedSearch — ein Katalogzugriff ueber diesen Klienten
// wuerde hart scheitern ("tender is undefined").
__makeSystemClient() {
return {
tenderSavedSearch: {
findMany: async () => {
systemCallLog.push({ model: 'tenderSavedSearch', method: 'findMany' });
return tenderSavedSearch.findMany();
},
},
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
@@ -445,6 +462,23 @@ describe('TenderMatchingService.matchDelta — Bindung an forTenant() (260909-la
expect(forTenant).toHaveBeenCalledWith(prisma, PROFILE_A.tenantId);
});
it('die Profilabfrage laeuft ueber den System-Klienten, der Katalog-Lesezugriff NICHT (weiter roher Client) (260914-eym)', async () => {
vi.mocked(forSystem).mockClear();
const matchingTenderIds = new Set(['new-1']);
const prisma = makeFakePrisma({ savedSearches: [PROFILE_A], matchingTenderIds });
const service = new TenderMatchingService(prisma as any, makeFakeMail() as any);
await service.matchDelta(['new-1']);
expect(forSystem).toHaveBeenCalledTimes(1);
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.__systemCallLog).toEqual([{ model: 'tenderSavedSearch', method: 'findMany' }]);
// Katalog: roher Client, genau einmal (ein Profil).
expect(prisma.tender.findMany).toHaveBeenCalledTimes(1);
// Treffer-Anlage gebunden.
expect(prisma.__boundCallLog.some((c: any) => c.model === 'tenderMatch' && c.method === 'upsert')).toBe(true);
});
it('die Treffer-Anlage bindet an den Mandanten DES PROFILS — EIN gebundener Client je Profil, nicht je Treffer', async () => {
vi.mocked(forTenant).mockClear();
const matchingTenderIds = new Set(['new-1', 'new-2', 'new-3']);
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { TenderMailService } from './tender-mail.service';
import { buildTenderWhere } from './tender-query.builder';
import type { TenderQueryDto } from './dto/tender-query.dto';
@@ -64,11 +64,17 @@ export class TenderMatchingService {
async matchDelta(newTenderIds: string[]): Promise<void> {
if (!newTenderIds.length) return;
// BEWUSST UNGEBUNDEN (260909-laa, Aufgabe 3) — Profile aller Mandanten
// werden gegen neue Treffer geprueft, der bewusste Fan-out dieses
// Bereichs; Etappe-3-Uebergabe (Systemkontext fuer Hintergrundlaeufe
// wird dort entschieden, hier NICHT vorweggenommen).
const savedSearches = await this.prisma.tenderSavedSearch.findMany();
// SYSTEMGEBUNDEN (Etappe 3c, 260914-eym; die Etappe-3-Uebergabe aus
// 260909-laa ist damit eingeloest): Profile ALLER Mandanten werden gegen
// neue Treffer geprueft — `forSystem()` liest TenderSavedSearch NUR
// lesend (`system_read_policy ... FOR SELECT`, Migration 20260914120000);
// ohne diese Regel saehe der Abgleich nach dem Scharfschalten 0 Profile
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
const systemPrisma = forSystem(this.prisma) as any;
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
await systemPrisma.tenderSavedSearch.findMany();
for (const search of savedSearches) {
try {
@@ -11,6 +11,8 @@ import { TenderDigestScheduler } from './tender-digest.scheduler';
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
// Systemkontext (260914-eym): Profil- und Kandidatenabfrage laufen ueber forSystem().
forSystem: vi.fn((prisma: any) => prisma.__makeSystemClient()),
}));
/**
@@ -136,6 +138,14 @@ function makeSharedFakePrisma(opts: {
user: userModel,
__store: { matches },
__boundCallLog: boundCallLog,
// Systemkontext-Klient (260914-eym): dieselben Fake-Implementierungen,
// ohne Bindungsprotokoll.
__makeSystemClient() {
return {
tenderSavedSearch: { findMany: async () => [savedSearch] },
tenderMatch: { findMany: async (args: any) => tenderMatch.findMany(args) },
};
},
__makeBoundClient(tenantId: string) {
const bound: any = {};
for (const [modelName, model] of Object.entries(modelsByName)) {
+34
View File
@@ -85,6 +85,25 @@ noetig sind:
ein Aufruf OHNE gesetzten Benutzer (Admin, Hintergrunddienst) sieht
weiterhin den ganzen Mandanten, das macht die Aenderung fuer heutige
Aufrufer wirkungslos.
- **Eine dritte Sitzungsvariable fuer den Systemkontext.** Migration
`20260914120000_rls_system_context_read` (Etappe 3c, 260914-eym) bringt
`app.system_context` und die Funktion `is_system_context()` —
`COALESCE(current_setting('app.system_context', true) = 'true', false)`,
damit die Regel ohne gesetzte Variable FALSE sieht, nicht NULL — sowie je
eine zusaetzliche PERMISSIVE Regel `system_read_policy ... FOR SELECT
USING (is_system_context())` auf genau den fuenf Tabellen, die die
Hintergrunddienste ueber alle Mandanten LESEN (DkvModuleConfig, LdapConfig,
LdapFieldMapping, TenderMatch, TenderSavedSearch). Permissive Regeln werden
ODER-verknuepft: fuer SELECT gilt (Mandantenregel ODER Systemregel), fuer
INSERT/UPDATE/DELETE weiter NUR die Mandantenregel — unter Systemkontext
ist `current_tenant_id()` der Leerstring, jedes Schreiben faellt durch
(gemessen: 42501 / count 0 / P2025). Der Helfer `forSystem()` setzt
`app.system_context = 'true'` und die beiden anderen Variablen
AUSDRUECKLICH leer; `forTenant()` und `withTenantTransaction()` setzen
umgekehrt `app.system_context = ''` — kein Kontext erbt vom anderen
(`local=true` als erstes Netz, der Reset als zweites, beides im Werkzeug
gemessen und durch Rueckbau belegt). Kein `GRANT EXECUTE` noetig, wie bei
den beiden anderen Funktionen.
## 3. Der Sperrgrund — warum die Umstellung noch nicht erfolgt ist
@@ -135,6 +154,21 @@ werden darf. Das ist **eigene Arbeit und nicht Teil dieser Aenderung**
Abschnitt 5 belegt ausschliesslich, dass die Datenbankseite stimmt — er sagt
nichts ueber diese Zugriffe aus.
**Nachtrag (260914-eym, Etappe 3c):** der Systemkontext ist gebaut — siehe
den Punkt "Eine dritte Sitzungsvariable" in Abschnitt 2 und
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"## Systemkontext (Etappe 3c, 260914-eym)". Vier Hintergrunddienst-Dateien
lesen ueber `forSystem()`, der Mail-Startpfad ist entfernt, die Erstanlage
des Administrators liest nur `Tenant` (keine Regel). Die Vorher-Pruefung
`ohne-kontext-leer` in `rls-preflight.mjs` (Abschnitt 5) bleibt GUELTIG und
wird durch die neue Regel NICHT gelockert: ohne gesetzte Variable ist
`is_system_context()` false — Werkzeugbeleg
`is-system-context-ungesetzt-false` (`rls-scratch-check.mjs`, Rohwert
`null` -> `false`). Etappe 4 ergaenzt die Vorher-Pruefung um
`mit-systemkontext-sichtbar` (mit `app.system_context = 'true'` sind die
fuenf Tabellen lesbar); `rls-preflight.mjs` ist in 3c bewusst nicht
angefasst.
**Zusaetzlicher Sperrgrund, ebenfalls am 2026-09-09 gemessen (WINDOWS #20):**
`forTenant()` selbst war bis Aufgabe 1 dieser Etappe defekt — `set_config()`
lief auf einer anderen Datenbankverbindung als die eigentliche Abfrage, sodass
@@ -985,6 +985,13 @@ abgeschrieben.
entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
`tenders` es vormachen.
**Nachtrag (260914-eym):** WINDOWS #21 ist GESCHLOSSEN — `loadActiveConfigsForScheduler()`
liest über `forSystem()` (Systemleseregel auf DkvModuleConfig) ALLE aktiven
Konfigurationen, der Planer registriert je Mandant einen eigenen Auftrag
`dkv-inbox-poll:<tenantId>`; die Erwähnung des einen Auftragsnamens
`dkv-inbox-poll` oben bleibt als historischer Stand stehen. Siehe
`## Systemkontext (Etappe 3c, 260914-eym)`.
### (d5) Was dieser Durchlauf bewusst nicht anfasst
- **Die beiden mehrschrittigen Stellen bleiben unatomar (Befund C, TEIL
@@ -3096,6 +3103,15 @@ unverändert und steht nicht in der Erlaubnisliste.
`SmtpConfig`-Zeile über die Wartungsrolle lesen und den gebundenen
`findUnique` daneben halten — dieselbe Form wie bei `dashboard`/`calendar`.
**Nachtrag (260914-eym):** WINDOWS #30 ist GESCHLOSSEN — nicht durch einen
Systemkontext, sondern durch ENTFERNEN des Startpfads: die Mailer-Fabrik in
`mail.module.ts` und die Startpfad-Methode in `settings.service.ts` sind
gelöscht, `MailService` baut je Versand einen Transport aus
`getDecryptedSmtpConfig(tenantId)` des Empfänger-Mandanten
(`requestPasswordReset` reicht `user.tenantId` durch). Deshalb trägt
SmtpConfig keine `system_read_policy`. Siehe
`## Systemkontext (Etappe 3c, 260914-eym)`.
### (s5) Was dieser Durchlauf bewusst nicht anfasst
- `settings.controller.ts` — nur gelesen (siehe (s4)(d)).
@@ -3214,6 +3230,11 @@ Forbidden zu NotFound.
nichts, `tenderrssfeed-gemeinsame-zeile-ohne-benutzer-weiterhin-entfernbar`
bestätigt das lediglich erneut.
**Nachtrag (260914-eym):** der erste Punkt ist eingelöst — der Systemkontext
für Hintergrunddienste ist gebaut (`## Systemkontext (Etappe 3c, 260914-eym)`),
`tender-digest.scheduler.ts` liest seine Kandidaten über `forSystem()` und
bleibt in der Schleife gebunden.
### (b5) Was dieser Durchlauf bewusst nicht anfasst
- Die vier Tabellen mit `userId`-Spalte, die KEINE persönlichen Daten tragen
@@ -3226,6 +3247,221 @@ Forbidden zu NotFound.
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS.
- Compose-/Umgebungsdateien — unangetastet.
## Systemkontext (Etappe 3c, 260914-eym)
Helfer-, Datenbank- und Dienstumbau: Migration `20260914120000_rls_system_context_read`
bringt die Funktion `is_system_context()` und je betroffener Tabelle eine
zusätzliche, NUR lesende Regel `system_read_policy … FOR SELECT` auf
DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch und
TenderSavedSearch. `forSystem(prisma)` ist der Schwesterhelfer von
`forTenant()` (gleiche Array-Form-Bauart, setzt `app.system_context = 'true'`
und die beiden anderen Sitzungsvariablen ausdrücklich leer; `forTenant()`
und `withTenantTransaction()` setzen umgekehrt `app.system_context = ''`).
Vier Dateien rufen ihn an fünf Stellen (Erlaubnisliste
`FORSYSTEM_ALLOWED_CALL_SITES` im Detektor, exakte Zahl je Datei): der
DKV-Planer-Startpfad (jetzt ein Cron-Auftrag je aktivem Mandanten,
WINDOWS #21), beide Leser in `ldap-config.service.ts`, die Kandidatenabfrage
des Digest, die Profilabfrage des Abgleichs. Der Mail-Startpfad ist nicht
umgestellt, sondern ENTFERNT (Transport je Versand nach Mandant des
Empfängers, WINDOWS #30); `admin-seed.service.ts` liest außerhalb seiner
Schleife nur `Tenant` (keine Regel) und ist unverändert. Der Schalter bleibt
AUS — nichts hiervon wirkt, bis Etappe 4 scharfschaltet.
### (y1) Die Messung
Wörtliche Werkzeugausgabe der vier Funktionsfälle (`rls-scratch-check.mjs`,
Wegwerf-Rolle ohne BYPASSRLS, Funktion aus der Migration geschnitten):
```
is-system-context-ungesetzt-false: bestanden — ohne gesetzte Variable: is_system_context() = false (Rohwert null) — die Vorher-Pruefung ohne-kontext-leer in rls-preflight.mjs bleibt gueltig
is-system-context-leer-false: bestanden — nach set_config('app.system_context', '', true): false
is-system-context-true-true: bestanden — nach set_config('app.system_context', 'true', true): true
is-system-context-fremdwert-false: bestanden — nach set_config('app.system_context', 'yes', true): false
```
Je Tabelle die drei Kern-Kennungen (zu wenig / zu viel / Erben) über den
GENERIERTEN Client, Wegwerf-Tabellen mit allen skalaren Spalten, Regeln
wortgleich aus ihren Migrationen geschnitten:
```
dkvmoduleconfig-systemkontext-sieht-beide-mandanten: bestanden — system.dkvModuleConfig.findMany() liefert 2 Zeile(n) aus Mandanten ["TENANT-A","TENANT-B"]
dkvmoduleconfig-systemkontext-insert-abgewiesen-42501: bestanden — system.dkvModuleConfig.create wirft PrismaClientUnknownRequestError, SQLSTATE 42501: ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(Post […]
dkvmoduleconfig-fortenant-a-nach-systemkontext-nur-a: bestanden — bound(TENANT-A).dkvModuleConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 1 Zeile(n) aus ["TENANT-A"]
ldapconfig-systemkontext-sieht-beide-mandanten: bestanden — system.ldapConfig.findMany() liefert 2 Zeile(n) aus Mandanten ["TENANT-A","TENANT-B"]
ldapconfig-systemkontext-insert-abgewiesen-42501: bestanden — system.ldapConfig.create wirft PrismaClientUnknownRequestError, SQLSTATE 42501: ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresError […]
ldapconfig-fortenant-a-nach-systemkontext-nur-a: bestanden — bound(TENANT-A).ldapConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 1 Zeile(n) aus ["TENANT-A"]
ldapfieldmapping-systemkontext-sieht-beide-mandanten: bestanden — system.ldapFieldMapping.findMany() liefert 2 Zeile(n) aus Mandanten ["TENANT-A","TENANT-B"]
ldapfieldmapping-systemkontext-insert-abgewiesen-42501: bestanden — system.ldapFieldMapping.create wirft PrismaClientUnknownRequestError, SQLSTATE 42501: ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(Po […]
ldapfieldmapping-fortenant-a-nach-systemkontext-nur-a: bestanden — bound(TENANT-A).ldapFieldMapping.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 1 Zeile(n) aus ["TENANT-A"]
tendermatch-systemkontext-sieht-beide-mandanten: bestanden — system.tenderMatch.findMany() liefert 2 Zeile(n) aus Mandanten ["TENANT-A","TENANT-B"]
tendermatch-systemkontext-insert-abgewiesen-42501: bestanden — system.tenderMatch.create wirft PrismaClientUnknownRequestError, SQLSTATE 42501: ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError(PostgresErro […]
tendermatch-fortenant-a-nach-systemkontext-nur-a: bestanden — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 1 Zeile(n) aus ["TENANT-A"]
tendersavedsearch-systemkontext-sieht-beide-mandanten: bestanden — system.tenderSavedSearch.findMany() liefert 2 Zeile(n) aus Mandanten ["TENANT-A","TENANT-B"]
tendersavedsearch-systemkontext-insert-abgewiesen-42501: bestanden — system.tenderSavedSearch.create wirft PrismaClientUnknownRequestError, SQLSTATE 42501: ConnectorError(ConnectorError { user_facing_error: None, kind: QueryError( […]
tendersavedsearch-fortenant-a-nach-systemkontext-nur-a: bestanden — bound(TENANT-A).tenderSavedSearch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 1 Zeile(n) aus ["TENANT-A"]
```
Die #27-Form unter Systemkontext (der Pfad von `getAllActiveConfigs()`):
```
ldapconfig-systemkontext-include-fieldmappings-beider-mandanten: bestanden — system.ldapConfig.findMany({ where: { isActive: true }, include: { fieldMappings: true } }) liefert 2 Zeile(n): ["TENANT-A:1","TENANT-B:1"] (Mandant:Anzahl Zuordnungen)
```
Schlusszeile: `Alle 253 Pruefungen bestanden.` (Baseline vor diesem Lauf: 203; nach Aufgabe 1: 216).
`pg_policies` der LEBENDEN Datenbank nach `prisma migrate deploy` (36
Migrationen, `pg_proc` kennt `is_system_context`, 34 Regeln gesamt, alle
PERMISSIVE; Form Tabelle#Regelname#Befehl#USING#WITH CHECK, beide Regeln je
Tabelle):
```
DkvModuleConfig#system_read_policy#SELECT#is_system_context()#
DkvModuleConfig#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
LdapConfig#system_read_policy#SELECT#is_system_context()#
LdapConfig#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
LdapFieldMapping#system_read_policy#SELECT#is_system_context()#
LdapFieldMapping#tenant_isolation_policy#ALL#("ldapConfigId" IN ( SELECT "LdapConfig".id FROM "LdapConfig" WHERE ("LdapConfig"."tenantId" = current_tenant_id())))#
TenderMatch#system_read_policy#SELECT#is_system_context()#
TenderMatch#tenant_isolation_policy#ALL#("tenantId" = current_tenant_id())#
TenderSavedSearch#system_read_policy#SELECT#is_system_context()#
TenderSavedSearch#tenant_isolation_policy#ALL#(("tenantId" = current_tenant_id()) AND ((current_user_id() IS NULL) OR ("userId" = current_user_id())))#
```
Endstand nach Aufgabe 2: Tests 1054 bestanden / 64 Dateien (Baseline
1028 / 62), Typprüfung sauber, Werkzeug 253.
### (y2) Signaltabelle — beide Fehlerrichtungen je Regel
| Fehlerrichtung | Erwartung | Gemessen | Befund |
|---|---|---|---|
| Zu streng: der Systemkontext sähe nichts (ein Systemleser OHNE Regel liefert nach dem Scharfschalten 0 Zeilen und schweigt — T-EYM-04) | Systemkontext liefert beide Mandanten | `<tabelle>-ungebunden-null-zeilen` UND `<tabelle>-systemkontext-sieht-beide-mandanten` als Paar (fünf Tabellen), dazu `ldapconfig-systemkontext-include-fieldmappings-beider-mandanten` | NICHT der Fall — jede der fünf Tabellen ist geöffnet; Rückbau (b) unten zeigt, dass das Werkzeug der Datei folgt |
| Zu locker: der Systemkontext könnte schreiben (T-EYM-02) | INSERT/UPDATE/DELETE scheitern an der Mandantenregel | `<tabelle>-systemkontext-insert-abgewiesen-42501`, `…-updatemany-count-0`, `…-deletemany-count-0` (fünf Tabellen) | NICHT der Fall — die Regel ist `FOR SELECT`; Rückbau (a): `FOR SELECT` entfernt → der Insert GELINGT, `cmd` wird `ALL` |
| Zu locker: ein Anfrageweg ruft `forSystem` (T-EYM-01) | Spec rot | `FORSYSTEM_ALLOWED_CALL_SITES` mit exakter Zahl je Datei; Rückbau (d): Zahl 0 → zwei Zusicherungen rot, Fremddatei → drei rot | Wachhund greift; keine Ausnahmeliste für die Zuweisungsform |
| Erben: eine Verbindung trägt `app.system_context` in eine spätere `forTenant`-Abfrage (T-EYM-03) | `forTenant(A)` nach `forSystem` sieht nur A; `is_system_context()` unter `forTenant` ist false | `<tabelle>-fortenant-a-nach-systemkontext-nur-a`, `<tabelle>-is-system-context-unter-fortenant-false` (fünf Tabellen) | NICHT der Fall — `local=true` (erstes Netz) UND ausdrücklicher Reset (zweites Netz); Rückbau (c) unten belegt, dass der Reset allein trägt |
**Rückbau (a)** — in der Migrationsdatei bei `"TenderMatch"` das `FOR SELECT`
entfernt (Regel wird `ALL`), Werkzeug:
```
tendermatch-systemkontext-insert-abgewiesen-42501: FEHLGESCHLAGEN — system.tenderMatch.create({"id":"tm-system-schreibversuch","tenderId":"tender-2","savedSearchId":"ss-a","userId":"user-a","tenantId":"TENANT-A"}) ist NICHT fehlgeschlagen — angelegt: "tm-system-schreibversuch"
tendermatch-systemkontext-updatemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.updateMany({ where: {}, data: {"notifiedChannel":"SYSTEM-SCHREIBVERSUCH"} }) liefert count=3
tendermatch-systemkontext-deletemany-count-0: FEHLGESCHLAGEN — system.tenderMatch.deleteMany({}) liefert count=3; Zeilen danach (Wartungsrolle): 0
tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 0 Zeile(n) aus []
tendermatch-pg-policies-genau-eine-system-read-policy-select: FEHLGESCHLAGEN — pg_policies fuer "TenderMatch" (system_read_policy): [{"policyname":"system_read_policy","cmd":"ALL","permissive":"PERMISSIVE","qual":"is_system_context()"}]
5 von 253 Pruefungen fehlgeschlagen.
```
**Rückbau (b)** — die `system_read_policy` für `"TenderSavedSearch"` aus der
Migrationsdatei entfernt. Die innere Routine bricht für diese Tabelle mit
einer eigenen roten Kennung ab (Muster `runSingleRulePersonalTableCheck`:
nicht raten, wenn die Regel fehlt) — deshalb 245 statt 253 Prüfungen, nicht
die im Plan erwarteten zwei roten Kennungen `…-sieht-beide-mandanten`/`…-pg-policies-…`;
die lebende Datenbank blieb währenddessen bei 34 Regeln und einer
`system_read_policy` auf TenderSavedSearch (per `pg_policies` gelesen):
```
tendersavedsearch-system-read-policy-aus-migration-gefunden: FEHLGESCHLAGEN — CREATE POLICY system_read_policy ON "TenderSavedSearch" nicht in der Systemkontext-Migration (20260914120000) gefunden
1 von 245 Pruefungen fehlgeschlagen.
```
**Rückbau (c)** — im Werkzeug `forSystemQuery`/`buildInlineSystemClient` auf
`set_config(…, false)` gestellt: `Alle 253 Pruefungen bestanden.` — alle
`…-fortenant-a-nach-systemkontext-nur-a` BLEIBEN grün, weil der Reset in
`buildInlineExtendedClient` trägt. Zusätzlich den Reset dort entfernt:
```
dkvmoduleconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).dkvModuleConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
ldapconfig-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapConfig.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
ldapfieldmapping-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).ldapFieldMapping.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
tendermatch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderMatch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
tendersavedsearch-fortenant-a-nach-systemkontext-nur-a: FEHLGESCHLAGEN — bound(TENANT-A).tenderSavedSearch.findMany() unmittelbar nach system.findMany() auf demselben Client liefert 2 Zeile(n) aus ["TENANT-A","TENANT-B"]
5 von 253 Pruefungen fehlgeschlagen.
```
**Rückbau (d)** — Detektor, Zahl für `tender-matching.service.ts` auf 0:
```
AssertionError: apps/api/src/tenders/tender-matching.service.ts: gemessen 1 forSystem(-Aufruf(e), erlaubt sind genau 0: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/tenders/tender-matching.service.ts: Erlaubnisliste nennt 0, gemessen 1 — der Eintrag ist ueberholt: expected [ Array(1) ] to deeply equal []
```
Fremddatei `admin-seed.service.ts` vorübergehend mit `forSystem(` versehen:
```
AssertionError: apps/api/src/user/admin-seed.service.ts: 2 forSystem(-Aufruf(e), Datei steht NICHT in FORSYSTEM_ALLOWED_CALL_SITES — ein Anfrageweg darf den Systemkontext nie rufen: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/user/admin-seed.service.ts: 1 forSystem(-Aufruf(e) ausserhalb der Zuweisungsform: expected [ Array(1) ] to deeply equal []
AssertionError: apps/api/src/user/admin-seed.service.ts: 1 include:/select:/_count:-Angabe(n) ausserhalb eines erkannten Modellaufrufs: expected [ Array(1) ] to deeply equal []
```
Alle Rückbauten zurückgenommen (`git checkout` bzw. Kopie mit gleichem
Hash), Werkzeug danach erneut 253, Detektor 30/30.
### (y3) Welcher Code Leere als Abwesenheit deutet
Die Frage aus dem Auftrag (`docs/mandantentrennung-etappe3-auftrag.md`, 3c):
deutet einer der sechs Pfade eine LEERE Systemkontext-Antwort als "es gibt
nichts" und löscht oder deaktiviert daraufhin? Je Pfad mit Datei und Stelle:
- **dkv-scheduler** (`apps/api/src/dkv/dkv-scheduler.service.ts`,
`onModuleInit`): leere Liste → Protokollzeile `no active config found`,
`return` — kein Auftrag, nichts gelöscht, nichts deaktiviert.
- **ldap-sync.scheduler** (`apps/api/src/ldap/ldap-sync.scheduler.ts`, ruft
`getAllActiveConfigs()`): leere Liste → kein Sync-Lauf. Der gefährliche
Löschzweig in `apps/api/src/ldap/ldap.service.ts` (Deaktivieren/Entfernen
nicht mehr im Verzeichnis gefundener Nutzer) liegt INNERHALB eines je
Mandant gebundenen Sync-Laufs (`forTenant(this.prisma, tenantId)`), den
eine leere Konfigurationsliste gar nicht erst startet — Leere auf der
Systemkontext-Ebene erreicht diesen Zweig strukturell nicht.
- **ldap onApplicationBootstrap** (`ldap-config.service.ts`): leere Liste →
`legacy.length === 0` → `return` — die Nachverschlüsselung ist Nichtstun.
- **tender-digest** (`tender-digest.scheduler.ts`, `runDigest`): leere
Kandidatenliste → `if (!candidates.length) return;` — kein Versand,
`notifiedAt` bleibt NULL (wiederholbar).
- **tender-matching** (`tender-matching.service.ts`, `matchDelta`): leere
Profilliste → die Schleife läuft nicht, keine Treffer, keine Sofortmeldung.
- **admin-seed** (`apps/api/src/user/admin-seed.service.ts`): leere
Mandantenliste → keine Reparatur; `Tenant` trägt keine Regel, ein
Systemkontext ist dort gar nicht nötig (gemessen: einziger Lesezugriff
außerhalb der Schleife ist `tenant.findMany`).
Fazit: KEIN Pfad löscht oder deaktiviert auf Leere. Die verbleibende Gefahr
war das STUMME Nichtstun nach dem Scharfschalten — genau die schließt die
Systemleseregel (Paar `…-ungebunden-null-zeilen` / `…-sieht-beide-mandanten`).
### (y4) Was dieser Durchlauf bewusst nicht löst
- Der Single-Flight-Riegel `processing` in `DkvService.processInbox` ist EIN
prozessweites Boolean, nicht je Mandant. Seit je aktivem Mandanten ein
eigener Cron-Auftrag läuft, können sich zwei Ticks verschiedener Mandanten
überschneiden — der zweite bricht still ab und wartet bis zum nächsten
Intervall (Verzögerung, kein Datenverlust; mit einem Mandanten
unverändert). Neuer WINDOWS-Eintrag (#37) mit Lösungsweg (Riegel je
Mandant, `Set<tenantId>`, Test "zwei Mandanten gleichzeitig, beide werden
bedient"). Der Tick bleibt in diesem Durchlauf unangetastet (Auftrag).
- `sendWelcomeEmail` hat weiterhin null Aufrufer; `@nestjs-modules/mailer`
bleibt in `package.json`/Lockfile installiert, ist aber unbenutzt —
Aufräumen, kein Defekt, kein Lockfile-Eingriff in diesem Durchlauf.
- Der Sonderfall "Nutzer mit Treffern unter zwei Mandanten" im Digest
(`distinct: ['userId']` liefert nur eine tenantId je Nutzer) bleibt wie in
(t4) beschrieben ungelöst.
- `rls-preflight.mjs` bekommt in Etappe 4 eine Prüfung
`mit-systemkontext-sichtbar` — hier nicht gebaut, weil das Werkzeug unter
der Wegwerf-Rolle dasselbe bereits misst (`…-sieht-beide-mandanten`).
### (y5) Was dieser Durchlauf bewusst nicht anfasst
- Der Schalter (`DATABASE_URL` → Rolle `tessera`, BYPASSRLS) — bleibt AUS;
Compose-/Umgebungsdateien unangetastet (Gate gegen `5e0e408` in jeder
Aufgabe).
- `schema.prisma`, bestehende Migrationen (Prüfsumme), `package.json`,
Lockfile.
- Die drei SECURITY-DEFINER-Anmeldefunktionen — unangetastet.
- `admin-seed.service.ts` — unverändert (nur dokumentiert, siehe (y3)).
- `rls-preflight.mjs` — `ohne-kontext-leer` bleibt gültig, weil
`is_system_context()` ohne Variable false ist (`is-system-context-ungesetzt-false`).
- Der Tick `DkvService.processInbox` (je Mandant gebunden seit 260909-mir)
und die 3b-Regeln der zehn persönlichen Tabellen.
## Etappe 2 — Abschluss
Etappe 2 der Mandantentrennung ist mit diesem Lauf (260911-gwh) vollständig:
@@ -3353,8 +3589,17 @@ jetzt erfüllte Befund-K-Bedingung, die entfällt):
- Das Verstummen des Mail-Startpfads (dieser Lauf, (s4)(a)) — EIGENES
Signal, NICHT an #21 angeschlossen.
**Nachtrag (260914-eym):** Etappe 3c ist abgeschlossen — Systemkontext für
die Hintergrunddienste (`## Systemkontext (Etappe 3c, 260914-eym)`); die
beiden Signale #21 (DKV-Planer) und #30 (Mail-Startpfad) aus dieser Liste
sind geschlossen, das eine durch Auftrag je Mandant über den Systemkontext,
das andere durch Entfernen des Startpfads. Die übrigen Vorher-Prüfungen für
Etappe 4 bleiben wie oben; hinzu kommt `mit-systemkontext-sichtbar` (siehe
(y4)).
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
steht in `docs/mandantentrennung-zugriffsklassifikation.md` (seit 260914-eym
mit dem vierten Stand-Wert `system-gebunden`).
+32
View File
@@ -142,6 +142,31 @@ Bauform:
### 3c zuletzt: Systemkontext fuer die Hintergrunddienste
**Erledigt (260914-eym, 3d64567/6e2a641 plus der Dokumentationscommit dieser
Aufgabe):** Migration `20260914120000_rls_system_context_read` bringt
`is_system_context()` (COALESCE, STABLE) und je eine zusaetzliche, NUR
lesende Regel `system_read_policy ... FOR SELECT` auf FUENF Tabellen —
DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch
(nicht sechs: SmtpConfig traegt keine, weil der Mail-Startpfad ENTFERNT und
nicht umgestellt wurde). Helfer `forSystem(prisma)` als Schwesterhelfer von
`forTenant()` (setzt `app.system_context = 'true'` und die beiden anderen
Variablen ausdruecklich leer; `forTenant()`/`withTenantTransaction()` setzen
umgekehrt `app.system_context = ''`). Die sechs Faelle: DKV-Planer je
Mandant (Auftrag `dkv-inbox-poll:<tenantId>`, WINDOWS #21 geschlossen);
Mail-Transport je Versand nach Mandant des Empfaengers, Startpfad und
Mailer-Fabrik geloescht (WINDOWS #30 geschlossen); ldap mit ZWEI
Systemkontext-Lesern (`getAllActiveConfigs`, Nachverschluesselung — die
Schreibzeile je Altzeile gebunden); digest und matching ueber den
Systemkontext, Schleifen gebunden; admin-seed nur dokumentiert (liest
ausserhalb der Schleife nur `Tenant`, keine Regel, Datei unveraendert).
Detektor mit fuenfter Erkennungsform und Erlaubnisliste (4 Dateien, 5
Aufrufe, exakt). Endzahlen: Tests 1054/64 Dateien, Typpruefung sauber,
Werkzeug `rls-scratch-check.mjs` 253/253 bestanden (Baseline vor diesem
Lauf: 203). Siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`,
Abschnitt "## Systemkontext (Etappe 3c, 260914-eym)" mit (y1)-(y5). Der
urspruengliche Auftragstext unten bleibt unveraendert stehen (historische
Planungsgrundlage).
Sechs Faelle, alle im Abschnitt "Der Hintergrunddienst als Falle" der
Klassifikation und in den Bereichs-Kritiken:
- `dkv-scheduler` / `loadAnyActiveConfigForScheduler()` (WINDOWS #21) —
@@ -188,6 +213,13 @@ Funktionsausbau in Etappe 3.
- Kein `mailhog` lokal — `ENOTFOUND mailhog` ist Umgebung, kein Defekt.
- Backticks in Heredoc-Python werden von der Shell ausgewertet — Skripte
in eine Datei schreiben, dann ausfuehren.
- Eine Mock-Fabrik ohne den neuen Export wirft erst beim ZUGRIFF
(vitest-Proxy) — jede Spec, deren Pruefling `forSystem` importiert,
braucht den Export im Mock (260914-eym: sechs Spec-Dateien).
- Ein Gate mit `grep -rh ... | grep -v spec` filtert KEINE Spec-Dateien
(`-h` laesst den Dateinamen weg, `spec` steht nicht im Zeilentext) —
Proben in einer Spec zaehlen mit; Empfaengernamen in Proben deshalb
anders waehlen als im Produktivcode (260914-eym, `sysPrisma`).
## Einstieg
+124 -37
View File
@@ -150,21 +150,31 @@ dieser Übersicht auf, zum Beispiel
Relationsfilter `group: { memberships: { some: { userId } } }` sichtbar,
niemals als `tenantPrisma.group` im Quelltext).
| Bereich | Ungebunden | Gebunden | Hinweis |
|---|---|---|---|
| tenders | 35 | 27 | **war 62/0**, dann 36/26 nach 260909-laa — 260910-jab (Aufgabe 2) hat `tender-rss-feed.service.ts`/`listForUser` zusätzlich auf `forTenant()` umgestellt (WINDOWS #19 geschlossen, Befund F: ungebunden hätte die Reparatur den Pfad sonst still auf nur die plattformweiten Zeilen reduziert): ein Rohtreffer wandert von ungebunden nach gebunden (36→35, 26→27). Die 35 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die zwei bewusst ungebundenen RSS-Pfade (`createPlatform`/`remove`, WINDOWS #24) plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe) |
| groups | 0 | 31 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
| ldap | 4 | 26 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer sind bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04) |
| dkv | 1 | 22 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer ist der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21) — bewusst, mit dreifacher Markierung |
| user | 8 | 14 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 12 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
| calendar | 0 | 12 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| settings | 1 | 3 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer ist der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30) — bewusst, mit dreifacher Markierung; Befund K (`tenders`/`dkv` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt |
| **Summe** | **68** | **178** | Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
**Dritte Spalte `System` (260914-eym, Etappe 3c):** Rohtreffer
`systemPrisma\.[a-zA-Z]*\.` je Bereich, gleiche Grep-Form wie die beiden
anderen Spalten (nur `.ts` ohne `.spec.ts`) — die direkten Modellaufrufe
über den Systemkontext-Klienten `forSystem()`. Dieselbe Grenze wie die
anderen beiden Spalten: Relationsziele (`include: { fieldMappings }` in
`ldap-config.service.ts`) zählt auch sie NICHT; autoritativ bleibt die
Bestandsaufnahme unten (Stand `system-gebunden`). Die Werte aller drei
Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
(`for d in apps/api/src/*/`), nicht abgeschrieben.
| Bereich | Ungebunden | Gebunden | System | Hinweis |
|---|---|---|---|---|
| tenders | 33 | 27 | 2 | **war 62/0**, dann 36/26 nach 260909-laa — 260910-jab (Aufgabe 2) hat `tender-rss-feed.service.ts`/`listForUser` zusätzlich auf `forTenant()` umgestellt (WINDOWS #19 geschlossen, Befund F: ungebunden hätte die Reparatur den Pfad sonst still auf nur die plattformweiten Zeilen reduziert): ein Rohtreffer wandert von ungebunden nach gebunden (36→35, 26→27). Die 35 verbleibenden ungebundenen Treffer sind die zwölf bewusst nicht angefassten Paare (D-03-Katalog, zwei Fan-out-Adapter) plus die zwei bewusst ungebundenen RSS-Pfade (`createPlatform`/`remove`, WINDOWS #24) plus die übergreifenden Hälften der beiden Hintergrunddienste (Etappe-3-Übergabe). **260914-eym:** diese beiden Hälften (Kandidatenabfrage des Digest, Profilabfrage des Abgleichs) lesen jetzt über `forSystem()` — 35→33 ungebunden, 2 System |
| groups | 0 | 31 | 0 | **war 37/0** — Aufgabe 2/3 (260909-jts) haben `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) vollständig auf `forTenant()`/`withTenantTransaction()` umgestellt. Die neun zusätzlichen, über `tx` gebundenen Zugriffe innerhalb der drei Transaktionen zählt dieses einfache Muster nicht mit (siehe Methodenhinweis oben) |
| ldap | 1 | 27 | 2 | **war 21/0** — Aufgabe 2/3 (260909-ipc) haben `ldap-config.service.ts` (5 Methoden) und `ldap.service.ts` (6 Methoden, 11 Abfragen) auf `forTenant()` umgestellt. Die 4 verbleibenden ungebundenen Treffer waren bewusst: `getAllActiveConfigs`/`onApplicationBootstrap` (Befund B) und `resolveEmailForWrite` (Befund A, T-IPC-04). **260914-eym:** die beiden Leser in `ldap-config.service.ts` laufen über `forSystem()` (4→1 ungebunden, 2 System), die Schreibzeile der Nachverschlüsselung über `forTenant()` (26→27 gebunden); der eine verbleibende ungebundene Rohtreffer ist `resolveEmailForWrite` |
| dkv | 0 | 22 | 1 | **war 21/0** — Aufgabe 2/3 (260909-mir) haben `dkv.service.ts` vollständig auf `forTenant()` umgestellt: Konfigurationspfade (`loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection`), Historie, Fahrzeugstammdaten und der neue Besitzriegel vor dem Ausfuhrdatei-Download. Gebunden sind es 22 statt 21, weil der Riegel einen zusätzlichen Lesezugriff auf `dkvInvoiceHistory` einführt (T-MIR-03). Der eine verbleibende ungebundene Treffer war der benannte Planer-Startpfad `loadAnyActiveConfigForScheduler()` (Befund D, WINDOWS #21). **260914-eym:** ersetzt durch `loadActiveConfigsForScheduler()` über `forSystem()` (1→0 ungebunden, 1 System) — WINDOWS #21 geschlossen |
| user | 8 | 14 | 0 | **war 17/0** — Aufgabe 2/3 (260910-das) haben `user.service.ts` (`findById`/`create`/`update`/`deactivate`/`delete` sowie die zwei neuen Plattform-Administratorsicht-Methoden), `admin-seed.service.ts` (Erstanlage des Administrators) und `user.controller.ts` (Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege ueber die Dienstmethoden, alle fuenf Selbstbedienungszugriffe) auf `forTenant()` umgestellt. Die 8 verbleibenden ungebundenen Rohtreffer sind bewusst: `findByUsername` in `user.service.ts` (plattformweit eindeutiger Schluessel, derselbe Fall wie `resolveEmailForWrite` im Bereich `ldap`), die Erstanlage-Pruefung und beide Zugriffe auf `tenant` in `admin-seed.service.ts`, sowie der neue Schleifentreiber `this.prisma.tenant.findMany` der beiden Plattform-Administratorsicht-Methoden in `user.service.ts` (`Tenant` traegt keinen Zeilenschutz) |
| module-registry | 7 | 10 | 0 | **war 17/0** — Aufgabe 2/3 (260910-exd) haben `module-access.service.ts` (`getAccessibleModuleIds`: Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge; `getCatalogFlags`: eigener Aktivierungs-Lesezugriff) und `module-registry.service.ts` (`findActiveForTenant`, `activateForTenant`, `deactivateForTenant`, `isModuleActive`) auf `forTenant()` umgestellt. Die 7 verbleibenden ungebundenen Rohtreffer sind bewusst: der eine Katalogzugriff in `module-access.service.ts` (`findAccessibleModules`) und die sechs Katalogzugriffe in `module-registry.service.ts` (`findAll`, `findBySlug`, die beiden Katalog-Existenzpruefungen in `activateForTenant`/`deactivateForTenant`, die Katalogsuche in `isModuleActive`, `seedModule`) — der Modulkatalog (`Module`) traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal; katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E) |
| dashboard | 1 | 12 | 0 | **war 13/0** — Aufgabe 2/3 (260910-krx) haben `dashboard.service.ts` vollständig umgestellt: `getLayout`/`saveLayout` (gemeinsam gebunden), `getWidgets`/`addWidget`/`updateWidgetConfig`/`removeWidget` sowie `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` laufen über `forTenant()`, je Methode ein Klient. Der eine verbleibende ungebundene Rohtreffer ist bewusst: der Modulkatalog (`Module`) trägt heute keinen Zeilenschutz, eine Bindung wäre heute wirkungslos, nicht katastrophal — katastrophal würde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt (Befund E aus `module-registry`, hier übernommen) |
| auth | 3 | 10 | 0 | **war 8/5** — 260911-fh9 (Aufgabe 2) hat `getMe`, `changePassword`, `adminResetPassword` (fünf Rohtreffer auf `user`, drei Methoden) auf `forTenant()` umgestellt. Die 3 verbleibenden ungebundenen Rohtreffer sind die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (`validateUser`, `requestPasswordReset`, `resetPassword`) — KEINE Modellzugriffe (`$` liegt nicht in `[a-zA-Z]`, die Bestandsaufnahme führt sie deshalb nicht als (Datei, Modell)-Paar), bewusst und dauerhaft ungebunden, siehe `20260909160000_auth_lookup_functions` und `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1) |
| calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg |
| tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet |
| favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile |
| settings | 0 | 3 | 0 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
| **Summe** | **61** | **179** | **5** | **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 72 Paare)
@@ -287,6 +297,25 @@ Benutzerdimension steht stattdessen in der Begründungsspalte der drei
betroffenen Bestandsaufnahme-Zeilen (`calendarSource`, `widgetInstance`,
`favoriteLink`) oben und im Abschnitt "Was diese Etappe NICHT entscheidet".
**Stand 260914-eym:** die Paarzahl (72) und die Klassen-Verteilung sind
UNVERÄNDERT — die fünfte Erkennungsform (`const X = forSystem(`) bringt
keine neue Fundstelle und lässt keine verschwinden. Sieben Paare ändern nur
ihren Stand: SECHS auf den neuen Wert `system-gebunden` —
`dkv/dkv.service.ts`/`dkvModuleConfig`,
`ldap/ldap-config.service.ts`/`ldapConfig`, `/ldapFieldMapping`, `/tenant`,
`tenders/tender-digest.scheduler.ts`/`tenderMatch`,
`tenders/tender-matching.service.ts`/`tenderSavedSearch` — und EINES auf
`gebunden` (`settings/settings.service.ts`/`smtpConfig`, Startpfad
gelöscht). Der neue Stand-Wert bedeutet: mindestens ein Zugriff dieses
Paars läuft über den Systemkontext-Klienten und KEIN Zugriff ist ungebunden;
Vorrang: ungebunden vorhanden UND anderes → `gemischt`, nur ungebunden →
`ungebunden`, System ohne ungebunden → `system-gebunden` (auch neben
mandantengebundenen Zugriffen — die Begründungsspalte nennt sie), sonst
`gebunden`. Die Zahlen sind der Ausgabe von `rls-access-inventory.spec.ts`
entnommen (30 Zusicherungen, darunter der Wachhund
`FORSYSTEM_ALLOWED_CALL_SITES`).
| Klasse | Anzahl Paare |
|---|---|
| muss-mandantengebunden | 35 |
@@ -529,14 +558,63 @@ Anmeldeweg (`validateUser`, `requestPasswordReset`, `resetPassword`) —
geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform
"übergreifend lesen, dann je Mandant binden".
**Regelschluss (260914-eym) — Etappe 3c hat den Systemkontext gebaut; je Fall:**
- **Regelschluss (260914-eym), Fall ldap** (`getAllActiveConfigs()` und die
Nachverschlüsselung in `onApplicationBootstrap()`): beide Methoden lesen
über `forSystem()` (`system_read_policy … FOR SELECT` auf LdapConfig und —
für `include: { fieldMappings }` — auf LdapFieldMapping); die
Nachverschlüsselung schreibt je Altzeile GEBUNDEN über
`forTenant(this.prisma, config.tenantId)`, weil Schreiben unter
Systemkontext abgewiesen wird (gemessen P2025/42501). `Tenant` braucht
keine Regel. Der Detektor zählt zwei `forSystem(`-Aufrufe in dieser Datei.
- **Regelschluss (260914-eym), Fall tender-digest** (`runDigest`): die
Kandidatenabfrage liest über `forSystem()` (Regel auf TenderMatch), die
Schleife bleibt je Kandidatenzeile gebunden, bewusst ohne Benutzer.
- **Regelschluss (260914-eym), Fall tender-matching** (`matchDelta`): die
Profilabfrage liest über `forSystem()` (Regel auf TenderSavedSearch);
Treffer-Anlage und Sofortmeldung bleiben je Profil gebunden, der
Katalog-Lesezugriff (`tender`, D-03) bleibt ungebunden.
- **Regelschluss (260914-eym), Fall admin-seed**: GEMESSEN — der einzige
Lesezugriff außerhalb der Schleife ist `tenant.findMany` auf `Tenant`, das
in keiner Migration `ENABLE ROW LEVEL SECURITY` trägt. Nichts umgebaut,
Datei unverändert (Gate gegen `5e0e408`), nur dokumentiert; Stand bleibt
`ungebunden`.
- **Regelschluss (260914-eym), fünfter Fall (DKV-Planer, WINDOWS #21
GESCHLOSSEN)**: `loadActiveConfigsForScheduler()` liest über `forSystem()`
ALLE aktiven Konfigurationen (Regel auf DkvModuleConfig), der Planer
registriert je Mandant einen eigenen Auftrag `dkv-inbox-poll:<tenantId>`
(einmal-abfragen-viele-bedienen); mit einem Mandanten beobachtbar
identisch (Cron-Expression, Tick, Protokollzeile — je ein Test).
- **Regelschluss (260914-eym), sechster Fall (Mail-Startpfad, WINDOWS #30
GESCHLOSSEN)**: der sechste Fall EXISTIERT NICHT MEHR — der Startpfad
(`findFirst()` beim Boot) ist entfernt, nicht umgestellt; `MailService`
baut je Versand einen Transport aus `getDecryptedSmtpConfig(tenantId)`
des Empfänger-Mandanten. Deshalb trägt SmtpConfig keine
`system_read_policy`.
## Bestandsaufnahme
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.<Modell>`
oder — seit 260909-ipc, Befund G — in `<gebundener Client>.<Modell>`
verwendet), Klasse, Stand (`gebunden`/`ungebunden`/`gemischt`, seit
oder — seit 260914-eym — in `<System-Client>.<Modell>` verwendet), Klasse,
Stand (`gebunden`/`ungebunden`/`gemischt`/`system-gebunden`, seit
260909-ipc maschinell gegen den Quelltext geprüft), Begründung.
**Fünfte Erkennungsform und vierter Stand-Wert (260914-eym, Etappe 3c):**
`const <Name> = forSystem(` und danach `<Name>.<Modell>` — der benannte
Systemkontext der Hintergrunddienste (liest über ALLE Mandanten, nur lesend,
Regel `system_read_policy … FOR SELECT`). Relationsziele über `include`/
`select` auf einem System-Klienten zählen ebenfalls als system-gebunden.
Vorrang der Stände je Paar: ungebunden vorhanden UND anderes → `gemischt`;
nur ungebunden → `ungebunden`; Systemkontext vorhanden und KEIN ungebundener
Zugriff → `system-gebunden` (auch wenn daneben mandantengebundene Zugriffe
stehen — die Begründungsspalte nennt sie); nur mandantengebunden →
`gebunden`. Wer `forSystem(` rufen darf, steht mit EXAKTER Zahl je Datei in
`FORSYSTEM_ALLOWED_CALL_SITES` (Detektor) — jede Fremddatei, jede Abweichung
der Zahl und jeder veraltete Eintrag machen die Spec rot.
**Erkennungslücke GESCHLOSSEN (260911-mkj, WINDOWS #27):** bis 260911-e2s sah
die Bestandsaufnahme ausschließlich (Datei, Modell)-Paare über
`this.prisma.<Modell>` bzw. `<gebundener Client>.<Modell>` — eine
@@ -553,8 +631,9 @@ true`, Relationsfilter in `where:`, `orderBy:` über Relationen und
verschachtelte Schreibzugriffe in `data:`.
Bewusst NICHT gesehen, und wie das begrenzt ist: ein Empfänger außerhalb der
vier Erkennungsformen (`this.prisma`, eine `const X = forTenant(`-Zuweisung,
ein Transaktionsparameter, `withTenantTransaction(`) — begrenzt durch den
fünf Erkennungsformen (`this.prisma`, eine `const X = forTenant(`-Zuweisung,
ein Transaktionsparameter, `withTenantTransaction(`, seit 260914-eym eine
`const X = forSystem(`-Zuweisung) — begrenzt durch den
Waechter Rohzahl (`include|select|_count` über den gesamten kommentarfreien
Quelltext) gegen die innerhalb erkannter Aufrufe gezählte Zahl, mit
begründeter Ausnahmeliste `RELATION_SPEC_EXCEPTIONS`; ein `include:`/
@@ -587,7 +666,7 @@ werden.
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | gebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. Seit 260909-mir (Aufgabe 3) laufen beide Historien-Schreibzugriffe der Verarbeitungsstrecke, beide parallelen Lesezugriffe von `getHistory` und der neue Riegel vor dem Ausfuhrdatei-Download vollstaendig ueber `forTenant()`. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | gemischt | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnyActiveConfigForScheduler()` (WINDOWS #21) — keine uebersehene Fundstelle. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | system-gebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 2) laufen `loadConfig`, `getConfigForApi`, `saveConfig`, `testConnection` und der Konfigurations-Lesezugriff der Verarbeitungsstrecke ueber `forTenant()`. Seit 260914-eym liest der Planer-Startpfad `loadActiveConfigsForScheduler()` ueber `forSystem()` (alle aktiven Konfigurationen, nur lesend, `system_read_policy`) — kein ungebundener Zugriff mehr, WINDOWS #21 geschlossen; alle uebrigen Zugriffe bleiben mandantengebunden (Stand-Vorrang: system ohne ungebunden = `system-gebunden`). |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | gebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. Seit 260909-mir (Aufgabe 3) laufen Fahrzeugliste, Anlegen, beide Paare aus Besitzpruefung und Schreibzugriff (Aendern/Loeschen), beide Zweige des CSV-Imports und der gebuendelte Lesezugriff beim Aufbau der Ausfuhrzeilen vollstaendig ueber `forTenant()`; die vorgeschalteten Besitzpruefungen bei Aendern/Loeschen bleiben zusaetzlich bestehen (Befund G — ein gebundenes UPDATE ueber die Kennung allein trifft eine fremde Zeile still, nicht laut). |
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | gebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `list`, `create`, `update`, `remove`, `getIconBytes` vollstaendig ueber `forTenant()`, je Methode EIN Klient `tenantPrisma`; die Besitzpruefungen (`findUnique`, Vergleich `link.userId !== userId`, dann Schreibzugriff auf DEMSELBEN Klienten) bleiben zusaetzlich bestehen — die Regel auf `FavoriteLink` kennt keine Benutzerdimension (Aufgabe 1, Pruefung 4), die `userId`-Filter sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN Mandanten. Die Mandantenquelle ist dieselbe wie bei `dashboard` (`extractContext` im Controller), nicht das Claim wie bei `auth`. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/favorites/favorites.service.ts | widgetInstance | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-gwh, Aufgabe 2): `create()` prueft ueber einen gebundenen `widgetInstance.findUnique` (`select: { userId: true }`), dass das Ziel-Widget (`dto.widgetId`) dem Aufrufer gehoert, BEVOR die Zeile angelegt wird — der Fremdschluessel `FavoriteLink.widgetId` prueft an der Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes PostgreSQL-Verhalten, Aufgabe 1 Pruefung 7 hat das GELINGEN eines gebundenen `create` mit einer fremdmandantigen `widgetId` bestaetigt); ohne den Riegel waere der Unterschied zwischen "Widget existiert nicht" (FK-Verletzung) und "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber Mandantengrenzen (T-GWH-05). |
@@ -602,9 +681,9 @@ werden.
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | gebunden | Modulfreigaben je Mandant. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung vor jedem Erteilen (`assertTargetBelongsToTenant`) bleibt zusätzlich bestehen. Bis 20260910120000_rls_widen_membership_grant_and_platform_read prüfte die Regel auf dieser Tabelle nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe/den referenzierten Benutzer (Befund F, T-JTS-03, Aufgabe 1) — seit 260910-jab prüft sie beide Ziele zusätzlich, die Anwendungsprüfung bleibt trotzdem der erste Schutz (Schalter weiterhin aus, #18). |
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. Seit 260909-jts gebunden. |
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | gebunden | Zielbenutzer eines Grants innerhalb des Mandanten. Seit 260909-jts gebunden; die Mandanten-Gegenprüfung bleibt bestehen. |
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | gemischt | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` bleiben bewusst uebergreifend: beide laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md). Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. |
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | beides | gemischt | Klassenkorrektur (260911-mkj, WINDOWS #27): wechselt von `muss-mandantengebunden` auf `beides`. Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. Die vierte Erkennung (260911-mkj) macht sichtbar, dass der bewusst uebergreifende Planer-Lesepfad `getAllActiveConfigs()` ueber `include: { fieldMappings: true }` in `LdapFieldMapping` hineinreicht — dieselbe Unterabfrage-Form wie die drei `tenant`-Zaehler aus WINDOWS #27, hier aber KEIN neuer Befund: der Elternpfad ist als erster Fall der Hintergrunddienst-Falle bereits an Etappe 3 uebergeben, die Feldzuordnungen teilen nach dem Scharfschalten sein Schicksal (Regel auf `LdapFieldMapping` ueber Join auf `LdapConfig`, Migration 20260618112133). Die Klasse folgt der Elternzeile `ldapConfig` (`beides`). |
| apps/api/src/ldap/ldap-config.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getAllActiveConfigs()` (Zeile 311) `include: { tenant: true, fieldMappings: true }` auf `this.prisma.ldapConfig.findMany`; `Tenant` traegt keinen Zeilenschutz (260911-e2s Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | beides | system-gebunden | `getConfig`, `createConfig` und `updateConfig` laufen ueber `forTenant()`, gebunden an den aus der Anfrage bereits bekannten Mandanten. `getAllActiveConfigs()` (Planer-Lesezugriff ueber ALLE Mandanten) und die Start-Nachverschluesselung in `onApplicationBootstrap` laufen, bevor bzw. unabhaengig davon, ob ein einzelner Mandantenkontext feststeht (Befund B, 260909-ipc-PLAN.md) — seit 260914-eym lesen beide ueber `forSystem()` (`system_read_policy`), die Schreibzeile je Altzeile der Nachverschluesselung laeuft ueber `forTenant(this.prisma, config.tenantId)`; kein ungebundener Zugriff mehr. Korrektur der Klasse von `muss-mandantengebunden`: das Paar ist tatsaechlich `beides`, keine Verhaltensaenderung. |
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | beides | system-gebunden | Klassenkorrektur (260911-mkj, WINDOWS #27): wechselt von `muss-mandantengebunden` auf `beides`. Kein eigenes `tenantId`, RLS ueber Join auf `LdapConfig`. `addFieldMapping` und `removeFieldMapping` nehmen den Mandanten als Parameter entgegen und laufen vollstaendig ueber `forTenant()` (T-IPC-01, 260909-ipc-PLAN.md) — schliesst zugleich die Fremdzugriffsluecke beim Loeschen einer Feldzuordnung ueber ihre Kennung. Die vierte Erkennung (260911-mkj) macht sichtbar, dass der bewusst uebergreifende Planer-Lesepfad `getAllActiveConfigs()` ueber `include: { fieldMappings: true }` in `LdapFieldMapping` hineinreicht — dieselbe Unterabfrage-Form wie die drei `tenant`-Zaehler aus WINDOWS #27, hier aber KEIN neuer Befund: der Elternpfad ist als erster Fall der Hintergrunddienst-Falle bereits an Etappe 3 uebergeben, die Feldzuordnungen teilen nach dem Scharfschalten sein Schicksal (Regel auf `LdapFieldMapping` ueber Join auf `LdapConfig`, Migration 20260618112133). Die Klasse folgt der Elternzeile `ldapConfig` (`beides`). Seit 260914-eym liest der Elternpfad ueber `forSystem()`, das Relationsziel ist damit `system-gebunden` und traegt eine eigene `system_read_policy` (Migration 20260914120000). |
| apps/api/src/ldap/ldap-config.service.ts | tenant | keine-mandantengebundene-tabelle | system-gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getAllActiveConfigs()` `include: { tenant: true, fieldMappings: true }` auf `ldapConfig.findMany`; `Tenant` traegt keinen Zeilenschutz (260911-e2s Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Seit 260914-eym laeuft der Ankeraufruf ueber `forSystem()` — der Stand folgt dem Empfaenger (`system-gebunden`), eine Regel braucht `Tenant` weiterhin nicht. |
| apps/api/src/ldap/ldap.service.ts | group | beides | gebunden | AD-Abgleich: `listGroups` (die "bereits importiert"-Markierung) und `importGroupsByDn` (die Idempotenzpruefung ueber `ldapObjectGuid`) sind mit Aufgabe 3 (260909-ipc) auf `forTenant()` umgestellt — zusammen mit den bereits vorher gebundenen Stellen (Anlage, Mitgliedschafts- und Gruppenabgleich) ist damit jeder `group`-Zugriff dieser Datei gebunden. Die Klasse bleibt `beides`, weil ein zukuenftiger uebergreifender Lesezugriff (z. B. ein neuer Planer-Pfad) hier ebenso legitim waere wie bei `ldapConfig` unten — nicht, weil heute noch ein ungebundener Zugriff bestuende. |
| apps/api/src/ldap/ldap.service.ts | groupMembership | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `Group`. `syncGroupMembershipsForTenant` laeuft vollstaendig ueber `forTenant()` (Plan 16-03/16-05) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. |
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | gebunden | Die `lastSyncAt`-Fortschreibung am Ende von `syncUsersForTenant` ist mit Aufgabe 3 (260909-ipc) auf den in derselben Methode bereits vorhandenen `forTenant()`-Client umgestellt — es entsteht kein zweiter. Damit ist der einzige `ldapConfig`-Zugriff dieser Datei gebunden. |
@@ -616,7 +695,7 @@ werden.
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. Seit 260910-exd (Aufgabe 2) laufen Kurzschlusszweig, Schnittmengenabfrage und der eigene Lesezugriff von `getCatalogFlags` ueber `forTenant()`. |
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Modulkatalog ist plattformweit. Der ungebundene Anteil bleibt bewusst so (260910-exd, Aufgabe 1, Befund E): keine Regel auf der Tabelle, eine Bindung waere heute wirkungslos, nicht katastrophal — katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. `findBySlug` ist zusaetzlich die Stelle, die `ModuleGuard` bei JEDER Modulanfrage aufruft. Die neu sichtbare gebundene Haelfte stammt ausschliesslich aus `include: { module: true }` auf `tenantPrisma.tenantModuleActivation` (Zeilen 56, 97, 148) — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. |
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | gebunden | Aktivierung je Mandant. Seit 260910-exd (Aufgabe 3) laufen `findActiveForTenant`, `activateForTenant`, beide Zugriffe von `deactivateForTenant` (ueber EINEN Klienten) und `isModuleActive` ueber `forTenant()`, je Methode EIN Klient. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gemischt | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Die Mischung stammt ausschliesslich vom einen benannten, bewusst ungebundenen Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — keine uebersehene Fundstelle, dieselbe Form wie `dkv.service.ts`/`dkvModuleConfig`. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | gebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. Seit 260911-gwh (Aufgabe 2) laufen `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` ueber `forTenant()`. Der bis 260914-eym einzige ungebundene Zugriff — der Startpfad des Mailmoduls (`findFirst()` beim Boot, WINDOWS #30, sechster Fall der Hintergrunddienst-Falle) — ist GELOESCHT: `MailService` baut je Versand einen Transport aus `getDecryptedSmtpConfig(tenantId)` des Empfaenger-Mandanten. Befund K (`tender-mail.service.ts`/`dkv-mail.service.ts`/`mail.service.ts` haengen an `getDecryptedSmtpConfig`) ist damit erfuellt. |
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`; gebunden und ungebunden liefern über Roh-SQL UND generierten Client dieselben Zeilen. |
| apps/api/src/tenant/tenant.controller.ts | user | muss-mandantengebunden | gebunden | Seit 260911-e2s (Aufgabe 3): `findAll`/`findOne`/`remove` zählen Benutzer je Mandant über drei gebundene Aufrufstellen (`tenantPrisma.user.count`, Fan-out-Muster aus `UserService.findAllForPlatformAdmin`) statt über den früheren Relationszähler (`include: { _count: { select: { users } } }`), der nach dem Scharfschalten unter der Regel von `User` unbemerkt null geliefert hätte (260911-e2s Aufgabe 1, Prüfungen 5-7). `where: { tenantId }` bleibt heute (Rolle mit BYPASSRLS, WINDOWS #18) der einzige wirksame Filter. |
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. Gemessen 260911-e2s (Aufgabe 1, Prüfung 1/2): keine Regel auf `Tenant` in irgendeiner der 34 ausgelieferten Migrationen einschließlich `20260910120000_rls_widen_membership_grant_and_platform_read`. |
@@ -625,7 +704,7 @@ werden.
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | ungebunden | Explizit im Dateikopf: "platform-global, RLS-exempt tables. Never wrap these queries in forTenant()." (D-03) |
| apps/api/src/tenders/tender-dedup.service.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | Dieselbe Begründung. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tender | keine-mandantengebundene-tabelle | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { tender: true, savedSearch: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `Tender` ist der plattformglobale Katalog (D-03) — die Bindung des Elternaufrufs ist fuer die Unterabfrage wirkungslos, nicht schaedlich. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | gemischt | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) bleibt die Kandidatenabfrage (`findMany` mit `distinct`) bewusst ungebunden, die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile laufen über `forTenant()`. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | system-gebunden | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf) — seit 260909-laa (Aufgabe 3) laufen die Je-Treffer-Abfrage (`findMany` nach Mandant) und die Stempelung (`updateMany`) je Kandidatenzeile über `forTenant()`; seit 260914-eym liest die Kandidatenabfrage (`findMany` mit `distinct`) über `forSystem()` (`system_read_policy`, nur lesend) statt ungebunden. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | gebunden | Präferenzen werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten der jeweiligen Zeile. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderSavedSearch | muss-mandantengebunden | gebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `include: { savedSearch: true }` plus `orderBy` ueber `savedSearch` auf `tenantPrisma.tenderMatch.findMany` (Zeile 149) innerhalb der Mandantenschleife des Planers. `TenderSavedSearch` ist mandantengebunden und ueber denselben gebundenen Klienten erreicht. |
| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | gebunden | E-Mail-Adressen für den Versand werden je Kandidatenzeile gelesen. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`. |
@@ -635,7 +714,7 @@ werden.
| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | gemischt | Stand-Aenderung (260911-mkj, WINDOWS #27): `ungebunden` -> `gemischt`, Klasse bleibt. Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. Die neu sichtbare gebundene Haelfte stammt aus `include: { tender: true }` auf `tenantPrisma.tenderMatch.findMany` (Zeile 139) im Sofortmeldungs-Dispatch — fuer die schutzlose Katalogtabelle wirkungslos, nicht schaedlich. |
| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | gebunden | Sofortmeldung — siehe Abschnitt "Der Hintergrunddienst als Falle". Seit 260909-laa (Aufgabe 3) laufen sowohl die Treffer-Anlage (`upsert`) als auch der Instant-Dispatch (`findMany`/`updateMany`) vollständig über `forTenant()`, EIN gebundener Client je Profil. |
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | ungebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend, Etappe-3-Übergabe (260909-laa, Aufgabe 3, unverändert). |
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | system-gebunden | Gespeicherte Suchprofile ALLER Mandanten werden gegen neue Treffer geprüft — bewusst übergreifend; seit 260914-eym über `forSystem()` (`system_read_policy`, nur lesend), die Etappe-3-Übergabe aus 260909-laa ist damit eingelöst. Treffer-Anlage und Sofortmeldung bleiben je Profil gebunden. |
| apps/api/src/tenders/tender-matching.service.ts | user | beides | gebunden | E-Mail-Adressen für die Sofortmeldung. Seit 260909-laa (Aufgabe 3) läuft der einzige Zugriff über `forTenant()`, gebunden an den Mandanten des jeweiligen Profils. |
| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | gebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. Seit 260909-laa (Aufgabe 2) laufen `getForUser`/`setForUser` vollständig über `forTenant()`; `setForUser` übersetzt eine P2002-Verletzung (Befund F) in eine verständliche deutsche Meldung. |
| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | beides | gemischt | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`). Seit 260909-laa (Aufgabe 2) bindet `createForUser` (Zähler + Anlage, beide ausschließlich auf persönlichen Zeilen mit gesetztem Mandanten) über `forTenant()`. Seit 260910-jab (Aufgabe 2, WINDOWS #19 geschlossen) bindet zusätzlich `listForUser` — die neue Leseregel (`tenant_platform_read_policy`, 20260910120000_rls_widen_membership_grant_and_platform_read) schließt die plattformweite Zeile ausdrücklich ein, ungebunden hätte die Reparatur den Pfad sonst still auf nur die plattformweiten Zeilen reduziert (Befund F). `createPlatform`/`remove` bleiben bewusst ungebunden — beide Pfade lassen sich unter der Anwendungsrolle grundsätzlich nicht anlegen/entfernen, weil jede Schreibregel einen Mandanten verlangt (WINDOWS #24, eigener offener Punkt). Klasse `beides` bleibt korrekt: zwei gebundene, zwei bewusst ungebundene Zugriffe in derselben Datei. |
@@ -646,7 +725,7 @@ werden.
| apps/api/src/tenders/tenders.controller.ts | tenderSource | keine-mandantengebundene-tabelle | ungebunden | NEUE Fundstelle (260911-mkj, WINDOWS #27): `getTender` (Zeile 612) `include: { sources: { select: ... } }` auf `this.prisma.tender.findUnique`; `TenderSource` plattformweit ohne Zeilenschutz (dieselbe Einordnung wie `tender-dedup.service.ts`/`tenderSource`). |
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | ungebunden | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). |
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Legt beim ersten Start den Standard-Mandanten selbst an und liest beim Start alle Mandanten fuer die Standardgruppen-Reparatur — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). Fuenfter und bislang einziger bereits vollstaendig richtiger Fall der Hintergrunddienst-Falle (Befund K, siehe Abschnitt unten). 3c-Befund (260914-eym): einziger Lesezugriff außerhalb der Schleife, `Tenant` ohne Regel — kein Systemkontext nötig, Datei unverändert, Stand bleibt `ungebunden`. |
| apps/api/src/user/admin-seed.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `bewusst-uebergreifend` auf `beides`, weil die bisherige Begruendung ("es gibt strukturell keinen Mandanten zum Binden") nachweislich FALSCH war (Befund J) — der Mandant wird eine Anweisung vorher angelegt und ist bekannt. Die Erstanlage-Pruefung bleibt bewusst ungebunden (kein Mandant existiert zu diesem Zeitpunkt, `username` ist plattformweit eindeutig); die Erstanlage des Administrators selbst laeuft seit Aufgabe 2 ueber `forTenant()`, gebunden an den unmittelbar zuvor angelegten Mandanten. Eine P2002-Kollision beim Anlegen wird wie "Administrator existiert bereits" behandelt statt den Start abzubrechen (Befund I). |
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. |
| apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). |
@@ -713,15 +792,23 @@ werden.
"Regelschluss Benutzerdimension (Etappe 3b, 260911-nke)". Was weiterhin
offen ist: ein Aufrufer, der `userId` vergisst, sieht den ganzen
Mandanten (kein Wächter gebaut, siehe `.planning/WINDOWS.md`);
Systemkontext (Etappe 3c) und Anmeldenamen pro Mandant (Etappe 3a) bleiben
offen.
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** Der
Startpfad `loadAnySmtpConfigForStartupTransport()` bleibt bewusst
ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #30, siehe
oben) — ein Umbau auf Transport je Versand aus
`getDecryptedSmtpConfig(tenantId)`, die Form, die `DkvMailService`/
`TenderMailService` bereits haben, ist eine Funktionsänderung
(Umbau des Mailmoduls), kein Bindungsumbau, und deshalb NICHT Gegenstand
dieser Etappe. Siehe
Anmeldenamen pro Mandant (Etappe 3a) bleibt offen. **Systemkontext
(Etappe 3c) — erledigt (260914-eym):** Migration
`20260914120000_rls_system_context_read` (`is_system_context()`,
`system_read_policy … FOR SELECT` auf fünf Tabellen), Schwesterhelfer
`forSystem()`, fünfte Erkennungsform des Detektors mit Erlaubnisliste;
siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
"## Systemkontext (Etappe 3c, 260914-eym)" und den Regelschluss je Fall
im Hintergrunddienst-Abschnitt oben.
- **Wie das Mailmodul künftig je Mandant versendet (260911-gwh).** ~~Der
Startpfad bleibt bewusst ungebunden (sechster Fall der
Hintergrunddienst-Falle, WINDOWS #30, siehe oben) — ein Umbau auf
Transport je Versand aus `getDecryptedSmtpConfig(tenantId)`, die Form,
die `DkvMailService`/`TenderMailService` bereits haben, ist eine
Funktionsänderung (Umbau des Mailmoduls), kein Bindungsumbau, und deshalb
NICHT Gegenstand dieser Etappe.~~ **Aufgelöst (260914-eym):** genau dieser
Umbau ist gebaut — Startpfad und Mailer-Fabrik entfernt, `MailService`
baut je Versand einen Transport nach Mandant des Empfängers, WINDOWS #30
geschlossen. Siehe
`docs/mandantentrennung-etappe2-fehlerrichtung.md`, "## Bereich settings",
(s4)(a).
(s4)(a) mit Nachtrag.