--- phase: quick-260911-fh9 plan: 01 type: execute wave: 1 depends_on: [] autonomous: true requirements: [WINDOWS-18, ETAPPE-2-AUTH] files_modified: - apps/api/scripts/rls-scratch-check.mjs - docs/mandantentrennung-etappe2-fehlerrichtung.md - 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/src/auth/auth.controller.spec.ts - docs/mandantentrennung-zugriffsklassifikation.md - .planning/WINDOWS.md estimate: tokens: 180000 raw_tokens: 180000 tasks: 3 confidence: low must_haves: truths: - "Die Grenze zwischen Anmeldeweg und Nach-Anmeldung ist GELESEN und GEMESSEN, nicht angenommen: `validateUser`, `requestPasswordReset`, `resetPassword` suchen VOR bekanntem Mandanten ueber die drei SECURITY-DEFINER-Funktionen auf dem UNGEBUNDENEN Klienten und bleiben es (drei `$queryRaw`-Stellen, Migration unangetastet, `pg_proc` bestaetigt SECURITY DEFINER, STABLE, festen Suchpfad und LIMIT 1 in der Wegwerf-Datenbank); `getMe`, `changePassword`, `adminResetPassword` laufen NACH der Anmeldung, der Mandant steht im signierten Sitzungsnachweis, und genau diese drei werden gebunden." - "Der Mandant der drei gebundenen Methoden stammt ausschliesslich aus dem Sitzungsnachweis (`@CurrentUser().tenantId`, das von `login()` aus der Funktionszeile signierte Claim) — NICHT aus `req.tenantId` (das fuer SUPER_ADMIN per `x-tenant-id` umschaltbar ist und den Anfragenden sich selbst gegenueber unsichtbar machen wuerde), NICHT aus Pfad, Rumpf oder Kopfzeile. Fuer SUPER_ADMIN bei `adminResetPassword` kommt der Mandant des ZIELS aus dem gebundenen Fan-out `UserService.findByIdForPlatformAdmin` (Praezedenzfall `user.controller.ts`, 260910-das). Gates zaehlen `req.tenantId` und `x-tenant-id` in `auth.controller.ts` auf null." - "Die Rechteausweitung ueber die Mandantengrenze ist geschlossen und gemessen: ein ADMIN von Mandant A, der `POST /auth/admin-reset-password/:userId` fuer einen Benutzer von Mandant B aufruft, findet unter dem gebundenen Klienten keine Zeile (Pruefung 6/9 im Werkzeug, ueber den GENERIERTEN Client) und bekommt die bestehende 400-Meldung ohne Aussage ueber den fremden Mandanten; als Testfall festgenagelt und durch Rueckbau falsifiziert." - "Die Rechteausweitung INNERHALB des Mandanten ist in diesem Handler geschlossen (ein ADMIN setzt das Kennwort eines SUPER_ADMIN nicht mehr — `ForbiddenException`, Testfall) und fuer den Schwesterweg `PATCH /users/:id` (ausserhalb der Erlaubnisliste) gemessen, in (h4) festgehalten und als OFFENER Ledger-Eintrag mit konkreter Reparatur uebergeben." - "Die umgekehrte Fehlerrichtung ist je Methode in ihrer tatsaechlichen Auspraegung benannt und gemessen: `getMe` liefert `null` — der Controller antwortet 200 mit leerem Rumpf, `fetchCurrentUser` macht daraus `null`, `header.tsx` und `account-settings-form.tsx` verschlucken das in eine Portalhuelle OHNE angemeldeten Benutzer (kein Name, keine Admin-Navigation) — NICHT laut, sondern die Familie von WINDOWS #26; `changePassword` liest sich im Frontend als `networkError` (nicht als falsches Kennwort — `auth-actions.ts` uebersetzt nur die eine Meldung); `adminResetPassword` als `User not found` ohne UI-Aufrufer. Steht in (h2)/(h3), als Ledger-Eintrag, mit Etappe-4-Vorabpruefung." - "Die Testlage traegt: `auth.service.spec.ts` hat KEINE Identitaets-Attrappe mehr, sondern zwei unterscheidbare Klienten (`__makeBoundClient`), der ungebundene Nachbau hat KEIN Benutzermodell und der gebundene KEIN `$queryRaw` — beide Grenzen falsifizierbar; `auth.controller.spec.ts` existiert neu und nagelt Mandantenquelle, Rollenverzweigung, Rollen- und Public-Metadaten fest." - "Etappe 3 wird durch diesen Plan nicht schwerer: die Bindung haengt am Claim `tenantId` und an `User.id` (plattformweite UUID, Kette aus 260911-cwh), nicht an `username`/`email`; was Etappe 3 am Anmeldeweg umbauen muss (Mandant VOR der Suche, Funktionen mit zwei Gleichheitsbedingungen — ENGER, nicht weiter), steht in (h4)(a) und im Klassifikationsdokument." - "Alle fuenf handgepflegten Dokumentstellen der Klassifikation sind nachgezogen und DERIVIERT gegatet (Uebersichtszeile aus der Messanweisung, Summenzeile, Bestandsaufnahme-Zeile, Klassen-Verteilung mit Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Punkt); der Umfang ist als ERLAUBNISLISTE gegen `6236b30` gegatet." - "Baseline gehalten am Ende JEDER Aufgabe: mindestens 911 Tests gruen (nach Aufgabe 2 und 3 mehr), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (mindestens 120). Der Schalter bleibt AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei angefasst, nichts in Active Directory, die drei Anmeldefunktionen unangetastet." artifacts: - "apps/api/scripts/rls-scratch-check.mjs — ein dreizehnter Abschnitt `runAuthAreaChecks` (getrennt von `runAuthLookupChecks`) mit mindestens zehn namentlich benannten Pruefungen, davon mindestens sechs ueber den GENERIERTEN Client an der auf den vollen Spaltensatz gebrachten Wegwerf-Tabelle `User` (Vergleich zur Laufzeit gegen die SKALAREN Felder von `model User` — neuer Helfer, der Relationsfelder ueber ihren Typ ausschliesst); `pg_proc`-Messung der drei Funktionen" - "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich auth` unmittelbar VOR `## Verweis` mit (h1) Messung, (h2) Signaltabelle je Pfad (Anmeldeweg UND Nach-Anmeldung), (h3) Leere als Abwesenheit im Backend UND Frontend, (h4) bewusst nicht geloest (darunter Etappe 3, Schwesterweg, Frontend), (h5) bewusst nicht angefasst" - "apps/api/src/auth/auth.service.ts — `getMe(tenantId, userId)`, `changePassword(tenantId, userId, ...)`, `adminResetPassword(tenantId, callerRole, userId, ...)` je mit EINEM Klienten `tenantPrisma`; die drei `$queryRaw`-Anmeldesuchen unveraendert auf dem ungebundenen Klienten; Kopfkommentare nennen Grenze, Quelle des Mandanten und Etappe-3-Vorbehalt" - "apps/api/src/auth/auth.service.spec.ts — Zwei-Klienten-Nachbau, alle bestehenden Faelle umgestellt, neue Faelle fuer die drei Methoden, Grenz-Test (Anmeldesuche ungebunden, Schreibzugriff gebunden), Wachhund" - "apps/api/src/auth/auth.controller.ts — `me`/`changePassword` reichen `user.tenantId` aus dem Sitzungsnachweis durch; `adminResetPassword` verzweigt nach Rolle (ADMIN: eigener Mandant; SUPER_ADMIN: Fan-out ueber `UserService.findByIdForPlatformAdmin`), Kopfkommentar nennt Grund und Praezedenzfall" - "apps/api/src/auth/auth.module.ts — importiert `UserModule` (zyklusfrei, gemessen)" - "apps/api/src/auth/auth.controller.spec.ts — NEU: Mandantenquelle je Handler, Rollenverzweigung, unbekanntes Ziel, Rollen-Metadaten (`ROLES_KEY`) und Public-Metadaten (`IS_PUBLIC_KEY`) fuer alle sieben Handler" - "docs/mandantentrennung-zugriffsklassifikation.md — Uebersichtszeile `auth` mit neu gemessenen Zahlen, Summenzeile, Bestandsaufnahme-Zeile `auth.service.ts`/`user` auf `gebunden`, Klassen-Verteilung mit Stand-Vermerk (unveraendert, ausdruecklich), Hintergrunddienst-Abschnitt (kein sechster Fall, gemessen), `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Anmeldeweg-Punkt" - ".planning/WINDOWS.md — zwei neue OFFENE Eintraege ueber `gsd-tools windows append`: die verschluckte Leere im Frontend (Familie #23/#25/#26) und die Rechteausweitung ADMIN -> SUPER_ADMIN im Schwesterweg `PATCH /users/:id`" key_links: - "`JwtStrategy.validate` liefert `{ id: payload.sub, username, role, tenantId }`; `login()` signiert `tenantId: user.tenantId` aus der Zeile von `auth_lookup_user_by_username`; `@CurrentUser()` gibt genau dieses Objekt zurueck — das ist die einzige Mandantenquelle der drei Methoden. `TenantGuard` setzt daneben `req.tenantId` (fuer SUPER_ADMIN per `x-tenant-id` umschaltbar, vier Sender im Marktplatz-Frontend) — fuer Selbstbedienung UNGEEIGNET, weil die eigene Zeile im eigenen Mandanten liegt." - "`UserService.findByIdForPlatformAdmin(id)` (user.service.ts) liest `Tenant` ungebunden (keine Regel, gemessen 260911-e2s) und sucht je Mandant gebunden — liefert die Zeile samt `tenantId`; `AuthModule` importiert dafuer `UserModule` (UserModule importiert GroupsModule, GroupsModule importiert nichts, kein Modul ausser AppModule importiert AuthModule — zyklusfrei, gemessen)." - "Die drei Funktionen (20260909160000) sind SECURITY DEFINER mit festem Spaltensatz und LIMIT 1; ihre `$queryRaw`-Aufrufe MUESSEN auf `this.prisma` bleiben — ein gebundener `$queryRaw` liefe durch `$allOperations` mit gesetztem Kontext (fuer SECURITY DEFINER wirkungslos, aber ein falsches Signal fuer jeden Leser). Der Nachbau im Test hat auf dem gebundenen Klienten KEIN `$queryRaw`." - "`getMe` liefert `null` -> NestJS `ExpressAdapter.reply` sendet bei `isNil(body)` einen leeren Rumpf mit 200 -> `fetchCurrentUser` (`auth-actions.ts`) laeuft in `response.json()` auf den leeren Rumpf, faengt und liefert `null` -> `header.tsx` `if (u)` und `account-settings-form.tsx` `if (u)` tun nichts. Zur Ausfuehrungszeit an allen vier Gliedern nachzulesen." - "Die Wegwerf-Tabelle `User` des Werkzeugs hat heute 10 der 15 skalaren Spalten (fehlend: createdAt, updatedAt, lastLoginAt, avatarPath, accentColor); `findUnique` ohne `select` (die Form von `changePassword`/`adminResetPassword`) und das `select` von `getMe` (nennt avatarPath/accentColor) scheitern auf dem generierten Client mit P2022, solange die Spalten fehlen — die dashboard-Lehre (260910-krx, Pruefung 5b)." --- Etappe 2 der Mandantentrennung, elfter Bereich: `auth`. Fuenf ungebundene Zugriffe auf das Benutzermodell in `auth.service.ts`, verteilt auf drei Methoden, die in Etappe 1 (260909-eor) BEWUSST liegen gelassen wurden: `getMe`, `changePassword`, `adminResetPassword`. Alle drei laufen nach der Anmeldung, der Mandant steht im signierten Sitzungsnachweis — aber keine der drei nimmt ihn heute entgegen. Binden heisst hier: Signaturen aendern, jeden Aufrufer in `auth.controller.ts` umstellen, und dabei entscheiden, WOHER der Mandant kommt. Zweck: dieser Bereich traegt die Grenze, an der die ganze Mandantentrennung haengt. Der Anmeldeweg selbst (Benutzer suchen, BEVOR der Mandant bekannt ist) wurde in Etappe 1 ueber drei enge SECURITY-DEFINER-Funktionen geloest, und nichts in diesem Plan darf diese Anordnung anfassen — die Grenze zwischen "vor der Anmeldung, auf den Funktionen" und "nach der Anmeldung, gebunden" wird hier gelesen, gemessen und festgenagelt, nicht angenommen. Der zweite Grund: `adminResetPassword` ist ein Administrator, der auf einen ANDEREN Benutzer wirkt, und prueft heute nicht, ob dieser Benutzer im eigenen Mandanten liegt. Mit einem Mandanten ist das harmlos; mit zweien ist es Rechteausweitung ueber die Mandantengrenze. Die Bindung an den Mandanten aus dem Sitzungsnachweis schliesst das — sofern der Mandant wirklich aus der Sitzung kommt und nicht aus etwas, das der Administrator selbst schicken koennte. Ueber diesem Bereich steht die Etappe-3-Entscheidung: Anmeldenamen werden je Mandant eindeutig. Dann muss der Anmeldeweg den Mandanten VOR der Suche kennen — ein Umbau der Funktionsanordnung, NICHT dieser Auftrag. Dieser Plan darf nichts entscheiden, was das schwerer macht, und muss sagen, was Etappe 3 hier wieder aufmachen wird. Ergebnis: eine gemessene Kritikschrift, drei gebundene Methoden mit geaenderten Signaturen, ein Controller, der den Mandanten ausschliesslich aus dem Sitzungsnachweis nimmt (und fuer die oberste Rolle aus dem gebundenen Fan-out), eine Testdatei ohne Identitaets-Attrappe und eine neue fuer den Controller, Dokumente, die am Ende nachweislich mit dem Quelltext uebereinstimmen, und zwei Ledger-Eintraege fuer das, was gemessen, aber bewusst nicht in diesem Bereich behoben wird. **Der Schalter bleibt AUS.** `DATABASE_URL` zeigt weiterhin auf die Rolle `tessera` mit `BYPASSRLS`. Schema und Migrationen werden NICHT angefasst, die drei Anmeldefunktionen NICHT. Nichts wird in Active Directory geaendert. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/STATE.md @docs/mandantentrennung-zugriffsklassifikation.md @docs/mandantentrennung-etappe2-fehlerrichtung.md @docs/mandantentrennung-datenbankrolle.md @apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql @apps/api/prisma/migrations/20260618112124_auth_multi_tenancy/migration.sql @apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql @apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql @apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql @apps/api/src/prisma/prisma-tenant.extension.ts @apps/api/src/prisma/rls-access-inventory.spec.ts @apps/api/scripts/rls-scratch-check.mjs @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/src/auth/strategies/jwt.strategy.ts @apps/api/src/auth/strategies/local.strategy.ts @apps/api/src/auth/decorators/current-user.decorator.ts @apps/api/src/auth/decorators/public.decorator.ts @apps/api/src/auth/decorators/roles.decorator.ts @apps/api/src/auth/guards/roles.guard.ts @apps/api/src/auth/dto/admin-reset-password.dto.ts @apps/api/src/tenant/tenant.guard.ts @apps/api/src/user/user.service.ts @apps/api/src/user/user.controller.ts @apps/api/src/user/user.module.ts @apps/api/src/user/user.service.spec.ts @apps/api/src/dashboard/dashboard.service.spec.ts @apps/api/src/tenant/tenant.controller.spec.ts @apps/web/src/lib/auth-actions.ts @apps/web/src/components/layout/header.tsx @apps/web/src/components/settings/account-settings-form.tsx @.planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-PLAN.md @.planning/quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/260909-eor-SUMMARY.md Alle Zahlen unten sind zur Planungszeit am 2026-09-11 gegen HEAD `6236b30` GEMESSEN, mit der jeweils angegebenen Anweisung. Sie leiten die Untersuchung, sie sind KEINE Bearbeitungsvollmacht — jede Datei wird vor jeder Aenderung erneut gelesen, jede Zahl zur Ausfuehrungszeit erneut gemessen, und weicht eine Messung ab, gilt die Messung und nicht dieser Plan. Baseline zur Planungszeit selbst nachgemessen: 911 Tests in 59 Dateien gruen, Typpruefung sauber, Werkzeug 110/110 gegen `tessera-ctl-db-1` (Adresse `172.19.0.2`). **Befund A — die Zahl haelt: acht ungebundene Rohtreffer, fuenf gebundene, und die fuenf umzustellenden sind das Benutzermodell in drei Methoden.** `grep -rno "this\.prisma\.[a-zA-Z]*" apps/api/src/auth | grep -v spec`: acht Treffer, alle in `auth.service.ts`. DREI davon sind `this.prisma.` OHNE Modellnamen — die `$queryRaw`-Aufrufe der drei Anmeldefunktionen (Zeilen 80, 188, 225): `$` liegt nicht in `[a-zA-Z]`, deshalb registriert die Bestandsaufnahme sie nicht als Modellzugriff, waehrend die Uebersichtstabelle sie als Rohtreffer zaehlt (ihr Muster laesst null Buchstaben zu). Die uebrigen FUENF sind das Benutzermodell: `getMe` (`findUnique` Zeile 273), `changePassword` (`findUnique` 312, `update` 326), `adminResetPassword` (`findUnique` 359, `update` 368). Gebunden (`grep -rno "tenantPrisma\.[a-zA-Z]*\." apps/api/src/auth | grep -v spec`): fuenf — `tenantPrisma.user.` in `validateUser` (115, 128) und `resetPassword` (250), `tenantPrisma.passwordResetToken.` in `requestPasswordReset` (208) und `resetPassword` (259). Das Paar `passwordResetToken` ist damit VOLLSTAENDIG gebunden (Stand `gebunden` im Dokument stimmt) und bleibt unangetastet. Nach diesem Plan: ungebunden 3 (nur die drei `$queryRaw`), gebunden 10; Summenzeile 83 -> 78 und 162 -> 167; Bestandsaufnahme-Zeile `auth.service.ts`/`user` von `gemischt` auf `gebunden`; 64 Paare und Klassen-Verteilung 32/17/13/2 UNVERAENDERT. Alle Zahlen zur Ausfuehrungszeit aus den Messanweisungen des Dokuments ableiten, nicht von hier abschreiben. **Befund B — die Grenze zwischen Anmeldeweg und Nach-Anmeldung, Glied fuer Glied gelesen.** ANMELDEWEG (Mandant VOR der Suche unbekannt): `validateUser` (aufgerufen von `local.strategy.ts` `validate()`, Route `POST /auth/login`, `@Public()`), `requestPasswordReset` (`POST /auth/request-reset`, `@Public()`), `resetPassword` (`POST /auth/reset-password`, `@Public()`). Alle drei suchen ueber `this.prisma.$queryRaw` mit `SELECT * FROM auth_lookup_*` — die drei SECURITY-DEFINER-Funktionen aus `20260909160000_auth_lookup_functions` (SECURITY DEFINER, STABLE, `SET search_path = public, pg_temp`, `LIMIT 1`, fester Spaltensatz, EXECUTE nur fuer `tessera_app`) — und binden DANACH mit `forTenant(this.prisma, )` fuer jeden Schreibzugriff. NACH-ANMELDUNG (Mandant bekannt): `getMe` (`GET /auth/me`), `changePassword` (`POST /auth/change-password`), `adminResetPassword` (`POST /auth/admin-reset-password/:userId`, `@Roles(ADMIN, SUPER_ADMIN)`) — keine davon `@Public()`, alle hinter dem globalen `JwtAuthGuard`; `req.user` kommt aus `jwt.strategy.ts` `validate()` als `{ id: payload.sub, username, role, tenantId }`, und `login()` signiert `tenantId: user.tenantId` aus der Funktionszeile. `logout` und `login` (Cookie) beruehren keine Datenbank. Die drei zu bindenden Methoden liegen also VOLLSTAENDIG auf der Nach-Anmeldungs-Seite; ihr Mandant ist ein signiertes Claim, das vor jedem Datenbankzugriff dieser Methoden vorliegt. `auth.service.ts:64-73` (Kopfkommentar von `validateUser`) und `docs/mandantentrennung-datenbankrolle.md:98-105` beschreiben die Anordnung korrekt und brauchen keine Aenderung. Der Etappe-1-SUMMARY (`260909-eor-SUMMARY.md:51,143`) bestaetigt, dass die drei Methoden ABSICHTLICH fuer Etappe 2 liegen blieben. **Befund C — die Mandantenquelle der drei Methoden ist das Claim, NICHT `req.tenantId`.** `tenant.guard.ts:42-56`: `req.tenantId` ist `user.tenantId`, fuer SUPER_ADMIN aber durch die Kopfzeile `x-tenant-id` ERSETZBAR (D-10; vier Sender in `apps/web/src/app/(portal)/marketplace/page.tsx` und `marketplace/[slug]/page.tsx`, gemessen mit `grep -rn "x-tenant-id" apps/web/src`). `getMe` und `changePassword` suchen die EIGENE Zeile des Anfragenden — die liegt in seinem eigenen Mandanten, nie im umgeschalteten. Ein SUPER_ADMIN, der gerade mit `x-tenant-id: B` den Marktplatz von B ansieht, wuerde sich unter `req.tenantId` selbst nicht finden: `getMe` null, Portalhuelle ohne Benutzer. Deshalb: Selbstbedienung bindet an `@CurrentUser().tenantId` (das Claim), wie `user.controller.ts` es fuer seine fuenf Selbstbedienungswege tut (`currentUser.tenantId`). `auth.controller.ts` liest heute weder `req.tenantId` noch `x-tenant-id` (`grep -n "req.tenantId\|x-tenant-id" apps/api/src/auth/auth.controller.ts`: null Treffer) — das bleibt so und wird gegatet. Ob `/auth/me` die Kopfzeile ueberhaupt bekommt (`auth-actions.ts:252-258` sendet nur `Cookie`), ist dabei unerheblich: die Entscheidung haengt an der Bauform, nicht am heutigen Aufrufer. **Befund D — `adminResetPassword` prueft heute KEINEN Mandanten, hat KEINEN Frontend-Aufrufer, und der Rollen-Praezedenzfall steht in `user.controller.ts`.** Controller (`auth.controller.ts:117-131`): `@Param('userId')`, Rumpf `AdminResetPasswordDto` (`newPassword`, `mustChangePassword` — KEIN Mandantenfeld, gemessen), kein `@CurrentUser()`. Dienst (`auth.service.ts:354-377`): `findUnique` ueber die Kennung, ungebunden — jeder Benutzer jedes Mandanten. Heute (ein Mandant, BYPASSRLS) harmlos; nach dem zweiten Mandanten setzt ein ADMIN von A das Kennwort eines Benutzers von B und meldet sich als dieser an (T-FH9-01). `grep -rn "admin-reset-password\|adminResetPassword" apps/web/src apps/api/src | grep -v "auth.service\|auth.controller"`: nur die DTO-Datei — der Weg hat KEINEN Aufrufer im Frontend und keinen im Backend; die Benutzerverwaltung setzt Kennwoerter ueber `PATCH /users/:id` mit `password` im `UpdateUserDto` (`UserService.update` hasht). Der Endpunkt ist damit ein UI-loser ZWEITER Weg zur selben Wirkung, ohne die Mandantenpruefung, die der Schwesterweg hat. Rollenverzweigung, wortgleicher Praezedenzfall `user.controller.ts` `resolveTargetUser` (260910-das): ADMIN -> `findById(currentUser.tenantId, id)` (gebunden an den EIGENEN Mandanten); SUPER_ADMIN -> `findByIdForPlatformAdmin(id)` (Fan-out je Mandant, gebunden im Rumpf; die uebergreifende Sicht der obersten Rolle ist GEWOLLT und "darf NICHT an den Mandanten des Aufrufers gebunden werden — das waere eine stille Funktionsminderung", `user.service.ts:207-230`). ENTSCHEIDUNG: exakt spiegeln. Controller: ADMIN -> `tenantId = currentUser.tenantId`; SUPER_ADMIN -> `userService.findByIdForPlatformAdmin(userId)`, nicht gefunden -> die heutige `BadRequestException('User not found')`, sonst `tenantId = ziel.tenantId`. Dienst: `adminResetPassword(tenantId, callerRole, userId, newPassword, mustChangePassword)` mit EINEM Klienten `tenantPrisma`. Dafuer importiert `AuthModule` das `UserModule` (exportiert `UserService`): `UserModule` importiert `GroupsModule`, `GroupsModule` importiert NICHTS (`grep -n "imports:" apps/api/src/groups/groups.module.ts`: null Treffer), kein Modul ausser `app.module.ts` importiert `AuthModule` (`grep -rn "AuthModule" apps/api/src --include=*.module.ts`) — zyklusfrei. `LdapModule`, das `AuthModule` bereits importiert, importiert `UserModule` selbst. Erwogen und verworfen: SUPER_ADMIN ueber `req.tenantId`/`x-tenant-id` — haette ohne Kopfzeile die Reichweite der obersten Rolle still auf den eigenen Mandanten verkuerzt und waere vom Schwesterweg `PATCH /users/:id` abgewichen. **Befund E — die Rechteausweitung INNERHALB des Mandanten, die dieser Bereich misst und nur zur Haelfte behebt.** `adminResetPassword` vergleicht heute KEINE Rollen: ein ADMIN setzt das Kennwort eines SUPER_ADMIN, der im selben Mandanten liegt, und meldet sich als Plattform-Administrator an. Derselbe Fall im Schwesterweg: `user.controller.ts` `update` (um Zeile 170-215) verhindert mit T-02-08 nur das ZUWEISEN der Rolle SUPER_ADMIN (`dto.role === Role.SUPER_ADMIN`), nicht das Aendern eines Benutzers, der diese Rolle bereits HAT — `password` im DTO geht durch; `deactivate`/`delete` (um 240-250) ebenso. Kein Mandantenproblem, gefunden, weil dieser Durchlauf jede Zeile aufschlaegt (dieselbe Art Fund wie der wirkungslose Selbstloesch-Riegel in 260910-das). ENTSCHEIDUNG: in DIESEM Handler schliessen (der Dienst kennt nach dem gebundenen `findUnique` die Rolle des Ziels; ist sie SUPER_ADMIN und der Aufrufer nicht, `ForbiddenException`); der Schwesterweg liegt ausserhalb der Erlaubnisliste dieses Plans und wird als OFFENER Ledger-Eintrag mit konkreter Reparatur uebergeben (T-FH9-05). Zur Ausfuehrungszeit an den drei Handlern erneut nachzulesen; faellt die Lesung anders aus, gilt die Lesung. **Befund F — die Testlage: eine Identitaets-Attrappe, Testnamen, die Bindung BEHAUPTEN, und drei Methoden ohne einen einzigen Test.** `auth.service.spec.ts` (302 Zeilen): `vi.mock` auf das Bindungshilfsmittel als `(p) => p` (Zeile 7-9, Kommentar verweist auf ein Muster in `ldap.service.spec.ts`, das dort ebenfalls noch steht). Vier `describe`-Bloecke, 13 Faelle: `validateUser` LDAP (8), `validateUser` lokal (1), `requestPasswordReset` (2), `resetPassword` (2). Die drei Faelle mit "mandantengebunden" im Namen pruefen `prisma.user.update`/ `prisma.passwordResetToken.create` auf dem UNGEBUNDENEN Nachbau — sie bestuenden auch, wenn kein einziger Aufruf gebunden waere. KEIN Fall fuer `getMe`, `changePassword`, `adminResetPassword`. KEINE `auth.controller.spec.ts` (`ls apps/api/src/auth/`). Vorlagen: `user.service.spec.ts` (`makeFakePrisma` mit `rawUser`/`makeScopedUser`, `boundCallLog`, P2025/P2002-Nachbau — dieselbe Tabelle), `tenant.controller.spec.ts` (Controller-Attrappen, Rollen-Metadaten ueber `Reflect.getMetadata(ROLES_KEY, ...)`, `reflect-metadata`), `dashboard.service.spec.ts` ab Zeile 545 (Wachhund `vi.mocked(forTenant).mock.calls.length`). **Befund G — die Wegwerf-Tabelle `User` des Werkzeugs hat 10 der 15 skalaren Spalten; der generierte Client braucht alle.** `runAuthLookupChecks` (`rls-scratch-check.mjs:268-281`) legt `"User"` mit `id, username, email, tenantId, passwordHash, ldapDn, isActive, role, displayName, mustChangePassword` an; `model User` in `schema.prisma` hat dazu `createdAt`, `updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` (und vier RELATIONSFELDER `tenant`, `passwordResetTokens`, `groupMemberships`, `moduleGrants`, die keine Spalten sind). `findUnique` ohne `select` (Form von `changePassword`/`adminResetPassword`) und das `select` von `getMe` (nennt `avatarPath`, `accentColor`) scheitern auf dem generierten Client mit P2022, solange die fuenf fehlen — die dashboard-Lehre (260910-krx, Pruefung 5b). Typen aus den ausgelieferten Migrationen: `createdAt TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`, `updatedAt TIMESTAMP(3) NOT NULL` (ohne Vorgabe — fuer die vorhandenen Wegwerf-Zeilen braucht es eine, Abweichung wie bei `Tenant` in 260911-e2s), `lastLoginAt TIMESTAMP(3)` (alle drei aus `20260618112124`, CREATE TABLE "User"), `avatarPath TEXT` (`20260630095533_add_user_avatar`), `accentColor TEXT` (`20260702000000_add_user_accent_color`). `readSchemaModelFieldNames()` zaehlt Relationsfelder mit (Befund M aus 260911-e2s) — fuer `User` ist ein Helfer noetig, der Felder ueber ihren TYP ausschliesst (zweites Wort der Zeile, `?`/`[]` abgestreift, ist der Name eines anderen `model` im Schema -> keine Spalte; `Role` ist ein `enum`, kein Modell, und bleibt). Zustand der Wegwerf-Datenbank an der Stelle, wo der neue Abschnitt laeuft (aus der Ausgabe von 260911-e2s, zur Ausfuehrungszeit ueber die Wartungsrolle nachzumessen, nicht anzunehmen): `Tenant` hat A und B (C geloescht), der Fremdschluessel `User_tenantId_fkey` ist nachgeruestet, `User` hat je zwei Zeilen in A und B (`user-a`, `user-a2`, `user-b`, `user-b2`); `runTransactionShapeMeasurement` und `runConcurrencyProbe` fassen `"User"` nicht an (260911-e2s, Befund M) — der neue Abschnitt ist ein Blatt: NACH `runTenantAreaChecks`, VOR `runTransactionShapeMeasurement`. **Befund H — was "die Anmeldefunktionen unangetastet" MESSBAR heisst.** Auf der Codeseite: die drei `$queryRaw`-Stellen bleiben auf `this.prisma` (ein gebundener `$queryRaw` liefe laut Kopf von `prisma-tenant.extension.ts` durch `$allOperations` mit gesetztem Kontext — fuer eine SECURITY-DEFINER- Funktion wirkungslos, aber ein falsches Signal fuer jeden Leser, der daraus "die Suche ist gebunden" liest), `git diff --name-only 6236b30 -- apps/api/prisma` bleibt leer. Auf der Datenbankseite: das Werkzeug spielt die Migration in `runAuthLookupChecks` bereits ein (`tessera_app` -> Wegwerf-Rolle); der neue Abschnitt liest `pg_proc` (`prosecdef`, `provolatile`, `proconfig`, `pg_get_functiondef`) und misst, dass alle drei Funktionen SECURITY DEFINER, STABLE, mit `search_path=public, pg_temp` und `LIMIT 1` sind, und dass `auth_lookup_user_by_username('alice')` nach der Spaltenerweiterung weiterhin genau EINE Zeile mit genau NEUN Spalten liefert — der feste Spaltensatz laesst die fuenf neuen Spalten NICHT durch. Das ist die Messung von "nichts weitet aus, was die Funktionen preisgeben". **Befund I — Etappe 3 und was sie hier wieder aufmacht.** Die Etappe-3- Entscheidung (1) macht `username` (und `email`) je Mandant eindeutig. Davon betroffen, alles NICHT dieser Auftrag: `auth_lookup_user_by_username(p_username)` (stuetzt sich auf `username @unique` plattformweit; braucht kuenftig `(p_tenant_id, p_username)` — die Funktion wird dabei ENGER, zwei Gleichheitsbedingungen statt einer, nicht weiter), gegebenenfalls `auth_lookup_user_by_email`, `local.strategy.ts` (kennt nur `username`/`password` — braucht eine Mandantenangabe VOR der Suche, etwa Mandantenkuerzel im Anmeldeformular oder aus dem Host), die `@unique`-Indizes auf `User` (`@@unique([tenantId, username])`), `UserService.findByUsername`, `resolveEmailForWrite` (ldap), die P2002-Uebersetzung in `UserService.create`/ `update` (WINDOWS #22). Was dieser Plan tut, ist dafuer NEUTRAL: die Bindung haengt am Claim `tenantId` (das es unabhaengig davon gibt, WIE der Anmeldeweg den Mandanten ermittelt) und an `User.id` (`@default(uuid())`, plattformweit eindeutig — Kette aus 260911-cwh: Schema -> `auth.service.ts` `sub: user.id` -> `JwtStrategy.validate` -> `@CurrentUser().id`). Was Etappe 3 SCHWERER gemacht haette und deshalb unterbleibt: Selbstbedienung ueber `req.tenantId` binden; irgendwo nach der Anmeldung den Mandanten aus `username`/`email` ableiten; die Funktionen um Spalten erweitern. **Befund J — kein Hintergrunddienst, keine Transaktion, keine Relationseinbindung.** `grep -rn "@Cron\|onModuleInit\|onApplicationBootstrap\|setInterval\|setTimeout" apps/api/src/auth --include=*.ts` und `grep -rn '\$transaction(' apps/api/src/auth --include=*.ts`: je null Treffer. `grep -rn "include:\|_count\|select:" apps/api/src/auth --include=*.ts | grep -v spec`: genau EIN Treffer, das `select` in `getMe` (Zeile 275) — ausschliesslich skalare Felder von `User`, keine Relation (Fehler 9 des Vorhabens, WINDOWS #27: hier keine Auspraegung). Andere `prisma`-Nennungen im Bereich (`roles.decorator.ts`, `roles.guard.ts`, `auth.controller.ts`) sind der Typ-Import `Role` aus `@prisma/client`, kein Datenbankzugriff. Kein sechster Fall der Hintergrunddienst-Falle. **Befund K — die umgekehrte Fehlerrichtung je Methode, gelesen (zur Ausfuehrungszeit an jedem Glied erneut nachzulesen).** (1) `getMe` liefert `null`; der Controller gibt `null` zurueck; NestJS' Express-Adapter sendet bei `isNil(body)` einen LEEREN Rumpf mit Status 200 (nachzulesen in `apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js`, Methode `reply`); `fetchCurrentUser` (`auth-actions.ts:243-270`) laeuft mit `response.json()` auf den leeren Rumpf, faengt im `catch` und liefert `null`; `header.tsx:26-40` (`if (u) setUser(...)`) und `account-settings-form.tsx:46-53` (`if (u) ...`) tun bei `null` NICHTS. Folge: die Portalhuelle rendert OHNE angemeldeten Benutzer — kein Name, kein Avatar, `isAdmin` falsch, Admin-Navigation weg — und `fetchCurrentUser` liefert fuer "nicht angemeldet" und "Zeile unsichtbar" denselben Wert. Das ist NICHT laut (der Auftrag vermutete "laut"), sondern die Familie von WINDOWS #23/#25/#26. Die Seite `change-password/page.tsx:20` haengt an demselben Aufruf; `ForcePasswordChangeInterceptor` laesst `/auth/me` und `/auth/change-password` ausdruecklich durch (Zeilen 56-58) — der erzwungene Kennwortwechsel braucht `getMe`. (2) `changePassword`: gebundener `findUnique` liefert `null` -> `UnauthorizedException('User not found or has no local password')`, 401 -> `auth-actions.ts:128-133` uebersetzt NUR `Current password is incorrect` in `wrongCurrentPassword`, alles andere in `networkError` — der Nutzer sieht eine Netzwerkfehler-Meldung fuer einen Trennungsfehler (der Auftrag vermutete "falsches altes Kennwort"; gemessen ist es `networkError`). (3) `adminResetPassword`: `BadRequestException('User not found')`, 400, kein UI-Aufrufer — fuer einen API-Aufrufer "diesen Benutzer gibt es nicht", und fuer ein fremdmandantiges Ziel ist das nach der Bindung die RICHTIGE Antwort (keine Existenzaussage ueber einen fremden Mandanten, T-DAS-08-Form). (4) Anmeldeweg: unveraendert — `validateUser` null -> 401 `Invalid credentials`, vom Scharfschalten nicht betroffen (Funktionen). Etappe-4-Vorabpruefung: fuer einen bekannten Benutzer die Zeile ueber die Wartungsrolle lesen und den gebundenen `findUnique` unter seinem Claim-Mandanten daneben halten. **Befund L — die Buchfuehrung nach diesem Plan.** Uebersichtszeile `auth`: heute 8/5; danach 3/10 (die drei `$queryRaw`-Rohtreffer bleiben und sind KEINE Modellzugriffe — das gehoert in den Hinweis). Summenzeile: 83 -> 78, 162 -> 167. Bestandsaufnahme: `auth.service.ts`/`user` von `gemischt` auf `gebunden`, Begruendung neu; `auth.service.ts`/`passwordResetToken` bleibt `gebunden`, unveraendert. Klassen-Verteilung: 64 Paare, 32/17/13/2, KEINE Verschiebung — als `**Stand 260911-fh9**`-Absatz ausdruecklich festgehalten (die Form von 260910-krx/260911-cwh). Hintergrunddienst-Abschnitt: ein PLAIN-Absatz, kein sechster Fall (Befund J). `Was diese Etappe NICHT entscheidet`: ein NEUER Punkt zum Etappe-3-Umbau des Anmeldewegs (Befund I). Keine Aenderung an `docs/anleitung-entwicklung.md` (nennt keine der drei Methoden, gemessen) und keine an `docs/mandantentrennung-datenbankrolle.md` (Abschnitt 3 beschreibt die Anordnung korrekt). Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — die Grenze zwischen Anmeldeweg (Funktionen) und Nach-Anmeldung (gebunden), ueber den generierten Client Der lokale Datenbank-Container `tessera-ctl-db-1` laeuft; `docker inspect tessera-ctl-db-1` liefert eine Adresse. Ohne ihn kann das Wegwerf-Werkzeug nichts messen und die Aufgabe ist zu stoppen, nicht zu schaetzen. apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md docs/mandantentrennung-datenbankrolle.md (Abschnitt 3, den Absatz zum Anmeldeweg VOLLSTAENDIG — bevor irgendetwas angefasst wird), apps/api/prisma/migrations/20260909160000_auth_lookup_functions/migration.sql (VOLLSTAENDIG), apps/api/scripts/rls-scratch-check.mjs (Kopf, `report`, `forTenantQuery`, `withAdminPrisma`, `runAuthLookupChecks` VOLLSTAENDIG (Anlage von "User", Einspielen der Migration), `sqlStateOf`, `runUserAreaChecks` (Kopfkommentar zur Reihenfolgebedingung), `readSchemaModelFieldNames`, `runCalendarAreaChecks` (Pruefung 8 und die Client-Pruefungen 9-12), `runTenantAreaChecks` VOLLSTAENDIG (Spaltenerweiterung von "Tenant", Fremdschluessel, welche Zeilen am Ende stehen), `buildInlineExtendedClient`, `main`), apps/api/prisma/schema.prisma (model User, model PasswordResetToken, enum Role), apps/api/prisma/migrations/20260618112124_auth_multi_tenancy/migration.sql (CREATE TABLE "User"), apps/api/prisma/migrations/20260630095533_add_user_avatar/migration.sql, apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql, apps/api/src/auth/auth.service.ts (VOLLSTAENDIG), apps/api/src/auth/auth.controller.ts (VOLLSTAENDIG), apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/strategies/local.strategy.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.ts, apps/api/src/tenant/tenant.guard.ts, apps/api/src/user/user.controller.ts (`resolveTargetUser`, `update`, `deactivate`, `delete`), apps/api/src/user/user.service.ts (`findByIdForPlatformAdmin` samt Kopfkommentar), apps/web/src/lib/auth-actions.ts (`changePasswordAction`, `fetchCurrentUser`), apps/web/src/components/layout/header.tsx (den `useEffect` um Zeile 26), apps/web/src/components/settings/account-settings-form.tsx (den `useEffect` um Zeile 46), apps/web/src/app/(portal)/change-password/page.tsx, apps/api/node_modules/@nestjs/platform-express/adapters/express-adapter.js (Methode `reply`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitte `## Bereich user` (u1)-(u5) und `## Bereich tenant` (n1)-(n5) vollstaendig, als Form) TEIL 1 — die Messung. Erweitere `apps/api/scripts/rls-scratch-check.mjs` um einen dreizehnten Abschnitt `runAuthAreaChecks(adminUrl, scratchRoleUrl, results)` — ausdruecklich GETRENNT vom bestehenden `runAuthLookupChecks`, der die drei Anmeldefunktionen misst — und rufe ihn in `main()` NACH `runTenantAreaChecks` und VOR `runTransactionShapeMeasurement` auf. Der Abschnitt setzt auf der vorhandenen Wegwerf-Tabelle `"User"` auf (aus `runAuthLookupChecks`, mit Zeilenschutz, wortgleicher Regel, seit `runTenantAreaChecks` mit Fremdschluessel auf `"Tenant"`) und auf der eingespielten Funktions-Migration; keine spaetere Pruefung setzt auf seinen Aenderungen auf (Befund G). Halte die Reihenfolgebedingung im Kopfkommentar fest, wie `runUserAreaChecks` es vormacht, und nenne dort in EINEM Satz, warum dieser Abschnitt neben `runAuthLookupChecks` steht: jener misst den Anmeldeweg VOR bekanntem Mandanten (Funktionen), dieser die drei Methoden NACH der Anmeldung (gebundener Modellzugriff) — die Grenze, die dieser Plan festnagelt. Vorbereitung ueber die Wartungsrolle: (a) `ALTER TABLE "User"` um die fuenf fehlenden Spalten `createdAt`, `updatedAt`, `lastLoginAt`, `avatarPath`, `accentColor` mit den Typen aus den drei ausgelieferten Migrationen (Befund G); `updatedAt` bekommt fuer die vorhandenen Wegwerf-Zeilen eine Vorgabe `CURRENT_TIMESTAMP`, die die Migration nicht hat — vermerke das im Kommentar als Abweichung, die nur das Nachruesten betrifft (Prisma setzt den Wert clientseitig, `@updatedAt`). (b) Lege einen neuen Helfer `readSchemaModelScalarFieldNames(modelName)` neben `readSchemaModelFieldNames` an: er liest wie jener die Feldzeilen des Modells, ermittelt aber zusaetzlich die Menge aller `model `-Namen des Schemas und laesst jedes Feld weg, dessen Typ (zweites Wort der Zeile, `?` und `[]` abgestreift) ein solcher Modellname ist — Relationsfelder haben keine Spalte (Befund M aus 260911-e2s, dort fuer `Tenant` ueber die Migration umgangen; hier ist die Spaltenmenge ueber drei Migrationen verteilt, deshalb der Weg ueber das Schema mit Relationsfilter). `Role` ist ein `enum` und bleibt. (c) Miss ueber die Wartungsrolle, welche `User`-Zeilen und Mandanten tatsaechlich vorhanden sind (Befund G nennt den erwarteten Stand — nicht annehmen), und benutze fuer die Pruefungen unten einen Benutzer aus TENANT-A (`user-a`, Kennwort-Hash `hash-a`) und den Mandanten TENANT-B als "fremden Administrator". Alle Pruefungen laufen unter der Rolle ohne `BYPASSRLS`; Vergleichswerte kommen ueber die Wartungsrolle. Mindestens zehn namentlich benannte Pruefungen, jede mit einer Belegausgabe, die die beobachteten Werte nennt (Konstruktorname und `code` woertlich, wo ein Fehler erwartet wird — rate das Ergebnis nicht vorweg): 1. `auth-anmeldefunktionen-security-definer-unveraendert` — ueber die Wartungsrolle `pg_proc` fuer `proname LIKE 'auth_lookup_%'`: genau DREI Zeilen (`auth_lookup_user_by_username`, `auth_lookup_user_by_email`, `auth_lookup_reset_token`), jede mit `prosecdef = true`, `provolatile = 's'`, `proconfig` enthaelt `search_path=public, pg_temp`, und `pg_get_functiondef(oid)` enthaelt `LIMIT 1`. Die Belegausgabe nennt die drei Namen und die vier Eigenschaften je Funktion. Das ist die Datenbankseite von "nichts an der Anordnung angefasst". 2. `auth-wegwerftabelle-user-deckt-alle-spalten-des-generierten-clients` — Spaltenmenge der Wegwerf-Tabelle aus `information_schema.columns` identisch mit `readSchemaModelScalarFieldNames('User')` (Befund G: fuenfzehn). Steht VOR den Client-Pruefungen; faellt sie durch, bricht der Abschnitt ab (Form von Pruefung 8 in `runCalendarAreaChecks`). 3. `auth-anmeldesuche-findet-benutzer-weiterhin-mit-festem-spaltensatz` — unter der Wegwerf-Rolle `SELECT * FROM auth_lookup_user_by_username('alice')`: genau EINE Zeile mit genau NEUN Schluesseln, und weder `avatarPath` noch `accentColor` noch `email` darunter — der feste Spaltensatz der Funktion laesst die Spaltenerweiterung NICHT durch. Belegausgabe nennt die Schluessel. 4. `auth-getme-generierter-client-ungebunden-liefert-null` — die tragende Belegzeile: `prisma.user.findUnique` mit der Kennung von `user-a` und dem `select`, das `getMe` heute stellt (die zehn Felder aus `auth.service.ts`, Zeile 275-286, zur Laufzeit dort abzulesen), UNGEBUNDEN auf dem generierten Client: `null`, waehrend die Wartungsrolle die Zeile sieht. Die Belegausgabe sagt beim Namen: das ist der Wert, den `GET /auth/me` nach dem Scharfschalten als leeren Rumpf ausliefert. 5. `auth-getme-generierter-client-gebunden-eigener-mandant-findet-benutzer` — dieselbe Abfrage ueber `buildInlineExtendedClient(prisma, 'TENANT-A')`: Zeile gefunden, `tenantId === 'TENANT-A'`, genau die zehn selektierten Schluessel. 6. `auth-getme-generierter-client-gebunden-fremder-mandant-liefert-null` — dieselbe Abfrage gebunden unter TENANT-B: `null`. Belegausgabe: ein Administrator von TENANT-B sieht `user-a` nicht — die Datenbankseite von T-FH9-01. 7. `auth-changepassword-generierter-client-ungebundenes-update-scheitert-laut` — die Schreibform, die `changePassword` heute stellt: `prisma.user.update({ where: { id: 'user-a' }, data: { passwordHash: 'hash-a-neu-ungebunden', mustChangePassword: false } })` UNGEBUNDEN: bestanden genau dann, wenn ein Fehler geworfen wird UND die Wartungsrolle danach noch `hash-a` liest. Konstruktorname und `code` woertlich. 8. `auth-changepassword-generierter-client-gebundenes-update-eigener-mandant-gelingt` — dasselbe `update` mit `passwordHash: 'hash-a-neu'` gebunden unter TENANT-A: gelingt, die Wartungsrolle liest `hash-a-neu`, `updatedAt` ist nicht null (bestaetigt nebenbei, dass die Wegwerf-Tabelle die clientseitig gesetzten Werte annimmt). 9. `auth-adminreset-generierter-client-gebundenes-update-fremder-mandant-scheitert-laut` — `update` mit `passwordHash: 'hash-a-fremd'` auf `user-a`, gebunden unter TENANT-B: bestanden genau dann, wenn ein Fehler geworfen wird UND die Wartungsrolle danach weiterhin `hash-a-neu` liest. Belegausgabe: ein ADMIN von TENANT-B kann das Kennwort von `user-a` nicht setzen — gemessen, nicht behauptet. 10. `auth-fan-out-je-mandant-gebunden-loest-mandant-der-kennung-auf` — die Form, die Aufgabe 2/3 fuer die oberste Rolle benutzt: `prisma.tenant.findMany` ungebunden als Treiber (Tenant ohne Regel, gemessen in 260911-e2s), dann je Mandant `buildInlineExtendedClient(prisma, t.id).user.findUnique({ where: { id: 'user-a' } })`: genau EIN Treffer, unter TENANT-A, mit `tenantId === 'TENANT-A'`. Das belegt, dass die Kennung allein den Mandanten des Ziels ergibt — weil `User.id` plattformweit eindeutig ist. Ergaenzt wird nur, gestrichen wird nicht; nenne im SUMMARY die tatsaechlich gezaehlte Zahl, nicht diese. TEIL 2 — die Codeaussagen, jede mit ihrer Reichweite. Fuehre die Nachpruefungen aus den Befunden B, C, D, E, J und K tatsaechlich aus und notiere jeweils die Anweisung oder Datei-und-Zeile, damit jede Aussage widerlegbar bleibt: (B) die Grenze Glied fuer Glied — je Methode: Route, `@Public()` ja/nein, Suche ueber Funktion oder Modell, Bindung nach dem Fund; dazu `jwt.strategy.ts` `validate()` und `login()` fuer die Herkunft des Claims; (C) `req.tenantId` und `x-tenant-id` in `auth.controller.ts` (null Treffer), die Kopfzeilen-Sender im Frontend, die Selbstbedienungsform in `user.controller.ts`; (D) alle Aufrufer von `adminResetPassword`/ `admin-reset-password` in `apps/api/src` und `apps/web/src`, das DTO ohne Mandantenfeld, `resolveTargetUser` als Praezedenzfall, die Modulgraphen- Messung (`grep -n "imports:" apps/api/src/groups/groups.module.ts`, `grep -rn "AuthModule" apps/api/src --include=*.module.ts`); (E) die drei Handler `update`, `deactivate`, `delete` in `user.controller.ts` auf eine Rollenpruefung des ZIELS — Zeilen nennen; (J) die drei Anweisungen aus Befund J; (K) die vier Glieder der `getMe`-Kette (Adapter `reply`, `fetchCurrentUser`, `header.tsx`, `account-settings-form.tsx`) und die Uebersetzungstabelle in `changePasswordAction`. Faellt eine Nachpruefung ANDERS aus als in den Planungsbefunden, gilt die Messung; schreibe sie auf und benenne die Abweichung ausdruecklich. Findet (E) KEINE Luecke, entfaellt der Ledger-Eintrag T-FH9-05 in Aufgabe 3, und das steht dann im SUMMARY mit den gelesenen Zeilen. TEIL 3 — die Kritikschrift. Erweitere `docs/mandantentrennung-etappe2-fehlerrichtung.md` um einen Abschnitt `## Bereich auth` unmittelbar VOR `## Verweis`, in der Form der vorhandenen Bereichsabschnitte, mit fuenf Unterabschnitten unter dem Buchstaben `h` (von aut-h; `a` ist durch die Abschnitte (a)-(e) des ldap-Kopfes belegt): - `### (h1) Die Messung` — die tatsaechlich beobachtete Werkzeugausgabe woertlich eingerueckt, die tragende Belegzeile (Pruefung 4) benannt, die Trennung von `runAuthLookupChecks` erklaert, die `pg_proc`-Messung (Pruefung 1) und der feste Spaltensatz (Pruefung 3) als Beleg dafuer, dass die Anmeldefunktionen unangetastet sind und nichts Zusaetzliches preisgeben. Nenne, welche Pruefungen ueber den generierten Client laufen und warum (`findUnique` ohne `select` und das `select` von `getMe` sind Client-Formen; Fehler 7 des Vorhabens), und die Spaltenerweiterung mit Relationsfilter (Befund G). - `### (h2) Signaltabelle je Pfad` — je Methode des Dienstes eine Zeile, BEIDE Seiten der Grenze: `validateUser`, `requestPasswordReset`, `resetPassword` (Anmeldeweg: Funktion, dann gebunden — vom Scharfschalten NICHT betroffen, mit der Pruefung aus `runAuthLookupChecks`, die das belegt), `login`/`logout` (kein Datenbankzugriff), `getMe`, `changePassword`, `adminResetPassword` (ADMIN-Zweig und SUPER_ADMIN-Zweig getrennt): Verhalten HEUTE nach dem Scharfschalten ohne diesen Plan, das konkrete Signal mit Statuscode und woertlicher Meldung, und in eigener Spalte, ob das Frontend es durchlaesst (mit Datei und Zeile). - `### (h3) Welcher Code Leere als Abwesenheit deutet` — die `getMe`-Kette aus Befund K mit allen vier Gliedern namentlich (Adapter, `auth-actions.ts`, `header.tsx`, `account-settings-form.tsx`, dazu `change-password/page.tsx` und der Interceptor), mit dem ausdruecklichen Satz, dass der Auftrag hier "laut" vermutete und die Lesung "verschluckt" ergibt — `fetchCurrentUser` liefert fuer "nicht angemeldet" und "Zeile unsichtbar" denselben Wert; `changePassword` mit der Uebersetzungstabelle (`networkError`, nicht "falsches Kennwort" — der Auftrag vermutete das Zweite); `adminResetPassword` ohne UI-Aufrufer und mit der Bemerkung, dass `User not found` fuer ein fremdmandantiges Ziel nach der Bindung die RICHTIGE Antwort ist. - `### (h4) Was dieser Durchlauf bewusst nicht löst` — (a) ETAPPE 3 in einem eigenen Absatz: was die Entscheidung (1) am Anmeldeweg wieder aufmacht (Befund I, jede Stelle namentlich), dass die Funktionen dabei ENGER werden (zwei Gleichheitsbedingungen), warum dieser Plan neutral ist (Claim und `User.id`, Kette aus 260911-cwh), und was er deshalb NICHT tut (`req.tenantId`, Ableitung aus `username`/`email`, Spaltenerweiterung der Funktionen); (b) die Rechteausweitung ADMIN -> SUPER_ADMIN im Schwesterweg `PATCH /users/:id` (und `deactivate`/`delete`) mit den gelesenen Zeilen, der Reparatur in einem Satz (Rolle des ZIELS pruefen, nicht nur die zugewiesene) und der Entscheidung fuer einen Ledger-Eintrag (ausserhalb der Erlaubnisliste; wird in Aufgabe 3 angelegt); (c) das Frontend, das `null` verschluckt (nicht angefasst; Ledger-Eintrag in Aufgabe 3, Familie #23/#25/#26); (d) die fehlende Existenzpruefung des `x-tenant-id`-Werts (bekannt aus (n4)(f), hier nur, weil sie die Entscheidung gegen `req.tenantId` stuetzt); (e) die Etappe-4-Vorabpruefung aus Befund K; (f) dass `changePassword` zwei verschiedene 401-Meldungen hat (kein lokales Kennwort vs. falsches Kennwort) — bestehend, nicht Teil dieses Auftrags. - `### (h5) Was dieser Durchlauf bewusst nicht anfasst` — die drei Funktionen und ihre Migration; die drei `$queryRaw`-Stellen; `local.strategy.ts` und `jwt.strategy.ts`; das DTO; `docs/mandantentrennung-datenbankrolle.md` Abschnitt 3 (beschreibt die Anordnung korrekt); `docs/anleitung-entwicklung.md` (nennt keine der drei Methoden); `user.controller.ts` (nur gelesen); das Frontend (nur beschrieben); Schema und Migrationen; die Kopfzeile in `auth.service.spec.ts` Zeile 4-6, die auf ein Muster in `ldap.service.spec.ts` verweist — wird in Aufgabe 2 ersetzt, hier nur als Befund F genannt. Aendere in dieser Aufgabe KEINE Datei unter `apps/api/src`, KEINE unter `apps/api/prisma` und KEINE unter `apps/web`. DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && OUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$OUT" && for K in 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; do echo "$OUT" | grep -q "^$K: bestanden" || { echo "FEHLENDE ODER FEHLGESCHLAGENE PRUEFUNG: $K"; exit 1; }; done && echo "$OUT" | grep -qE '^Alle [0-9]+ Pruefungen bestanden\.$' && N=$(echo "$OUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test "$N" -ge 120 || { echo "PRUEFUNGSZAHL: $N, erwartet mindestens 120 (110 bisherige plus mindestens 10 neue)"; exit 1; }; } && grep -q 'async function runAuthAreaChecks' apps/api/scripts/rls-scratch-check.mjs && grep -q 'async function runAuthLookupChecks' apps/api/scripts/rls-scratch-check.mjs && grep -q 'function readSchemaModelScalarFieldNames' apps/api/scripts/rls-scratch-check.mjs && grep -q 'pg_proc' apps/api/scripts/rls-scratch-check.mjs && grep -q 'pg_get_functiondef' apps/api/scripts/rls-scratch-check.mjs && awk '/await runTenantAreaChecks\(/{c=NR} /await runAuthAreaChecks\(/{n=NR} /await runTransactionShapeMeasurement\(/{t=NR} END{ if(!(c&&n&&t&&c&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -ge 911 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mindestens 911"; exit 1; }; } && npm --prefix apps/api run type-check && git rev-parse --verify 6236b30 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 6236b30 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 6236b30) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } `apps/api/scripts/rls-scratch-check.mjs` hat einen dreizehnten Abschnitt `runAuthAreaChecks`, getrennt von `runAuthLookupChecks`, in der richtigen Reihenfolge, mit mindestens zehn neuen, namentlich benannten Pruefungen, davon mindestens sechs ueber den generierten Client an einer Wegwerf-Tabelle `User`, deren Spaltenmenge zur Laufzeit gegen die skalaren Felder des Schemas geprueft wird (neuer Helfer mit Relationsfilter); die drei Anmeldefunktionen sind ueber `pg_proc` als SECURITY DEFINER, STABLE, mit festem Suchpfad und LIMIT 1 gemessen und geben nach der Spaltenerweiterung weiterhin genau neun Spalten preis; alle Pruefungen des Werkzeugs bestehen (mindestens 120). `docs/mandantentrennung-etappe2-fehlerrichtung.md` hat einen Abschnitt `## Bereich auth` unmittelbar vor `## Verweis` mit (h1) bis (h5), die tatsaechlich beobachtete Werkzeugausgabe woertlich, die Signaltabelle fuer BEIDE Seiten der Grenze, die `getMe`-Kette mit allen Gliedern und dem Befund "verschluckt, nicht laut", die `changePassword`-Uebersetzung als `networkError`, den Etappe-3-Absatz, den Schwesterweg mit gelesenen Zeilen. Baseline gehalten: mindestens 911 Tests gruen, Typpruefung sauber. Unter `apps/api/src`, `apps/api/prisma`, `apps/web` und den Compose-/Umgebungsdateien ist nichts geaendert. Aufgabe 2: Die drei Methoden binden, die Signaturen und jeden Aufrufer umstellen, den Mandanten ausschliesslich aus dem Sitzungsnachweis nehmen — und die Testlage von der Identitaets-Attrappe auf zwei Klienten heben 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/src/auth/auth.service.ts (VOLLSTAENDIG, erneut), apps/api/src/auth/auth.service.spec.ts (VOLLSTAENDIG), apps/api/src/auth/auth.controller.ts (VOLLSTAENDIG), apps/api/src/auth/auth.module.ts, apps/api/src/user/user.service.spec.ts (`makeFakePrisma`, `rawUser`, `makeScopedUser`, `throwNotFound`, `boundCallLog`), apps/api/src/user/user.controller.ts (`resolveTargetUser`, Kopfkommentar der Klasse), apps/api/src/user/user.service.ts (`findByIdForPlatformAdmin`), apps/api/src/user/user.module.ts, apps/api/src/dashboard/dashboard.service.spec.ts (Zeilen 545-570, Wachhund), apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile` — die Zuweisungsform `const X = forTenant(`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich auth` aus Aufgabe 1, besonders (h2) und (h4)(a)) ZUERST die Testlage, DANN der Umbau. Baue `apps/api/src/auth/auth.service.spec.ts` so um, dass die Identitaets-Attrappe verschwindet: `vi.mock` auf das Bindungshilfsmittel wird auf `prisma.__makeBoundClient(tenantId)` umgeleitet (Form von `user.service.spec.ts`, dieselbe Tabelle). Ein handgerollter `makeFakePrisma()` mit In-Memory-Zeilen fuer `user` (Map ueber die Kennung, Felder wie die Funktionszeilen plus `avatarPath`/`accentColor`/`role`) und einem Bindungsprotokoll `boundCallLog` (`{ tenantId, model, method, args }`): - Der UNGEBUNDENE Nachbau hat `$queryRaw` (die bestehende Tagged-Template- Attrappe `fakeQueryRaw`, die SQL-Textstuecke und Werte aufzeichnet und konfigurierbare Zeilen liefert) und `__makeBoundClient` — aber KEIN `user`- und KEIN `passwordResetToken`-Modell. Ein versehentlich ungebundener Modellzugriff scheitert mit "Cannot read properties of undefined" (die dkv-Form der Falsifizierung). - Der GEBUNDENE Klient (`__makeBoundClient(tenantId)`) bietet `user.findUnique` (liefert die Zeile nur, wenn ihr `tenantId` dem Klienten entspricht, sonst `null`; wendet ein uebergebenes `select` an), `user.update` (wirft bei unsichtbarer Zeile einen Fehler mit `code: 'P2025'` und der Prisma-Meldung "Record to update not found", sonst Merge und Rueckgabe), `passwordResetToken.create`/`update` — und KEIN `$queryRaw`. Eine gebundene Anmeldesuche scheitert damit ebenso hart wie ein ungebundener Modellzugriff. Jeder Aufruf schreibt ins Protokoll. Jeder Fall eigenstaendig, jeder mit sprechendem Namen: BESTEHENDE dreizehn Faelle (`validateUser` LDAP und lokal, `requestPasswordReset`, `resetPassword`): auf den Nachbau umstellen, KEINEN loeschen; die drei Faelle mit "mandantengebunden" im Namen pruefen ab jetzt das PROTOKOLL (Modell, Methode, Mandant `t1`), nicht mehr eine Attrappe auf dem ungebundenen Objekt. NEU dazu, die Grenze in einem Fall: `validateUser` mit lokalem Kennwort — die Suche lief GENAU EINMAL ueber das ungebundene `$queryRaw` (Aufzeichnung der Attrappe), der `lastLoginAt`-Schreibzugriff GENAU EINMAL ueber den gebundenen Klienten unter dem Mandanten der Funktionszeile, und das Bindungshilfsmittel wurde genau einmal mit diesem Mandanten aufgerufen. `getMe(tenantId, userId)`: - eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, `isLocalUser === true`, `hasAvatar` je nach `avatarPath`; die Schluessel `passwordHash`, `ldapDn`, `avatarPath` fehlen in der Antwort (T-gbh-03); - eigener Mandant, LDAP-Benutzer (kein Hash, `ldapDn` gesetzt): `isLocalUser === false`; - FREMDER Mandant (Klient unter `t2`, Zeile unter `t1`): `null`, kein Fehler; - genau EIN gebundener Klient je Aufruf (Wachhund ueber `vi.mocked(forTenant).mock.calls.length`, Muster `dashboard.service.spec.ts` Zeile 545). `changePassword(tenantId, userId, currentPassword, newPassword, response)`: - eigener Mandant, richtiges aktuelles Kennwort (echter argon2-Hash wie im bestehenden lokalen Fall): der gebundene `update` traegt einen neuen Hash (per `argon2.verify` gegen das neue Kennwort geprueft) und `mustChangePassword: false`; `jwtService.sign` wurde mit einer Nutzlast aufgerufen, die `tenantId: 't1'` und `mustChangePassword: false` traegt; `response.cookie` wurde mit `'session'`, dem signierten Wert und `httpOnly: true` aufgerufen; - FREMDER Mandant: `UnauthorizedException`, Meldung woertlich `User not found or has no local password`, KEIN Schreibzugriff, KEIN Cookie; - falsches aktuelles Kennwort: `Current password is incorrect`, kein Schreibzugriff; - LDAP-Benutzer (kein Hash): `UnauthorizedException`, kein Schreibzugriff; - genau EIN gebundener Klient je Aufruf (Suche und Schreiben auf demselben). `adminResetPassword(tenantId, callerRole, userId, newPassword, mustChangePassword)`: - eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundener `update` mit neuem Hash (per `argon2.verify` geprueft), `mustChangePassword` TRUE, wenn der Parameter weggelassen wird (Vorgabe bleibt); - `mustChangePassword: false` wird durchgereicht; - FREMDER Mandant: `BadRequestException`, Meldung woertlich `User not found`, KEIN Schreibzugriff — die Meldung nennt weder Halter noch Mandanten; - Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: `ForbiddenException`, KEIN Schreibzugriff (T-FH9-04); - Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt; - genau EIN gebundener Klient je Aufruf. Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten Zahl genannt. TEIL 1 — der Dienst. Stelle in `apps/api/src/auth/auth.service.ts` die drei Methoden um, je Methode EIN Klient in der Zuweisungsform, die `rls-access-inventory.spec.ts` erkennt und die die drei bestehenden Stellen dieser Datei vormachen: Konstante `tenantPrisma` aus dem Bindungshilfsmittel mit `this.prisma` und dem uebergebenen Mandanten, `as any`. - `getMe(tenantId: string, userId: string)`: der `findUnique` mit dem unveraenderten `select` laeuft ueber `tenantPrisma`; Rueckgabeform, `isLocalUser`/`hasAvatar`, das Wegfiltern von `passwordHash`/`ldapDn`/ `avatarPath` bleiben WORTGLEICH. - `changePassword(tenantId: string, userId: string, currentPassword, newPassword, response)`: `findUnique` UND `update` auf demselben `tenantPrisma`; Meldungen, argon2-Pruefung, Neu-Signieren des Cookies bleiben WORTGLEICH. - `adminResetPassword(tenantId: string, callerRole: Role, userId: string, newPassword: string, mustChangePassword: boolean = true)`: `findUnique` UND `update` auf demselben `tenantPrisma`; nach dem Fund und VOR dem Schreiben der neue Riegel: ist `user.role === Role.SUPER_ADMIN` und `callerRole !== Role.SUPER_ADMIN`, `ForbiddenException` mit einer Meldung, die die Regel nennt, aber keine Aussage ueber andere Benutzer macht (T-FH9-04). Importiere `Role` aus `@prisma/client` und `ForbiddenException` aus `@nestjs/common`. Die `BadRequestException('User not found')` bleibt wortgleich — sie ist nach der Bindung fuer ein fremdmandantiges Ziel die richtige Antwort und nennt nichts Fremdes. Die drei `$queryRaw`-Aufrufe der Anmeldefunktionen in `validateUser`, `requestPasswordReset`, `resetPassword` bleiben UNVERAENDERT auf `this.prisma` — sie sind die Grenze, nicht der Umbau (Befund H). Aendere an diesen drei Methoden NICHTS ausser Kommentaren. Schreibe drei Kopfkommentare (je Methode) und ergaenze den Klassen- oder Dateikopf: warum die drei Methoden binden (nach der Anmeldung, Mandant im signierten Sitzungsnachweis — 260911-fh9), woher der Mandant kommt (das Claim, nicht die umschaltbare Guard-Eigenschaft, mit Grund aus Befund C; fuer die oberste Rolle der gebundene Fan-out des Aufrufers), warum der Anmeldeweg auf den Funktionen bleibt (Befund B/H, Verweis auf die Migration), was Etappe 3 hier wieder aufmacht ((h4)(a), in zwei Saetzen: Mandant VOR der Suche, Funktionen mit zwei Gleichheitsbedingungen — enger, nicht weiter), und dass der SUPER_ADMIN-Riegel in `adminResetPassword` den Fall INNERHALB des Mandanten schliesst, waehrend der Schwesterweg im Ledger steht. Kommentare in dieser Datei duerfen den ungebundenen Zugriff auf das Benutzermodell NICHT woertlich nennen (der Kopfkommentar von `validateUser` macht mit "dot" vor, wie das geht) und keinen gebundenen `$queryRaw` woertlich — beide Gates zaehlen ueber die ganze Datei, Kommentare eingeschlossen. TEIL 2 — der Controller und das Modul. In `apps/api/src/auth/auth.controller.ts`: - `me(@CurrentUser() user)` reicht `user.tenantId` und `user.id` durch — woertlich `this.authService.getMe(user.tenantId, user.id)`. - `changePassword` reicht `user.tenantId, user.id, dto.currentPassword, dto.newPassword, res` durch. - `adminResetPassword` bekommt zusaetzlich `@CurrentUser() currentUser` und verzweigt ueber einen privaten Helfer `resolveTargetTenantId(currentUser, userId)` in der Form von `user.controller.ts` `resolveTargetUser`: ist die Rolle `Role.SUPER_ADMIN`, wird `this.userService.findByIdForPlatformAdmin(userId)` aufgerufen — nicht gefunden: `BadRequestException('User not found')` (die heutige Meldung des Dienstes, damit ein API-Aufrufer denselben Statuscode sieht wie bisher), sonst der `tenantId` des Ziels; jede andere Rolle bekommt `currentUser.tenantId`. Danach `this.authService.adminResetPassword(tenantId, currentUser.role, userId, dto.newPassword, dto.mustChangePassword ?? true)`. `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` und `@UseGuards(RolesGuard)` bleiben WORTGLEICH. Der Controller liest an KEINER Stelle die vom Guard gesetzte Anfrageobjekt-Kennung oder die SUPER_ADMIN-Kopfzeile (Befund C), und er bekommt KEINEN direkten Datenbankzugang — die oberste Rolle geht ueber `UserService`. Konstruktor: `authService` und `userService`. - Kopfkommentar des Handlers `adminResetPassword`: warum der Mandant aus dem Sitzungsnachweis kommt und nicht aus Pfad, Rumpf oder Kopfzeile (T-FH9-02), warum die oberste Rolle den Fan-out benutzt (Praezedenzfall 260910-das, stille Funktionsminderung sonst), dass der Weg keinen Frontend-Aufrufer hat (gemessen, Befund D) und der Schwesterweg `PATCH /users/:id` ist. Kopfkommentar von `me`: warum das Claim und nicht die Guard-Kennung (ein umgeschalteter SUPER_ADMIN muss sich selbst sehen). In `apps/api/src/auth/auth.module.ts`: `UserModule` zu `imports` — mit einem Kommentar, der die Zyklusfreiheit als Messung nennt (`UserModule` importiert `GroupsModule`, dieses nichts; kein Modul ausser `AppModule` importiert `AuthModule`) — die Anweisungen aus Befund D vor der Aenderung erneut ausfuehren. Falsifizierungsnachweise am Ende dieser Aufgabe, jeder zurueckgenommen und mit Testname und Fehlermeldung woertlich notiert: (a) ersetze in `getMe` probeweise den gebundenen Klienten durch den ungebundenen Basisclient — `auth.service.spec.ts` wird rot (erwartete Form: der Nachbau hat kein ungebundenes Benutzermodell); (b) verschiebe in `validateUser` probeweise die Anmeldesuche auf den gebundenen Klienten — der Grenz-Fall wird rot (erwartete Form: der gebundene Nachbau hat kein `$queryRaw`); (c) entferne probeweise den SUPER_ADMIN-Riegel in `adminResetPassword` — genau der `ForbiddenException`-Fall wird rot. Zaehle jeweils, wie viele Faelle rot wurden, und nenne die Zahl. Aendere keine Datei ausserhalb der vier genannten. Insbesondere: KEINE Aenderung am DTO, an den Strategien, am Guard, an `user.controller.ts`. npm --prefix apps/api run test -- src/auth/auth.service.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 911 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 911 (neue Dienst-Faelle)"; exit 1; }; } && npm --prefix apps/api run type-check && F=apps/api/src/auth/auth.service.ts && SRC=$(grep -vE '^\s*(//|\*|/\*)' "$F") && test 0 -eq "$(grep -c 'this\.prisma\.user' "$F")" && test 0 -eq "$(grep -c 'tenantPrisma\.\$queryRaw' "$F")" && B=$(printf '%s\n' "$SRC" | grep -o 'tenantPrisma\.user\.' | wc -l | tr -d ' ') && { test "$B" -eq 8 || { echo "BINDUNG: $B gebundene Benutzerzugriffe in auth.service.ts, erwartet genau 8 (validateUser 2, resetPassword 1, getMe 1, changePassword 2, adminResetPassword 2)"; exit 1; }; } && R=$(printf '%s\n' "$SRC" | grep -o 'tenantPrisma\.passwordResetToken\.' | wc -l | tr -d ' ') && { test "$R" -eq 2 || { echo "RESET-TOKEN: $R gebundene Zugriffe, erwartet genau 2 (unveraendert)"; exit 1; }; } && C=$(printf '%s\n' "$SRC" | grep -o 'forTenant(this\.prisma' | wc -l | tr -d ' ') && { test "$C" -eq 6 || { echo "KLIENTEN: $C Aufrufstellen des Bindungshilfsmittels in auth.service.ts, erwartet genau 6 (drei bestehende plus getMe, changePassword, adminResetPassword)"; exit 1; }; } && Q=$(printf '%s\n' "$SRC" | grep -c 'this\.prisma\.\$queryRaw') && { test "$Q" -eq 3 || { echo "ANMELDESUCHE: $Q ungebundene queryRaw-Stellen, erwartet genau 3 (die Grenze bleibt)"; exit 1; }; } && L=$(printf '%s\n' "$SRC" | grep -c 'SELECT \* FROM auth_lookup_') && { test "$L" -eq 3 || { echo "ANMELDEFUNKTIONEN: $L Aufrufe, erwartet genau 3"; exit 1; }; } && grep -q 'async getMe(tenantId: string, userId: string)' "$F" && sed -n '/async changePassword(/,/): Promise {/p' "$F" | grep -q 'tenantId: string' && sed -n '/async adminResetPassword(/,/): Promise {/p' "$F" | grep -q 'tenantId: string' && sed -n '/async adminResetPassword(/,/): Promise {/p' "$F" | grep -q 'callerRole: Role' && for M in getMe changePassword adminResetPassword; do REG=$(awk -v m="async $M(" 'index($0,m){f=1} f{print} f&&/^ \}$/{f=0}' "$F" | grep -vE '^\s*(//|\*|/\*)'); K=$(printf '%s\n' "$REG" | grep -c 'forTenant(this\.prisma, tenantId)'); test "$K" -eq 1 || { echo "METHODE $M: $K Klienten, erwartet genau 1"; exit 1; }; U=$(printf '%s\n' "$REG" | grep -c 'this\.prisma\.'); test "$U" -eq 0 || { echo "METHODE $M: $U ungebundene Zugriffe (this.prisma.) neben dem Bindungsaufruf, erwartet 0"; exit 1; }; done && for M in validateUser requestPasswordReset resetPassword; do REG=$(awk -v m="async $M(" 'index($0,m){f=1} f{print} f&&/^ \}$/{f=0}' "$F" | grep -vE '^\s*(//|\*|/\*)'); Q1=$(printf '%s\n' "$REG" | grep -c 'this\.prisma\.\$queryRaw'); test "$Q1" -eq 1 || { echo "ANMELDEWEG $M: $Q1 ungebundene queryRaw-Stellen, erwartet genau 1"; exit 1; }; done && awk '/async adminResetPassword\(/{f=1} f&&/^ \}$/{f=0} f' "$F" | grep -q 'ForbiddenException' && awk '/async adminResetPassword\(/{f=1} f&&/^ \}$/{f=0} f' "$F" | grep -q 'Role.SUPER_ADMIN' && grep -q "import { Role } from '@prisma/client'" "$F" && grep -q '260911-fh9' "$F" && git diff --quiet 6236b30 -- apps/api/prisma/migrations/20260909160000_auth_lookup_functions && S=apps/api/src/auth/auth.service.spec.ts && test 0 -eq "$(grep -c 'forTenant: vi.fn((p' "$S")" && grep -q '__makeBoundClient' "$S" && grep -q 'vi.mocked(forTenant).mock.calls.length' "$S" && grep -q 'getMe' "$S" && grep -q 'changePassword' "$S" && grep -q 'adminResetPassword' "$S" && grep -q 'ForbiddenException' "$S" && grep -q 'User not found or has no local password' "$S" && grep -q 'P2025' "$S" && CT=apps/api/src/auth/auth.controller.ts && grep -q 'getMe(user.tenantId, user.id)' "$CT" && test 0 -eq "$(grep -c 'req.tenantId' "$CT")" && test 0 -eq "$(grep -c 'x-tenant-id' "$CT")" && test 0 -eq "$(grep -c 'PrismaService' "$CT")" && grep -q 'findByIdForPlatformAdmin' "$CT" && grep -q 'resolveTargetTenantId' "$CT" && grep -q "import { UserService } from '../user/user.service'" "$CT" && grep -q '@Roles(Role.ADMIN, Role.SUPER_ADMIN)' "$CT" && awk '/this\.authService\.changePassword\(/{f=1} f{print} f&&/\);/{f=0}' "$CT" | grep -q 'user.tenantId' && awk '/this\.authService\.adminResetPassword\(/{f=1} f{print} f&&/\);/{f=0}' "$CT" | grep -q 'currentUser.role' && grep -q '260911-fh9' "$CT" && grep -q 'UserModule' apps/api/src/auth/auth.module.ts && test 0 -eq "$(grep -rl 'AuthModule' apps/api/src --include=*.module.ts | grep -vE 'app\.module\.ts|auth\.module\.ts' | wc -l | tr -d ' ')" && git diff --quiet 6236b30 -- apps/api/src/auth/dto apps/api/src/auth/strategies apps/api/src/auth/guards apps/api/src/auth/decorators apps/api/src/user && test 0 -eq "$(grep -c 'tenantId' apps/api/src/auth/dto/admin-reset-password.dto.ts)" && git rev-parse --verify 6236b30 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 6236b30 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 6236b30) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|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|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } `auth.service.ts`: `getMe`, `changePassword`, `adminResetPassword` nehmen den Mandanten als ersten Parameter, laufen je ueber genau EINEN Klienten `tenantPrisma` (acht gebundene Benutzerzugriffe, sechs Aufrufstellen des Bindungshilfsmittels), `adminResetPassword` kennt die Rolle des Aufrufers und verweigert einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN; die drei `$queryRaw`-Anmeldesuchen stehen unveraendert auf dem ungebundenen Klienten, die Funktions-Migration ist unangetastet; kein ungebundener Benutzerzugriff mehr, auch nicht in Kommentaren; Kopfkommentare nennen Grenze, Mandantenquelle, Etappe-3-Vorbehalt. `auth.service.spec.ts` hat keine Identitaets-Attrappe mehr, zwei unterscheidbare Klienten (ungebunden ohne Modelle, gebunden ohne `$queryRaw`), alle bestehenden Faelle umgestellt, jeden in `` genannten Fall und den Wachhund. `auth.controller.ts` reicht fuer `me`/`changePassword` das Claim durch, verzweigt in `adminResetPassword` nach Rolle ueber `resolveTargetTenantId` mit `UserService.findByIdForPlatformAdmin` fuer die oberste Rolle, liest weder die Guard-Kennung noch die Kopfzeile und hat keinen Datenbankzugang. `auth.module.ts` importiert `UserModule`, zyklusfrei gemessen. Alle drei Falsifizierungsnachweise durchgefuehrt, zurueckgenommen, woertlich notiert. Baseline gehalten, Testzahl gestiegen, Typpruefung sauber, DTO/Strategien/Guards/Decorators/`user`-Bereich unveraendert. Aufgabe 3: Die Mandantenquelle im Controller festnageln (neue Testdatei), alle Dokumentstellen der Klassifikation nachziehen, zwei Ledger-Eintraege anlegen, Gates falsifizieren apps/api/src/auth/auth.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md apps/api/src/auth/auth.controller.ts (Stand nach Aufgabe 2), apps/api/src/tenant/tenant.controller.spec.ts (Attrappen, `reflect-metadata`, Rollen-Metadaten-Faelle), apps/api/src/auth/decorators/public.decorator.ts (`IS_PUBLIC_KEY`), apps/api/src/auth/decorators/roles.decorator.ts (`ROLES_KEY`), apps/api/src/prisma/rls-access-inventory.spec.ts (`analyzeFile`, `computeStandByKey`, `parseDocEntries`), docs/mandantentrennung-zugriffsklassifikation.md (VOLLSTAENDIG: Uebersichtstabelle samt Messanweisung und `auth`-Zeile, Summenzeile, Klassen-Verteilung, Hintergrunddienst-Abschnitt, beide `auth`-Zeilen der Bestandsaufnahme, `Was diese Etappe NICHT entscheidet`), docs/mandantentrennung-etappe2-fehlerrichtung.md (Abschnitt `## Bereich auth` aus Aufgabe 1, besonders (h3), (h4)(a), (h4)(b)), .planning/WINDOWS.md (Kopfzaehler, Eintraege #22, #26, #27 als Form), .planning/quick/260911-cwh-mandantentrennung-etappe-2-bereich-calen/260911-cwh-PLAN.md (Absatz zu `gsd-tools windows append`) ZUERST die Testlage. Lege `apps/api/src/auth/auth.controller.spec.ts` NEU an, in der Form von `tenant.controller.spec.ts`: `AuthService` und `UserService` als Attrappen (`vi.fn`), `new AuthController(authService, userService)`, `reflect-metadata` am Kopf. Jeder Fall eigenstaendig, jeder mit sprechendem Namen: - `me` mit `user = { id: 'u1', tenantId: 't1', role: 'USER' }`: `getMe` wurde GENAU mit `('t1', 'u1')` aufgerufen — der Mandant ist das Claim. - `me` mit einem SUPER_ADMIN, dessen Claim `t1` traegt: ebenfalls `('t1', 'u1')` — es gibt keine Kopfzeile und keine Guard-Kennung, die das aendern koennte, weil der Handler nur `@CurrentUser()` liest. - `me`, wenn der Dienst `null` liefert: der Handler gibt `null` zurueck und wirft NICHT — festgenagelt als das Verhalten, das (h3) beschreibt (der leere Rumpf beginnt hier). - `changePassword`: `('t1', 'u1', dto.currentPassword, dto.newPassword, res)`, Antwort `{ message: 'Password changed successfully.' }`. - `adminResetPassword`, Aufrufer ADMIN (`tenantId: 't1'`), Ziel `'target'`: Dienst mit `('t1', 'ADMIN', 'target', 'new-password', true)` aufgerufen; `userService.findByIdForPlatformAdmin` NICHT aufgerufen; Antwort `{ message: 'User password has been reset.' }`. - `adminResetPassword`, Aufrufer ADMIN, `mustChangePassword: false` im Rumpf: `false` wird durchgereicht. - `adminResetPassword`, Aufrufer SUPER_ADMIN (`tenantId: 't1'`), Fan-out liefert `{ id: 'target', tenantId: 't9', role: 'USER' }`: Dienst mit `('t9', 'SUPER_ADMIN', 'target', ...)` — der Mandant des ZIELS, nicht der des Aufrufers; `findByIdForPlatformAdmin` genau einmal mit `'target'`. - `adminResetPassword`, Aufrufer SUPER_ADMIN, Fan-out liefert `null`: `BadRequestException` mit Meldung `User not found`, Dienst NICHT aufgerufen. - Rollen-Metadaten: `Reflect.getMetadata(ROLES_KEY, AuthController.prototype.adminResetPassword)` ist genau `[Role.ADMIN, Role.SUPER_ADMIN]`; auf der Klasse und auf `me`, `changePassword`, `logout`, `login`, `requestReset`, `resetPassword` undefiniert. - Public-Metadaten: `Reflect.getMetadata(IS_PUBLIC_KEY, ...)` ist `true` fuer `login`, `requestReset`, `resetPassword` (der Anmeldeweg) und undefiniert fuer `me`, `changePassword`, `adminResetPassword`, `logout` — die Grenze aus Befund B als Metadaten-Test: wird eine Nach-Anmeldungs-Methode je `@Public()`, ist ihr Claim leer und die Bindung liefe ins Leere. Die Zahl der Faelle wird am Ende ABGEZAEHLT und im SUMMARY mit der gezaehlten Zahl genannt. TEIL 1 — die Testdatei aus ``, dann ein Falsifizierungsnachweis: lass `resolveTargetTenantId` in `auth.controller.ts` probeweise auch fuer SUPER_ADMIN `currentUser.tenantId` liefern — genau der Fan-out-Fall wird rot (erwartete Form: Dienst mit `t1` statt `t9` aufgerufen); Testname und Meldung woertlich notieren, Zustand wiederherstellen. Der Controller selbst wird in dieser Aufgabe NICHT dauerhaft veraendert. TEIL 2 — die Klassifikation. Ziehe `docs/mandantentrennung-zugriffsklassifikation.md` an ALLEN handgepflegten Stellen nach, jede einzeln nachgesehen, keine ueberflogen (Fehler 4 des Vorhabens): 1. Uebersichtszeile `auth` mit den NEU GEMESSENEN Zahlen aus der im Dokument genannten Messanweisung (beide Spalten), im etablierten Stil mit Vermerk des vorherigen Standes (`**war 8/5**`): welche fuenf Zugriffe gebunden wurden (drei Methoden), und dass die verbleibenden ungebundenen Rohtreffer die `$queryRaw`-Aufrufe der drei Anmeldefunktionen sind — KEINE Modellzugriffe (die Bestandsaufnahme fuehrt sie nicht, weil `$` kein Modellname ist), bewusst und dauerhaft ungebunden, Verweis auf `20260909160000_auth_lookup_functions` und (h1). 2. Summenzeile derselben Tabelle mit fortgeschriebener Herkunftsspur (ungebunden und gebunden je um die Differenz aus Schritt 1). 3. Bestandsaufnahme-Zeile `apps/api/src/auth/auth.service.ts | user`: Stand von `gemischt` auf `gebunden`, Begruendung NEU: die drei Methoden binden an den Mandanten aus dem Sitzungsnachweis (260911-fh9); die drei Anmeldesuchen laufen ueber die Funktionen und sind keine Modellzugriffe; die oberste Rolle loest den Mandanten des Ziels ueber `UserService.findByIdForPlatformAdmin` auf; der SUPER_ADMIN-Riegel; Etappe-3-Vorbehalt in einem Satz. Die Zeile `apps/api/src/auth/auth.service.ts | passwordResetToken` bleibt UNVERAENDERT. Ohne Schritt 3 ist `rls-access-inventory.spec.ts` am Ende dieser Aufgabe rot (Stand-Vergleich). 4. Klassen-Verteilung: ein `**Stand 260911-fh9 (Aufgabe 3): unveraendert, ausdruecklich festgehalten statt uebersprungen.**`-Absatz in der Form von 260910-krx/260911-cwh — weiterhin 64 Paare, keine Klasse verschiebt sich, nur die Stand-Spalte EINES Paares aendert sich. Tabelle und Ueberschrift bleiben. 5. Hintergrunddienst-Abschnitt: ein PLAIN-Absatz (kein Aufzaehlungspunkt, keine Zeile der Form "Der ... Fall, anderer Bauart"), der festhaelt, dass dieser Bereich keinen sechsten Fall hinzufuegt, mit den drei Anweisungen aus Befund J, und dass die einzige Stelle, an der dieser Bereich ohne Mandantenkontext liest, der Anmeldeweg ist — geloest durch Funktionen, nicht durch die Bauform "uebergreifend lesen, dann je Mandant binden". 6. Abschnitt `Was diese Etappe NICHT entscheidet`: ein NEUER Punkt — wie der Anmeldeweg unter je Mandant eindeutigen Anmeldenamen (Etappe-3- Entscheidung (1)) den Mandanten VOR der Benutzersuche erfaehrt; dass die drei Funktionen dabei ENGER werden (zwei Gleichheitsbedingungen), nicht weiter; dass die Bindung dieses Bereichs (Claim, `User.id`) davon unberuehrt bleibt; Verweis auf (h4)(a) und `260911-fh9`. TEIL 3 — die Ledger-Eintraege, ueber `gsd-tools windows append` (damit Tabelle, JSON-Block und Kopfzaehler zusammenpassen — nicht von Hand), je mit `--kind`, `--phase quick-260911-fh9`, `--file`, `--description`: (1) `--kind deviation --file apps/web/src/components/layout/header.tsx`: die verschluckte Auspraegung der umgekehrten Fehlerrichtung im Bereich `auth` aus (h3): `getMe` liefert nach dem Scharfschalten `null`, der Controller antwortet 200 mit leerem Rumpf, `fetchCurrentUser` (`auth-actions.ts`) macht daraus `null`, `header.tsx` und `account-settings-form.tsx` (mit Stellen) tun bei `null` nichts — die Portalhuelle rendert ohne angemeldeten Benutzer, "nicht angemeldet" und "Zeile unsichtbar" sind fuer das Frontend derselbe Wert; dazu `changePassword` als `networkError`; die Etappe-4-Vorabpruefung aus (h4)(e); an dieselbe Bedingung gebunden wie #18; Familie #23/#25/#26; das Frontend wird von 260911-fh9 NICHT geaendert. (2) `--kind unmet-truth --file apps/api/src/user/user.controller.ts` — NUR, wenn Aufgabe 1 (E) die Luecke bestaetigt hat: ein ADMIN kann im eigenen Mandanten das Kennwort eines SUPER_ADMIN setzen (`PATCH /users/:id` mit `password`) und ihn deaktivieren/loeschen, weil T-02-08 nur das ZUWEISEN der Rolle SUPER_ADMIN prueft, nicht die Rolle des Ziels — mit den gelesenen Zeilen; kein Mandantenproblem, sondern Rechteausweitung innerhalb des Mandanten; die Reparatur in einem Satz (Rolle des Ziels in `update`, `deactivate`, `delete` pruefen — die Vorlage steht seit 260911-fh9 in `AuthService.adminResetPassword`); ausserhalb der Erlaubnisliste dieses Plans, deshalb Ledger statt Reparatur; vor dem ersten Mandanten mit einem zweiten Administrator zu schliessen. Pruefe nach dem Anlegen, dass jeder Eintrag in Tabelle UND JSON-Block steht und die Kopfzaehler (`open_count`, `total_count`) mit den Zeilen uebereinstimmen. TEIL 4 — zwei Falsifizierungsnachweise fuer die Dokument-Gates, jeder zurueckgenommen und mit Meldung woertlich notiert: (a) setze die Bestandsaufnahme-Zeile `auth.service.ts | user` probeweise zurueck auf `gemischt` — `rls-access-inventory.spec.ts` muss rot werden; (b) setze die Uebersichtszeile `auth` probeweise auf eine falsche Zahl — das herleitende Gate dieser Aufgabe muss fehlschlagen. Aendere keine Datei ausserhalb der drei genannten. DB_IP=$(docker inspect tessera-ctl-db-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$v.IPAddress}}{{end}}') && SOUT=$(TESSERA_SCRATCH_ADMIN_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/postgres" node apps/api/scripts/rls-scratch-check.mjs) && echo "$SOUT" | tail -1 && N=$(echo "$SOUT" | sed -nE 's/^Alle ([0-9]+) Pruefungen bestanden\.$/\1/p') && { test -n "$N" && test "$N" -ge 120 || { echo "WERKZEUG: ${N:-nicht alle} Pruefungen bestanden, erwartet mindestens 120"; exit 1; }; } && test -f apps/api/src/auth/auth.controller.spec.ts && npm --prefix apps/api run test -- src/auth/auth.controller.spec.ts && npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts && TOUT=$(npm --prefix apps/api run test 2>&1) && echo "$TOUT" | tail -6 && T=$(echo "$TOUT" | sed -nE 's/^ *Tests +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$T" && test "$T" -gt 911 || { echo "TESTZAHL: ${T:-unbekannt}, erwartet mehr als 911"; exit 1; }; } && TF=$(echo "$TOUT" | sed -nE 's/^ *Test Files +([0-9]+) passed.*/\1/p' | head -1) && { test -n "$TF" && test "$TF" -ge 60 || { echo "TESTDATEIEN: ${TF:-unbekannt}, erwartet mindestens 60 (59 plus auth.controller.spec.ts)"; exit 1; }; } && npm --prefix apps/api run type-check && CS=apps/api/src/auth/auth.controller.spec.ts && grep -q 'reflect-metadata' "$CS" && grep -q 'ROLES_KEY' "$CS" && grep -q 'IS_PUBLIC_KEY' "$CS" && grep -q 'findByIdForPlatformAdmin' "$CS" && grep -q "'t9'" "$CS" && grep -q 'User not found' "$CS" && grep -q 'toBeNull\|toBe(null)' "$CS" && { test -z "$(git status --porcelain -- apps/api/src/auth/auth.controller.ts)" || { echo "auth.controller.ts hat in Aufgabe 3 uncommittete Aenderungen — der Falsifizierungsnachweis wurde nicht zurueckgenommen"; exit 1; }; } && DU=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/auth | grep -v spec | wc -l | tr -d ' ') && DB=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/auth | grep -v spec | wc -l | tr -d ' ') && { test "$DU" -eq 3 || { echo "UEBERSICHTSZEILE: ungebundene Rohtreffer in apps/api/src/auth sind $DU, erwartet 3 (die drei Anmeldesuchen)"; exit 1; }; } && { test "$DB" -eq 10 || { echo "UEBERSICHTSZEILE: gebundene Rohtreffer in apps/api/src/auth sind $DB, erwartet 10"; exit 1; }; } && { grep -qE "^\| auth \| ${DU} \| ${DB} \| \*\*war 8/5\*\*" docs/mandantentrennung-zugriffsklassifikation.md || { echo "UEBERSICHTSZEILE auth nennt nicht die neu gemessenen Zahlen ${DU}/${DB} im etablierten Stil"; exit 1; }; } && grep -E "^\| auth \| " docs/mandantentrennung-zugriffsklassifikation.md | grep -q '20260909160000' && awk -F'|' '$2 ~ /^ *[a-z][a-z-]* *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *[0-9]+ *$/ { su+=$3; sb+=$4; n++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 !~ /^ *$/ { g3=$3; g4=$4; gsub(/[^0-9]/,"",g3); gsub(/[^0-9]/,"",g4); ru=g3+0; rb=g4+0; seen=1 } END { if (!seen || n+0 != 12) { print "UEBERSICHTSTABELLE nicht erkannt, Bereichszeilen: " n; exit 1 } if (su+0 != ru || sb+0 != rb) { print "SUMMENZEILE stimmt nicht: Bereichszeilen ergeben " su "/" sb ", Summenzeile nennt " ru "/" rb; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && awk -F'|' '$2 ~ /^ *apps\/api\/src\// { k=$4; gsub(/^ +| +$/,"",k); cls[k]++; pairs++ } $2 ~ /^ *(muss-mandantengebunden|keine-mandantengebundene-tabelle|beides|bewusst-uebergreifend) *$/ && $3 ~ /^ *[0-9]+ *$/ && $4 ~ /^ *$/ { k=$2; gsub(/^ +| +$/,"",k); v=$3; gsub(/[^0-9]/,"",v); tab[k]=v+0; tn++ } $2 ~ /^ *\*\*Summe\*\* *$/ && $4 ~ /^ *$/ { v=$3; gsub(/[^0-9]/,"",v); tsum=v+0; tseen=1 } /^## Klassen-Verteilung/ { h=$0; gsub(/[^0-9]/,"",h); hp=h+0; hseen=1 } END { if (tn+0 != 4 || !tseen || !hseen) { print "KLASSEN-VERTEILUNG nicht erkannt: Klassenzeilen " tn ", Summenzeile " tseen ", Ueberschrift " hseen; exit 1 } if (tsum != pairs+0) { print "KLASSEN-SUMME stimmt nicht: Bestandsaufnahme hat " pairs " Paare, Tabellensumme nennt " tsum; exit 1 } if (hp != pairs+0) { print "UEBERSCHRIFT der Klassen-Verteilung nennt " hp " Paare, Bestandsaufnahme hat " pairs; exit 1 } s=0; for (k in tab) { if (tab[k] != cls[k]+0) { print "KLASSE " k ": Tabelle nennt " tab[k] ", Bestandsaufnahme zaehlt " cls[k]+0; exit 1 } s+=tab[k] } if (s != pairs+0) { print "KLASSENZEILEN ergeben " s ", Bestandsaufnahme hat " pairs; exit 1 } }' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/auth/auth\.service\.ts \| user \| muss-mandantengebunden \| gebunden \|.*260911-fh9' docs/mandantentrennung-zugriffsklassifikation.md && grep -qE '^\| apps/api/src/auth/auth\.service\.ts \| passwordResetToken \| muss-mandantengebunden \| gebunden \|' docs/mandantentrennung-zugriffsklassifikation.md && test 0 -eq "$(grep -cE '^\| apps/api/src/auth/auth\.service\.ts \| [a-zA-Z]+ \| [a-z-]+ \| gemischt \|' docs/mandantentrennung-zugriffsklassifikation.md)" && awk '/^## Klassen-Verteilung/{f=1; next} /^## /{f=0} f && /Stand 260911-fh9/{m=1} END{ if(!m){print "KLASSEN-VERTEILUNG: kein Stand-Vermerk fuer 260911-fh9"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk 'BEGIN{split("ein zwei drei vier x sechs sieben acht neun",w," "); w[5]="fünf"} /^## Der Hintergrunddienst als Falle/{seen=1; head=$0; f=1; next} /^## /{f=0} f && /^- \*\*`/{n++} f && /^\*\*Der .* Fall, anderer Bauart/{n++} f && /260911-fh9/{m=1} END{ if(!seen){print "ABSCHNITT Hintergrunddienst nicht gefunden"; exit 1} want="## Der Hintergrunddienst als Falle — " w[n] " Fälle"; if(head != want){printf "HINTERGRUNDDIENST-UEBERSCHRIFT nennt \"%s\", gezaehlt wurden %d Faelle, erwartet \"%s\"\n", head, n, want; exit 1} if(!m){print "ABSCHNITT Hintergrunddienst nennt 260911-fh9 nicht — die Abwesenheit eines sechsten Falls ist nicht belegt"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && awk '/^## Was diese Etappe NICHT entscheidet/{f=1; next} f && /260911-fh9/{m=1} f && /Etappe 3|Etappe-3/{e=1} END{ if(!m||!e){print "ABSCHNITT \"Was diese Etappe NICHT entscheidet\": der Etappe-3-Punkt zum Anmeldeweg (260911-fh9) fehlt"; exit 1} }' docs/mandantentrennung-zugriffsklassifikation.md && W=.planning/WINDOWS.md && FH=$(grep -cE '^\| [0-9]+ \| quick-260911-fh9 \|' "$W") && { test "$FH" -ge 1 || { echo "LEDGER: kein Eintrag fuer quick-260911-fh9 in der Tabelle"; exit 1; }; } && grep -q 'header.tsx' "$W" && OC=$(sed -nE 's/^open_count: ([0-9]+)$/\1/p' "$W") && OR=$(grep -cE '^\| [0-9]+ \|.*\| open \|' "$W") && { test "$OC" -eq "$OR" || { echo "LEDGER: open_count=$OC, offene Tabellenzeilen=$OR"; exit 1; }; } && TC=$(sed -nE 's/^total_count: ([0-9]+)$/\1/p' "$W") && TR=$(grep -cE '^\| [0-9]+ \| ' "$W") && { test "$TC" -eq "$TR" || { echo "LEDGER: total_count=$TC, Tabellenzeilen=$TR"; exit 1; }; } && JC=$(grep -c '"phase": "quick-260911-fh9"' "$W") && { test "$JC" -eq "$FH" || { echo "LEDGER: $FH Tabellenzeilen, aber $JC JSON-Eintraege fuer quick-260911-fh9"; exit 1; }; } && git rev-parse --verify 6236b30 >/dev/null && PRISMA_CHANGED=$(git diff --name-only 6236b30 -- apps/api/prisma) && { test -z "$PRISMA_CHANGED" || { printf 'SCHEMA/MIGRATION GEAENDERT — in diesem Plan verboten:\n%s\n' "$PRISMA_CHANGED"; exit 1; }; } && WEB_CHANGED=$(git diff --name-only 6236b30 -- apps/web docker-compose.yml docker-compose.prod.yml) && { test -z "$WEB_CHANGED" || { printf 'FRONTEND/COMPOSE GEAENDERT — in diesem Plan verboten:\n%s\n' "$WEB_CHANGED"; exit 1; }; } && CHANGED=$(git diff --name-only 6236b30) && UNEXPECTED=$(printf '%s\n' "$CHANGED" | grep -v '^$' | grep -vE '^(apps/api/scripts/rls-scratch-check\.mjs|docs/mandantentrennung-etappe2-fehlerrichtung\.md|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/src/auth/auth\.controller\.spec\.ts|docs/mandantentrennung-zugriffsklassifikation\.md|\.planning/.*)$' || true) && { test -z "$UNEXPECTED" || { printf 'UNERWARTETE AENDERUNGEN AUSSERHALB DES PLANUMFANGS:\n%s\n' "$UNEXPECTED"; exit 1; }; } `auth.controller.spec.ts` existiert neu und deckt jeden in `` genannten Fall ab — Mandantenquelle je Handler (das Claim; fuer die oberste Rolle der Mandant des Ziels aus dem Fan-out), unbekanntes Ziel, `null`-Durchreichung von `me`, Rollen-Metadaten und Public-Metadaten fuer alle sieben Handler; der Falsifizierungsnachweis am Fan-out-Zweig ist durchgefuehrt, zurueckgenommen und woertlich notiert, `auth.controller.ts` ist gegenueber Aufgabe 2 unveraendert. Alle handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und maschinell gegatet: Uebersichtszeile `auth` mit neu gemessenen Zahlen und Verweis auf die Funktions-Migration, Summenzeile, Bestandsaufnahme-Zeile `user` auf `gebunden` (keine `gemischt`-Zeile mehr fuer diese Datei), `passwordResetToken` unveraendert, Klassen-Verteilung mit 64 Paaren und Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Punkt. `.planning/WINDOWS.md` traegt die neuen offenen Eintraege in Tabelle und JSON-Block, die Kopfzaehler stimmen. Beide Dokument-Falsifizierungen durchgefuehrt, zurueckgenommen, woertlich notiert. Werkzeug alle Pruefungen bestanden (mindestens 120), Baseline gehalten, Testzahl und Testdateizahl gestiegen, Typpruefung sauber, Schalter unveraendert aus, Funktionen unangetastet. Konfiguriert: ASVS-Stufe 1, blockierend ab `high`. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Unangemeldeter Browser -> Anmeldeweg (`POST /auth/login`, `/auth/request-reset`, `/auth/reset-password`) | Der Mandant ist VOR der Suche unbekannt; die Suche laeuft ueber drei SECURITY-DEFINER-Funktionen mit festem Spaltensatz, Gleichheitsvergleich und LIMIT 1 (20260909160000). Dieser Plan aendert daran NICHTS und misst das (Pruefung 1/3). | | Angemeldeter Benutzer -> Nach-Anmeldung (`GET /auth/me`, `POST /auth/change-password`) | Der Mandant ist das signierte Claim `tenantId` aus `JwtStrategy.validate`; die eigene Zeile liegt im eigenen Mandanten. `req.tenantId` (Guard, fuer SUPER_ADMIN per Kopfzeile umschaltbar) ist hier die FALSCHE Quelle. | | ADMIN / SUPER_ADMIN -> `POST /auth/admin-reset-password/:userId` | Die Kennung im Pfad ist frei waehlbare Eingabe und bezeichnet das ZIEL; der Mandant kommt fuer ADMIN aus dem eigenen Claim, fuer SUPER_ADMIN aus dem gebundenen Fan-out ueber die Kennung. Kein Aufrufer im Frontend (gemessen). | | ADMIN -> SUPER_ADMIN desselben Mandanten | Rollengrenze INNERHALB des Mandanten: heute in beiden Wegen (`admin-reset-password`, `PATCH /users/:id`) nicht geprueft. | | API -> PostgreSQL, Tabelle `User` | Regel `"tenantId" = current_tenant_id()` (20260618112133); nach dem Scharfschalten liefert ein ungebundener Zugriff null Zeilen (gemessen, Pruefung 4/7). | | API -> `auth_lookup_*`-Funktionen | Die schmale Ausnahme; EXECUTE nur fuer `tessera_app`; nichts in diesem Plan weitet sie (Pruefung 1/3, `git diff` auf die Migration leer). | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-FH9-01 | Elevation of Privilege | `auth.controller.ts`/`auth.service.ts` `adminResetPassword`, Mandantengrenze | high | mitigate | Ein ADMIN von Mandant A setzt das Kennwort eines Benutzers von Mandant B (heute: `findUnique` ueber die Kennung, ungebunden, kein Mandantenvergleich). Gebundener Klient unter dem Mandanten aus dem Sitzungsnachweis: die fremde Zeile ist unsichtbar (Pruefung 6/9 ueber den generierten Client), Antwort bleibt die bestehende `400 User not found` ohne Aussage ueber den fremden Mandanten. Testfaelle im Dienst (fremder Mandant: kein Schreibzugriff) und im Controller (Mandant = Claim); Falsifizierung durch Rueckbau. | | T-FH9-02 | Spoofing | `auth.controller.ts`, Herkunft des Mandanten | high | mitigate | Der Mandant koennte aus Pfad, Rumpf oder Kopfzeile kommen. Der Controller liest ausschliesslich `@CurrentUser()` (signiertes Claim) und fuer SUPER_ADMIN das Ergebnis des gebundenen Fan-outs; das DTO hat kein Mandantenfeld (gegatet); `req.tenantId` und `x-tenant-id` kommen in der Datei nicht vor (gegatet auf null); Controller-Tests nageln je Handler die uebergebene Mandantenkennung fest. | | T-FH9-03 | Information Disclosure | `auth_lookup_*`-Funktionen, Migration 20260909160000 | high | mitigate | Jede Ausweitung dessen, was die Funktionen preisgeben, oeffnete den Anmeldeweg zur Tabelle. Migration unangetastet (`git diff` gegatet), die drei `$queryRaw`-Stellen bleiben auf dem ungebundenen Klienten (gegatet: genau 3), `pg_proc` misst SECURITY DEFINER/STABLE/Suchpfad/LIMIT 1 (Pruefung 1), der feste Spaltensatz laesst die fuenf neuen Wegwerf-Spalten nicht durch (Pruefung 3). | | T-FH9-04 | Elevation of Privilege | `auth.service.ts` `adminResetPassword`, Rollengrenze im Mandanten | high | mitigate | Ein ADMIN setzt das Kennwort eines SUPER_ADMIN im eigenen Mandanten und meldet sich als Plattform-Administrator an. Neuer Riegel nach dem gebundenen Fund: Ziel SUPER_ADMIN und Aufrufer nicht SUPER_ADMIN -> `ForbiddenException`; Testfaelle beide Richtungen; Falsifizierung durch Entfernen des Riegels. | | T-FH9-05 | Elevation of Privilege | `user.controller.ts` `update`/`deactivate`/`delete` (Schwesterweg) | high | transfer | Derselbe Fall wie T-FH9-04 ueber `PATCH /users/:id` mit `password` (T-02-08 prueft nur das Zuweisen der Rolle, nicht die Rolle des Ziels). Ausserhalb der Erlaubnisliste dieses Plans; in Aufgabe 1 (E) gelesen, in (h4)(b) festgehalten, in Aufgabe 3 als OFFENER Ledger-Eintrag mit konkreter Reparatur uebergeben (die Vorlage steht in `adminResetPassword`). Kein Mandantenproblem. | | T-FH9-06 | Denial of Service | `getMe`/`changePassword` unter `x-tenant-id` | medium | mitigate | Waeren die Selbstbedienungswege an `req.tenantId` gebunden, saehe ein SUPER_ADMIN, der gerade einen fremden Mandanten ansieht, sich selbst nicht mehr (Portalhuelle ohne Benutzer, Kennwortwechsel unmoeglich). Bindung an das Claim; Controller-Test mit SUPER_ADMIN belegt, dass nur das Claim durchgereicht wird. | | T-FH9-07 | Repudiation / Information Disclosure | `getMe` -> Frontend, `changePassword` -> `auth-actions.ts` | medium | accept | Die umgekehrte Fehlerrichtung ist NICHT laut: `null` wird zur Portalhuelle ohne Benutzer, `401` wird zu `networkError`. Gemessen (Pruefung 4), in (h2)/(h3) beschrieben, als Ledger-Eintrag (Familie #23/#25/#26) festgehalten, Etappe-4-Vorabpruefung benannt. Das Frontend wird nicht geaendert (Umfang). | | T-FH9-08 | Tampering | Etappe-3-Umbau des Anmeldewegs | low | accept | Dieser Plan koennte Entscheidungen treffen, die den Umbau auf je Mandant eindeutige Anmeldenamen erschweren. Er bindet ausschliesslich an Claim und `User.id` (plattformweite UUID) und veraendert die Funktionen nicht; was Etappe 3 wieder aufmacht, steht in (h4)(a) und im Klassifikationsdokument. | | T-FH9-09 | Information Disclosure | `changePassword`, zwei 401-Meldungen | low | accept | `User not found or has no local password` vs. `Current password is incorrect` unterscheidet LDAP- von lokalen Konten fuer den ANGEMELDETEN Benutzer selbst — bestehendes Verhalten, betrifft nur die eigene Zeile, unveraendert; in (h4)(f) festgehalten. | | T-FH9-10 | Spoofing | `x-tenant-id` ohne Existenzpruefung | low | accept | Bekannt aus (n4)(f); hier nur relevant, weil es die Entscheidung gegen `req.tenantId` fuer Selbstbedienung stuetzt. Nicht dieser Auftrag. | | T-FH9-11 | Denial of Service | `AuthModule` importiert `UserModule` | low | accept | Ein Modulzyklus braeche den Start. Gemessen zyklusfrei (UserModule -> GroupsModule -> nichts; kein Modul ausser AppModule importiert AuthModule), gegatet; Typpruefung ueber den gesamten API-Quelltext. | | T-FH9-SC | Tampering | Paketinstallation | low | accept | Dieser Plan installiert kein Paket (npm/pip/cargo) und fuegt keine Abhaengigkeit hinzu. Das Legitimitaets-Gate faellt nicht an; ausdruecklich festgehalten statt schweigend ausgelassen. | Nach Abschluss aller drei Aufgaben: 1. `node apps/api/scripts/rls-scratch-check.mjs` meldet alle Pruefungen bestanden (110 bisherige plus die neuen, mindestens 120), Rueckgabewert 0; `runAuthAreaChecks` steht nach `runTenantAreaChecks` und vor `runTransactionShapeMeasurement`. 2. `npm --prefix apps/api run test` meldet mehr als 911 Tests gruen in mindestens 60 Dateien (59 bisherige plus `auth.controller.spec.ts`). 3. `npm --prefix apps/api run type-check` ist sauber. 4. `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` ist gruen — 64 Paare, `auth.service.ts`/`user` steht auf `gebunden`, kein `gemischt`-Paar mehr fuer diese Datei. 5. In `auth.service.ts`: kein ungebundener Benutzerzugriff (auch nicht in Kommentaren), acht gebundene Benutzerzugriffe, sechs Aufrufstellen des Bindungshilfsmittels, genau drei `$queryRaw`-Anmeldesuchen auf dem ungebundenen Klienten, kein gebundener `$queryRaw`. In `auth.controller.ts`: kein `req.tenantId`, kein `x-tenant-id`, kein direkter Datenbankzugang, `getMe(user.tenantId, user.id)`, `findByIdForPlatformAdmin` fuer die oberste Rolle. 6. `git diff --name-only 6236b30 -- apps/api/prisma` ist leer — Schema, Migrationen und die drei Anmeldefunktionen unangetastet. 7. Alle handgepflegten Stellen der Klassifikation sind maschinell gegatet und gruen, die Zaehlgates LEITEN ihre Werte aus den im Dokument genannten Messanweisungen ab. 8. Der Umfang ist als ERLAUBNISLISTE gegatet: jede Datei, die sich gegenueber `6236b30` geaendert hat, ist eine der neun in `files_modified` genannten (oder liegt unter `.planning/`). Unter `apps/api/prisma`, `apps/web`, `apps/api/src/user`, `apps/api/src/auth/dto|strategies|guards|decorators` und den Compose-/Umgebungsdateien hat sich nichts geaendert. 9. `.planning/WINDOWS.md`: neue offene Eintraege in Tabelle UND JSON-Block, Kopfzaehler stimmen mit den Zeilen ueberein. 10. `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera`; nichts in Active Directory. - Die Grenze zwischen Anmeldeweg und Nach-Anmeldung ist gelesen, gemessen (Werkzeug UND Test) und festgenagelt: drei Funktionen fuer die Suche vor bekanntem Mandanten, unveraendert; drei gebundene Methoden danach. - `getMe`, `changePassword`, `adminResetPassword` binden je ueber EINEN Klienten `tenantPrisma` an den Mandanten aus dem signierten Sitzungsnachweis; jeder Aufrufer im Controller ist umgestellt; die oberste Rolle behaelt ihre Reichweite ueber den gebundenen Fan-out. - Die Rechteausweitung ueber die Mandantengrenze (T-FH9-01) und die innerhalb des Mandanten in diesem Handler (T-FH9-04) sind geschlossen, gemessen und getestet; die im Schwesterweg ist gelesen und im Ledger. - Die umgekehrte Fehlerrichtung ist in ihrer TATSAECHLICHEN Auspraegung benannt (verschluckt, nicht laut; `networkError`, nicht "falsches Kennwort") — mit jedem Glied der Kette, als Ledger-Eintrag, mit Etappe-4-Vorabpruefung. - Etappe 3 ist nicht schwerer geworden; was sie wieder aufmacht, steht an zwei Stellen. - Die Testlage hat keine Identitaets-Attrappe mehr; sieben Falsifizierungsnachweise (drei in Aufgabe 2, eine am Controller, zwei an den Dokument-Gates in Aufgabe 3, dazu die Lesung des Schwesterwegs) sind durchgefuehrt, zurueckgenommen und woertlich notiert. - Fuenf handgepflegte Dokumentstellen nachgezogen und deriviert gegatet; Erlaubnisliste gegen `6236b30`; Baseline gehalten nach jeder Aufgabe; Schalter aus; Schema, Migrationen, Funktionen, Frontend, Active Directory unberuehrt. Nach jeder Aufgabe committen (Praefix `feat(260911-fh9): ...` fuer Aufgabe 2, `docs(quick-260911-fh9): ...` fuer Aufgabe 1 und 3, wie die Vorgaenger), nach dem letzten Commit pushen (schlichtes `git push`, die Push-URL zeigt auf `localhost:3002`). Am Ende `.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-SUMMARY.md` anlegen: tatsaechlich gezaehlte Pruefungs- und Testzahlen, alle Falsifizierungsnachweise mit Testname und Meldung woertlich, jede Abweichung von den Planungsbefunden ausdruecklich, die beiden Ledger-Nummern, und der Etappe-3-Vorbehalt in einem Absatz. Naechster Lauf laut Auftrag: `favorites` (7) und `settings` (4) als EIN Durchlauf — danach ist Etappe 2 vollstaendig.