--- 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 `` 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*