From 4c3172b5a5f3469501afead181f3ecf90a5fdbfe Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 11:35:19 +0200 Subject: [PATCH] docs(quick-260911-fh9): Plan fuer Etappe 2, Bereich auth Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../260911-fh9-PLAN.md | 1074 +++++++++++++++++ 1 file changed, 1074 insertions(+) create mode 100644 .planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md diff --git a/.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md b/.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md new file mode 100644 index 0000000..b6506e7 --- /dev/null +++ b/.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md @@ -0,0 +1,1074 @@ +--- +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. + +