Files
tessera-ctl/.planning/quick/260911-fh9-mandantentrennung-etappe-2-bereich-auth-/260911-fh9-PLAN.md
T

101 KiB

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260911-fh9 01 execute 1
true
WINDOWS-18
ETAPPE-2-AUTH
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
tokens raw_tokens tasks confidence
180000 180000 3 low
truths artifacts key_links
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.
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`
`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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.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

<planning_time_findings>

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, <tenantId aus der Zeile>) 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).

</planning_time_findings>

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 <Name>-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<n&&n<t)){print "REIHENFOLGE in main(): runAuthAreaChecks muss nach runTenantAreaChecks und vor runTransactionShapeMeasurement stehen"; exit 1} }' apps/api/scripts/rls-scratch-check.mjs && grep -q '^## Bereich auth$' docs/mandantentrennung-etappe2-fehlerrichtung.md && for S in h1 h2 h3 h4 h5; do grep -qE "^### ($S) " docs/mandantentrennung-etappe2-fehlerrichtung.md || { echo "FEHLENDER UNTERABSCHNITT: ($S)"; exit 1; }; done && awk '/^## Bereich auth$/{f=1; next} /^## /{f=0} f && /20260909160000/{m=1} f && /auth_lookup_user_by_username/{u=1} f && /header.tsx/{h=1} f && /auth-actions.ts/{a=1} f && /networkError/{n=1} f && /account-settings-form.tsx/{s=1} f && /findByIdForPlatformAdmin/{p=1} f && /x-tenant-id/{x=1} f && /Etappe 3/{e=1} f && /user.controller.ts/{c=1} f && /pg_proc/{q=1} f && /SUPER_ADMIN/{r=1} END{ if(!m||!u){print "(h1)/(h5): Migration 20260909160000 oder die Funktion auth_lookup_user_by_username nicht benannt"; exit 1} if(!h||!a||!s||!n){print "(h3): die Frontend-Glieder (header.tsx, auth-actions.ts, account-settings-form.tsx) oder networkError nicht benannt"; exit 1} if(!p){print "(h2)/(h4): der Fan-out findByIdForPlatformAdmin ist nicht benannt"; exit 1} if(!x){print "(h4): die Entscheidung gegen req.tenantId/x-tenant-id ist nicht benannt"; exit 1} if(!e){print "(h4)(a): Etappe 3 ist nicht benannt"; exit 1} if(!c||!r){print "(h4)(b): der Schwesterweg in user.controller.ts / die SUPER_ADMIN-Rechteausweitung ist nicht benannt"; exit 1} if(!q){print "(h1): die pg_proc-Messung ist nicht benannt"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && awk '/^## Bereich auth$/{f=1} /^## Verweis$/{ if(f){ok=1} f=0 } END{ if(!ok){print "ABSCHNITT ## Bereich auth steht nicht unmittelbar vor ## Verweis"; exit 1} }' docs/mandantentrennung-etappe2-fehlerrichtung.md && 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" -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 <behavior> 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 <behavior>, 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; }; }</automated> </verify> <done>auth.controller.spec.tsexistiert neu und deckt jeden ingenannten 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.tsist gegenueber Aufgabe 2 unveraendert. Alle handgepflegten Stellen vondocs/mandantentrennung-zugriffsklassifikation.mdsind nachgezogen und maschinell gegatet: Uebersichtszeileauthmit neu gemessenen Zahlen und Verweis auf die Funktions-Migration, Summenzeile, Bestandsaufnahme-Zeileuseraufgebunden(keinegemischt-Zeile mehr fuer diese Datei), passwordResetTokenunveraendert, Klassen-Verteilung mit 64 Paaren und Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall,Was diese Etappe NICHT entscheidetmit 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.

<threat_model>

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.

</threat_model>

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.

<success_criteria>

  • 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.

</success_criteria>

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.