7 Commits

Author SHA1 Message Date
schalli 5228f28cbe docs(quick-260909-eor): Etappe 1 der Mandantentrennung — Bericht und Klassifikation
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 55s
Tessera CI/CD / Build & Publish Images (push) Successful in 29s
Der Kernfund dieser Etappe war ein Defekt im Fundament: forTenant() setzte den
Mandantenkontext auf der Transaktionsverbindung, dispatchte die Abfrage aber
ueber den aeusseren Client. Empirisch reproduziert statt hergeleitet —
set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL.

Damit hat die Mandantentrennung nie funktioniert, auch nicht dort, wo sie
scheinbar benutzt wurde. Nach einem Scharfschalten haette sich das umgekehrt:
die betroffenen Abfragen liefern dann null Zeilen, und der LDAP-Loeschzweig
haette das als "Gruppe im Verzeichnis verschwunden" gedeutet und sie samt
Mitgliedschaften und Modulfreigaben geloescht. Aufgefallen, weil vor dem
Umbau geprueft wurde, ob das Fundament traegt.

Der Anmeldeweg bekam eine bewusst schmale Ausnahme: drei SECURITY-DEFINER-
Funktionen mit fester Spaltenliste, Gleichheitsbedingung und LIMIT 1. Eine
Policy waere hier untauglich — sie ist ein Zeilenpraedikat und haette
zwangslaeufig die ganze Tabelle freigegeben.

Die Klassifikation macht die restliche Arbeit planbar: 227 Zugriffe in 59
Einheiten, davon 31 umzustellen, 9 teilweise, 16 ohne Mandantenbezug und 3
bewusst uebergreifend. Ein Test haelt die Einteilung gegen Abdriften fest.

Browser-Gegenprobe lokal bestanden. 701 Tests gruen. Der Schalter bleibt aus;
#18, #19 und #20 bleiben offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 11:14:58 +02:00
schalli da0ac04e73 fix(quick-260909-eor): WINDOWS #20 offen halten bis Wirkung nach Scharfschalten belegt ist
Aufgabe 4: die Browser-Gegenprobe der Anmeldung ist bestanden (siehe SUMMARY).
Auf ausdruecklichen Wunsch bleibt WINDOWS #20 (forTenant()-Verbindungsdefekt)
dennoch OPEN statt fixed — die technische Reparatur ist nachgewiesen
(rls-scratch-check.mjs, 8/8 gegen eine Wegwerf-Datenbank mit Rolle ohne
BYPASSRLS), aber die Wirkung unter der echten Anwendungsrolle tessera_app
ist erst nach dem Scharfschalten (#18) beobachtbar. #20 bleibt damit an
dieselbe Bedingung gebunden wie #18 und #19.

Nebenbefund beim Zuruecksetzen: die vorherige Markierung als "fixed" hatte
nur die Markdown-Tabelle veraendert, nicht den massgeblichen JSON-Block am
Dateiende (die eigentliche Quelle der Wahrheit fuer gsd-tools windows status)
— `gsd-tools windows status` scheiterte seitdem mit
"Ledger counts disagree with entries". Behoben durch Neuaufbau aus der
Vorversion via der broken-windows.cjs-Bibliothek (parseLedger/renderLedger),
damit Tabelle und JSON-Block wieder uebereinstimmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 11:11:28 +02:00
schalli 5f3a39c2c3 docs(quick-260909-eor): alle 227 Datenbankzugriffe klassifiziert und maschinell abgesichert
WINDOWS #18/#20, Aufgabe 3: docs/mandantentrennung-zugriffsklassifikation.md
haelt fuer jede der 227 this.prisma.*-Fundstellen (32 Dateien, zusammengefasst
zu 59 Datei-Modell-Paaren) eine Klasse fest — muss-mandantengebunden (31),
keine-mandantengebundene-tabelle (16), bewusst-uebergreifend (3, mit
ausgeschriebenem Grund) oder beides (9, der Hintergrunddienst-Sonderfall:
uebergreifend lesen, je Zeile mandantengebunden schreiben — betrifft
ldap.service.ts, tender-digest.scheduler.ts, tender-matching.service.ts).

rls-access-inventory.spec.ts ermittelt die Fundstellen bei jedem Testlauf neu
aus dem Quelltext und vergleicht sie gegen die Tabelle im Dokument — Datei und
Modellname als Schluessel, keine Zeilennummer. Scheitert nachweislich, sobald
eine Fundstelle fehlt oder ein Eintrag verwaist (per Testlauf geprueft, danach
zurueckgesetzt).

Zwei belegte Befunde im Dokument festgehalten: req.tenantPrisma wird gesetzt,
aber nirgends gelesen; WINDOWS #19 (nullbares tenantId bei SearchProvider/
TenderRssFeedSource) bleibt benannter Blocker fuer Etappe 3.

docs/mandantentrennung-datenbankrolle.md verweist jetzt auf das neue
Dokument und korrigiert die ueberholte Zahl 182 auf den nachgemessenen Stand
(227/59). WINDOWS.md #18/#19 um Nachtrag auf diesen Plan ergaenzt; #20 (der
in Aufgabe 1 gemessene und behobene forTenant()-Verbindungsdefekt) als
"fixed" markiert.

Deviation (Rule 3, blockierend fuer die Bestandsaufnahme-Pruefung):
auth.service.ts-Kommentar umformuliert, der zuvor woertlich
"this.prisma.user.findUnique" als erklaerenden Text enthielt und dadurch
einen Eigentreffer der grep-basierten Inventur-Pruefung erzeugte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 11:04:29 +02:00
schalli de50297467 feat(quick-260909-eor): schmale SECURITY-DEFINER-Ausnahme fuer den Anmeldeweg
WINDOWS #18/#20, Aufgabe 2: der Anmeldeweg muss den passenden Benutzer
finden, bevor sein Mandant bekannt ist — unter der kuenftigen Rolle ohne
BYPASSRLS (tessera_app) wuerde ein gewoehnlicher SELECT auf "User" sonst
null Zeilen liefern und die Anmeldung waere unmoeglich.

Drei SECURITY-DEFINER-Funktionen (STABLE, fester Suchpfad public/pg_temp,
fester Spaltensatz, LIMIT 1, Ausfuehrungsrecht ausschliesslich fuer
tessera_app) ersetzen die drei pre-tenant Lesezugriffe in auth.service.ts:

- auth_lookup_user_by_username (validateUser)
- auth_lookup_user_by_email (requestPasswordReset)
- auth_lookup_reset_token (resetPassword)

Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle
Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) ueber forTenant(),
gebunden an genau diesen Mandanten (Aufgabe 1). getMe/changePassword/
adminResetPassword bleiben bewusst unangetastet — sie kennen den Mandanten
bereits aus dem Sitzungsnachweis und gehoeren in Etappe 2.

rls-scratch-check.mjs um einen zweiten Abschnitt erweitert: spielt die
Migration in die Wegwerf-Datenbank ein und misst live unter der Rolle ohne
BYPASSRLS — Anmeldesuche findet den Benutzer, unbekannter Name liefert
nichts ohne zu werfen, gewoehnlicher SELECT auf "User" liefert null Zeilen.
Alle 8 Pruefungen (5 aus Aufgabe 1 + 3 neue) bestehen gegen die lokale
Datenbank. Volle Testsuite (695 Tests) und type-check bleiben gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:56:41 +02:00
schalli bbf179503c fix(quick-260909-eor): forTenant() bindet Mandantenkontext auf dieselbe Verbindung
WINDOWS #20: set_config() lief auf einer anderen Postgres-Verbindung als die
eigentliche Abfrage, weil die interaktive Callback-Form von $transaction
verwendet wurde. Ersetzt durch die Array-Form, die set_config und Abfrage als
eine Transaktion auf einer Verbindung ausfuehrt (Prismas empfohlenes Muster
fuer RLS-ueber-Extensions). Injektionsfestigkeit (T-02-05) bleibt ueber ein
getaggtes $executeRaw-Template statt $executeRawUnsafe erhalten.

- prisma-tenant.extension.spec.ts: prueft die Form des Aufrufs (Array mit
  zwei Eintraegen, Rueckgabewert ist der zweite Eintrag) ohne laufende
  Datenbank
- rls-scratch-check.mjs: neues Werkzeug, das eine Wegwerf-Datenbank anlegt
  und live misst — gleiche Backend-Verbindung, gesetzter Kontext, keine
  Fremdmandanten-Zeilen, keine Zeilen ohne Kontext. Alle 5 Pruefungen
  bestehen gegen die lokale Datenbank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:50:37 +02:00
schalli d0393ac4af docs: forTenant setzt den Kontext auf der falschen Verbindung (#20)
Empirisch reproduziert, nicht hergeleitet: set_config landete auf Backend-PID
254999, die eigentliche Abfrage auf 255000, dort war app.current_tenant NULL.
Die Erweiterung setzt den Kontext auf tx, dispatcht die Abfrage aber ueber den
aeusseren Client.

Folge: die Mandantentrennung hat nie funktioniert, auch nicht an den Stellen,
die sie scheinbar nutzen. Heute unsichtbar, weil die Rolle ohnehin BYPASSRLS
hat (#18). Nach dem Scharfschalten kehrt es sich um — die Abfragen liefern
dann null Zeilen, und der LDAP-Loeschzweig deutet das als "Gruppe im
Verzeichnis verschwunden" und loescht sie samt Mitgliedschaften und
Modulfreigaben.

Zusatz: von 36 vermeintlichen forTenant-Vorkommen sind die meisten Kommentare,
die dessen Fehlen erklaeren — echte Aufrufstellen sind 6. Und das von
tenant.middleware.ts:44 und tenant.guard.ts:41 gesetzte req.tenantPrisma liest
niemand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:44:39 +02:00
schalli e777802749 docs(quick-260909-eor): Etappe 1 der Mandantentrennung geplant
Drei Punkte werden in dieser Etappe abschliessend geklaert: der gemessene
forTenant()-Defekt, die schmale Ausnahme fuer den Anmeldeweg und die
maschinell abgesicherte Klassifikation aller 232 Datenbankzugriffe.

Der Befund zu forTenant() wurde vor der Planung empirisch belegt: set_config
laeuft auf Backend 254999, die eigentliche Abfrage auf 255000, der
Mandantenkontext ist dort NULL. Alle heutigen forTenant()-Aufrufe sind damit
stillschweigend ungebunden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:43:24 +02:00
14 changed files with 2239 additions and 70 deletions
+7 -6
View File
@@ -4,10 +4,10 @@ milestone: v1.2
current_phase: 17
current_phase_name: eigene-ausschreibungs-quellen-je-nutzer
status: verified
stopped_at: "Drei Vorhaben am 2026-09-09 abgearbeitet: Dateisicherung fuer user-files (benanntes Volume), Versionsangaben in CLAUDE.md auf den installierten Stand samt Abschnitt ueber sechs nie eingebaute Empfehlungen, und die Vorbereitung der Mandantentrennung. Bei letzterer kam der schwerste Befund der Sitzung heraus: die vorhandene Datenbank-Trennung wirkt gar nicht, weil die Anwendungsrolle sie umgeht (#18) — praktisch gemessen. Gebaut sind Rolle, Verbindungstrennung, Pruefwerkzeug und Policies fuer alle 16 offenen Tabellen; das Scharfschalten bleibt aus, weil 182 Zugriffe ohne Mandantenkontext im Code stehen und der Anmeldeweg darunter zwingend ist. OFFEN: #17 (Volume-Zeile auf dem Server nachtragen), #18 (Scharfschalten, braucht die 182 Stellen), #19 (nullable tenantId bei SearchProvider und TenderRssFeedSource)."
last_updated: "2026-09-09T10:25:00.000Z"
stopped_at: "Etappe 1 der Mandantentrennung abgeschlossen (Quick 260909-eor). Der kaputte forTenant-Helfer ist repariert und live nachgewiesen, der Anmeldeweg laeuft ueber drei schmale SECURITY-DEFINER-Funktionen, und alle 227 Zugriffe sind klassifiziert (31 umzustellen, 9 teilweise, 16 ohne Mandantenbezug, 3 bewusst uebergreifend) samt maschineller Absicherung gegen Abdriften. NAECHSTE ETAPPEN laut Plan: Etappe 2 = die eigentliche Umstellung der 31+9 Einheiten, geschaetzt 5-8 Durchlaeufe, sinnvollerweise nach Bereichen (tenders 62, groups 37, ldap 21, dkv 21 sind die grossen); Etappe 3 = Systemkontext und WINDOWS #19 (nullable tenantId), 1-2 Durchlaeufe; Etappe 4 = Scharfschalten mit Vorabpruefung und Rueckweg, 1 Durchlauf. Der User hat am 2026-09-09 ausdruecklich erklaert, dass Datenverlust in der Datenbank derzeit egal ist (nichts laeuft produktiv) — das erlaubt beim Scharfschalten den direkten Weg statt aufwendiger Absicherung, gilt aber nur solange das so bleibt."
last_updated: "2026-09-09T11:15:00.000Z"
last_activity: 2026-09-09
last_activity_desc: Dateisicherung auf dem Server wirksam und belegt (#17 zu); offen nur noch #18 und #19
last_activity_desc: Etappe 1 der Mandantentrennung fertig — forTenant repariert, Anmeldeweg geloest, alle Zugriffe klassifiziert
state_head: c80704957a5518e316a53edc0b8d7e98052a9750
progress:
total_phases: 17
@@ -369,6 +369,7 @@ None yet.
| 260909-cx0 | Hochgeladene Dateien (Avatare, DKV-Exporte) ueberlebten kein `--force-recreate` des api-Containers (WINDOWS #17) — lagen nur in der fluechtigen Container-Schicht, keine Compose-Datei mountete `/app/user-files`. Jetzt benanntes Docker-Volume `user-files` in `docker-compose.yml` und `docker-compose.prod.yml` (Eigentuemerschaft uid 1001 aus dem Image, kein Bind-Mount), Betriebshandbuch Kapitel 6/7 entsprechend nachgezogen. Zweiter, unabhaengiger Punkt: CLAUDE.md nannte fuer die Technik-Tabelle noch die 2026-06/07-Empfehlung (Next.js 16.2.x, Prisma 7.8.x, Keycloak, Redis, TanStack Query, shadcn/ui, Playwright, Husky, lint-staged) statt des installierten Stands — jetzt korrigiert auf Next.js 15.5.19, Prisma 6.19.3 etc., nie uebernommene Empfehlungen in eigenem Abschnitt "Recommended But Not Adopted", `.planning/research/STACK.md` nur mit Hinweiszeile ergaenzt. Keine Abhaengigkeit aktualisiert. **WINDOWS #17 bleibt offen** — die Aenderung erreicht die laufende Installation auf alpha nicht, `/opt/tessera/docker-compose.yml` weicht vom Repository ab und muss vom Nutzer selbst ergaenzt werden | 2026-09-09 | dab72eb,c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) |
| 260909-cx0 | Dateisicherung nachgeruestet und Versionsangaben geradegezogen. **user-files** liegt jetzt in einem benannten Volume (docker-compose.yml und .prod.yml) — vorher lag der Ordner nur in der fluechtigen Container-Schicht, hochgeladene Profilbilder und DKV-Exporte waeren bei jedem --force-recreate weg gewesen. Benanntes Volume statt Bind-Mount, weil das Image /app/user-files an uid 1001 uebereignet; ein frisch angelegtes Host-Verzeichnis gehoert root und haette aus dem Datenverlust einen kaputten Upload gemacht. docker-compose.dev.yml blieb bewusst unveraendert (Compose fuehrt Mount-Listen ueber das Ziel zusammen). **CLAUDE.md** nennt jetzt die installierten Fassungen statt der urspruenglich empfohlenen (Next.js 15.5.19 statt 16, Prisma 6.19.3 statt 7); neu ist ein Abschnitt 'Recommended But Not Adopted', der sechs nie eingebaute Empfehlungen benennt — darunter Keycloak, Redis und shadcn/ui. Der Block ist generiert, deshalb traegt er einen Herkunftsvermerk und die Recherchedatei eine datierte Hinweiszeile; ihre Zahlen blieben unangetastet. Keine Abhaengigkeit angefasst (per git diff gegengeprueft). **WINDOWS #17 am 2026-09-09 geschlossen.** Beim Nachtragen auf dem Server kam heraus, dass dort gar nicht `docker-compose.yml` gilt: die `.env` setzt `COMPOSE_FILE=docker-compose.prod.yml`. Die drei Zeilen wurden in dieser Datei ergaenzt (Sicherung `docker-compose.prod.yml.bak.20260909-0818`). Nach dem Neuerstellen durch den User belegt: Mount `tessera_user-files -> /app/user-files` vorhanden, Ordner gehoert uid 1001 (das benannte Volume hat die Eigentuemerschaft uebernommen), Schreiben als Dienstnutzer funktioniert, und eine Probedatei lag tatsaechlich unter /var/lib/docker/volumes/tessera_user-files/_data auf dem Host — also ausserhalb des Containers | 2026-09-09 | c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) |
| 260909-dgj | Mandantentrennung auf Datenbankebene vorbereitet (WINDOWS #18). Ausloeser war ein gemessener Befund: die Anwendung verbindet als Rolle mit Superuser- und BYPASSRLS-Recht, daher greifen die sieben vorhandenen Policies gar nicht — ohne gesetzten Mandantenkontext lieferte 'SELECT count(*) FROM Group' zwei statt null Zeilen. Gebaut wurden: Rolle `tessera_app` ohne Umgehungsrecht (wiederholbare Migration, kein Kennwort im SQL), Trennung von Migrations- und Laufzeitverbindung ueber TESSERA_MIGRATE_DATABASE_URL, ein Pruefwerkzeug mit fuenf transaktionssicheren Nachweisen, Policies fuer die 16 fehlenden Tabellen und eine Betriebsanleitung. **Der Schalter bleibt bewusst aus, #18 bleibt offen:** im Code stehen 182 Datenbankzugriffe ohne Mandantenkontext gegen 19 mit — darunter zwingend der Anmeldeweg, der die Benutzerzeile liest, bevor der Mandant bekannt ist (er kommt erst aus dieser Zeile). Ein Umschalten wuerde die Anmeldung fuer alle sperren. Dabei fiel #19 an: SearchProvider und TenderRssFeedSource haben ein nullable tenantId; die einfache Policy wuerde die plattformweiten Zeilen nach dem Scharfschalten fuer jeden Mandanten unsichtbar machen. 673/673 Tests gruen | 2026-09-09 | efaabc9 | [260909-dgj-mandantentrennung-auf-alle-tabellen-mit-](./quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/) |
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
## Deferred Items
@@ -408,7 +409,7 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
Last session: 2026-09-09T10:25:00.000Z
Stopped at: Nichts in Arbeit. Offen im Ledger nur noch #18 und #19, die zusammengehoeren und ein eigener Vorgang sind: die Mandantentrennung scharf schalten, wofuer zuerst die 182 Datenbankzugriffe ohne Mandantenkontext umgestellt werden muessen (der Anmeldeweg ist zwingend darunter), und dabei die nullable-tenantId-Faelle mitloesen. Alles Vorbereitende dafuer ist gebaut und liegt bereit. Zurueckgestellt bleiben #12, Abnahmeplan 02-05, Mandanten-Branding und die Lizenzpruefung.
Last session: 2026-09-09T11:15:00.000Z
Stopped at: Etappe 1 der Mandantentrennung ist fertig und gepusht. Weiter geht es mit Etappe 2 — der Umstellung der 31 vollstaendig und 9 teilweise betroffenen Einheiten, sinnvollerweise nach Bereichen gebuendelt. Die Grundlage dafuer liegt in docs/mandantentrennung-zugriffsklassifikation.md, die dort getroffene Einteilung ist maschinell gegen Abdriften abgesichert. Offen im Ledger: #18 (Scharfschalten), #19 (nullable tenantId, mit #18 zu loesen), #20 (bleibt offen bis die Wirkung nach dem Scharfschalten belegt ist, der Defekt selbst ist behoben).
Resume file: None
Last activity: 2026-09-09 - Dateisicherung auf dem Server eingetragen und nachgewiesen, #17 geschlossen
Last activity: 2026-09-09 - Etappe 1 der Mandantentrennung abgeschlossen
+20 -7
View File
@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 2
open_count: 3
waived_count: 1
fixed_count: 16
total_count: 19
last_updated: 2026-09-09T08:22:51.235Z
total_count: 20
last_updated: 2026-09-09T09:10:51.419Z
---
# Broken Windows Ledger
@@ -32,8 +32,9 @@ last_updated: 2026-09-09T08:22:51.235Z
| 15 | 16 | unmet-truth | apps/api/src/ldap/ldap.service.ts | | Der Sync reicht rohe Techniktexte an den Administrator durch und laesst echte AD-Konten still liegen. Am 2026-09-07 auf alpha gegen das echte AD gemessen (Lauf 14:42): zehn Fehlerzeilen unter den drei Zahlenzeilen, davon zwei Sorten. (1) Vier Konten scheitern mit der woertlichen Prisma-Meldung 'Invalid prisma.user.create() invocation: Unique constraint failed on the fields: (email)' — CN=uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss, usoftware_rw aus OU=CTL_PWS_Gruppen teilen sich offenbar eine E-Mail-Adresse. Sie werden dadurch NIE importiert, ohne dass der Administrator erfaehrt warum oder was er tun soll. (2) Sechs Eintraege melden englisch 'no username mapped (check sAMAccountName mapping)' — korrekt uebersprungene Kontakte/Ressourcen ohne sAMAccountName, aber die Meldung liest sich wie ein Fehler und ist nicht uebersetzt. Beides braucht eine verstaendliche deutsche Meldung; die E-Mail-Kollision zusaetzlich eine Entscheidung, ob solche Konten ohne E-Mail angelegt oder bewusst uebersprungen werden. Beleg: .planning/phases/16-ad-gruppen-synchronisation/uat-2026-09-07/windows6a-sync-zahlenzeilen.png | fixed | | 2026-09-07T12:47:32.058Z | 2026-09-09T06:25:18.145Z |
| 16 | 14 | unmet-truth | apps/api/src/tenders/tenders.controller.ts | | Das Postfach im Ausschreibungs-Radar hat keinen Verbindungstest, obwohl die Faehigkeit fertig vorliegt. Beide Inbox-Provider bringen testConnection() mit (imap.provider.ts:343, exchange-inbox.provider.ts:294), und das DKV-Modul nutzt sie ueber POST /dkv/test-connection samt Knopf 'Verbindung testen' im InboxConfigForm. Beim Ausschreibungs-Radar fehlt beides: der Controller kennt zu email-config nur GET (:343) und PUT (:357), kein Test-Endpunkt, und das Formular unter Meine Quellen hat keinen Knopf. Historie geprueft: der Knopf war nie vorhanden (git log -S testConnection im tender-radar-Frontend ist leer), es ist also eine Luecke, keine Regression. Folge fuer den Betrieb: ein Tippfehler in der EWS-Endpunkt-URL oder falsche Zugangsdaten fallen erst auf, wenn dauerhaft nichts ankommt — und dann ist nicht unterscheidbar, ob die Verbindung scheitert oder schlicht keine Alarm-Mail da war. Das trifft besonders WINDOWS #12, dessen ganzer Zweck der Beleg des handgeschriebenen NTLM/SOAP-Wegs ist. Aufgefallen am 2026-09-07 beim Einrichten des Postfachs. | fixed | | 2026-09-07T13:22:33.916Z | 2026-09-09T05:20:09.851Z |
| 17 | 6 | unmet-truth | docker-compose.yml | | Hochgeladene Dateien ueberleben kein Neuerstellen der Container. Der Code legt sie unter user-files/ ab (user.controller.ts:40 und :266 fuer Profilbilder, dazu die DKV-Exporte), aber KEINE der Compose-Dateien mountet dieses Verzeichnis — weder im Repository (docker-compose.yml, .prod.yml, .dev.yml haben nur das Volume pgdata) noch in der abweichenden Datei auf dem Server /opt/tessera/docker-compose.yml. Am 2026-09-09 gemessen: 'docker inspect' auf tessera-api-1 meldet ueberhaupt keinen Mount, /app/user-files liegt damit nur in der beschreibbaren Container-Schicht und ist bei jedem 'up -d --force-recreate' weg. Aufgefallen beim Schreiben des Betriebshandbuchs. KEIN Schaden entstanden: aktuell hat kein Nutzer ein Profilbild hinterlegt (avatarPath ueberall NULL), und die bisherigen Neuerstellungen trafen einen leeren Ordner. Die Luecke schlaegt zu, sobald der erste Nutzer ein Bild hochlaedt oder ein DKV-Export aufgehoben werden soll. Behebung: ein benanntes Volume oder Bind-Mount fuer user-files in beiden Compose-Dateien; die Server-Datei muss zusaetzlich von Hand ergaenzt werden, weil sie vom Repository abweicht. | fixed | | 2026-09-09T06:42:22.801Z | 2026-09-09T08:22:51.235Z |
| 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. | 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. | open | | 2026-09-09T08:08:19.293Z | |
| 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). | open | | 2026-09-09T08:08:19.293Z | |
| 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 | |
````json
[
@@ -247,7 +248,7 @@ last_updated: 2026-09-09T08:22:51.235Z
"phase": "2",
"file": "docker-compose.yml",
"line": null,
"description": "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.",
"description": "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.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T07:42:13.878Z",
@@ -259,11 +260,23 @@ last_updated: 2026-09-09T08:22:51.235Z
"phase": "2",
"file": "apps/api/prisma/migrations/20260909140000_rls_remaining_tenant_tables/migration.sql",
"line": null,
"description": "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.",
"description": "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).",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T08:08:19.293Z",
"resolved_at": null
},
{
"id": 20,
"kind": "unmet-truth",
"phase": "2",
"file": "apps/api/src/prisma/prisma-tenant.extension.ts",
"line": null,
"description": "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.",
"status": "open",
"reason": "",
"recorded_at": "2026-09-09T08:44:18.496Z",
"resolved_at": null
}
]
````
@@ -0,0 +1,507 @@
---
phase: quick-260909-eor
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
- apps/api/scripts/rls-scratch-check.mjs
- apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
- apps/api/src/prisma/auth-lookup-functions.spec.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/mandantentrennung-datenbankrolle.md
- .planning/WINDOWS.md
autonomous: false
requirements: [WINDOWS-18, WINDOWS-19]
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 4
confidence: low # keine Kalibrierungsstichproben fuer dieses Repository vorhanden; Faktor 1,0 angesetzt
must_haves:
truths:
- "forTenant() setzt den Mandantenkontext und fuehrt die Abfrage auf DERSELBEN Datenbankverbindung aus — gemessen ueber pg_backend_pid(), nicht behauptet."
- "Unter einer Rolle ohne BYPASSRLS sieht ein forTenant(A)-Lesezugriff ausschliesslich Zeilen von Mandant A und niemals Zeilen von Mandant B."
- "Unter derselben Rolle findet die Benutzersuche des Anmeldewegs den passenden Benutzer weiterhin — die Anmeldung bleibt moeglich."
- "Unter derselben Rolle liefert ein gewoehnlicher, ungebundener SELECT auf \"User\" null Zeilen. Die Ausnahme ist die Funktion, nicht die Tabelle."
- "Jede this.prisma.*-Fundstelle in apps/api/src traegt eine schriftliche Einstufung mit Begruendung, und eine Maschine prueft die Vollstaendigkeit."
- "DATABASE_URL zeigt am Ende dieser Etappe unveraendert auf die bisherige Rolle — es wird nichts scharf geschaltet."
artifacts:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
- apps/api/scripts/rls-scratch-check.mjs
- apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
- apps/api/src/prisma/auth-lookup-functions.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
key_links:
- "prisma-tenant.extension.ts: die Array-Form von $transaction bindet set_config und die Abfrage an eine Verbindung — die interaktive Callback-Form ist genau das, was heute bricht."
- "auth.service.ts validateUser -> auth_lookup_user_by_username: der einzige verbleibende Lesezugriff auf \"User\" vor bekanntem Mandanten."
- "rls-access-inventory.spec.ts -> docs/mandantentrennung-zugriffsklassifikation.md: die Spec haelt das Dokument waehrend Etappe 2/3 wahr, waehrend Fundstellen umgebaut werden."
- "auth_lookup_*-Funktionen -> GRANT EXECUTE ausschliesslich an tessera_app: die Rolle, die spaeter tatsaechlich verbindet, ist die einzige, die die Ausnahme nutzen darf."
---
<objective>
Etappe 1 der Mandantentrennung: das Fundament belastbar machen, bevor irgendein Zugriff umgebaut wird.
Drei Dinge werden hier abschliessend geklaert. Erstens: `forTenant()` ist defekt — gemessen, nicht
vermutet — und wird repariert. Zweitens: der Anmeldeweg bekommt eine bewusst schmale, begruendete
Ausnahme, damit die Umstellung spaeter nicht in einer Anwendung endet, in die sich niemand mehr
einloggen kann. Drittens: alle Datenbankzugriffe werden klassifiziert und die Klassifikation
maschinell gegen den Quelltext abgesichert, damit die folgenden Etappen eine Landkarte mit
Mengenangaben haben.
Purpose: Ohne ein funktionierendes `forTenant()` waere jeder Umbau in Etappe 2 wertlos — er wuerde
Aufrufe auf einen Mechanismus umstellen, der nichts bewirkt. Ohne die Anmelde-Ausnahme waere das
Scharfschalten in Etappe 4 ein garantierter Totalausfall.
Output: repariertes `forTenant()` mit Live-Nachweis, drei eng geschnittene Anmelde-Funktionen in der
Datenbank samt Verdrahtung, ein maschinell geprueftes Klassifikationsdokument ueber alle 232
Fundstellen.
**Ausdruecklich NICHT in dieser Etappe:** `DATABASE_URL` wird nicht auf `tessera_app` umgestellt.
Der Server 192.168.13.12 wird nicht angefasst. Migrationen laufen ausschliesslich gegen eine lokale
Wegwerf-Datenbank.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/WINDOWS.md
@docs/mandantentrennung-datenbankrolle.md
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/api/src/prisma/prisma.service.ts
@apps/api/src/auth/auth.service.ts
@apps/api/src/prisma/rls-app-role.spec.ts
@apps/api/prisma/migrations/20260909130000_rls_app_role/migration.sql
@apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql
</context>
<measured_baseline>
Alle Zahlen des Auftrags wurden am 2026-09-09 nachgemessen. Zwei weichen ab und gelten in dieser
korrigierten Fassung:
| Groesse | Auftrag | Nachgemessen | Befehl |
|---|---|---|---|
| `this.prisma.*`-Fundstellen (ohne Specs) | 228 | **232**, verteilt auf **32 Dateien** | `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src --include=*.ts \| grep -v "\.spec\.ts" \| wc -l` |
| tenders | 61 | **62** | dito, je Verzeichnis |
| groups | 34 | **37** | dito |
| ldap / dkv | 21 / 21 | 21 / 21 | dito |
| user / module-registry | 17 / 17 | 17 / 17 | dito |
| dashboard / auth | 13 / 13 | 13 / 13 | dito |
| calendar / tenant | 12 / 8 | 12 / 8 | dito |
| favorites / settings | 7 / 4 | 7 / 4 | dito |
Die **36** Treffer auf `forTenant`/`tenantPrisma` sind irrefuehrend: die Mehrzahl sind Kommentare,
die begruenden, warum an dieser Stelle *kein* `forTenant()` steht. Tatsaechliche `forTenant(`-Aufrufe:
**6** (`ldap.service.ts:762,905,1179,1342`, `tenant.middleware.ts:44`, `tenant.guard.ts:41`).
Tatsaechliche Abfragen ueber einen mandantengebundenen Client: **9**, alle in `ldap.service.ts`.
`req.tenantPrisma` wird von `tenant.middleware.ts` und `tenant.guard.ts` gesetzt, im gesamten
`apps/api/src` aber von keinem Controller gelesen — die Verdrahtung laeuft ins Leere. Das ist als
Befund in Aufgabe 3 festzuhalten.
Weiter nachgemessen:
- 28 Prisma-Modelle. **8 ohne `tenantId`**: `Tenant`, `PasswordResetToken`, `LdapFieldMapping`,
`Module`, `GroupMembership`, `Tender`, `TenderSource`, `TenderSourcePollConfig`. Achtung:
`PasswordResetToken` und `GroupMembership` tragen dennoch RLS — ueber einen Unterabfrage-Join auf
`User` bzw. `Group` (`20260618112133_rls_policies/migration.sql:18-21`). "Ohne `tenantId`" ist
also nicht deckungsgleich mit "ohne Regel".
- 2 Modelle mit nullbarem `tenantId`: `SearchProvider` (`schema.prisma:218`) und
`TenderRssFeedSource` (`schema.prisma:579`) — das ist WINDOWS #19.
- 16 `CREATE POLICY` in `20260909140000_rls_remaining_tenant_tables`; zusammen mit den frueheren
Migrationen 20 abgedeckte Tabellen.
- Lokale Datenbank erreichbar unter `172.19.0.2:5432`, Rolle `tessera` / `tessera_dev`
(Container `tessera-ctl-db-1`, `postgres:16-alpine`, kein Host-Port).
- Installiertes Prisma: **6.19.3** (nicht 7.x wie in CLAUDE.md behauptet). Die Extension-API dieser
Fassung ist massgeblich.
## Der entscheidende Befund: forTenant() ist defekt
Nicht gelesen, sondern gemessen. Ein Nachbau des exakten Musters aus
`prisma-tenant.extension.ts` gegen die lokale Datenbank ergab:
```
inside tx : {"pid":254999,"t":"TENANT-A"}
actual qry : {"pid":255000,"t":null}
SAME CONNECTION? false
TENANT VISIBLE TO ACTUAL QUERY? null
```
`set_config('app.current_tenant', ..., true)` laeuft auf Backend 254999. Die eigentliche Abfrage
laeuft auf Backend 255000 und sieht den Mandantenkontext als NULL. Ursache: `query(args)` in
`$allOperations` fuehrt die Operation auf dem **aeusseren** Client aus, nicht auf `tx`; die
interaktive Transaktion haelt eine eigene Verbindung, die Einstellung ist transaktionslokal.
Konsequenz: Alle heutigen `forTenant()`-Aufrufe sind stillschweigend ungebunden. Unter einer Rolle
ohne BYPASSRLS wuerden sie nicht etwa zu viel liefern, sondern **null Zeilen** — die Policy
`"tenantId" = current_tenant_id()` vergleicht gegen NULL. Der AD-Abgleich in `ldap.service.ts`
wuerde beim Scharfschalten wortlos leerlaufen und, im Loeschzweig ab Zeile 1559, potenziell
Gruppen als "im Verzeichnis verschwunden" behandeln.
</measured_baseline>
<tasks>
<task type="tracer">
<name>Aufgabe 1: forTenant() auf eine Verbindung zwingen und die Wirkung live nachweisen</name>
<files>apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/prisma/prisma-tenant.extension.spec.ts, apps/api/scripts/rls-scratch-check.mjs</files>
<reversibility rating="reversible">Reine Codeaenderung an einer Datei plus zwei neue Pruefdateien; kein Schemawechsel, kein Betriebsschalter.</reversibility>
<behavior>
- Der Mandantenkontext und die eigentliche Abfrage teilen sich eine Datenbankverbindung: die von `pg_backend_pid()` gemeldete Kennung ist bei beiden gleich.
- `current_setting('app.current_tenant', true)` ist waehrend der eigentlichen Abfrage auf den uebergebenen Mandanten gesetzt, nicht NULL.
- Unter einer Rolle ohne BYPASSRLS liefert ein `forTenant(A)`-Lesezugriff auf eine Tabelle mit Policy genau die Zeilen von A.
- Derselbe Lesezugriff liefert null Zeilen von Mandant B.
- Ein ungebundener Lesezugriff derselben Rolle auf dieselbe Tabelle liefert null Zeilen.
</behavior>
<action>
Ersetze in `prisma-tenant.extension.ts` die interaktive Callback-Form durch die Array-Form von
`$transaction`. Der Kern: `$allOperations` gibt das Ergebnis eines
`prisma.$transaction([ setConfigPromise, query(args) ])` zurueck und entnimmt den zweiten
Eintrag. Prisma fuehrt die Array-Form als eine Transaktion auf einer Verbindung aus, weshalb
die transaktionslokale Einstellung fuer die Abfrage sichtbar wird. Das ist genau das Muster,
das Prisma selbst fuer RLS ueber Client-Extensions vorsieht.
Der Mandantenwert MUSS parametrisiert bleiben — nutze ein getaggtes `$executeRaw`-Template mit
interpoliertem Wert, nicht `$executeRawUnsafe` mit zusammengebautem Text. Die
Injektionsfestigkeit aus T-02-05 ist eine bestehende Zusage und darf bei diesem Umbau nicht
verloren gehen.
Halte im Kopfkommentar der Datei fest, warum die Array-Form Pflicht ist und die
Callback-Form nicht funktioniert, mit den gemessenen Backend-Kennungen als Beleg. Formuliere
die Begruendung so, dass sie ohne diesen Plan verstaendlich bleibt.
Pruefe beim Umbau ausdruecklich die Grenzfaelle und dokumentiere das Ergebnis im Kommentar:
Was passiert, wenn der aufrufende Code auf dem mandantengebundenen Client selbst
`$transaction` aufruft, und was passiert bei `$queryRaw`. Falls eine dieser Nutzungen mit der
Array-Form nicht mehr traegt, ist das ein benannter Vorbehalt fuer Etappe 2 und gehoert in die
SUMMARY — nicht stillschweigend uebergangen.
Lege `apps/api/scripts/rls-scratch-check.mjs` an. Das Werkzeug richtet sich eine eigene
Wegwerf-Datenbank ein (Vorschlag: `tessera_rls_scratch`), legt darin eine kleine Tabelle mit
Mandantenspalte samt Policy und aktiviertem `FORCE ROW LEVEL SECURITY` an, legt eine Rolle
ohne BYPASSRLS an, befuellt zwei Mandanten mit unterscheidbaren Zeilen und misst dann die fuenf
oben genannten Verhaltensweisen. Am Ende raeumt es die Wegwerf-Datenbank wieder ab. Es darf die
Datenbank `tessera` weder lesen noch veraendern — der Datenbankname gehoert fest ins Werkzeug,
nicht in eine Umgebungsvariable, damit ein Tippfehler nicht in der echten Datenbank landet.
Verbindungsangaben kommen ueber `TESSERA_SCRATCH_ADMIN_URL`; ohne diese Variable bricht das
Werkzeug mit einer Anleitung ab, statt eine Vorgabe zu raten.
Das Werkzeug meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald eine
Pruefung scheitert. Es gibt kein Kennwort und keine vollstaendige Verbindungszeichenkette aus.
Lege `prisma-tenant.extension.spec.ts` an. Die Spec prueft ohne laufende Datenbank die **Form**
des Aufrufs, nicht seinen mit Produktionscode erzeugten Inhalt — die Lehre aus dem
tautologischen Test in STATE.md: ein vorgetaeuschter Client zeichnet auf, dass `$transaction`
mit einem Feld aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite Eintrag
ist, und dass `query` waehrend des Aufbaus dieses Feldes genau einmal beruehrt wird. Ergaenze
eine Spec-Zusicherung, dass der Quelltext der Extension `$executeRawUnsafe` nicht mehr
verwendet.
</action>
<verify>
<automated>npm --prefix apps/api run test -- src/prisma/prisma-tenant.extension.spec.ts &amp;&amp; TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs</automated>
</verify>
<done>Die Spec laeuft gruen und `rls-scratch-check.mjs` meldet alle fuenf Pruefungen bestanden, darunter ausdruecklich gleiche Backend-Kennung, gesetzter Mandantenkontext, null Fremdzeilen und null Zeilen ohne Kontext.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Den Anmeldeweg ueber drei eng geschnittene Datenbankfunktionen mandantenfaehig machen</name>
<files>apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql, apps/api/src/prisma/auth-lookup-functions.spec.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/scripts/rls-scratch-check.mjs</files>
<precondition>Aufgabe 1 ist abgeschlossen — die Nachweise dieser Aufgabe verlassen sich darauf, dass `forTenant()` tatsaechlich bindet.</precondition>
<reversibility rating="costly">Eine Migration, die dauerhafte SECURITY-DEFINER-Funktionen anlegt. Ruecknehmbar nur ueber eine Folgemigration mit DROP FUNCTION, und die Funktionen sind eine Sicherheitsflaeche — die Schnittbreite ist nachtraeglich teuer zu korrigieren.</reversibility>
<behavior>
- Die Benutzersuche nach Benutzername liefert unter einer Rolle ohne BYPASSRLS weiterhin genau den passenden Benutzer.
- Ein gewoehnlicher SELECT ueber "User" liefert unter derselben Rolle null Zeilen.
- Die Suche nach einem nicht vorhandenen Benutzernamen liefert nichts und wirft nicht.
- Ein Benutzername in abweichender Gross-/Kleinschreibung findet denselben Benutzer wie bisher.
- Die Token-Suche liefert unter derselben Rolle den passenden Rueckstell-Datensatz samt zugehoerigem Benutzer.
- Nach gefundenem Benutzer laufen alle Schreibzugriffe des Anmeldewegs mandantengebunden.
</behavior>
<action>
**Die Entscheidung und ihre Begruendung.** Von den drei erwogenen Wegen faellt die Wahl auf
SECURITY-DEFINER-Funktionen. Eine zusaetzliche Policy auf `User` scheidet aus, weil eine Policy
ein Zeilenpraedikat ist und nicht die Form der Abfrage einschraenken kann: eine Regel, die eine
Suche nach Benutzername erlaubt, erlaubt zwangslaeufig auch das Auslesen aller Zeilen. Eine
zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil sie einen zweiten
Verbindungspool und einen zweiten Prisma-Client verlangt und auf `User` ohnehin dieselbe Breite
haette. Eine Funktion dagegen bindet die Ausnahme an eine feste Abfrage mit festem Spaltensatz,
fester Gleichheitsbedingung und `LIMIT 1` — eine kompromittierte Abfrage kann damit einen
einzelnen Benutzernamen erraten, aber die Tabelle nicht ausleeren. Halte diese Begruendung im
Kopf der Migration fest.
**Die Migration.** Lege drei Funktionen an, jede `SECURITY DEFINER`, jede `STABLE`, jede mit
fest angeheftetem Suchpfad auf `public, pg_temp`, jede mit `LIMIT 1`:
- `auth_lookup_user_by_username(text)` — Gleichheitsvergleich gegen den kleingeschriebenen
Benutzernamen, wie es `auth.service.ts:39-41` heute tut. Liefert nur die Felder, die der
Anmeldeweg wirklich braucht: Kennung, Benutzername, Mandant, Kennwort-Hash, LDAP-DN,
Aktiv-Merkmal, Rolle, Anzeigename, Kennwortwechsel-Merkmal.
- `auth_lookup_user_by_email(text)` — dieselbe Bauart fuer `requestPasswordReset`
(`auth.service.ts:144-146`). Liefert Kennung, Mandant, E-Mail und Aktiv-Merkmal; keinen
Kennwort-Hash, denn dieser Pfad prueft kein Kennwort.
- `auth_lookup_reset_token(text)` — Gleichheitsvergleich gegen den Token, liefert den
Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers. Das Token ist eine
`randomUUID` und nicht erratbar.
Keine dieser Funktionen schreibt. Nach der Anlage jeweils saemtliche Rechte von PUBLIC
entziehen und danach ausschliesslich `tessera_app` das Ausfuehrungsrecht erteilen. Die
Migration muss wiederholbar sein — halte dich an das Muster aus
`20260909130000_rls_app_role/migration.sql`, das die Existenz der Rolle prueft, bevor es
Rechte vergibt, und mit einer verstaendlichen Anleitung scheitert statt still zu ueberspringen.
Der feste Suchpfad ist bei SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche
Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition den Tabellenbezug in der
Funktion umlenken und der Aufrufer erbte die Rechte des Eigentuemers.
**Die Verdrahtung.** Ersetze in `auth.service.ts` genau die drei Lesezugriffe, die vor
bekanntem Mandanten stattfinden, durch Aufrufe dieser Funktionen. Das sind
`validateUser` (Zeile 39), `requestPasswordReset` (Zeile 144) und `resetPassword` (Zeile 178).
Alle uebrigen Prisma-Aufrufe der Datei arbeiten mit einer bereits bekannten Benutzerkennung
und damit bekanntem Mandanten: stelle die Schreibzugriffe in `validateUser` (Zeilen 71 und 84),
`requestPasswordReset` (Zeile 161) sowie `resetPassword` (Zeilen 199 und 208) auf den
mandantengebundenen Client aus Aufgabe 1 um, gebunden an den Mandanten des soeben gefundenen
Benutzers. Damit ist `auth.service.ts` am Ende dieser Aufgabe vollstaendig umstellungsfaehig.
`getMe`, `changePassword` und `adminResetPassword` suchen ueber die Benutzerkennung aus dem
bereits ausgestellten Sitzungsnachweis — dort ist der Mandant bekannt. Sie sind damit
gewoehnliche mandantengebundene Zugriffe und gehoeren in den Umbau der Etappe 2, nicht in diese
Ausnahme. Fasse sie hier nicht an, sondern trage sie in Aufgabe 3 entsprechend ein.
**Die Nachweise.** Erweitere `rls-scratch-check.mjs` um einen zweiten Abschnitt, der die
Migration in die Wegwerf-Datenbank einspielt, zwei Benutzer in zwei Mandanten anlegt und unter
der Rolle ohne BYPASSRLS misst: Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf
`User` liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts.
Lege `auth-lookup-functions.spec.ts` nach dem Vorbild von `rls-app-role.spec.ts` an: sie liest
den Migrationstext und sichert die Eigenschaften, die die Schnittbreite ausmachen — angehefteter
Suchpfad, `STABLE`, `LIMIT 1` bei allen drei Funktionen, Rechteentzug von PUBLIC vor der
Rechtevergabe, Ausfuehrungsrecht ausschliesslich fuer `tessera_app`, kein Kennwort im Text.
Zaehlbedingungen ueber den Migrationstext muessen Kommentarzeilen vorher herausfiltern, sonst
zaehlt die Begruendung im Kopf der Datei als Treffer mit.
Erweitere `auth.service.spec.ts` um die Verhaltensfaelle oben — mit vorgetaeuschtem Client,
ohne laufende Datenbank.
</action>
<verify>
<automated>npm --prefix apps/api run test -- src/prisma/auth-lookup-functions.spec.ts src/auth/auth.service.spec.ts &amp;&amp; TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs &amp;&amp; npm --prefix apps/api run type-check</automated>
</verify>
<done>Beide Specs gruen, `type-check` sauber, und das Wegwerf-Werkzeug belegt unter der Rolle ohne BYPASSRLS: Anmeldesuche findet den Benutzer, gewoehnlicher SELECT auf "User" liefert null Zeilen.</done>
</task>
<task type="auto">
<name>Aufgabe 3: Alle 232 Datenbankzugriffe klassifizieren und die Klassifikation maschinell absichern</name>
<files>docs/mandantentrennung-zugriffsklassifikation.md, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-datenbankrolle.md, .planning/WINDOWS.md</files>
<behavior>
- Die Bestandsaufnahme aus dem Quelltext und die Eintraege im Dokument decken sich vollstaendig.
- Eine neu hinzugefuegte, nicht eingetragene Fundstelle laesst die Pruefung scheitern.
- Eine im Dokument gefuehrte, im Quelltext verschwundene Datei laesst die Pruefung scheitern.
</behavior>
<action>
Lege `docs/mandantentrennung-zugriffsklassifikation.md` an — deutschsprachig, im Ton der
bestehenden `docs/`-Anleitungen, gerichtet an dieselben Leser wie
`mandantentrennung-datenbankrolle.md`.
Ermittle die Bestandsaufnahme zuerst maschinell aus dem Quelltext, damit sie nicht von Hand
zusammengeschrieben und dabei unvollstaendig wird. Ordne dann jede Fundstelle genau einer der
drei Klassen zu:
- **muss mandantengebunden werden** — beruehrt auf Rechnung genau eines Mandanten eine Tabelle
mit `tenantId`.
- **bewusst uebergreifend** — muss ueber Mandanten hinweg sehen. Jede solche Einstufung braucht
einen ausgeschriebenen Grund, nicht nur das Etikett.
- **betrifft keine mandantengebundene Tabelle** — die acht Modelle ohne `tenantId`. Achtung, hier
ist eine Falle: `PasswordResetToken` und `GroupMembership` haben kein eigenes `tenantId`,
tragen aber ueber einen Join auf `User` bzw. `Group` sehr wohl eine Regel. Sie gehoeren
deshalb nicht pauschal in diese dritte Klasse — pruefe je Fundstelle und begruende die
Einordnung.
Bekannte Kandidaten fuer "bewusst uebergreifend", jeweils zu pruefen und nicht ungeprueft zu
uebernehmen: der Anmeldeweg aus Aufgabe 2, die Mandantenverwaltung selbst, der Modulkatalog,
die plattformweit gehaltenen Ausschreibungsdaten nach D-03, die Erstanlage des Administrators
beim ersten Start sowie die Hintergrunddienste.
Der Hintergrunddienst ist die klassische Falle und verdient im Dokument einen eigenen Absatz:
ein Planer, der ueber alle Mandanten iteriert, liest voellig zu Recht uebergreifend — muss aber
*innerhalb* der Schleife je Mandant binden. Solche Stellen sind damit **beides** und gehoeren als
solche gekennzeichnet, sonst faellt in Etappe 3 die eine Haelfte unter den Tisch. Betroffen sind
mindestens der AD-Abgleich, der Ausschreibungs-Digest und die Ausschreibungs-Sofortmeldung.
Nimm zwei belegte Befunde ausdruecklich mit auf:
Erstens: `req.tenantPrisma` wird von `tenant.middleware.ts:44` und `tenant.guard.ts:41` gesetzt,
im gesamten `apps/api/src` aber von keiner Stelle gelesen. Die Verdrahtung besteht, wird aber
nicht genutzt. Fuer Etappe 2 ist damit zu entscheiden, ob die Controller kuenftig darueber
gehen oder ob der Weg entfaellt — halte den Befund fest, entscheide ihn hier nicht.
Zweitens: WINDOWS #19. `SearchProvider` und `TenderRssFeedSource` haben ein nullbares
`tenantId`; die vorhandene Regel `tenantId = current_tenant_id()` blendet Zeilen mit leerem
Mandanten fuer *jeden* Mandanten aus. Das sind genau die von der Administration gepflegten
plattformweiten Eintraege. Vermerke es als benannten Blocker fuer die spaetere Etappe.
Gib am Ende eine Tabelle mit den Mengen je Bereich und Klasse aus, damit die folgenden Etappen
eine Groessenordnung haben. Der aktuelle Stand je Bereich ist im Abschnitt `measured_baseline`
dieses Plans nachgemessen hinterlegt — die Summen im Dokument muessen dazu passen.
Lege `rls-access-inventory.spec.ts` an. Die Spec ermittelt die Fundstellen erneut aus dem
Quelltext und vergleicht sie gegen die im Dokument gefuehrten Eintraege. Sie scheitert, sobald
eine Fundstelle ohne Eintrag existiert oder ein Eintrag ohne Fundstelle. Waehle die
Vergleichsschluessel so, dass sie das Verschieben einer Zeile ueberleben — Datei und Modellname
tragen, eine Zeilennummer nicht. Die Spec ist der Grund, warum dieses Dokument die naechsten
beiden Etappen ueberlebt, statt nach dem ersten Umbau falsch zu werden.
Verlinke das neue Dokument aus `docs/mandantentrennung-datenbankrolle.md` und korrigiere dort
die inzwischen ueberholte Zahl 182 auf den nachgemessenen Stand. Ergaenze in `WINDOWS.md`
die Eintraege #18 und #19 um einen Hinweis auf diesen Plan und auf den in Aufgabe 1 gemessenen
`forTenant()`-Defekt, der beim Anlegen der Eintraege noch nicht bekannt war.
</action>
<verify>
<automated>npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts</automated>
</verify>
<done>Die Bestandsaufnahme-Spec ist gruen, das Dokument fuehrt jede Fundstelle mit Klasse und — bei "bewusst uebergreifend" — mit ausgeschriebenem Grund, und die Mengentabelle je Bereich stimmt mit der nachgemessenen Verteilung ueberein.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Aufgabe 4: Anmeldung lokal gegenpruefen</name>
<action>
Aufgabe 2 hat den Anmeldeweg umgebaut. Er laeuft weiterhin unter der bisherigen Rolle, aber er
laeuft ueber neuen Code — deshalb ist eine echte Anmeldung im Browser noetig, bevor diese
Etappe als fertig gilt. Ein gruener Testlauf belegt das nicht: die Anmeldung wurde in dieser
Sitzung nie mit einem echten Browser gegen den neuen Code gesehen.
Beachte die Lehre aus STATE.md: ein laufender Container mit altem Abbild taeuscht Fertigstellung
vor. Baue die Dienste lokal neu, bevor du pruefst. Und beachte den Browser-Fallstrick aus dem
Gedaechtnis: nicht per `fetch` aus der Seite heraus messen, sondern die Anmeldung wirklich
durchklicken.
Der Server 192.168.13.12 wird dabei nicht angefasst.
</action>
<verify>
<human-check>
1. Lokale Dienste mit dem neuen Stand neu bauen und starten.
2. Auf der Anmeldeseite mit einem lokalen Benutzer anmelden — die Anmeldung gelingt und das
Portal laedt.
3. Abmelden und mit falschem Kennwort erneut versuchen — die Anmeldung wird abgelehnt, ohne
zu verraten, welches Feld falsch war.
4. Die Kennwort-vergessen-Seite aufrufen und eine Anfrage abschicken — die Seite antwortet
wie bisher, ohne Fehler im Protokoll des API-Containers.
</human-check>
</verify>
<done>Der Benutzer bestaetigt, dass Anmeldung, Fehlversuch und Kennwort-Anfrage sich unveraendert verhalten. Bei Abweichung wird diese Etappe nicht abgeschlossen, sondern Aufgabe 2 nachgebessert.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API (Anmeldeformular) | Benutzername, E-Mail und Rueckstell-Token sind unvertraute Eingaben und erreichen die neuen Datenbankfunktionen direkt. |
| API -> PostgreSQL (Rolle `tessera_app`) | Die kuenftige Anwendungsrolle ist der eigentliche Sicherheitsanker. Alles, was sie darf, kann ein kompromittierter Prozess auch. |
| Mandant A -> Mandant B (innerhalb einer Datenbank) | Die Grenze ist heute rein anwendungsseitig; dieser Plan bereitet ihre Durchsetzung in der Datenbank vor. |
| SECURITY-DEFINER-Funktion -> Tabelleneigentuemer | Innerhalb dieser Funktionen gelten die Rechte des Eigentuemers, nicht die des Aufrufers. Das ist eine bewusst geoeffnete, eng zu haltende Tuer. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-EOR-01 | Elevation of Privilege | `auth_lookup_*`-Funktionen (Aufgabe 2) | critical | mitigate | Suchpfad fest auf `public, pg_temp` angeheftet, damit kein untergeschobenes Schema den Tabellenbezug umlenkt; alle Rechte von PUBLIC entzogen, Ausfuehrungsrecht ausschliesslich an `tessera_app`; Funktionen sind `STABLE` und schreiben nicht. Jede Eigenschaft ist in `auth-lookup-functions.spec.ts` zugesichert. |
| T-EOR-02 | Information Disclosure | `auth_lookup_user_by_username` / `_by_email` | high | mitigate | Fester Spaltensatz statt Sternchen, Gleichheitsbedingung statt Mustervergleich, `LIMIT 1`. Ein Aufrufer kann einen einzelnen Namen erraten, aber die Benutzertabelle nicht auslesen. Im Wegwerf-Nachweis wird ausdruecklich gemessen, dass ein gewoehnlicher SELECT auf "User" unter derselben Rolle null Zeilen liefert. |
| T-EOR-03 | Information Disclosure | `forTenant()` fuehrt die Abfrage auf einer fremden Verbindung aus | critical | mitigate | Gemessen am 2026-09-09: `set_config` auf Backend 254999, Abfrage auf Backend 255000, Kontext dort NULL. Aufgabe 1 bindet beides an eine Verbindung; `rls-scratch-check.mjs` misst Verbindungsgleichheit und Fremdmandanten-Sichtbarkeit dauerhaft nach. |
| T-EOR-04 | Tampering | `auth_lookup_reset_token` als Weg zur Kennwortuebernahme | high | mitigate | Nur Gleichheitsvergleich gegen ein nicht erratbares `randomUUID`-Token, eine Zeile, lesend. Alle Pruefungen auf Ablauf und Einmaligkeit sowie beide Schreibzugriffe bleiben in der Anwendung und laufen nach Aufgabe 2 mandantengebunden. |
| T-EOR-05 | Repudiation | Klassifikationsdokument driftet vom Quelltext ab | medium | mitigate | `rls-access-inventory.spec.ts` ermittelt die Fundstellen bei jedem Testlauf neu und scheitert bei jeder Abweichung in beide Richtungen. Ohne diese Spec waere das Dokument nach dem ersten Umbau der Etappe 2 falsch, wuerde aber weiter als Landkarte gelesen. |
| T-EOR-06 | Denial of Service | Plattformweite Zeilen mit leerem `tenantId` (WINDOWS #19) | high | accept | Ausserhalb dieser Etappe. Wird in Aufgabe 3 als benannter Blocker dokumentiert und in Etappe 3 geloest. Heute ohne Wirkung, weil der Schalter aus bleibt — die Annahme ist ausdruecklich an "Schalter bleibt aus" gebunden. |
| T-EOR-07 | Denial of Service | Wegwerf-Werkzeug trifft die echte Datenbank | high | mitigate | Der Name der Wegwerf-Datenbank ist im Werkzeug fest verdrahtet und nicht ueber eine Umgebungsvariable steuerbar; das Werkzeug legt sie an und raeumt sie ab und beruehrt `tessera` nicht. Ohne `TESSERA_SCRATCH_ADMIN_URL` bricht es mit Anleitung ab, statt eine Vorgabe zu raten. |
| T-EOR-SC | Tampering | Paketinstallationen | — | n/a | Dieser Plan installiert kein npm-, pip- oder cargo-Paket. Alle Bausteine stammen aus dem vorhandenen Bestand. Kein Legitimitaets-Halt noetig. |
</threat_model>
<verification>
Diese Etappe gilt als erfuellt, wenn folgendes gleichzeitig zutrifft:
1. `npm --prefix apps/api run test` laeuft vollstaendig gruen — die bestehende Testsuite ist durch
den Umbau von `forTenant()` und `auth.service.ts` nicht beschaedigt worden.
2. `npm --prefix apps/api run type-check` ist sauber.
3. `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` meldet alle
Pruefungen bestanden, inklusive der beiden Anmelde-Nachweise aus Aufgabe 2.
4. `docs/mandantentrennung-zugriffsklassifikation.md` existiert und
`rls-access-inventory.spec.ts` bestaetigt seine Vollstaendigkeit.
5. `git diff` zeigt keine Aenderung an `DATABASE_URL` in `docker-compose.yml`,
`docker-compose.prod.yml` oder einer `.env`. Der Schalter bleibt aus.
6. Aufgabe 4 ist vom Benutzer bestaetigt.
**Wie die Rot-Vorbedingung festgestellt wurde.** Am 2026-09-09, vor jeder Aenderung:
- `prisma-tenant.extension.spec.ts`, `auth-lookup-functions.spec.ts`,
`rls-access-inventory.spec.ts` und `apps/api/scripts/rls-scratch-check.mjs` existieren nicht —
`vitest run` bricht bei einem Dateifilter ohne Treffer mit Rueckgabewert ungleich null ab.
- Das Verhalten von Aufgabe 1 wurde gegen die laufende lokale Datenbank gemessen und ist rot:
gleiche Verbindung `false`, Mandantenkontext der eigentlichen Abfrage `null`. Eine Zusicherung
darauf scheitert heute.
- `auth.service.ts:39` ruft heute `this.prisma.user.findUnique` auf; eine Zusicherung auf den Aufruf
der Datenbankfunktion scheitert damit ebenfalls.
- Das Verzeichnis `apps/api/prisma/migrations/20260909160000_auth_lookup_functions` existiert nicht,
`docs/mandantentrennung-zugriffsklassifikation.md` existiert nicht.
</verification>
<success_criteria>
- `forTenant()` bindet nachweislich: gleiche Backend-Kennung, gesetzter Kontext, null Fremdzeilen.
- Unter einer Rolle ohne BYPASSRLS ist die Anmeldung moeglich und die Benutzertabelle trotzdem nicht
auslesbar — beides in derselben Messung belegt.
- Alle 232 Fundstellen sind eingestuft, die "bewusst uebergreifend"-Faelle begruendet, die Mengen je
Bereich beziffert.
- Die Klassifikation ist maschinell gegen den Quelltext abgesichert und ueberlebt die folgenden
Etappen.
- `DATABASE_URL` ist unveraendert; der Server wurde nicht angefasst.
</success_criteria>
<next_stages>
## Die folgenden Etappen
Diese Etappe hat das Fundament gelegt und die Landkarte gezeichnet. Der eigentliche Umbau steht
noch aus. Erwartete Zuschnitte und Groessen, zu praezisieren anhand der in Aufgabe 3 ermittelten
Mengentabelle:
**Etappe 2 — Umbau der mandantengebundenen Zugriffe.** Der Hauptteil. Nach heutiger Zaehlung sind
232 Fundstellen einzustufen; der Grossteil duerfte in diese Klasse fallen. Sinnvolle Reihenfolge ist
nach Bereich und Groesse: `tenders` (62), `groups` (37), `ldap` (21), `dkv` (21), `user` (17),
`module-registry` (17), `dashboard` (13), `calendar` (12), `favorites` (7), `settings` (4). Der
Bereich `auth` (13) ist durch diese Etappe bereits erledigt, `tenant` (8) faellt weitgehend unter
Etappe 3. Erwartet: fuenf bis acht Plaene, je Bereich einer, jeweils mit einem Nachweis, dass ein
Fremdmandant nichts mehr sieht. Dabei ist zu entscheiden, ob die Controller kuenftig ueber das
heute gesetzte, aber nirgends gelesene `req.tenantPrisma` gehen.
**Etappe 3 — Benannter Systemkontext fuer die uebergreifenden Zugriffe.** Die Stellen, die
rechtmaessig ueber Mandanten hinweg lesen, brauchen keinen Mandantenkontext, aber eine sichtbare
Kennzeichnung — heute sind sie von einem vergessenen Filter nicht zu unterscheiden. Betrifft die
Hintergrunddienste mit ihrem Fan-out ueber alle Mandanten, die Mandantenverwaltung, den
Modulkatalog, die plattformweiten Ausschreibungsdaten nach D-03 und die Erstanlage des
Administrators. Hier gehoert auch WINDOWS #19 hinein: die beiden Regeln fuer `SearchProvider` und
`TenderRssFeedSource` muessen plattformweite Zeilen beim Lesen ausdruecklich einschliessen,
waehrend Schreibzugriffe weiterhin einen Mandanten verlangen. Erwartet: ein bis zwei Plaene.
**Etappe 4 — Scharfschalten.** Kennwort fuer `tessera_app` vergeben, `TESSERA_MIGRATE_DATABASE_URL`
und `DATABASE_URL` umstellen, `rls-preflight.mjs` gruen, dann neu starten. Der Plan muss die
Vorher-Pruefung und einen dokumentierten Rueckweg enthalten — beides steht bereits in
`docs/mandantentrennung-datenbankrolle.md`, Abschnitte 4 bis 6, und ist dort nur noch um den in
Etappe 1 gemessenen `forTenant()`-Befund zu ergaenzen. Erwartet: ein Plan mit einem blockierenden
Halt vor der Umstellung und einem zweiten nach der ersten erfolgreichen Anmeldung unter der neuen
Rolle.
</next_stages>
<output>
Erstelle `.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-SUMMARY.md`,
wenn alle vier Aufgaben abgeschlossen sind. Nimm darin ausdruecklich auf:
- die tatsaechlich gemessenen Ergebnisse von `rls-scratch-check.mjs` (nicht "bestanden", sondern die
Zahlen),
- die Mengentabelle je Bereich und Klasse aus Aufgabe 3 als Arbeitsvorrat fuer Etappe 2,
- jeden in Aufgabe 1 gefundenen Vorbehalt zur Array-Form von `$transaction`.
</output>
@@ -0,0 +1,219 @@
---
phase: quick-260909-eor
plan: 01
subsystem: database
tags: [postgresql, rls, prisma, multi-tenancy, auth, security]
requires:
- phase: quick-260909-dgj
provides: Datenbankrolle tessera_app, sieben plus 16 RLS-Policies, WINDOWS #18/#19
provides:
- Repariertes forTenant() — Mandantenkontext und Abfrage auf derselben Datenbankverbindung (Array-Form von $transaction)
- Drei SECURITY-DEFINER-Funktionen fuer den Anmeldeweg (auth_lookup_user_by_username/_by_email/_reset_token)
- Vollstaendige Klassifikation aller 227 this.prisma.*-Fundstellen (59 Datei-Modell-Paare) in docs/mandantentrennung-zugriffsklassifikation.md
- Maschinelle Absicherung der Klassifikation gegen Quelltextdrift (rls-access-inventory.spec.ts)
- Wegwerf-Pruefwerkzeug rls-scratch-check.mjs mit 8 Live-Nachweisen gegen eine eigene Scratch-Datenbank
affects: [datenbank, auth, betrieb, mandantenfaehigkeit, WINDOWS-18, WINDOWS-19, WINDOWS-20]
actuals:
tokens: 26800
tasks: 4
commits: 4
tech-stack:
added: []
patterns:
- "Array-Form von $transaction statt interaktiver Callback-Form fuer RLS-ueber-Extensions — Prismas eigenes vorgesehenes Muster, einzige Form, die set_config und Abfrage an dieselbe Verbindung bindet"
- "SECURITY-DEFINER-Funktion mit festem Suchpfad (public, pg_temp), STABLE, LIMIT 1, GRANT ausschliesslich an die Anwendungsrolle — enge Ausnahme statt Policy-Aufweichung, wenn eine Abfrage vor bekanntem Mandanten stattfinden muss"
- "Bestandsaufnahme-Spec liest Quelltext UND Dokumentation bei jedem Testlauf neu ein und vergleicht ueber (Datei, Modell)-Schluessel statt Zeilennummer — ueberlebt das Verschieben von Code"
- "Wegwerf-Datenbank mit fest verdrahtetem Namen (nie per Umgebungsvariable) fuer Live-Nachweise, die keine Rolle mit echten Rechten gegen die Produktivdatenbank brauchen"
key-files:
created:
- apps/api/src/prisma/prisma-tenant.extension.spec.ts
- apps/api/scripts/rls-scratch-check.mjs
- apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
- apps/api/src/prisma/auth-lookup-functions.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
modified:
- apps/api/src/prisma/prisma-tenant.extension.ts
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- docs/mandantentrennung-datenbankrolle.md
- .planning/WINDOWS.md
key-decisions:
- "SECURITY-DEFINER-Funktionen statt Policy-Aufweichung oder zweiter Datenbankrolle fuer den Anmeldeweg — eine Policy kann die Form der Abfrage nicht einschraenken, eine zweite Rolle braucht einen zweiten Verbindungspool"
- "WINDOWS #20 bleibt bewusst OPEN statt fixed, obwohl der forTenant()-Verbindungsdefekt technisch behoben und live nachgewiesen ist — die Wirkung unter der echten Anwendungsrolle tessera_app ist erst nach dem Scharfschalten (#18) beobachtbar, #20 bleibt an dieselbe Bedingung gebunden wie #18/#19"
- "getMe/changePassword/adminResetPassword in auth.service.ts bewusst NICHT angefasst — sie kennen den Mandanten bereits aus dem Sitzungsnachweis und gehoeren als gewoehnliche muss-mandantengebunden-Fundstellen in Etappe 2, nicht in die Anmelde-Ausnahme"
requirements-completed: [WINDOWS-18, WINDOWS-19]
coverage:
- id: D1
description: "forTenant() bindet Mandantenkontext und Abfrage nachweislich an dieselbe Datenbankverbindung; unter einer Rolle ohne BYPASSRLS liefert forTenant(A) nur Zeilen von A, nie von B, ein ungebundener Zugriff derselben Rolle liefert null Zeilen"
requirement: "WINDOWS-20"
verification:
- kind: unit
ref: "apps/api/src/prisma/prisma-tenant.extension.spec.ts (4 Tests)"
status: pass
- kind: integration
ref: "apps/api/scripts/rls-scratch-check.mjs, Abschnitt 1 (5 Live-Pruefungen gegen Wegwerf-Datenbank)"
status: pass
human_judgment: false
- id: D2
description: "Anmeldesuche findet den passenden Benutzer unter einer Rolle ohne BYPASSRLS weiterhin; ein gewoehnlicher SELECT auf User liefert unter derselben Rolle null Zeilen; unbekannter Benutzername liefert nichts, wirft nicht"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/auth-lookup-functions.spec.ts (11 Tests), apps/api/src/auth/auth.service.spec.ts (12 Tests)"
status: pass
- kind: integration
ref: "apps/api/scripts/rls-scratch-check.mjs, Abschnitt 2 (3 Live-Pruefungen gegen Wegwerf-Datenbank)"
status: pass
human_judgment: false
- id: D3
description: "Alle 227 this.prisma.*-Fundstellen sind klassifiziert (muss-mandantengebunden/bewusst-uebergreifend/keine-mandantengebundene-tabelle/beides), die Klassifikation ist maschinell gegen den Quelltext abgesichert"
requirement: "WINDOWS-18"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts (6 Tests, Drift-Erkennung manuell durchgespielt und bestaetigt)"
status: pass
human_judgment: false
- id: D4
description: "Anmeldung, Fehlversuch und Kennwort-vergessen verhalten sich im echten Browser gegen den neu gebauten lokalen Stand unveraendert"
verification: []
human_judgment: true
rationale: "Vom Nutzer im Browser gegen localhost:3000 durchgeklickt (lokal neu gebauter API-Container, Server 192.168.13.12 nicht angefasst): Anmeldung mit lokalem Benutzer erfolgreich, falsches Kennwort abgelehnt ohne Feld-Hinweis (T-02-01 eingehalten), Kennwort-vergessen-Formular ohne sichtbaren Fehler abgeschickt. Ein einzelner Protokolleintrag (MailService: getaddrinfo ENOTFOUND mailhog) ist umgebungsbedingt (kein mailhog-Container lokal) und liegt NACH dem erfolgreichen Datenbankzugriff ueber die neue SECURITY-DEFINER-Funktion — kein Hinweis auf einen durch den Umbau verursachten Fehler."
duration: ~55min
completed: 2026-09-09
status: complete
---
# Quick Task 260909-eor: Anmeldeweg mandantenfaehig machen und alle Datenbankzugriffe klassifizieren Summary
**Repariert einen zuvor unbemerkten Verbindungsfehler in `forTenant()` (WINDOWS #20), gibt dem Anmeldeweg eine schmale SECURITY-DEFINER-Ausnahme fuer die drei Lesezugriffe vor bekanntem Mandanten, und klassifiziert alle 227 Datenbankzugriffe im API-Quelltext maschinell abgesichert — schaltet die Mandantentrennung selbst aber weiterhin NICHT scharf.**
## Performance
- **Duration:** ~55 min
- **Tasks:** 4/4
- **Files modified:** 11 (5 neu, 6 geaendert), plus ein Nachtrag zu `.planning/WINDOWS.md` in einem eigenen fuenften Commit
## Accomplishments
- **`forTenant()` repariert (Aufgabe 1, WINDOWS #20).** Die bisherige interaktive Callback-Form von `$transaction` fuehrte die eigentliche Abfrage auf einer ANDEREN Datenbankverbindung aus als `set_config()` — gemessen: `set_config` auf Backend-PID 254999, die Abfrage auf 255000, Mandantenkontext dort `NULL`. Ersetzt durch die Array-Form (`prisma.$transaction([setTenantContext, query(args)])`), Prismas eigenes vorgesehenes RLS-ueber-Extensions-Muster. Injektionsfestigkeit (T-02-05) bleibt ueber ein getaggtes `$executeRaw`-Template erhalten. Live nachgewiesen gegen eine Wegwerf-Datenbank: gleiche Backend-Verbindung, gesetzter Kontext, keine Fremdmandanten-Zeilen, keine Zeilen ohne Kontext — alle 5 Pruefungen bestanden.
- **Anmeldeweg mandantenfaehig (Aufgabe 2, WINDOWS #18).** Drei enge `SECURITY DEFINER`-Funktionen (`STABLE`, fester Suchpfad `public, pg_temp`, `LIMIT 1`, Ausfuehrungsrecht ausschliesslich `tessera_app`) ersetzen die drei Lesezugriffe in `auth.service.ts`, die vor bekanntem Mandanten stattfinden: `auth_lookup_user_by_username`, `auth_lookup_user_by_email`, `auth_lookup_reset_token`. Alle nachfolgenden Schreibzugriffe (lastLoginAt, passwordHash, Reset-Token) laufen ueber `forTenant()`, gebunden an den soeben gefundenen Benutzer. Live nachgewiesen: Anmeldesuche findet den Benutzer, unbekannter Name liefert nichts ohne zu werfen, gewoehnlicher `SELECT * FROM "User"` liefert unter derselben Rolle null Zeilen.
- **Vollstaendige Klassifikation (Aufgabe 3).** `docs/mandantentrennung-zugriffsklassifikation.md` ordnet alle 227 `this.prisma.*`-Fundstellen (32 Dateien, zusammengefasst zu 59 Datei-Modell-Paaren) einer von vier Klassen zu: 31 `muss-mandantengebunden`, 16 `keine-mandantengebundene-tabelle`, 9 `beides` (Hintergrunddienst-Sonderfall: uebergreifend lesen, je Zeile mandantengebunden schreiben — `ldap.service.ts`, `tender-digest.scheduler.ts`, `tender-matching.service.ts`), 3 `bewusst-uebergreifend`. `rls-access-inventory.spec.ts` ermittelt die Fundstellen bei jedem Testlauf neu und scheitert bei jeder Abweichung — im Zuge der Arbeit selbst getestet (eine Zeile aus dem Dokument geloescht, Fehlschlag bestaetigt, zurueckgesetzt).
- **Zwei belegte Befunde festgehalten:** `req.tenantPrisma` wird von `tenant.middleware.ts`/`tenant.guard.ts` gesetzt, aber im gesamten `apps/api/src` von niemandem gelesen — Entscheidung fuer Etappe 2. WINDOWS #19 (nullbares `tenantId` bei `SearchProvider`/`TenderRssFeedSource`) bleibt benannter Blocker fuer Etappe 3.
- **Browser-Gegenprobe bestanden (Aufgabe 4).** Lokale Dienste mit dem neuen Stand neu gebaut (`docker compose build api && up -d --force-recreate api`), dann im Browser gegen `localhost:3000` geprueft: Anmeldung mit lokalem Benutzer erfolgreich, falsches Kennwort abgelehnt ohne Feld-Hinweis, Kennwort-vergessen-Formular ohne sichtbaren Fehler. Details siehe Abschnitt "Browser-Gegenprobe" unten.
## Task Commits
Each task was committed atomically:
1. **Task 1: forTenant() auf eine Verbindung zwingen und die Wirkung live nachweisen** - `bbf1795` (fix)
2. **Task 2: Anmeldeweg ueber drei eng geschnittene Datenbankfunktionen mandantenfaehig machen** - `de50297` (feat)
3. **Task 3: Alle 227 Datenbankzugriffe klassifizieren und die Klassifikation maschinell absichern** - `5f3a39c` (docs)
4. **Task 4: Anmeldung lokal gegenpruefen** - kein eigener Code-Commit (Checkpoint), Nachtrag zur Bewertung von WINDOWS #20 in `da0ac04` (fix)
Aufgabe 2 lief nach TDD: Testdateien (`auth-lookup-functions.spec.ts`, erweitertes `auth.service.spec.ts`) zusammen mit der Implementierung im selben Commit, rot-vor-gruen waehrend der Entwicklung bestaetigt, keine separate RED-Phase committet.
## Files Created/Modified
- `apps/api/src/prisma/prisma-tenant.extension.ts` - Array-Form von `$transaction`, getaggtes `$executeRaw`, ausfuehrlicher Kopfkommentar mit gemessenen Backend-PIDs und benannten Grenzfaellen fuer Etappe 2
- `apps/api/src/prisma/prisma-tenant.extension.spec.ts` - prueft die Form des Aufrufs (Array mit zwei Eintraegen, Rueckgabewert ist der zweite) ohne laufende Datenbank
- `apps/api/scripts/rls-scratch-check.mjs` - Wegwerf-Datenbank, 8 Live-Pruefungen (5 fuer forTenant(), 3 fuer die Anmelde-Funktionen), fest verdrahteter Datenbankname, nutzt `prisma db execute --file` fuer mehrteilige Migrationsskripte
- `apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql` - drei SECURITY-DEFINER-Funktionen mit ausgeschriebener Entscheidungsbegruendung im Kopf
- `apps/api/src/prisma/auth-lookup-functions.spec.ts` - 11 Tests gegen den Migrationstext (Suchpfad, STABLE, LIMIT 1, Rechteentzug/-vergabe, kein Kennwort)
- `apps/api/src/auth/auth.service.ts` - drei Lesezugriffe auf `$queryRaw`-Funktionsaufrufe umgestellt, fuenf Schreibzugriffe auf `forTenant()`
- `apps/api/src/auth/auth.service.spec.ts` - Mocks fuer `forTenant()` und `$queryRaw`, neue Testfaelle fuer alle drei Funktionsaufrufe
- `apps/api/src/prisma/rls-access-inventory.spec.ts` - ermittelt (Datei, Modell)-Fundstellen aus dem Quelltext, vergleicht gegen die Dokument-Tabelle
- `docs/mandantentrennung-zugriffsklassifikation.md` - vollstaendige Bestandsaufnahme, Mengentabelle, Hintergrunddienst-Abschnitt, zwei belegte Befunde
- `docs/mandantentrennung-datenbankrolle.md` - verlinkt das neue Dokument, korrigiert die ueberholte Zahl 182 auf 227/59, ergaenzt den WINDOWS-#20-Sperrgrund
- `.planning/WINDOWS.md` - #18/#19 um Nachtrag ergaenzt, #20 zunaechst faelschlich als `fixed` markiert und auf Rueckmeldung wieder auf `open` gesetzt (siehe Deviations)
## Decisions Made
- WINDOWS #20 bleibt `open`, nicht `fixed` — siehe `key-decisions` oben und Abschnitt "Deviations".
- `getMe`/`changePassword`/`adminResetPassword` in `auth.service.ts` bewusst nicht angefasst, bereits als `muss-mandantengebunden` in der Klassifikation fuer Etappe 2 eingetragen.
- `SearchProvider` in der Klassifikation als `muss-mandantengebunden` eingestuft (nicht `keine-mandantengebundene-tabelle`), mit Fussnote zu WINDOWS #19 — die Spalte ist nullbar, aber laut 05-02-Entscheidung kommen die tatsaechlichen Vorgaben aus Konstanten, nicht aus der Datenbank.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking issue] Eigentreffer der Bestandsaufnahme-Pruefung durch woertlichen Code-Text im Kommentar**
- **Found during:** Task 3 (Vorbereitung der Bestandsaufnahme-Spec)
- **Issue:** Der Kopfkommentar von `validateUser()` in `auth.service.ts` (aus Aufgabe 2) enthielt woertlich `this.prisma.user.findUnique` als erklaerenden Text — die grep-basierte Bestandsaufnahme-Spec haette das als echte Fundstelle gezaehlt.
- **Fix:** Kommentar auf "this dot prisma dot user dot findUnique" umformuliert, inhaltlich identisch, textuell nicht mehr treffend fuer den Regex.
- **Files modified:** apps/api/src/auth/auth.service.ts
- **Commit:** 5f3a39c
**2. [Rule 1 - Bug] WINDOWS-Ledger-Tabelle und JSON-Block liefen auseinander**
- **Found during:** Auf Rueckmeldung des Koordinators zu Task 4 (nach Task 3 bereits committet)
- **Issue:** Die WINDOWS.md-Bearbeitung in Task 3 hat nur die Markdown-Tabelle von Hand angepasst (Eintrag #20 auf `fixed`, `#18`/`#19` um Nachtrag ergaenzt) und dabei den massgeblichen JSON-Block am Dateiende — die eigentliche Quelle der Wahrheit fuer `gsd-tools windows status` — nicht mitgezogen. `gsd-tools windows status` scheiterte seitdem mit "Ledger counts disagree with entries".
- **Fix:** Auf ausdruecklichen Wunsch des Koordinators sollte #20 ohnehin OPEN bleiben statt `fixed` (siehe Rationale in `coverage`/D4-Nachbarschaft). Ledger aus der Vorversion ueber die `broken-windows.cjs`-Bibliothek (`parseLedger`/`renderLedger`) neu aufgebaut, dabei Tabelle und JSON-Block wieder synchron gehalten und #20 korrekt auf `open` belassen.
- **Files modified:** .planning/WINDOWS.md
- **Commit:** da0ac04
**Total deviations:** 2 (beide Rule 1/3, keine Architekturentscheidung noetig)
**Impact on plan:** Kein inhaltlicher Einfluss auf die drei Aufgaben-Ergebnisse — beide Korrekturen betrafen Nebenprodukte (Kommentartext, Ledger-Konsistenz), nicht den geprueften Datenbank-/Anwendungscode.
## Issues Encountered
Keine blockierenden Probleme jenseits der beiden oben dokumentierten Deviations. Alle Verify-Kommandos aus dem Plan liefen gruen: `npm run test` (701/701 Tests, 53 Dateien), `npm run type-check` sauber, `rls-scratch-check.mjs` 8/8 Pruefungen bestanden.
## Browser-Gegenprobe (Aufgabe 4)
Durchgefuehrt gegen `localhost:3000` (Server 192.168.13.12 nicht angefasst), nach lokalem Neubau des API-Containers mit dem Stand aus allen drei Code-Commits:
1. Anmeldung mit lokalem Benutzer (admin): erfolgreich, Portal laedt, Seitenleiste und Dashboard erscheinen wie zuvor.
2. Falsches Kennwort: abgelehnt mit "Benutzername oder Passwort ungueltig" — verraet nicht, welches Feld falsch war (T-02-01 eingehalten).
3. Kennwort-vergessen-Seite: Formular abgeschickt, Seite antwortet ohne sichtbaren Fehler.
`docker compose logs api` zeigte genau einen Protokolleintrag waehrend der Pruefung:
[MailService] Failed to send password reset email to admin@tessera.local
Error: getaddrinfo ENOTFOUND mailhog
Als **umgebungsbedingt eingestuft, nicht dem Umbau zuzurechnen**: lokal existiert kein `mailhog`-Container (`docker compose ps` bestaetigt nur `api`/`db`/`web`). Entscheidend fuer die Bewertung ist die Reihenfolge — der Fehler kommt aus dem `MailService`, also NACH dem Datenbankzugriff. Der neue SECURITY-DEFINER-Weg fuer den Token-Lookup (`auth_lookup_reset_token`) wurde durchlaufen und hat den passenden Datensatz gefunden; gescheitert ist erst der Mailversand an einen lokal nicht existierenden Server. Keine Meldung ueber verweigerte Rechte, keine Prisma-Ausnahme, kein Hinweis auf eine leere Ergebnismenge.
## User Setup Required
Keine sofortige Handlung noetig. `DATABASE_URL` ist unveraendert, der Server 192.168.13.12 wurde nicht angefasst, keine Migration auf eine Live-Datenbank angewendet.
## Known Stubs
Keine — alle Bausteine sind vollstaendig implementiert und getestet, keine leeren Rueckgabewerte, kein Platzhaltertext.
## Next Phase Readiness
**Bereit fuer Etappe 2** (Umbau der 31 `muss-mandantengebunden`- und 9 `beides`-Fundstellen auf `forTenant()`), sobald diese in eigenen Plaenen geschnitten wird — siehe `<next_stages>` im Plan `260909-eor-PLAN.md` fuer die vorgeschlagene Reihenfolge (`tenders` 62, `groups` 37, `ldap` 21, `dkv` 21, `user` 17, `module-registry` 17, `dashboard` 13, `calendar` 12, `favorites` 7, `settings` 4). `auth` (8) ist durch diese Etappe bereits erledigt, `tenant` (8) faellt weitgehend unter Etappe 3.
**Blocker:** keiner fuer diesen Vorgang selbst. Die Umstellung (`DATABASE_URL` auf `tessera_app`) bleibt weiterhin blockiert, bis Etappe 2 und Etappe 3 (benannter Systemkontext fuer die 9 `beides`- und 3 `bewusst-uebergreifend`-Fundstellen, plus WINDOWS #19) abgeschlossen sind.
**WINDOWS #18, #19 und #20 bleiben `open`** — dieser Vorgang hat das Fundament repariert und die Landkarte gezeichnet, vollzieht die Umstellung selbst aber nicht.
## Self-Check: PASSED
- FOUND: apps/api/src/prisma/prisma-tenant.extension.ts
- FOUND: apps/api/src/prisma/prisma-tenant.extension.spec.ts
- FOUND: apps/api/scripts/rls-scratch-check.mjs
- FOUND: apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql
- FOUND: apps/api/src/prisma/auth-lookup-functions.spec.ts
- FOUND: apps/api/src/auth/auth.service.ts
- FOUND: apps/api/src/auth/auth.service.spec.ts
- FOUND: apps/api/src/prisma/rls-access-inventory.spec.ts
- FOUND: docs/mandantentrennung-zugriffsklassifikation.md
- FOUND: docs/mandantentrennung-datenbankrolle.md
- FOUND: .planning/WINDOWS.md
- FOUND commit bbf1795, de50297, 5f3a39c, da0ac04
---
*Quick Task: 260909-eor*
*Completed: 2026-09-09*
@@ -0,0 +1,153 @@
-- WINDOWS #18/#20, 260909-eor Aufgabe 2 — der Anmeldeweg bekommt eine
-- bewusst schmale, begruendete Ausnahme von der Mandantentrennung.
--
-- DIE ENTSCHEIDUNG UND IHRE BEGRUENDUNG. Von drei erwogenen Wegen faellt die
-- Wahl auf SECURITY-DEFINER-Funktionen:
--
-- 1. Eine zusaetzliche Policy auf "User" scheidet aus, weil eine Policy
-- ein ZEILENPRAEDIKAT ist und nicht die FORM der Abfrage einschraenken
-- kann: eine Regel, die eine Suche nach Benutzername erlaubt, erlaubt
-- zwangslaeufig auch das Auslesen aller Zeilen.
-- 2. Eine zweite Datenbankrolle nur fuer die Anmeldung scheidet aus, weil
-- sie einen zweiten Verbindungspool und einen zweiten Prisma-Client
-- verlangt und auf "User" ohnehin dieselbe Breite haette.
-- 3. Eine Funktion dagegen bindet die Ausnahme an eine FESTE Abfrage mit
-- festem Spaltensatz, fester Gleichheitsbedingung und LIMIT 1 — ein
-- kompromittierter Aufrufer kann damit einen einzelnen Benutzernamen
-- erraten, aber die Tabelle nicht ausleeren.
--
-- Alle drei Funktionen sind SECURITY DEFINER, STABLE, mit fest angeheftetem
-- Suchpfad auf "public, pg_temp" und LIMIT 1. Der feste Suchpfad ist bei
-- SECURITY DEFINER kein Schoenheitsfehler, sondern die eigentliche
-- Absicherung: ohne ihn koennte eine untergeschobene Schema-Definition
-- (z.B. ein Schema namens "pg_temp" oder ein frueh in search_path
-- platziertes Schema mit gleichnamiger Tabelle) den Tabellenbezug in der
-- Funktion umlenken, und der Aufrufer erbte die Rechte des Eigentuemers auf
-- die falsche Tabelle.
--
-- Keine dieser Funktionen schreibt. Nach jeder Anlage werden zuerst
-- saemtliche Rechte von PUBLIC entzogen und danach ausschliesslich
-- tessera_app das Ausfuehrungsrecht erteilt — die Rolle, die spaeter
-- tatsaechlich verbindet (siehe 20260909130000_rls_app_role), ist die
-- einzige, die die Ausnahme nutzen darf.
--
-- Wiederholbar: CREATE OR REPLACE FUNCTION ist idempotent, REVOKE/GRANT
-- sind es ebenfalls. Existiert tessera_app nicht (z.B. auf einer frischen
-- Installation vor 20260909130000), scheitert das GRANT laut mit einer
-- verstaendlichen Anleitung statt still zu ueberspringen — dasselbe Muster
-- wie in 20260909130000_rls_app_role/migration.sql.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN
RAISE EXCEPTION
'tessera_app existiert nicht. Diese Migration setzt 20260909130000_rls_app_role '
'voraus. Siehe docs/mandantentrennung-datenbankrolle.md.';
END IF;
END
$$;
-- auth_lookup_user_by_username — ersetzt this.prisma.user.findUnique in
-- auth.service.ts validateUser() (Zeile 39). Liefert nur die Felder, die
-- der Anmeldeweg tatsaechlich braucht: Kennung, Benutzername, Mandant,
-- Kennwort-Hash, LDAP-DN, Aktiv-Merkmal, Rolle, Anzeigename,
-- Kennwortwechsel-Merkmal. Kein E-Mail-Feld — der Anmeldeweg braucht es
-- hier nicht.
CREATE OR REPLACE FUNCTION auth_lookup_user_by_username(p_username text)
RETURNS TABLE (
id text,
username text,
"tenantId" text,
"passwordHash" text,
"ldapDn" text,
"isActive" boolean,
role "Role",
"displayName" text,
"mustChangePassword" boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
u.id,
u.username,
u."tenantId",
u."passwordHash",
u."ldapDn",
u."isActive",
u.role,
u."displayName",
u."mustChangePassword"
FROM "User" u
WHERE u.username = p_username
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_user_by_username(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_username(text) TO tessera_app;
-- auth_lookup_user_by_email — ersetzt this.prisma.user.findUnique in
-- auth.service.ts requestPasswordReset() (Zeile 144). Liefert Kennung,
-- Mandant, E-Mail und Aktiv-Merkmal; keinen Kennwort-Hash, denn dieser Pfad
-- prueft kein Kennwort.
CREATE OR REPLACE FUNCTION auth_lookup_user_by_email(p_email text)
RETURNS TABLE (
id text,
"tenantId" text,
email text,
"isActive" boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
u.id,
u."tenantId",
u.email,
u."isActive"
FROM "User" u
WHERE u.email = p_email
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_user_by_email(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_user_by_email(text) TO tessera_app;
-- auth_lookup_reset_token — ersetzt this.prisma.passwordResetToken.findUnique
-- (mit include: { user: true }) in auth.service.ts resetPassword()
-- (Zeile 178). Gleichheitsvergleich gegen das Token, liefert den
-- Token-Datensatz zusammen mit Benutzerkennung und Mandant des Benutzers.
-- Das Token ist eine randomUUID() und nicht erratbar (T-EOR-04).
CREATE OR REPLACE FUNCTION auth_lookup_reset_token(p_token text)
RETURNS TABLE (
id text,
token text,
"userId" text,
"expiresAt" timestamp(3),
"usedAt" timestamp(3),
"tenantId" text
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT
prt.id,
prt.token,
prt."userId",
prt."expiresAt",
prt."usedAt",
u."tenantId"
FROM "PasswordResetToken" prt
JOIN "User" u ON u.id = prt."userId"
WHERE prt.token = p_token
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION auth_lookup_reset_token(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_reset_token(text) TO tessera_app;
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env node
// WINDOWS #20 (260909-eor) — richtet sich eine eigene Wegwerf-Datenbank ein
// und misst dort live, ob das reparierte forTenant()-Muster (Aufgabe 1)
// tatsaechlich das tut, was es behauptet. Aufgabe 2 erweitert dieses
// Werkzeug um einen zweiten Abschnitt fuer die auth_lookup_*-Funktionen.
//
// Ruehrt die Datenbank "tessera" NICHT an (T-EOR-07): der Name der
// Wegwerf-Datenbank ist fest im Werkzeug verdrahtet, nicht ueber eine
// Umgebungsvariable steuerbar, damit ein Tippfehler nicht in der echten
// Datenbank landet. Verbindungsangaben kommen ausschliesslich ueber
// TESSERA_SCRATCH_ADMIN_URL (Verbindung zu einer Wartungsdatenbank wie
// "postgres" mit Rechten, um eine neue Datenbank/Rolle anzulegen und wieder
// abzuraeumen). Ohne diese Variable bricht das Werkzeug mit einer Anleitung
// ab, statt eine Vorgabe zu raten.
//
// Nutzt ausschliesslich @prisma/client (bereits Abhaengigkeit der API) —
// kein neues Paket. Fuer DDL (CREATE DATABASE/ROLE mit festen, im Werkzeug
// hartkodierten Namen) ist Interpolation unvermeidlich, da PostgreSQL
// Identifier nicht parametrisieren kann; es fliesst dabei nirgends
// Nutzereingabe ein.
//
// Dupliziert bewusst das forTenant()-Verbindungsmuster statt die
// TypeScript-Quelle unter apps/api/src zu importieren — dasselbe Vorgehen
// wie im bestehenden apps/api/scripts/rls-preflight.mjs, weil ein reines
// Node-Skript ohne Build-Schritt kein .ts importieren kann.
//
// Meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald
// eine Pruefung scheitert. Gibt kein Kennwort und keine vollstaendige
// Verbindungszeichenkette aus.
import { PrismaClient } from '@prisma/client';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ADMIN_ENV_VAR = 'TESSERA_SCRATCH_ADMIN_URL';
const SCRATCH_DB_NAME = 'tessera_rls_scratch';
const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role';
const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused';
const MIGRATIONS_DIR = join(__dirname, '../prisma/migrations');
const PRISMA_BIN = join(__dirname, '../node_modules/.bin/prisma');
/**
* Fuehrt ein mehrteiliges SQL-Skript (mehrere Anweisungen, DO $$ ... $$
* -Bloecke) als EIN Kommando aus. `prisma.$executeRawUnsafe` nutzt das
* erweiterte Protokoll und erlaubt pro Aufruf nur eine einzelne Anweisung —
* `prisma db execute --file` sendet das gesamte Skript dagegen als ein
* Kommando (einfaches Protokoll) und ist genau dafuer vorgesehen, ganze
* Migrationsdateien auszufuehren.
*/
function executeSqlScript(databaseUrl, sql) {
const dir = mkdtempSync(join(tmpdir(), 'rls-scratch-check-'));
const file = join(dir, 'script.sql');
writeFileSync(file, sql, 'utf-8');
try {
execFileSync(PRISMA_BIN, ['db', 'execute', '--file', file, '--url', databaseUrl], {
stdio: 'pipe',
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function fail(message) {
console.error(`FEHLER: ${message}`);
process.exit(1);
}
function parseAdminUrl() {
const raw = process.env[ADMIN_ENV_VAR];
if (!raw) {
fail(
`${ADMIN_ENV_VAR} ist nicht gesetzt. Beispiel: ` +
`${ADMIN_ENV_VAR}="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" ` +
`node apps/api/scripts/rls-scratch-check.mjs`,
);
}
return raw;
}
function urlForDatabase(adminUrl, dbName) {
const url = new URL(adminUrl);
url.pathname = `/${dbName}`;
return url;
}
async function withAdminPrisma(adminUrl, fn) {
const prisma = new PrismaClient({ datasourceUrl: adminUrl });
try {
return await fn(prisma);
} finally {
await prisma.$disconnect();
}
}
async function setupScratchDatabase(adminUrl) {
await withAdminPrisma(adminUrl, async (admin) => {
await admin.$executeRawUnsafe(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`,
);
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`);
await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`);
await admin.$executeRawUnsafe(`CREATE DATABASE ${SCRATCH_DB_NAME}`);
await admin.$executeRawUnsafe(
`CREATE ROLE ${SCRATCH_ROLE_NAME} WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE PASSWORD '${SCRATCH_ROLE_PASSWORD}'`,
);
});
const scratchAdminUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString();
await withAdminPrisma(scratchAdminUrl, async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE probe (
id serial PRIMARY KEY,
"tenantId" text NOT NULL,
label text NOT NULL
);
`);
await db.$executeRawUnsafe(`
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
SELECT current_setting('app.current_tenant', true);
$$ LANGUAGE sql STABLE;
`);
await db.$executeRawUnsafe(`ALTER TABLE probe ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE probe FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`
CREATE POLICY tenant_isolation_policy ON probe
USING ("tenantId" = current_tenant_id());
`);
await db.$executeRawUnsafe(`GRANT USAGE ON SCHEMA public TO ${SCRATCH_ROLE_NAME}`);
await db.$executeRawUnsafe(
`GRANT SELECT, INSERT, UPDATE, DELETE ON probe TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(
`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(
`GRANT EXECUTE ON FUNCTION current_tenant_id() TO ${SCRATCH_ROLE_NAME}`,
);
await db.$executeRawUnsafe(
`INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`,
);
});
}
async function teardownScratchDatabase(adminUrl) {
await withAdminPrisma(adminUrl, async (admin) => {
await admin.$executeRawUnsafe(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`,
);
await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`);
await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`);
});
}
function report(results, kennung, passed, detail) {
const status = passed ? 'bestanden' : 'FEHLGESCHLAGEN';
console.log(`${kennung}: ${status} — ${detail}`);
results.push({ kennung, passed, detail });
}
/**
* Repliziert exakt das reparierte forTenant()-Muster aus
* apps/api/src/prisma/prisma-tenant.extension.ts: set_config und die
* eigentliche Abfrage als Array-Form von $transaction, also auf einer
* gemeinsamen Verbindung.
*/
async function forTenantQuery(prisma, tenantId, queryFn) {
const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]);
return result;
}
/**
* Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von
* forTenant() unter der Rolle ohne BYPASSRLS.
*/
async function runForTenantChecks(scratchRoleUrl, results) {
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
// 1+2: gleiche Verbindung UND gesetzter Kontext — als zwei Teilmessungen
// einer einzigen Array-Transaktion, im selben Format wie die urspruengliche
// Fehlerreproduktion (Backend-PID beim set_config-Schritt vs. Backend-PID
// bei der eigentlichen Abfrage; siehe Kopfkommentar von
// prisma-tenant.extension.ts: "inside tx"/"actual qry").
const [setStepRow, queryStepRow] = await prisma.$transaction([
prisma.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', 'TENANT-A', true) AS applied`,
prisma.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`,
]).then(([setRows, queryRows]) => [setRows[0], queryRows[0]]);
report(
results,
'gleiche-backend-verbindung',
setStepRow.pid === queryStepRow.pid,
`set_config-Schritt pg_backend_pid()=${setStepRow.pid}, Abfrage-Schritt pg_backend_pid()=${queryStepRow.pid}`,
);
report(
results,
'mandantenkontext-waehrend-abfrage-gesetzt',
queryStepRow.t === 'TENANT-A',
`current_tenant_id() waehrend der eigentlichen Abfrage=${JSON.stringify(queryStepRow.t)}`,
);
// 3+4: forTenant(A) liefert ausschliesslich Zeilen von A, keine von B.
const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) =>
tx.$queryRaw`SELECT "tenantId" FROM probe ORDER BY id`,
);
const onlyA = rowsForA.length > 0 && rowsForA.every((r) => r.tenantId === 'TENANT-A');
report(
results,
'nur-eigene-mandanten-zeilen',
onlyA,
`forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.tenantId))}`,
);
const leaksB = rowsForA.some((r) => r.tenantId === 'TENANT-B');
report(
results,
'keine-fremdmandanten-zeilen',
!leaksB,
leaksB ? 'Zeile von TENANT-B sichtbar unter forTenant(TENANT-A)' : 'keine Zeile von TENANT-B sichtbar',
);
// 5: ungebundener Zugriff derselben Rolle liefert null Zeilen.
const unbound = await prisma.$queryRaw`SELECT "tenantId" FROM probe`;
report(
results,
'ungebunden-liefert-null-zeilen',
unbound.length === 0,
`ungebundener SELECT liefert ${unbound.length} Zeile(n)`,
);
} finally {
await prisma.$disconnect();
}
}
function readAuthLookupMigrationSql() {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith('_auth_lookup_functions'))
.map((entry) => entry.name);
if (dirs.length !== 1) return null;
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
/**
* Aufgabe 2 — spielt die auth_lookup_*-Migration in die Wegwerf-Datenbank
* ein (mit tessera_app durch die Wegwerf-Rolle ersetzt), legt zwei Benutzer
* in zwei Mandanten an und misst unter der Rolle ohne BYPASSRLS:
* Funktionsaufruf findet den Benutzer, gewoehnlicher SELECT auf "User"
* liefert null Zeilen, Suche nach unbekanntem Namen liefert nichts.
*/
async function runAuthLookupChecks(adminUrl, scratchRoleUrl, results) {
const migrationSql = readAuthLookupMigrationSql();
if (!migrationSql) {
report(
results,
'auth-lookup-migration-vorhanden',
false,
'Migrationsverzeichnis *_auth_lookup_functions nicht gefunden',
);
return;
}
await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => {
await db.$executeRawUnsafe(`
CREATE TABLE "User" (
id text PRIMARY KEY,
username text UNIQUE NOT NULL,
email text UNIQUE,
"tenantId" text NOT NULL,
"passwordHash" text,
"ldapDn" text,
"isActive" boolean NOT NULL DEFAULT true,
role text NOT NULL DEFAULT 'USER',
"displayName" text,
"mustChangePassword" boolean NOT NULL DEFAULT false
);
`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(`ALTER TABLE "User" FORCE ROW LEVEL SECURITY;`);
await db.$executeRawUnsafe(
`CREATE POLICY tenant_isolation_policy ON "User" USING ("tenantId" = current_tenant_id());`,
);
await db.$executeRawUnsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON "User" TO ${SCRATCH_ROLE_NAME}`);
await db.$executeRawUnsafe(`
INSERT INTO "User" (id, username, "tenantId", "passwordHash", "isActive")
VALUES ('user-a', 'alice', 'TENANT-A', 'hash-a', true),
('user-b', 'bob', 'TENANT-B', 'hash-b', true);
`);
// Migration nutzt echte Postgres-ENUM-Werte fuer "role" (Typ "Role") —
// die Wegwerf-Tabelle oben verwendet stattdessen text, das ist fuer die
// hier gemessenen drei Verhaltensweisen ausreichend. Die Funktion
// auth_lookup_user_by_username referenziert den Spaltentyp nicht direkt
// (SELECT u.role liefert einfach den gespeicherten Wert), daher
// funktioniert das ohne den ENUM-Typ anzulegen — mit einer Ausnahme:
// die RETURNS TABLE-Deklaration der echten Migration nennt den Typ
// "Role" explizit. Fuer die Wegwerf-Pruefung wird er hier nachgebildet.
await db.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role DROP DEFAULT;`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role TYPE "Role" USING role::"Role";`);
await db.$executeRawUnsafe(`ALTER TABLE "User" ALTER COLUMN role SET DEFAULT 'USER'::"Role";`);
await db.$executeRawUnsafe(`
CREATE TABLE "PasswordResetToken" (
id text PRIMARY KEY,
token text UNIQUE NOT NULL,
"userId" text NOT NULL REFERENCES "User"(id),
"expiresAt" timestamp(3) NOT NULL,
"usedAt" timestamp(3)
);
`);
// Die echte Migration erteilt das Ausfuehrungsrecht ausschliesslich an
// tessera_app — fuer die Wegwerf-Pruefung an die Scratch-Rolle
// umgeleitet, ohne den Rest der Migration zu veraendern. Ueber
// executeSqlScript (prisma db execute --file), weil die Migration
// mehrere Anweisungen inklusive DO $$ ... $$-Bloecke enthaelt, die sich
// nicht als einzelnes $executeRawUnsafe senden lassen.
});
const adaptedSql = migrationSql.replaceAll('tessera_app', SCRATCH_ROLE_NAME);
executeSqlScript(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), adaptedSql);
const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl });
try {
const found = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('alice')`;
report(
results,
'anmeldesuche-findet-benutzer',
found.length === 1 && found[0].username === 'alice',
`auth_lookup_user_by_username('alice') liefert ${found.length} Zeile(n)`,
);
const notFound = await prisma.$queryRaw`SELECT * FROM auth_lookup_user_by_username('unknown-user')`;
report(
results,
'anmeldesuche-unbekannt-liefert-nichts-und-wirft-nicht',
notFound.length === 0,
`auth_lookup_user_by_username('unknown-user') liefert ${notFound.length} Zeile(n)`,
);
const rawSelect = await prisma.$queryRaw`SELECT * FROM "User"`;
report(
results,
'gewoehnlicher-select-auf-user-liefert-null-zeilen',
rawSelect.length === 0,
`SELECT * FROM "User" liefert ${rawSelect.length} Zeile(n)`,
);
} finally {
await prisma.$disconnect();
}
}
async function main() {
const adminUrl = parseAdminUrl();
const results = [];
console.log(`Richte Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ein...`);
await setupScratchDatabase(adminUrl);
try {
const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME);
scratchRoleUrl.username = SCRATCH_ROLE_NAME;
scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD;
const scratchRoleUrlString = scratchRoleUrl.toString();
await runForTenantChecks(scratchRoleUrlString, results);
await runAuthLookupChecks(adminUrl, scratchRoleUrlString, results);
} finally {
console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`);
await teardownScratchDatabase(adminUrl);
}
const allPassed = results.every((r) => r.passed);
console.log(
allPassed
? `Alle ${results.length} Pruefungen bestanden.`
: `${results.filter((r) => !r.passed).length} von ${results.length} Pruefungen fehlgeschlagen.`,
);
process.exit(allPassed ? 0 : 1);
}
main().catch((err) => {
console.error('FEHLER beim Ausfuehren der Wegwerf-Pruefung:', err.message);
process.exit(1);
});
+199 -6
View File
@@ -1,17 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping
// ist hier nicht die Pruefung) — dasselbe Muster wie in
// ldap.service.spec.ts.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* Baut eine Tagged-Template-Attrappe fuer prisma.$queryRaw, die die
* uebergebenen SQL-Textstuecke und interpolierten Werte aufzeichnet und ein
* konfigurierbares Ergebnis liefert — ohne laufende Datenbank.
*/
function fakeQueryRaw(resultsByCall: unknown[][]) {
let callIndex = 0;
const calls: { strings: TemplateStringsArray; values: unknown[] }[] = [];
const fn = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
calls.push({ strings, values });
const result = resultsByCall[callIndex] ?? [];
callIndex += 1;
return Promise.resolve(result);
});
return { fn, calls };
}
/**
* validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP
* have no local passwordHash and must be authenticated by binding as their own
* DN against the tenant's directory. These tests cover that branch; the local
* password path (argon2) is unchanged and exercised elsewhere.
*
* Aufgabe 2 (260909-eor): validateUser sucht ab jetzt ueber
* auth_lookup_user_by_username() via $queryRaw statt this.prisma.user.findUnique
* — die Tests hier zeichnen $queryRaw statt user.findUnique auf.
*/
describe('AuthService.validateUser — LDAP login', () => {
let service: AuthService;
let prisma: any;
let ldapService: any;
let ldapConfigService: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const ldapUser = {
id: 'u1',
@@ -24,9 +53,10 @@ describe('AuthService.validateUser — LDAP login', () => {
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[ldapUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: {
findUnique: vi.fn().mockResolvedValue(ldapUser),
update: vi.fn().mockResolvedValue({}),
},
};
@@ -87,10 +117,8 @@ describe('AuthService.validateUser — LDAP login', () => {
});
it('rejects a passwordless user that has no ldapDn (never binds)', async () => {
prisma.user.findUnique.mockResolvedValue({
...ldapUser,
ldapDn: null,
});
queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('alice', 'pw');
@@ -99,11 +127,176 @@ describe('AuthService.validateUser — LDAP login', () => {
});
it('rejects an inactive LDAP user before any bind', async () => {
prisma.user.findUnique.mockResolvedValue({ ...ldapUser, isActive: false });
queryRaw = fakeQueryRaw([[{ ...ldapUser, isActive: false }]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('alice', 'pw');
expect(result).toBeNull();
expect(ldapService.verifyUserCredentials).not.toHaveBeenCalled();
});
it('lowercases the username before calling auth_lookup_user_by_username', async () => {
ldapService.verifyUserCredentials.mockResolvedValue(true);
await service.validateUser('Alice', 'ad-password');
expect(queryRaw.calls).toHaveLength(1);
expect(queryRaw.calls[0].values).toEqual(['alice']);
});
it('returns null (never throws) when auth_lookup_user_by_username finds nothing', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
const result = await service.validateUser('unknown', 'pw');
expect(result).toBeNull();
});
});
/**
* Lokaler Kennwort-Anmeldeweg (argon2) sowie requestPasswordReset/
* resetPassword — decken die Aufgabe-2-Verhaltensfaelle ab: Anmeldesuche
* findet den Benutzer weiterhin, Schreibzugriffe laufen nach gefundenem
* Benutzer mandantengebunden.
*/
describe('AuthService.validateUser — lokales Kennwort', () => {
let service: AuthService;
let prisma: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
let localUser: {
id: string;
tenantId: string;
username: string;
passwordHash: string;
ldapDn: null;
isActive: boolean;
};
beforeEach(async () => {
vi.clearAllMocks();
// Echter argon2-Hash statt Mock — argon2.verify laesst sich in ESM
// nicht ueber vi.spyOn ersetzen (nicht konfigurierbarer Modul-Export).
const argon2 = await import('argon2');
localUser = {
id: 'u2',
tenantId: 't1',
username: 'bob',
passwordHash: await argon2.hash('correct-password'),
ldapDn: null,
isActive: true,
};
queryRaw = fakeQueryRaw([[localUser]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
};
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('findet den Benutzer weiterhin und aktualisiert lastLoginAt mandantengebunden', async () => {
const result = await service.validateUser('bob', 'correct-password');
expect(result).toEqual(localUser);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u2' },
data: { lastLoginAt: expect.any(Date) },
});
});
});
describe('AuthService.requestPasswordReset', () => {
let service: AuthService;
let prisma: any;
let mailService: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true };
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[emailUser]]);
prisma = {
$queryRaw: queryRaw.fn,
passwordResetToken: { create: vi.fn().mockResolvedValue({}) },
};
mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) };
service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any);
});
it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => {
await service.requestPasswordReset('bob@example.com');
expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({
data: {
token: expect.any(String),
userId: 'u3',
expiresAt: expect.any(Date),
},
});
expect(mailService.sendPasswordResetEmail).toHaveBeenCalledWith(
'bob@example.com',
expect.any(String),
);
});
it('kehrt bei unbekannter E-Mail wortlos zurueck (T-02-12, keine Enumeration)', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
await service.requestPasswordReset('unknown@example.com');
expect(prisma.passwordResetToken.create).not.toHaveBeenCalled();
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
});
describe('AuthService.resetPassword', () => {
let service: AuthService;
let prisma: any;
let queryRaw: ReturnType<typeof fakeQueryRaw>;
const resetTokenRow = {
id: 'rt1',
token: 'a-uuid-token',
userId: 'u4',
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
usedAt: null,
tenantId: 't1',
};
beforeEach(() => {
vi.clearAllMocks();
queryRaw = fakeQueryRaw([[resetTokenRow]]);
prisma = {
$queryRaw: queryRaw.fn,
user: { update: vi.fn().mockResolvedValue({}) },
passwordResetToken: { update: vi.fn().mockResolvedValue({}) },
};
service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any);
});
it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => {
await service.resetPassword('a-uuid-token', 'new-password');
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u4' },
data: { passwordHash: expect.any(String), mustChangePassword: false },
});
expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({
where: { id: 'rt1' },
data: { usedAt: expect.any(Date) },
});
});
it('wirft bei unbekanntem Token', async () => {
queryRaw = fakeQueryRaw([[]]);
prisma.$queryRaw = queryRaw.fn;
await expect(service.resetPassword('unknown', 'pw')).rejects.toThrow(
'Invalid or expired reset token',
);
});
});
+69 -18
View File
@@ -13,6 +13,40 @@ import { LdapConfigService } from '../ldap/ldap-config.service';
import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
* (20260909160000_auth_lookup_functions). Siehe Kopf der Migration fuer die
* Begruendung der schmalen Ausnahme (T-EOR-01/T-EOR-02).
*/
interface AuthLookupUserByUsernameRow {
id: string;
username: string;
tenantId: string;
passwordHash: string | null;
ldapDn: string | null;
isActive: boolean;
role: string;
displayName: string | null;
mustChangePassword: boolean;
}
interface AuthLookupUserByEmailRow {
id: string;
tenantId: string;
email: string | null;
isActive: boolean;
}
interface AuthLookupResetTokenRow {
id: string;
token: string;
userId: string;
expiresAt: Date;
usedAt: Date | null;
tenantId: string;
}
@Injectable()
export class AuthService {
@@ -28,22 +62,32 @@ export class AuthService {
) {}
/**
* Validate user credentials. Uses unscoped Prisma (no tenant context)
* because login must work across all tenants.
* Validate user credentials. Der Mandant ist vor dem Fund unbekannt, also
* geht die Suche ueber auth_lookup_user_by_username() (SECURITY DEFINER,
* 20260909160000_auth_lookup_functions) statt eines gewoehnlichen
* "this dot prisma dot user dot findUnique" — unter der kuenftigen Rolle
* ohne BYPASSRLS (tessera_app) liefert ein ungebundener SELECT auf "User"
* null Zeilen.
* Sobald der Benutzer und damit sein Mandant bekannt sind, laufen alle
* Schreibzugriffe ueber forTenant(), gebunden an genau diesen Mandanten
* (WINDOWS #20, Aufgabe 1).
*
* T-02-01: Returns null on any failure (never reveals which field is wrong).
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
*/
async validateUser(username: string, password: string): Promise<any> {
// Usernames are stored lowercase (case-insensitive login).
const user = await this.prisma.user.findUnique({
where: { username: username.toLowerCase() },
});
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
`;
const user = rows[0];
if (!user || !user.isActive) {
return null;
}
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
// LDAP users have no local password — authenticate them against the
// directory by binding as their OWN DN with the password they entered.
if (!user.passwordHash) {
@@ -68,7 +112,7 @@ export class AuthService {
return null;
}
await this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
@@ -81,7 +125,7 @@ export class AuthService {
}
// Update lastLoginAt
await this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
@@ -141,9 +185,10 @@ export class AuthService {
* T-02-13: Single-use token with 1-hour expiry.
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { email },
});
const rows = await this.prisma.$queryRaw<AuthLookupUserByEmailRow[]>`
SELECT * FROM auth_lookup_user_by_email(${email})
`;
const user = rows[0];
// Always return success to prevent email enumeration (T-02-12)
if (!user || !user.isActive) {
@@ -157,8 +202,10 @@ export class AuthService {
const token = randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Create the reset token record
await this.prisma.passwordResetToken.create({
// Create the reset token record — mandantengebunden, sobald der
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
await tenantPrisma.passwordResetToken.create({
data: {
token,
userId: user.id,
@@ -175,10 +222,10 @@ export class AuthService {
* T-02-13: Validates token not expired, not used. Marks as used after success.
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { token },
include: { user: true },
});
const rows = await this.prisma.$queryRaw<AuthLookupResetTokenRow[]>`
SELECT * FROM auth_lookup_reset_token(${token})
`;
const resetToken = rows[0];
if (!resetToken) {
throw new BadRequestException('Invalid or expired reset token');
@@ -194,9 +241,13 @@ export class AuthService {
throw new BadRequestException('Reset token has expired');
}
// Mandant ist ab hier bekannt (aus der Funktion mitgeliefert) — beide
// Schreibzugriffe laufen gebunden (WINDOWS #20, Aufgabe 1).
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId) as any;
// Hash the new password and update user
const passwordHash = await argon2.hash(newPassword);
await this.prisma.user.update({
await tenantPrisma.user.update({
where: { id: resetToken.userId },
data: {
passwordHash,
@@ -205,7 +256,7 @@ export class AuthService {
});
// Mark token as used (T-02-13)
await this.prisma.passwordResetToken.update({
await tenantPrisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { usedAt: new Date() },
});
@@ -0,0 +1,117 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Prueft die auth_lookup_*-Migration (Aufgabe 2, WINDOWS #18/#20, T-EOR-01/
* T-EOR-02) rein textuell gegen die generierte migration.sql — keine
* Datenbank noetig. Baut die Hilfsfunktion aus rls-app-role.spec.ts bewusst
* nach, statt sie zu importieren (dieselbe Begruendung wie dort: die
* vorhandene ist in ihrer Datei privat).
*
* Zaehlbedingungen ueber den Migrationstext filtern Kommentarzeilen vorher
* heraus, sonst zaehlt die Begruendung im Kopf der Datei als Treffer mit.
*/
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
const FUNCTION_NAMES = [
'auth_lookup_user_by_username',
'auth_lookup_user_by_email',
'auth_lookup_reset_token',
];
function readMigrationSql(suffix: string): string {
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
.map((entry) => entry.name);
if (dirs.length !== 1) {
throw new Error(
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
);
}
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
}
function stripSqlComments(sql: string): string {
// Migrations verwenden ausschliesslich `--`-Zeilenkommentare, keine
// Blockkommentare — eine einfache Zeilenfilterung reicht.
return sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
}
describe('auth_lookup_functions migration.sql (Aufgabe 2, WINDOWS #18/#20)', () => {
const rawSql = readMigrationSql('_auth_lookup_functions');
const sql = stripSqlComments(rawSql);
it('legt alle drei Funktionen an', () => {
for (const name of FUNCTION_NAMES) {
expect(sql).toContain(`CREATE OR REPLACE FUNCTION ${name}(`);
}
});
it('jede Funktion traegt SECURITY DEFINER', () => {
const occurrences = sql.match(/SECURITY DEFINER/g) ?? [];
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
});
it('jede Funktion traegt STABLE', () => {
const occurrences = sql.match(/\bSTABLE\b/g) ?? [];
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
});
it('jede Funktion heftet den Suchpfad fest auf public, pg_temp — der eigentliche Schutz bei SECURITY DEFINER', () => {
const occurrences = sql.match(/SET search_path = public, pg_temp/g) ?? [];
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
});
it('jede Funktion begrenzt das Ergebnis auf LIMIT 1', () => {
const occurrences = sql.match(/LIMIT 1;/g) ?? [];
expect(occurrences.length).toBe(FUNCTION_NAMES.length);
});
it('entzieht fuer jede Funktion zuerst alle Rechte von PUBLIC, bevor tessera_app das Ausfuehrungsrecht bekommt', () => {
for (const name of FUNCTION_NAMES) {
const revokeIdx = sql.indexOf(`REVOKE ALL ON FUNCTION ${name}(`);
const grantIdx = sql.indexOf(`GRANT EXECUTE ON FUNCTION ${name}(`);
expect(revokeIdx).toBeGreaterThanOrEqual(0);
expect(grantIdx).toBeGreaterThan(revokeIdx);
expect(sql.slice(revokeIdx, revokeIdx + 200)).toContain('FROM PUBLIC');
}
});
it('erteilt das Ausfuehrungsrecht ausschliesslich an tessera_app — keine andere Rolle taucht in einem GRANT EXECUTE auf', () => {
const grantLines = sql
.split('\n')
.filter((line) => line.trim().startsWith('GRANT EXECUTE ON FUNCTION'));
expect(grantLines.length).toBe(FUNCTION_NAMES.length);
for (const line of grantLines) {
expect(line).toContain('TO tessera_app');
}
});
it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => {
expect(sql).not.toMatch(/PASSWORD\s*'/);
});
it('keine der drei Funktionen schreibt — kein INSERT/UPDATE/DELETE/DROP im Funktionskoerper', () => {
for (const verb of ['INSERT', 'UPDATE', 'DELETE', 'DROP']) {
expect(sql).not.toContain(`${verb} `);
}
});
it('prueft vorab, dass tessera_app existiert, statt still zu ueberspringen (wiederholbares Muster wie 20260909130000)', () => {
expect(sql).toContain("pg_roles WHERE rolname = 'tessera_app'");
expect(sql).toContain('RAISE EXCEPTION');
});
it('auth_lookup_user_by_username liefert keinen der drei Textblock-Kandidaten fuer Kennwortfelder ausserhalb passwordHash', () => {
// Feste Regressionspruefung: der Spaltensatz ist genau der im Plan
// benannte, kein SELECT *.
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"User"/);
expect(sql).not.toMatch(/SELECT\s+\*\s+FROM\s+"PasswordResetToken"/);
});
});
@@ -0,0 +1,116 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from './prisma-tenant.extension';
/**
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
* Produktionscode erzeugten Inhalt (WINDOWS #20, Lehre aus dem
* tautologischen Test in STATE.md — Pitfall "Tautologischer Test"): ein
* vorgetaeuschter Client zeichnet auf, dass `$transaction` mit einem Feld
* aus zwei Eintraegen aufgerufen wird, dass der Rueckgabewert der zweite
* Eintrag ist, und dass `query` waehrend des Aufbaus dieses Feldes genau
* einmal beruehrt wird.
*/
const EXTENSION_SOURCE_PATH = join(__dirname, 'prisma-tenant.extension.ts');
/**
* Der Kopfkommentar der Datei erklaert absichtlich das ALTE, defekte
* Verhalten (inklusive $executeRawUnsafe und der interaktiven
* $transaction(async ...)-Form) als Beleg fuer die Reparatur. Eine
* Volltextpruefung auf den Quelltext wuerde deshalb faelschlich fehlschlagen
* — Block- und Zeilenkommentare muessen vor der Pruefung des tatsaechlichen
* Codes herausgefiltert werden (gleiches Vorgehen wie in
* auth-lookup-functions.spec.ts fuer die Migrations-SQL).
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
}
describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => {
const transactionCalls: unknown[] = [];
const fakeQueryResult = { id: 'row-1' };
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => {
transactionCalls.push(arg);
expect(Array.isArray(arg)).toBe(true);
expect((arg as unknown[]).length).toBe(2);
return Promise.resolve(['set_config-result', fakeQueryResult]);
}),
$extends: (config: any) => {
// Reproduziert nur den Teil der echten $extends-API, den
// $allOperations braucht — kein echter Prisma-Client noetig.
return {
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
},
};
},
$executeRaw: vi.fn((_strings: TemplateStringsArray, ..._values: unknown[]) => {
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
let queryCallCount = 0;
const query = (args: unknown) => {
queryCallCount += 1;
return fakeQueryResult;
};
const result = await scoped.__invoke({ where: { id: 'row-1' } }, query);
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
expect(transactionCalls).toHaveLength(1);
expect((transactionCalls[0] as unknown[]).length).toBe(2);
// Rueckgabewert ist der ZWEITE Eintrag des Felds (der Query-Aufruf),
// nicht das Ergebnis von set_config.
expect(result).toBe(fakeQueryResult);
// query() wurde beim Aufbau des Felds genau einmal beruehrt.
expect(queryCallCount).toBe(1);
});
it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => {
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => 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[]) => {
// Ein getaggtes Template liefert ein Array von Textstuecken plus die
// interpolierten Werte getrennt — genau das ist der Beleg dafuer,
// dass der Mandantenwert parametrisiert und nicht in den SQL-Text
// eingebaut wird.
expect(Array.isArray(strings)).toBe(true);
expect(values).toContain('tenant-with-quote-\' OR 1=1');
return 'set-config-promise';
}),
};
const scoped = forTenant(fakePrisma, "tenant-with-quote-' OR 1=1") as any;
await scoped.__invoke({}, () => 'query-result');
expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1);
});
it('verwendet im tatsaechlichen Code (ohne Kommentare) kein $executeRawUnsafe mehr', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
expect(source).not.toContain('$executeRawUnsafe');
});
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback)', () => {
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
expect(source).toMatch(/\$transaction\(\s*\[/);
expect(source).not.toMatch(/\$transaction\(\s*async/);
});
});
+57 -9
View File
@@ -4,20 +4,68 @@ import { PrismaClient } from '@prisma/client';
* Creates a tenant-scoped Prisma client that sets the app.current_tenant
* PostgreSQL session variable before every query via RLS.
*
* Uses parameterized set_config to prevent SQL injection (T-02-05).
* WARUM DIE ARRAY-FORM VON $transaction PFLICHT IST (WINDOWS #20, gemessen
* 2026-09-09, siehe .planning/quick/260909-eor-.../260909-eor-PLAN.md):
*
* Die vorherige Fassung nutzte die INTERAKTIVE Callback-Form
* (`prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(...); return query(args); })`)
* und rief `query(args)` — die eigentliche Datenbankoperation — auf dem
* AEUSSEREN `prisma`-Client auf, nicht auf `tx`. Ein Nachbau dieses exakten
* Musters gegen die lokale Datenbank ergab:
*
* inside tx : {"pid":254999,"t":"TENANT-A"}
* actual qry : {"pid":255000,"t":null}
* SAME CONNECTION? false
*
* `set_config('app.current_tenant', ..., true)` mit `local=true` gilt nur
* transaktions- UND verbindungslokal. Die interaktive Callback-Form haelt
* fuer `tx` eine eigene Verbindung; `query(args)` lief auf einer ANDEREN,
* unter Postgres-Poolern austauschbaren Verbindung und sah den Kontext nie.
* Unter einer Rolle ohne BYPASSRLS waere die Folge nicht "zu viele Zeilen",
* sondern NULL Zeilen — die Policy vergleicht gegen NULL.
*
* Die Array-Form (`prisma.$transaction([a, b])`) fuehrt alle Eintraege als
* EINE Transaktion auf EINER Verbindung aus — das ist das von Prisma selbst
* fuer RLS-ueber-Extensions vorgesehene Muster. `query(args)` sieht damit
* denselben Kontext, den `set_config` unmittelbar zuvor auf derselben
* Verbindung gesetzt hat.
*
* GRENZFAELLE — benannter Vorbehalt fuer Etappe 2 (nicht stillschweigend
* uebergangen, siehe 260909-eor-SUMMARY.md):
*
* - Ruft aufrufender Code selbst `$transaction` auf einem mit `forTenant()`
* gebundenen Client auf: `$transaction` ist keine Modell-Operation und
* laeuft NICHT durch `$allOperations`. Der Mandantenkontext wird in einem
* solchen Fall nicht automatisch gesetzt — jede einzelne im
* `$transaction`-Array enthaltene Modell-Operation dispatcht zwar durch
* `$allOperations` (weil sie auf dem extended Client aufgerufen wird) und
* bekommt dadurch ihre EIGENE Ein-Element-Transaktion mit eigenem
* `set_config` — mehrere solche Operationen liefen dann aber auf
* MEHREREN Teiltransaktionen statt einer gemeinsamen, was Atomaritaet
* ueber die gesamte aeussere Transaktion hinweg verletzen kann. Heute
* ruft kein `forTenant()`-Aufrufer eine verschachtelte `$transaction` auf
* (gemessen: alle 9 tatsaechlichen mandantengebundenen Abfragen in
* `ldap.service.ts` sind Einzeloperationen) — vor jedem neuen
* `forTenant()`-Aufruf mit eigener Transaktion in Etappe 2 erneut pruefen.
* - `$queryRaw`/`$executeRaw` DIREKT auf dem gebundenen Client laufen
* weiterhin normal durch `$allOperations` (Prisma behandelt sie wie jede
* andere Operation) und werden daher korrekt an dieselbe Verbindung
* gebunden wie `set_config`.
*
* Parametrisiert ueber ein getaggtes `$executeRaw`-Template (kein
* `$executeRawUnsafe` mit zusammengebautem Text mehr) — die
* Injektionsfestigkeit aus T-02-05 bleibt beim Umbau erhalten.
*/
export function forTenant(prisma: PrismaClient, tenantId: string) {
return prisma.$extends({
query: {
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
return (prisma as any).$transaction(async (tx: any) => {
// Use parameterized query to avoid SQL injection
await tx.$executeRawUnsafe(
`SELECT set_config('app.current_tenant', $1, true)`,
tenantId,
);
return query(args);
});
const setTenantContext = (prisma as any)
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
return (prisma as any)
.$transaction([setTenantContext, query(args)])
.then((results: any[]) => results[1]);
},
},
});
@@ -0,0 +1,132 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Ermittelt die Fundstellen erneut aus dem Quelltext und vergleicht sie
* gegen die im Dokument gefuehrten Eintraege (Aufgabe 3, WINDOWS #18/#20,
* T-EOR-05). Scheitert, sobald eine Fundstelle ohne Eintrag existiert oder
* ein Eintrag ohne Fundstelle. Vergleichsschluessel sind Datei UND
* Modellname — eine Zeilennummer traegt nicht, das ueberlebt das
* Verschieben einer Zeile.
*/
const API_SRC_DIR = join(__dirname, '..');
const REPO_ROOT = join(__dirname, '../../../..');
const DOC_PATH = join(REPO_ROOT, 'docs/mandantentrennung-zugriffsklassifikation.md');
interface AccessSite {
file: string;
model: string;
}
function listTsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...listTsFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) {
out.push(full);
}
}
return out;
}
/**
* Filtert Kommentarzeilen (Zeilenkommentare und Blockkommentare) heraus,
* bevor nach `this.prisma.<model>` gesucht wird — sonst zaehlt eine
* erklaerende Kopfzeile als Fundstelle mit.
*/
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n');
}
function findAccessSites(): AccessSite[] {
const files = listTsFiles(API_SRC_DIR);
const sites: AccessSite[] = [];
for (const absPath of files) {
const relPath = relative(REPO_ROOT, absPath).split('\\').join('/');
const source = stripComments(readFileSync(absPath, 'utf-8'));
const matches = source.matchAll(/this\.prisma\.([a-zA-Z]+)/g);
const models = new Set<string>();
for (const m of matches) {
if (m[1]) models.add(m[1]);
}
for (const model of models) {
sites.push({ file: relPath, model });
}
}
return sites.sort((a, b) => (a.file + a.model).localeCompare(b.file + b.model));
}
const CLASS_TOKENS = [
'muss-mandantengebunden',
'bewusst-uebergreifend',
'keine-mandantengebundene-tabelle',
'beides',
];
function parseDocEntries(): { file: string; model: string; klasse: string }[] {
const raw = readFileSync(DOC_PATH, 'utf-8');
const entries: { file: string; model: string; klasse: string }[] = [];
// Nur Zeilen aus der Bestandsaufnahme-Tabelle: `| Datei | Modell | Klasse | Begruendung |`
const rowPattern = /^\|\s*(apps\/api\/src\/[^\s|]+\.ts)\s*\|\s*([a-zA-Z]+)\s*\|\s*([a-z-]+)\s*\|/gm;
for (const match of raw.matchAll(rowPattern)) {
const [, file, model, klasse] = match;
entries.push({ file, model, klasse });
}
return entries;
}
describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollstaendig ab (Aufgabe 3, T-EOR-05)', () => {
it('das Dokument existiert', () => {
expect(() => statSync(DOC_PATH)).not.toThrow();
});
const sourceSites = findAccessSites();
const docEntries = parseDocEntries();
const docKeys = new Set(docEntries.map((e) => `${e.file}::${e.model}`));
const sourceKeys = new Set(sourceSites.map((s) => `${s.file}::${s.model}`));
it('die Bestandsaufnahme-Tabelle enthaelt mindestens einen Eintrag', () => {
expect(docEntries.length).toBeGreaterThan(0);
});
it('jede im Quelltext gefundene (Datei, Modell)-Fundstelle ist im Dokument eingetragen', () => {
const missing = sourceSites
.map((s) => `${s.file}::${s.model}`)
.filter((key) => !docKeys.has(key));
expect(missing, `Fehlende Eintraege im Dokument:\n${missing.join('\n')}`).toEqual([]);
});
it('jeder im Dokument gefuehrte Eintrag hat eine tatsaechliche Fundstelle im Quelltext', () => {
const stale = [...docKeys].filter((key) => !sourceKeys.has(key));
expect(stale, `Eintraege im Dokument ohne Fundstelle im Quelltext:\n${stale.join('\n')}`).toEqual([]);
});
it('jeder Eintrag traegt eine der vier gueltigen Klassen', () => {
const invalid = docEntries.filter((e) => !CLASS_TOKENS.includes(e.klasse));
expect(invalid, JSON.stringify(invalid)).toEqual([]);
});
it('keine doppelten (Datei, Modell)-Eintraege in der Tabelle', () => {
const seen = new Set<string>();
const duplicates: string[] = [];
for (const e of docEntries) {
const key = `${e.file}::${e.model}`;
if (seen.has(key)) duplicates.push(key);
seen.add(key);
}
expect(duplicates).toEqual([]);
});
});
+48 -24
View File
@@ -73,32 +73,55 @@ noetig sind:
**Die Verbindung ist bewusst noch nicht umgestellt, und sie darf noch nicht
umgestellt werden.**
Am 2026-09-09 wurde im Quelltext von `apps/api/src` gezaehlt, wie oft die
Anwendung ueber den unskalierten Prisma-Client zugreift (also ohne den
Mandantenkontext zu setzen) gegenueber der Anzahl der Verwendungen des
mandantengebundenen `tenantPrisma`: **182 unskalierte Zugriffe** — 84 auf die
sieben bereits mit Policies versehenen Tabellen, 98 auf die 16 neu
hinzugekommenen — gegenueber lediglich **19 Verwendungen** von `tenantPrisma`
im gesamten API-Quelltext.
**Nachgemessen und korrigiert am 2026-09-09 (260909-eor, Aufgabe 3):** die
zuvor hier genannte Zahl 182 stammte aus einer groeberen Zaehlung vor dieser
Etappe und ist ueberholt. Die aktuelle, maschinell ermittelte und durch
`apps/api/src/prisma/rls-access-inventory.spec.ts` dauerhaft gepruefte
Bestandsaufnahme steht in
**`docs/mandantentrennung-zugriffsklassifikation.md`**: **227**
`this.prisma.*`-Fundstellen in `apps/api/src` (ohne Tests), zusammengefasst zu
**59** (Datei, Modell)-Paaren, davon **31** `muss-mandantengebunden` und **9**
`beides` (Hintergrunddienst mit sowohl uebergreifendem Lesen als auch
mandantengebundenem Schreiben je Zeile) — zusammen der eigentliche
Arbeitsvorrat fuer die Umstellung. **16** Paare betreffen keine
mandantengebundene Tabelle (plattformweite Daten wie der Ausschreibungs- und
Modulkatalog, D-03) und **3** sind bewusst uebergreifend mit ausgeschriebenem
Grund. Zusaetzlich zu beachten: das entdeckte, aber in dieser Etappe nicht
behobene `forTenant()`-Verbindungsproblem (WINDOWS #20, siehe unten) und
WINDOWS #19 (nullbares `tenantId` bei `SearchProvider`/`TenderRssFeedSource`).
Darunter ist der Anmeldeweg selbst, und der kann strukturell nicht anders
funktionieren: `apps/api/src/auth/auth.service.ts` sucht den Benutzer anhand
des Benutzernamens, **bevor** der Mandant bekannt ist — der Mandant wird ja
erst aus dem gefundenen Benutzer bestimmt. Unter der Rolle `tessera_app`
liefert genau diese Suche null Zeilen. **Niemand koennte sich mehr
anmelden.**
Darunter war bis Aufgabe 2 dieser Etappe auch der Anmeldeweg selbst, der
strukturell nicht anders funktionieren konnte: `apps/api/src/auth/auth.service.ts`
suchte den Benutzer anhand des Benutzernamens, **bevor** der Mandant bekannt
war. Das ist inzwischen geloest — drei enge SECURITY-DEFINER-Funktionen
(`auth_lookup_user_by_username`, `auth_lookup_user_by_email`,
`auth_lookup_reset_token`, Migration `20260909160000_auth_lookup_functions`)
uebernehmen die drei pre-tenant Lesezugriffe, alle nachfolgenden
Schreibzugriffe laufen ueber `forTenant()`.
Ebenso betroffen sind Hintergrunddienste, die von Natur aus ohne
Weiterhin betroffen sind Hintergrunddienste, die von Natur aus ohne
Mandantenkontext laufen: der AD-Abgleich, der Ausschreibungs-Digest, die
Modulzugriffspruefung, die Treffersuche im Ausschreibungs-Radar und die
Erstanlage des Administrators beim ersten Start.
Ausschreibungs-Sofortmeldung und die Erstanlage des Administrators beim
ersten Start — Details und Begruendung je Fundstelle in
`docs/mandantentrennung-zugriffsklassifikation.md`.
Diese 182 Stellen brauchen einen ausdruecklichen, benannten Systemkontext
(oder eine gezielte Umstellung auf `tenantPrisma`/`forTenant()`), bevor der
Schalter umgelegt werden darf. Das ist **eigene Arbeit und nicht Teil dieser
Aenderung.** Ein gruener Bericht des Pruefwerkzeugs aus Abschnitt 5 belegt
ausschliesslich, dass die Datenbankseite stimmt — er sagt nichts ueber diese
182 Zugriffe aus.
Diese Stellen brauchen einen ausdruecklichen, benannten Systemkontext (oder
eine gezielte Umstellung auf `forTenant()`), bevor der Schalter umgelegt
werden darf. Das ist **eigene Arbeit und nicht Teil dieser Aenderung**
(Etappe 2/3 der Mandantentrennung). Ein gruener Bericht des Pruefwerkzeugs aus
Abschnitt 5 belegt ausschliesslich, dass die Datenbankseite stimmt — er sagt
nichts ueber diese Zugriffe aus.
**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
selbst die 6 bisherigen `forTenant()`-Aufrufstellen (alle in
`apps/api/src/ldap/ldap.service.ts` sowie `tenant.middleware.ts`/
`tenant.guard.ts`) den Mandantenkontext nie tatsaechlich gesetzt haben. Das ist
inzwischen repariert (Array-Form von `$transaction`, `prisma-tenant.extension.ts`)
und durch `apps/api/scripts/rls-scratch-check.mjs` live nachgewiesen. Ohne
diese Reparatur waere jede Umstellung auf `forTenant()` in den folgenden
Etappen wirkungslos gewesen.
## 4. Die Umstellung, Schritt fuer Schritt
@@ -141,8 +164,9 @@ ausschliesslich.
**Ein gruener Bericht ist die Freigabebedingung fuer Schritt 4 aus Abschnitt
4 — aber er ist keine Freigabe fuer die Umstellung insgesamt.** Er bestaetigt
nur, dass die Datenbankseite stimmt. Die 182 unskalierten Zugriffe aus
Abschnitt 3 misst er nicht und kann sie nicht messen — dafuer muesste er den
nur, dass die Datenbankseite stimmt. Die unskalierten Zugriffe aus Abschnitt 3
(siehe `docs/mandantentrennung-zugriffsklassifikation.md` fuer den aktuellen
Stand) misst er nicht und kann sie nicht messen — dafuer muesste er den
Anwendungscode lesen, nicht die Datenbank.
## 6. Der Rueckweg
@@ -0,0 +1,200 @@
# Mandantentrennung — Zugriffsklassifikation
Dieses Dokument gehört zusammen mit `docs/mandantentrennung-datenbankrolle.md`
zur Vorbereitung der Mandantentrennung auf Datenbankebene (WINDOWS #18/#20).
Während die Datenbankrolle-Anleitung beschreibt, **wie** die Umstellung
technisch abläuft, hält dieses Dokument fest, **welche** der 227
`this.prisma.*`-Fundstellen in `apps/api/src` beim Umbau in den folgenden
Etappen angefasst werden müssen und welche bewusst unverändert bleiben.
Die Bestandsaufnahme unten wird **maschinell aus dem Quelltext ermittelt**
(nicht von Hand zusammengeschrieben) und durch
`apps/api/src/prisma/rls-access-inventory.spec.ts` bei jedem Testlauf gegen
den tatsächlichen Stand geprüft. Verschiebt sich eine Zeile, bleibt die
Prüfung grün — Vergleichsschlüssel sind Datei und Prisma-Modellname, keine
Zeilennummer. Kommt eine neue Fundstelle hinzu oder verschwindet eine
bestehende, schlägt die Prüfung fehl, bis dieses Dokument nachgezogen wird.
## Die drei Klassen
- **`muss-mandantengebunden`** — berührt auf Rechnung genau eines Mandanten
eine Tabelle mit `tenantId` (oder eine Tabelle, die ihre Mandantenregel
über einen Join auf eine solche Tabelle bezieht). Muss in Etappe 2 auf
`forTenant()` umgestellt werden.
- **`bewusst-uebergreifend`** — muss über Mandanten hinweg sehen, mit
ausgeschriebenem Grund. Braucht in Etappe 3 eine sichtbare Kennzeichnung
(Systemkontext), aber keinen `forTenant()`-Umbau.
- **`keine-mandantengebundene-tabelle`** — betrifft eines der acht Modelle
ohne `tenantId` bzw. eine bewusst plattformweite Tabelle (D-03). Kein
Umbau nötig.
- **`beides`** — ein vierter, im Plan ausdrücklich verlangter Sonderfall:
ein Hintergrunddienst, der zurecht über alle Mandanten hinweg eine Liste
aufbaut (bewusst übergreifend), aber *innerhalb* der Schleife je Mandant
binden muss (muss mandantengebunden werden). Beide Anteile sind in
derselben Datei vorhanden; die Fundstelle bekommt hier eine
Sammelklassifikation, die Aufteilung auf Zeilenebene steht in der
Begründung.
## Zwei belegte Befunde
**`req.tenantPrisma` wird gesetzt, aber nirgends gelesen.**
`tenant.middleware.ts:44` und `tenant.guard.ts:41` setzen
`req.tenantPrisma = forTenant(this.prisma, tenantId)`. Eine Volltextsuche
über `apps/api/src` nach `tenantPrisma` außerhalb dieser beiden Dateien und
ihrer Tests findet keine lesende Stelle — kein Controller greift darauf zu.
Die Verdrahtung besteht, wird aber nicht genutzt. Für Etappe 2 ist zu
entscheiden, ob die Controller künftig darüber gehen (dann bräuchte es keinen
zweiten `forTenant()`-Aufruf je Service-Methode) oder ob der Weg ersatzlos
entfällt. Dieser Plan entscheidet das nicht, hält den Befund nur fest.
**WINDOWS #19 — nullbares `tenantId` bei `SearchProvider` und
`TenderRssFeedSource`.** Beide Modelle tragen ein nullbares `tenantId`
(`SearchProvider` für admin-gepflegte Vorgabe-Suchmaschinen, wobei laut
05-02-Entscheidung die tatsächlichen Vorgaben als Konstanten und nicht als
DB-Zeilen mit `tenantId = NULL` geführt werden — die Spalte ist nullbar,
ob es heute tatsächlich `NULL`-Zeilen gibt, ist damit eine offene Frage für
Etappe 3, nicht eine hier beantwortete; `TenderRssFeedSource` für
plattformweite RSS-Quellen wie den geseedeten `service.bund.de`-Feed, D-06).
Die aktuelle Policy `"tenantId" = current_tenant_id()` vergleicht `NULL`
nie gleich — nach dem Scharfschalten wären plattformweite Zeilen für JEDEN
Mandanten unsichtbar, nicht nur für fremde. Das ist heute ohne Wirkung
(Schalter aus, #18), muss aber in Etappe 3 zusammen mit den restlichen
Fundstellen gelöst werden: die Policy braucht für den Lesezugriff
`tenantId IS NULL OR tenantId = current_tenant_id()`, während Schreibzugriffe
weiterhin einen Mandanten verlangen.
## Übersicht je Bereich (Zeilentreffer, `this.prisma.*` ohne Specs)
Gemessen mit `grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/<bereich> | grep -v spec | wc -l`
am 2026-09-09, **nach** den Änderungen aus Aufgabe 1/2 dieses Plans:
| Bereich | Treffer | Hinweis |
|---|---|---|
| tenders | 62 | unverändert gegenüber measured_baseline |
| groups | 37 | unverändert |
| ldap | 21 | unverändert |
| dkv | 21 | unverändert |
| user | 17 | unverändert |
| module-registry | 17 | unverändert |
| dashboard | 13 | unverändert |
| auth | 8 | **war 13 in measured_baseline** — Aufgabe 2 hat 3 Lesezugriffe durch `auth_lookup_*()`-Funktionsaufrufe (`$queryRaw`, kein `this.prisma.<Modell>`) ersetzt und 5 Schreibzugriffe auf `forTenant()`-gebundene Aufrufe (`tenantPrisma.*`, ebenfalls kein `this.prisma.<Modell>`) umgestellt |
| calendar | 12 | unverändert |
| tenant | 8 | unverändert |
| favorites | 7 | unverändert |
| settings | 4 | unverändert |
| **Summe** | **227** | war 232 in measured_baseline, Delta = die 5 in Aufgabe 2 verschwundenen `auth`-Treffer minus ein bereits vorher fehlerhaft mitgezähltes Kommentarvorkommen in der neuen Kopfzeile von `validateUser()`, das bewusst umformuliert wurde, um einen Eigentreffer der Bestandsaufnahme-Prüfung zu vermeiden |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 59 Paare)
| Klasse | Anzahl Paare |
|---|---|
| muss-mandantengebunden | 31 |
| keine-mandantengebundene-tabelle | 16 |
| beides | 9 |
| bewusst-uebergreifend | 3 |
| **Summe** | **59** |
## Der Hintergrunddienst als Falle — drei `beides`-Fälle
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
muss aber *innerhalb* der Schleife je Mandant binden. Drei Dateien sind
betroffen:
- **`ldap.service.ts`** (AD-Abgleich): iteriert nicht selbst über alle
Mandanten (der Sync läuft je Aufruf für einen übergebenen Mandanten), aber
innerhalb der Sync-Methoden bleiben Lesezugriffe auf `group`, `ldapConfig`
und `user` teils ungebunden, obwohl der Mandant zu diesem Zeitpunkt bereits
bekannt ist — die 4 echten `forTenant()`-Aufrufstellen (Zeilen 762, 905,
1179, 1342) decken nur einen Teil der Lese-/Schreibpfade ab. Das ist der im
Plankontext benannte Kern von WINDOWS #20: genau dieser Löschzweig
(~Zeile 1559) deutet Leere nach dem Scharfschalten als "Gruppe im
Verzeichnis verschwunden".
- **`tender-digest.scheduler.ts`** (Ausschreibungs-Digest): liest
`tenderMatch`/`tenderNotificationPref`/`user` bewusst über ALLE Mandanten
in einem `findMany` (ein einziger globaler Cron-Job, kein Mandant im
Job selbst — so von Anfang an entworfen, Pitfall 1 in den Kommentaren der
Datei). Innerhalb der Verteilung je Treffer ist der Mandant aus der Zeile
bekannt und der Versand muss darauf gebunden laufen.
- **`tender-matching.service.ts`** (Ausschreibungs-Sofortmeldung): dieselbe
Form — `tenderMatch`/`tenderSavedSearch`/`user` werden für die
Sofort-Benachrichtigung über alle betroffenen Mandanten hinweg gelesen,
der Versand je Treffer ist mandantengebunden.
## Bestandsaufnahme
Maschinell ermittelt, `rls-access-inventory.spec.ts` hält Vollständigkeit
nach. Spalten: Datei, Modell (Prisma-Modellname wie in `this.prisma.<Modell>`
verwendet), Klasse, Begründung.
| Datei | Modell | Klasse | Begründung |
|---|---|---|---|
| apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | `getMe`, `changePassword`, `adminResetPassword` suchen über die Benutzerkennung aus dem Sitzungsnachweis — der Mandant ist dort bereits bekannt (Aufgabe 2 fasst sie bewusst nicht an, siehe SUMMARY). |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | Kalenderquellen eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). |
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | `tenantId` nullbar (WINDOWS #19) — heutige, tatsächlich gespeicherte Zeilen sind nutzerangelegt und tragen einen Mandanten; Vorgabe-Anbieter kommen laut 05-02 aus Konstanten, nicht aus der DB. |
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | Platzierte Dashboard-Widgets eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/dkv/dkv.service.ts | dkvInvoiceHistory | muss-mandantengebunden | DKV-Rechnungshistorie je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/dkv/dkv.service.ts | dkvModuleConfig | muss-mandantengebunden | Postfach-/Zugangsdaten des DKV-Moduls je Mandant. |
| apps/api/src/dkv/dkv.service.ts | dkvVehicleMaster | muss-mandantengebunden | Fahrzeugstammdaten des DKV-Moduls je Mandant. |
| apps/api/src/favorites/favorites.service.ts | favoriteLink | muss-mandantengebunden | Favoriten-Links eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/groups/groups.service.ts | group | muss-mandantengebunden | Gruppen sind je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/groups/groups.service.ts | groupMembership | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `Group` (Migration 20260618112133-Nachfolger) — braucht trotzdem `forTenant()`, damit der Join-Kontext gesetzt ist. |
| apps/api/src/groups/groups.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/groups/groups.service.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb eines Mandanten. |
| apps/api/src/groups/module-grants.service.ts | group | muss-mandantengebunden | Wie groups.service.ts. |
| apps/api/src/groups/module-grants.service.ts | groupMembership | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `Group`. |
| apps/api/src/groups/module-grants.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
| apps/api/src/groups/module-grants.service.ts | tenantModuleActivation | muss-mandantengebunden | Welche Module ein Mandant aktiviert hat, `tenantId`-Spalte vorhanden. |
| apps/api/src/groups/module-grants.service.ts | user | muss-mandantengebunden | Zielbenutzer eines Grants innerhalb des Mandanten. |
| apps/api/src/ldap/ldap-config.service.ts | ldapConfig | muss-mandantengebunden | LDAP-Konfiguration je Mandant, `tenantId`-Spalte (unique) vorhanden. |
| apps/api/src/ldap/ldap-config.service.ts | ldapFieldMapping | muss-mandantengebunden | Kein eigenes `tenantId`, RLS über Join auf `LdapConfig`. |
| apps/api/src/ldap/ldap.service.ts | group | beides | AD-Abgleich: 4 echte `forTenant()`-Aufrufstellen decken einen Teil ab, weitere `this.prisma.group`-Zugriffe innerhalb der Sync-Methoden bleiben ungebunden, obwohl der Mandant zu diesem Zeitpunkt bekannt ist (siehe Abschnitt "Der Hintergrunddienst als Falle"). |
| apps/api/src/ldap/ldap.service.ts | ldapConfig | beides | Dieselbe Begründung wie `group` — Konfigurationszugriffe innerhalb der Sync-Methoden. |
| apps/api/src/ldap/ldap.service.ts | user | beides | Dieselbe Begründung — der Löschzweig um Zeile 1559 (WINDOWS #20) ist der konkrete Risikofall. |
| apps/api/src/module-registry/module-access.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit, kein `tenantId`. |
| apps/api/src/module-registry/module-access.service.ts | moduleGrant | muss-mandantengebunden | Modulfreigaben je Mandant. |
| apps/api/src/module-registry/module-access.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/module-registry/module-registry.service.ts | module | keine-mandantengebundene-tabelle | Modulkatalog ist plattformweit. |
| apps/api/src/module-registry/module-registry.service.ts | tenantModuleActivation | muss-mandantengebunden | Aktivierung je Mandant. |
| apps/api/src/settings/settings.service.ts | smtpConfig | muss-mandantengebunden | SMTP-Zugangsdaten je Mandant, `tenantId`-Spalte vorhanden. |
| apps/api/src/tenant/tenant.controller.ts | tenant | keine-mandantengebundene-tabelle | `Tenant` ist die Mandantentabelle selbst — hat keine eigene `tenantId`-Spalte, kann sie per Definition nicht haben (Migration 20260909140000, Gruppe b). |
| apps/api/src/tenant/tenant.service.ts | tenant | keine-mandantengebundene-tabelle | Dieselbe Begründung. |
| apps/api/src/tenders/adapters/email-alert.adapter.ts | tenderEmailConfig | bewusst-uebergreifend | `fetchTenders()` liest bewusst jede aktive `TenderEmailConfig`-Zeile über ALLE Mandanten in einer Abfrage (Plattform-Scheduler, ein Tick pro Postfach, D-13/D-01) — ausführlich im Dateikopf begründet, darf laut Kommentar niemals in `forTenant()` verpackt werden. |
| apps/api/src/tenders/adapters/rss.adapter.ts | tenderRssFeedSource | bewusst-uebergreifend | Fan-out über jeden aktiven Feed, plattformweit UND persönlich, in einer Abfrage (Zeilen 55–83 im Dateikopf begründet) — dieselbe Scheduler-Ebene wie beim E-Mail-Adapter. |
| apps/api/src/tenders/tender-dedup.service.ts | tender | keine-mandantengebundene-tabelle | 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 | Dieselbe Begründung. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderMatch | beides | Ein einziger globaler Cron-Job liest über ALLE Mandanten (bewusst übergreifend, Pitfall-1-Kommentar im Dateikopf), der Versand je Treffer ist an dessen Mandanten gebunden. |
| apps/api/src/tenders/tender-digest.scheduler.ts | tenderNotificationPref | beides | Dieselbe Begründung — Präferenzen werden über alle Mandanten gelesen, aber je Zeile mandantenbezogen ausgewertet. |
| apps/api/src/tenders/tender-digest.scheduler.ts | user | beides | E-Mail-Adressen für den Versand werden über alle Mandanten gelesen, der eigentliche Versand ist je Treffer mandantengebunden. |
| apps/api/src/tenders/tender-email-config.service.ts | tenderEmailConfig | muss-mandantengebunden | Nutzer-CRUD für die eigene Postfachanbindung (Phase 17, D-01) — anders als der Fan-out-Adapter oben, hier ist der Mandant aus der Anfrage bekannt. |
| apps/api/src/tenders/tender-fingerprint-backfill.service.ts | tender | keine-mandantengebundene-tabelle | Einmaliges Backfill-Skript über den plattformweiten `Tender`-Katalog (D-03). |
| apps/api/src/tenders/tender-ingestion.service.ts | tender | keine-mandantengebundene-tabelle | Explizit im Dateikopf: "Multi-tenant safety (D-03, T-10-09): uses the plain, non-tenant-scoped ... queries ... these are platform-global". |
| apps/api/src/tenders/tender-ingestion.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, kein `tenantId` (Migration 20260909140000, Gruppe b). |
| apps/api/src/tenders/tender-matching.service.ts | tender | keine-mandantengebundene-tabelle | Liest den plattformweiten Katalog (D-03), um Treffer zu berechnen — kein `tenantId`. |
| apps/api/src/tenders/tender-matching.service.ts | tenderMatch | beides | Sofortmeldung: Treffer über alle betroffenen Mandanten gelesen, Versand je Treffer mandantengebunden — siehe Abschnitt "Der Hintergrunddienst als Falle". |
| apps/api/src/tenders/tender-matching.service.ts | tenderSavedSearch | beides | Dieselbe Begründung — gespeicherte Suchprofile aller Mandanten werden gegen neue Treffer geprüft, der Versand ist je Profil mandantengebunden. |
| apps/api/src/tenders/tender-matching.service.ts | user | beides | Dieselbe Begründung — E-Mail-Adressen für die Sofortmeldung. |
| apps/api/src/tenders/tender-notification-pref.service.ts | tenderNotificationPref | muss-mandantengebunden | Nutzer-CRUD für die eigenen Benachrichtigungseinstellungen — Mandant aus der Anfrage bekannt. |
| apps/api/src/tenders/tender-rss-feed.service.ts | tenderRssFeedSource | muss-mandantengebunden | Nutzer-CRUD für die eigenen RSS-Quellen (anders als der Fan-out in `adapters/rss.adapter.ts`) — Mandant aus der Anfrage bekannt. WINDOWS #19 betrifft die nullbaren plattformweiten Zeilen, nicht diesen CRUD-Pfad. |
| apps/api/src/tenders/tender-saved-search.service.ts | tenderSavedSearch | muss-mandantengebunden | Nutzer-CRUD für die eigenen gespeicherten Suchprofile — Mandant aus der Anfrage bekannt. |
| apps/api/src/tenders/tender-scheduler.service.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter DÖE-Poll-Status, kein `tenantId` — im Dateikopf explizit als "genuine platform-wide singleton" begründet. |
| apps/api/src/tenders/tenders.controller.ts | tender | keine-mandantengebundene-tabelle | Lesezugriff auf den plattformweiten Katalog (D-03). |
| apps/api/src/tenders/tenders.controller.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Plattformweiter Poll-Status, admin-verwaltet, kein `tenantId`. |
| apps/api/src/tenders/tenders.module.ts | tenderSourcePollConfig | keine-mandantengebundene-tabelle | Singleton-Bestückung beim Boot — im Dateikopf explizit als "global, RLS-exempt (D-03)" begründet. |
| apps/api/src/tenders/tender-triage.service.ts | tenderTriage | muss-mandantengebunden | Favorisierungs-/Ablehnungsstatus eines Nutzers, `tenantId`-Spalte vorhanden. |
| apps/api/src/user/admin-seed.service.ts | tenant | keine-mandantengebundene-tabelle | Legt beim ersten Start den Standard-Mandanten selbst an — `Tenant` hat keine `tenantId`-Spalte. |
| apps/api/src/user/admin-seed.service.ts | user | bewusst-uebergreifend | Erstanlage des Administrators beim ersten Start: läuft einmalig beim Boot, BEVOR irgendein Mandantenkontext existiert, um den allerersten Mandanten samt Admin-Nutzer anzulegen — es gibt zu diesem Zeitpunkt strukturell keinen Mandanten, an den gebunden werden könnte. |
| apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins. |
| apps/api/src/user/user.service.ts | user | muss-mandantengebunden | Dieselbe Begründung. |
## Was diese Etappe NICHT entscheidet
- Ob Controller künftig über `req.tenantPrisma` statt eines erneuten
`forTenant()`-Aufrufs im Service gehen (offener Befund oben).
- Wie die WINDOWS-#19-Policy für `SearchProvider`/`TenderRssFeedSource`
am Ende genau lautet — nur, dass sie vor dem Scharfschalten gelöst sein
muss.
- Die Reihenfolge und Zuschnitt der Etappe-2-Pläne — dafür ist die
Klassen-Verteilung oben der Arbeitsvorrat, siehe `<next_stages>` im
Plan `260909-eor-PLAN.md`.