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
This commit is contained in:
2026-09-14 11:52:47 +02:00
parent 6e2a641d76
commit 939c8121a1
5 changed files with 435 additions and 38 deletions
+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
@@ -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,6 +558,41 @@ 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
@@ -661,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`). |
@@ -728,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.