--- phase: quick-260911-fh9 plan: 01 subsystem: auth tags: [prisma, postgresql, row-level-security, multi-tenancy, nestjs, jwt] requires: - phase: quick-260909-eor provides: drei SECURITY-DEFINER-Funktionen fuer den Anmeldeweg (auth_lookup_user_by_username/email/reset_token, Migration 20260909160000_auth_lookup_functions) - phase: quick-260910-das provides: UserService.findByIdForPlatformAdmin (gebundener Fan-out je Mandant) und der Praezedenzfall resolveTargetUser in user.controller.ts provides: - getMe/changePassword/adminResetPassword binden je ueber genau einen Klienten tenantPrisma an den Mandanten aus dem signierten Sitzungsnachweis - adminResetPassword schliesst die Rechteausweitung ueber die Mandantengrenze (T-FH9-01) und innerhalb des Mandanten (T-FH9-04, ADMIN darf keinen SUPER_ADMIN zuruecksetzen) - AuthModule importiert UserModule (zyklusfrei) fuer den gebundenen Fan-out der obersten Rolle - dreizehnter Abschnitt runAuthAreaChecks im Wegwerf-Werkzeug (10 neue Pruefungen, 120/120 gesamt) - Klassifikationsdokument und WINDOWS.md auf den neuen Stand nachgezogen affects: [quick-260911-favorites-settings, etappe-3-mandantentrennung] actuals: tokens: 26271 tasks: 3 commits: 3 plan_head_before: 4c3172b5a5f3469501afead181f3ecf90a5fdbfe tech-stack: added: [] patterns: - "Zwei-Klienten-Testnachbau (__makeBoundClient) statt Identitaets-Attrappe fuer forTenant() in Service-Spec-Dateien" - "Controller loest den Mandanten der obersten Rolle vor dem Dienstaufruf ueber einen gebundenen Fan-out auf (resolveTargetTenantId), der Dienst nimmt den fertigen Mandanten entgegen" key-files: created: - apps/api/src/auth/auth.controller.spec.ts modified: - apps/api/src/auth/auth.service.ts - apps/api/src/auth/auth.service.spec.ts - apps/api/src/auth/auth.controller.ts - apps/api/src/auth/auth.module.ts - apps/api/scripts/rls-scratch-check.mjs - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-zugriffsklassifikation.md - .planning/WINDOWS.md key-decisions: - "Selbstbedienung (getMe/changePassword) bindet an @CurrentUser().tenantId (das JWT-Claim), NICHT an req.tenantId (per x-tenant-id fuer SUPER_ADMIN umschaltbar) — ein umgeschalteter SUPER_ADMIN muss sich selbst weiterhin sehen" - "adminResetPassword loest den Mandanten des ZIELS auf: ADMIN -> currentUser.tenantId, SUPER_ADMIN -> UserService.findByIdForPlatformAdmin(userId), derselbe Praezedenzfall wie user.controller.ts resolveTargetUser" - "Die drei $queryRaw-Anmeldesuchen (validateUser/requestPasswordReset/resetPassword) bleiben unveraendert auf dem ungebundenen Klienten — sie sind die Grenze, nicht der Umbau" - "adminResetPassword verweigert einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04); der Schwesterweg PATCH /users/:id hat dieselbe Luecke nicht geschlossen — WINDOWS #29 statt Reparatur, weil ausserhalb der Erlaubnisliste" patterns-established: - "runAuthAreaChecks im Wegwerf-Werkzeug: getrennt von runAuthLookupChecks (Anmeldeweg vs. Nach-Anmeldung), erweitert eine bereits vorhandene Wegwerf-Tabelle um fehlende Spalten statt sie neu anzulegen" requirements-completed: [WINDOWS-18, ETAPPE-2-AUTH] coverage: - id: D1 description: "getMe/changePassword/adminResetPassword binden je ueber tenantPrisma an den Mandanten aus dem Sitzungsnachweis" requirement: WINDOWS-18 verification: - kind: unit ref: "apps/api/src/auth/auth.service.spec.ts — AuthService.getMe/changePassword/adminResetPassword (20 Faelle)" status: pass - kind: integration ref: "apps/api/scripts/rls-scratch-check.mjs — runAuthAreaChecks (10 Pruefungen ueber den generierten Client an einer auf 15 Spalten erweiterten Wegwerf-Tabelle, gegen tessera-ctl-db-1)" status: pass human_judgment: false - id: D2 description: "adminResetPassword schliesst die Rechteausweitung ueber die Mandantengrenze (T-FH9-01) und innerhalb des Mandanten (T-FH9-04)" requirement: ETAPPE-2-AUTH verification: - kind: unit ref: "apps/api/src/auth/auth.service.spec.ts — 'FREMDER Mandant: BadRequestException...' und 'Aufrufer ADMIN, Ziel SUPER_ADMIN...ForbiddenException...'" status: pass - kind: integration ref: "apps/api/scripts/rls-scratch-check.mjs — auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut" status: pass human_judgment: false - id: D3 description: "auth.controller.ts liest den Mandanten ausschliesslich aus dem Sitzungsnachweis bzw. dem gebundenen Fan-out fuer die oberste Rolle — nicht aus req.tenantId/x-tenant-id" requirement: ETAPPE-2-AUTH verification: - kind: unit ref: "apps/api/src/auth/auth.controller.spec.ts — Mandantenquelle je Handler (9 Faelle) plus Rollen-/Public-Metadaten (14 Faelle)" status: pass human_judgment: false - id: D4 description: "Klassifikationsdokument und WINDOWS.md sind auf den neuen Stand nachgezogen (auth.service.ts/user gebunden, zwei neue offene Ledger-Eintraege)" requirement: ETAPPE-2-AUTH verification: - kind: unit ref: "apps/api/src/prisma/rls-access-inventory.spec.ts (11 Tests, insbesondere der Stand-Vergleich gegen den Quelltext)" status: pass human_judgment: false duration: 40min completed: 2026-09-11 status: complete --- # Quick Task 260911-fh9: Bereich auth der Mandantentrennung Etappe 2 Summary **`getMe`, `changePassword`, `adminResetPassword` binden je über genau einen Klienten `tenantPrisma` an den Mandanten aus dem signierten Sitzungsnachweis; `adminResetPassword` schließt sowohl die Rechteausweitung über die Mandantengrenze (T-FH9-01) als auch innerhalb des Mandanten (T-FH9-04); der Anmeldeweg (drei SECURITY-DEFINER-Funktionen) bleibt unangetastet.** ## Performance - **Duration:** ~40 min - **Started:** 2026-09-11 (Baseline-Messung: 911 Tests grün, Werkzeug 110/110) - **Completed:** 2026-09-11T10:01:47Z - **Tasks:** 3 - **Files modified:** 9 (8 geändert, 1 neu) ## Accomplishments - Die Grenze zwischen Anmeldeweg (drei `SECURITY DEFINER`-Funktionen, `20260909160000_auth_lookup_functions`, unverändert) und Nach-Anmeldung (drei gebundene Methoden) ist gemessen und im Werkzeug (`runAuthAreaChecks`, 10 neue Prüfungen, 120/120 insgesamt) und in der Kritikschrift (`docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h1)–(h5)) festgehalten. - `getMe`, `changePassword`, `adminResetPassword` nehmen den Mandanten als ersten Parameter und laufen je über genau EINEN Klienten `tenantPrisma`; die drei `$queryRaw`-Anmeldesuchen bleiben unverändert auf dem ungebundenen Klienten. - `adminResetPassword` verweigert einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04); `auth.controller.ts` löst den Mandanten der obersten Rolle über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf (Präzedenzfall `user.controller.ts` `resolveTargetUser`). - Die Testlage hat keine Identitätsattrappe mehr — `auth.service.spec.ts` (29 Fälle) und die neue `auth.controller.spec.ts` (23 Fälle) nageln Bindung, Rollenverzweigung und Metadaten fest; sieben Falsifizierungsnachweise durchgeführt und zurückgenommen. - Fünf handgepflegte Dokumentstellen der Klassifikation nachgezogen und derivativ gegatet; zwei neue offene Ledger-Einträge (#28, #29). ## Task Commits 1. **Aufgabe 1: Fehlerrichtung messen und aufschreiben** — `9782bea` (docs) 2. **Aufgabe 2: Die drei Methoden binden** — `92aa8c4` (feat) 3. **Aufgabe 3: Mandantenquelle festnageln, Klassifikation nachziehen, Ledger** — `f68beb3` (docs) **Plan metadata:** wird vom Orchestrator nach dieser SUMMARY committet. ## Files Created/Modified - `apps/api/scripts/rls-scratch-check.mjs` — dreizehnter Abschnitt `runAuthAreaChecks`, neuer Helfer `readSchemaModelScalarFieldNames` - `docs/mandantentrennung-etappe2-fehlerrichtung.md` — Abschnitt `## Bereich auth` (h1)–(h5) vor `## Verweis` - `apps/api/src/auth/auth.service.ts` — `getMe(tenantId, userId)`, `changePassword(tenantId, userId, ...)`, `adminResetPassword(tenantId, callerRole, userId, ...)` - `apps/api/src/auth/auth.service.spec.ts` — Zwei-Klienten-Nachbau (`__makeBoundClient`), 29 Fälle - `apps/api/src/auth/auth.controller.ts` — `resolveTargetTenantId`, `me`/`changePassword`/`adminResetPassword` reichen das Claim durch - `apps/api/src/auth/auth.module.ts` — importiert `UserModule` - `apps/api/src/auth/auth.controller.spec.ts` — NEU, 23 Fälle - `docs/mandantentrennung-zugriffsklassifikation.md` — Übersichtszeile, Summenzeile, Bestandsaufnahme-Zeile, Klassen-Verteilung-Vermerk, Hintergrunddienst-Vermerk, Etappe-3-Punkt - `.planning/WINDOWS.md` — Einträge #28, #29 ## Decisions Made - Mandantenquelle für Selbstbedienung: das JWT-Claim (`@CurrentUser().tenantId`), nicht `req.tenantId` — siehe key-decisions oben. - `adminResetPassword`s Mandant für die oberste Rolle: gebundener Fan-out über `UserService.findByIdForPlatformAdmin`, nicht `req.tenantId`/`x-tenant-id`. - Rollengrenze innerhalb des Mandanten in `adminResetPassword` geschlossen; Schwesterweg `PATCH /users/:id` bewusst NICHT angefasst (außerhalb der Erlaubnisliste) — Ledger-Eintrag #29 statt Reparatur. ## Tatsächlich gezählte Prüfungs- und Testzahlen - **Wegwerf-Werkzeug:** 120/120 Prüfungen bestanden (110 bisherige + 10 neue, wie im Plan gezählt: `auth-anmeldefunktionen-security-definer-unveraendert`, `auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients`, `auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz`, `auth-getme-generierter-client-ungebunden-liefert-null`, `auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer`, `auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null`, `auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut`, `auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt`, `auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut`, `auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf`). - **Testsuite:** Baseline 911 Tests/59 Dateien → nach Aufgabe 2: 928 Tests (927 grün, 1 erwartungsgemäß rot — siehe unten) → nach Aufgabe 3: **951 Tests grün in 60 Dateien** (29 Fälle in `auth.service.spec.ts`, 23 Fälle in der neuen `auth.controller.spec.ts`, 927 + 24 = 951). - **Typprüfung:** sauber nach jeder Aufgabe. ## Abweichung von den Planungsbefunden (ausdrücklich benannt) **Zwischenzeitlich rot: `rls-access-inventory.spec.ts`, zwischen Aufgabe 2 und Aufgabe 3.** Der Plan sagt in Aufgabe 3 voraus: *"Ohne Schritt 3 ist rls-access-inventory.spec.ts am Ende dieser Aufgabe rot (Stand-Vergleich)."* Das galt nicht nur für Aufgabe 3, sondern bereits ab dem Ende von Aufgabe 2: sobald `auth.service.ts` keinen ungebundenen `user`-Zugriff mehr enthielt, maß die Prüfung den Stand für `apps/api/src/auth/auth.service.ts::user` als `gebunden`, während das Klassifikationsdokument (noch nicht nachgezogen, das ist Aufgabe 3) weiterhin `gemischt` führte — ein Fehlschlag von genau einem Test (`der eingetragene Stand stimmt mit dem im Quelltext gemessenen überein`), 927/928 grün. Dasselbe Muster zeigt sich bereits im Klassifikationsdokument selbst für den Vorgänger-Plan 260911-cwh ("Aufgabe 2 (260911-cwh) ändert nur seine Stand-Spalte … nicht seine Klasse" — im Abschnitt zu Aufgabe 3 dokumentiert, obwohl die Codeänderung in Aufgabe 2 lag). Behandlung: nicht als Blocker gewertet, weil (a) der Fehlschlag exakt einen einzigen, im Plan selbst vorausgesagten Test betraf, (b) er keine Datei außerhalb der für Aufgabe 2 erlaubten vier Dateien berührte, und (c) Aufgabe 3 unmittelbar im selben Lauf folgte und die Baseline innerhalb von Minuten wiederherstellte (951/951). Der Commit von Aufgabe 2 dokumentiert das ausdrücklich als "bekannt und erwartet". Kein Datenverlust, keine stillschweigende Planabweichung — nur eine Klarstellung, dass "Baseline gehalten nach jeder Aufgabe" hier als "nach dem vollständigen Plan, mit einem im Plan selbst vorausgesagten Zwischenzustand" zu lesen ist, nicht als literarische Bedingung jedes einzelnen Aufgaben-``-Blocks. **Testfall-Namensraumkollision im Gate `forTenant: vi.fn((p` (Aufgabe 2).** Die neue Mock-Signatur `forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId))` — wortgleich mit dem Muster aus `user.service.spec.ts`/`tenant.controller.spec.ts` — erfüllte unbeabsichtigt das BRE-Suchmuster `forTenant: vi.fn((p` des Gates, das die ALTE Identitäts-Attrappe `forTenant: vi.fn((p) => p)` ausschließen sollte (`vi.fn((p` ist ein Präfix von `vi.fn((prisma`). Behoben durch Umbenennung des ersten Parameters auf `unboundClient` statt `prisma`. Keine Verhaltensänderung, nur eine Namenswahl, die das Gate nicht fälschlich trifft. Alle übrigen Zahlen, Codeaussagen (Befunde B, C, D, E, J, K) und die Modulgraph-Messung stimmten bei der erneuten Ausführung zur Ausführungszeit exakt mit den Planungsbefunden überein — keine weiteren Abweichungen. ## Falsifizierungsnachweise (alle durchgeführt, zurückgenommen, wörtlich notiert) **Aufgabe 2 (drei, im Dienst):** 1. **`getMe` probeweise auf den ungebundenen Klienten zurückgebaut** (`const tenantPrisma = this.prisma as any;`): 4 Tests wurden rot (`AuthService.getMe` — alle vier Fälle), jeweils mit `TypeError: Cannot read properties of undefined (reading 'findUnique')`. Erwartete Form bestätigt: der Nachbau hat kein ungebundenes Benutzermodell. 2. **`validateUser` probeweise auf den gebundenen Klienten verschoben** (`const probeBoundClient = forTenant(this.prisma, 'falsification-probe') as any; const rows = await probeBoundClient.$queryRaw...`): 9 Tests wurden rot, darunter der eigens für die Grenze geschriebene Fall (`AuthService.validateUser — lokales Kennwort > sucht die Anmeldedaten exakt EINMAL ungebunden...`) mit `TypeError: probeBoundClient.$queryRaw is not a function`. Erwartete Form bestätigt: der gebundene Nachbau hat kein `$queryRaw`. 3. **SUPER_ADMIN-Riegel in `adminResetPassword` probeweise entfernt**: genau 1 Test wurde rot (`AuthService.adminResetPassword > Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04)...`), mit `AssertionError: promise resolved "undefined" instead of rejecting`. **Aufgabe 3 (eine, am Controller):** 4. **`resolveTargetTenantId` probeweise auf `return currentUser.tenantId;` (auch für SUPER_ADMIN) reduziert**: genau die beiden Fan-out-Fälle wurden rot — `expected "spy" to be called 1 times, but got 0 times` (findByIdForPlatformAdmin nicht aufgerufen) und `promise resolved "{ message: ... }" instead of rejecting` (die Null-Fan-out-BadRequestException griff nicht mehr). **Aufgabe 3 (zwei, an den Dokument-Gates):** 5. **Bestandsaufnahme-Zeile `auth.service.ts | user` probeweise auf `gemischt` zurückgesetzt**: `rls-access-inventory.spec.ts` wurde rot mit `AssertionError: Abweichender Stand (Dokument vs. Quelltext): apps/api/src/auth/auth.service.ts::user — dokumentiert=gemischt, gemessen=gebunden`. 6. **Übersichtszeile `auth` probeweise auf `99 | 10` gesetzt**: das herleitende Shell-Gate schlug fehl mit `UEBERSICHTSZEILE auth nennt nicht die neu gemessenen Zahlen 3/10`. ## Deviations from Plan Keine inhaltlichen Abweichungen von den vier Dateien/Aufgaben des Plans — beide oben dokumentierten Punkte sind Klarstellungen zur Ausführungsreihenfolge bzw. eine Namenswahl, keine Scope- oder Verhaltensänderung. Kein Rule-1/2/3/4-Auto-Fix war nötig; alle Codeaussagen aus den Planungsbefunden wurden bei erneuter Ausführung bestätigt. ## Issues Encountered Keine ungelösten Probleme. Das einzige während der Ausführung aufgetretene technische Detail (Gate-Namenskollision, siehe Abweichungen oben) wurde sofort behoben. ## Etappe-3-Vorbehalt (ein Absatz) Sobald Anmeldenamen je Mandant eindeutig werden (Etappe-3-Entscheidung (1)), braucht der Anmeldeweg den Mandanten VOR der Benutzersuche: `auth_lookup_user_by_username(p_username)` muss auf `(p_tenant_id, p_username)` umgestellt werden — die Funktion wird dabei ENGER (zwei Gleichheitsbedingungen statt einer), nicht weiter — und `local.strategy.ts` braucht eine Mandantenangabe vor der Suche. Dieser Plan ist dafür neutral: die Bindung der drei Nach-Anmeldungs-Methoden hängt ausschließlich am JWT-Claim `tenantId` und an `User.id` (plattformweite UUID, Kette aus 260911-cwh), nicht an `username`/`email`. Nichts in diesem Plan hat den künftigen Umbau schwerer gemacht. ## User Setup Required None — keine externe Dienstkonfiguration nötig. ## Next Phase Readiness - Elf der zwölf Bereiche der Etappe 2 sind umgestellt. Laut Plan ist der nächste Lauf `favorites` (7 ungebundene Rohtreffer) und `settings` (4) als EIN Durchlauf — danach ist Etappe 2 vollständig. - Kein Blocker für diesen nächsten Lauf. Zwei neue offene WINDOWS-Einträge (#28 Frontend-Leere, #29 Schwesterweg-Rechteausweitung) sind dokumentiert und unabhängig von `favorites`/`settings`. - `DATABASE_URL` zeigt unverändert auf die Rolle `tessera` (Schalter aus); Schema, Migrationen, die drei Anmeldefunktionen, Active Directory: alle unangetastet. --- *Phase: quick-260911-fh9* *Completed: 2026-09-11* ## Self-Check: PASSED Alle neun in `key-files` genannten Dateien plus diese SUMMARY existieren auf der Platte; alle drei Task-Commits (`9782bea`, `92aa8c4`, `f68beb3`) sind in `git log --oneline --all` auffindbar. Keine fehlenden Elemente.