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

1075 lines
101 KiB
Markdown

---
phase: quick-260911-fh9
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [WINDOWS-18, ETAPPE-2-AUTH]
files_modified:
- apps/api/scripts/rls-scratch-check.mjs
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/auth/auth.controller.ts
- apps/api/src/auth/auth.module.ts
- apps/api/src/auth/auth.controller.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- .planning/WINDOWS.md
estimate:
tokens: 180000
raw_tokens: 180000
tasks: 3
confidence: low
must_haves:
truths:
- "Die Grenze zwischen Anmeldeweg und Nach-Anmeldung ist GELESEN und GEMESSEN, nicht angenommen: `validateUser`, `requestPasswordReset`, `resetPassword` suchen VOR bekanntem Mandanten ueber die drei SECURITY-DEFINER-Funktionen auf dem UNGEBUNDENEN Klienten und bleiben es (drei `$queryRaw`-Stellen, Migration unangetastet, `pg_proc` bestaetigt SECURITY DEFINER, STABLE, festen Suchpfad und LIMIT 1 in der Wegwerf-Datenbank); `getMe`, `changePassword`, `adminResetPassword` laufen NACH der Anmeldung, der Mandant steht im signierten Sitzungsnachweis, und genau diese drei werden gebunden."
- "Der Mandant der drei gebundenen Methoden stammt ausschliesslich aus dem Sitzungsnachweis (`@CurrentUser().tenantId`, das von `login()` aus der Funktionszeile signierte Claim) — NICHT aus `req.tenantId` (das fuer SUPER_ADMIN per `x-tenant-id` umschaltbar ist und den Anfragenden sich selbst gegenueber unsichtbar machen wuerde), NICHT aus Pfad, Rumpf oder Kopfzeile. Fuer SUPER_ADMIN bei `adminResetPassword` kommt der Mandant des ZIELS aus dem gebundenen Fan-out `UserService.findByIdForPlatformAdmin` (Praezedenzfall `user.controller.ts`, 260910-das). Gates zaehlen `req.tenantId` und `x-tenant-id` in `auth.controller.ts` auf null."
- "Die Rechteausweitung ueber die Mandantengrenze ist geschlossen und gemessen: ein ADMIN von Mandant A, der `POST /auth/admin-reset-password/:userId` fuer einen Benutzer von Mandant B aufruft, findet unter dem gebundenen Klienten keine Zeile (Pruefung 6/9 im Werkzeug, ueber den GENERIERTEN Client) und bekommt die bestehende 400-Meldung ohne Aussage ueber den fremden Mandanten; als Testfall festgenagelt und durch Rueckbau falsifiziert."
- "Die Rechteausweitung INNERHALB des Mandanten ist in diesem Handler geschlossen (ein ADMIN setzt das Kennwort eines SUPER_ADMIN nicht mehr — `ForbiddenException`, Testfall) und fuer den Schwesterweg `PATCH /users/:id` (ausserhalb der Erlaubnisliste) gemessen, in (h4) festgehalten und als OFFENER Ledger-Eintrag mit konkreter Reparatur uebergeben."
- "Die umgekehrte Fehlerrichtung ist je Methode in ihrer tatsaechlichen Auspraegung benannt und gemessen: `getMe` liefert `null` — der Controller antwortet 200 mit leerem Rumpf, `fetchCurrentUser` macht daraus `null`, `header.tsx` und `account-settings-form.tsx` verschlucken das in eine Portalhuelle OHNE angemeldeten Benutzer (kein Name, keine Admin-Navigation) — NICHT laut, sondern die Familie von WINDOWS #26; `changePassword` liest sich im Frontend als `networkError` (nicht als falsches Kennwort — `auth-actions.ts` uebersetzt nur die eine Meldung); `adminResetPassword` als `User not found` ohne UI-Aufrufer. Steht in (h2)/(h3), als Ledger-Eintrag, mit Etappe-4-Vorabpruefung."
- "Die Testlage traegt: `auth.service.spec.ts` hat KEINE Identitaets-Attrappe mehr, sondern zwei unterscheidbare Klienten (`__makeBoundClient`), der ungebundene Nachbau hat KEIN Benutzermodell und der gebundene KEIN `$queryRaw` — beide Grenzen falsifizierbar; `auth.controller.spec.ts` existiert neu und nagelt Mandantenquelle, Rollenverzweigung, Rollen- und Public-Metadaten fest."
- "Etappe 3 wird durch diesen Plan nicht schwerer: die Bindung haengt am Claim `tenantId` und an `User.id` (plattformweite UUID, Kette aus 260911-cwh), nicht an `username`/`email`; was Etappe 3 am Anmeldeweg umbauen muss (Mandant VOR der Suche, Funktionen mit zwei Gleichheitsbedingungen — ENGER, nicht weiter), steht in (h4)(a) und im Klassifikationsdokument."
- "Alle fuenf handgepflegten Dokumentstellen der Klassifikation sind nachgezogen und DERIVIERT gegatet (Uebersichtszeile aus der Messanweisung, Summenzeile, Bestandsaufnahme-Zeile, Klassen-Verteilung mit Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Punkt); der Umfang ist als ERLAUBNISLISTE gegen `6236b30` gegatet."
- "Baseline gehalten am Ende JEDER Aufgabe: mindestens 911 Tests gruen (nach Aufgabe 2 und 3 mehr), Typpruefung sauber, das Wegwerf-Werkzeug meldet alle Pruefungen bestanden (mindestens 120). Der Schalter bleibt AUS, Schema und Migrationen unveraendert, keine Compose- oder Umgebungsdatei angefasst, nichts in Active Directory, die drei Anmeldefunktionen unangetastet."
artifacts:
- "apps/api/scripts/rls-scratch-check.mjs — ein dreizehnter Abschnitt `runAuthAreaChecks` (getrennt von `runAuthLookupChecks`) mit mindestens zehn namentlich benannten Pruefungen, davon mindestens sechs ueber den GENERIERTEN Client an der auf den vollen Spaltensatz gebrachten Wegwerf-Tabelle `User` (Vergleich zur Laufzeit gegen die SKALAREN Felder von `model User` — neuer Helfer, der Relationsfelder ueber ihren Typ ausschliesst); `pg_proc`-Messung der drei Funktionen"
- "docs/mandantentrennung-etappe2-fehlerrichtung.md — Abschnitt `## Bereich auth` unmittelbar VOR `## Verweis` mit (h1) Messung, (h2) Signaltabelle je Pfad (Anmeldeweg UND Nach-Anmeldung), (h3) Leere als Abwesenheit im Backend UND Frontend, (h4) bewusst nicht geloest (darunter Etappe 3, Schwesterweg, Frontend), (h5) bewusst nicht angefasst"
- "apps/api/src/auth/auth.service.ts — `getMe(tenantId, userId)`, `changePassword(tenantId, userId, ...)`, `adminResetPassword(tenantId, callerRole, userId, ...)` je mit EINEM Klienten `tenantPrisma`; die drei `$queryRaw`-Anmeldesuchen unveraendert auf dem ungebundenen Klienten; Kopfkommentare nennen Grenze, Quelle des Mandanten und Etappe-3-Vorbehalt"
- "apps/api/src/auth/auth.service.spec.ts — Zwei-Klienten-Nachbau, alle bestehenden Faelle umgestellt, neue Faelle fuer die drei Methoden, Grenz-Test (Anmeldesuche ungebunden, Schreibzugriff gebunden), Wachhund"
- "apps/api/src/auth/auth.controller.ts — `me`/`changePassword` reichen `user.tenantId` aus dem Sitzungsnachweis durch; `adminResetPassword` verzweigt nach Rolle (ADMIN: eigener Mandant; SUPER_ADMIN: Fan-out ueber `UserService.findByIdForPlatformAdmin`), Kopfkommentar nennt Grund und Praezedenzfall"
- "apps/api/src/auth/auth.module.ts — importiert `UserModule` (zyklusfrei, gemessen)"
- "apps/api/src/auth/auth.controller.spec.ts — NEU: Mandantenquelle je Handler, Rollenverzweigung, unbekanntes Ziel, Rollen-Metadaten (`ROLES_KEY`) und Public-Metadaten (`IS_PUBLIC_KEY`) fuer alle sieben Handler"
- "docs/mandantentrennung-zugriffsklassifikation.md — Uebersichtszeile `auth` mit neu gemessenen Zahlen, Summenzeile, Bestandsaufnahme-Zeile `auth.service.ts`/`user` auf `gebunden`, Klassen-Verteilung mit Stand-Vermerk (unveraendert, ausdruecklich), Hintergrunddienst-Abschnitt (kein sechster Fall, gemessen), `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Anmeldeweg-Punkt"
- ".planning/WINDOWS.md — zwei neue OFFENE Eintraege ueber `gsd-tools windows append`: die verschluckte Leere im Frontend (Familie #23/#25/#26) und die Rechteausweitung ADMIN -> SUPER_ADMIN im Schwesterweg `PATCH /users/:id`"
key_links:
- "`JwtStrategy.validate` liefert `{ id: payload.sub, username, role, tenantId }`; `login()` signiert `tenantId: user.tenantId` aus der Zeile von `auth_lookup_user_by_username`; `@CurrentUser()` gibt genau dieses Objekt zurueck — das ist die einzige Mandantenquelle der drei Methoden. `TenantGuard` setzt daneben `req.tenantId` (fuer SUPER_ADMIN per `x-tenant-id` umschaltbar, vier Sender im Marktplatz-Frontend) — fuer Selbstbedienung UNGEEIGNET, weil die eigene Zeile im eigenen Mandanten liegt."
- "`UserService.findByIdForPlatformAdmin(id)` (user.service.ts) liest `Tenant` ungebunden (keine Regel, gemessen 260911-e2s) und sucht je Mandant gebunden — liefert die Zeile samt `tenantId`; `AuthModule` importiert dafuer `UserModule` (UserModule importiert GroupsModule, GroupsModule importiert nichts, kein Modul ausser AppModule importiert AuthModule — zyklusfrei, gemessen)."
- "Die drei Funktionen (20260909160000) sind SECURITY DEFINER mit festem Spaltensatz und LIMIT 1; ihre `$queryRaw`-Aufrufe MUESSEN auf `this.prisma` bleiben — ein gebundener `$queryRaw` liefe durch `$allOperations` mit gesetztem Kontext (fuer SECURITY DEFINER wirkungslos, aber ein falsches Signal fuer jeden Leser). Der Nachbau im Test hat auf dem gebundenen Klienten KEIN `$queryRaw`."
- "`getMe` liefert `null` -> NestJS `ExpressAdapter.reply` sendet bei `isNil(body)` einen leeren Rumpf mit 200 -> `fetchCurrentUser` (`auth-actions.ts`) laeuft in `response.json()` auf den leeren Rumpf, faengt und liefert `null` -> `header.tsx` `if (u)` und `account-settings-form.tsx` `if (u)` tun nichts. Zur Ausfuehrungszeit an allen vier Gliedern nachzulesen."
- "Die Wegwerf-Tabelle `User` des Werkzeugs hat heute 10 der 15 skalaren Spalten (fehlend: createdAt, updatedAt, lastLoginAt, avatarPath, accentColor); `findUnique` ohne `select` (die Form von `changePassword`/`adminResetPassword`) und das `select` von `getMe` (nennt avatarPath/accentColor) scheitern auf dem generierten Client mit P2022, solange die Spalten fehlen — die dashboard-Lehre (260910-krx, Pruefung 5b)."
---
<!-- planner-discipline-allow: this.prisma.user -->
<!-- planner-discipline-allow: this\.prisma\.user -->
<!-- planner-discipline-allow: forTenant: vi.fn((p -->
<!-- planner-discipline-allow: req.tenantId -->
<!-- planner-discipline-allow: x-tenant-id -->
<!-- planner-discipline-allow: tenantPrisma.$queryRaw -->
<!-- planner-discipline-allow: tenantPrisma\.\$queryRaw -->
<!-- planner-discipline-allow: forTenant -->
<!-- planner-discipline-allow: tenantId -->
<!-- planner-discipline-allow: PrismaService -->
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Die Fehlerrichtung fuer diesen Bereich MESSEN und aufschreiben — die Grenze zwischen Anmeldeweg (Funktionen) und Nach-Anmeldung (gebunden), ueber den generierten Client</name>
<precondition>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.</precondition>
<files>apps/api/scripts/rls-scratch-check.mjs, docs/mandantentrennung-etappe2-fehlerrichtung.md</files>
<read_first>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)</read_first>
<action>
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`.
</action>
<verify>
<automated>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; }; }</automated>
</verify>
<done>`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.</done>
</task>
<task type="auto" tdd="true">
<name>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</name>
<files>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</files>
<read_first>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))</read_first>
<behavior>
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.
</behavior>
<action>
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`.
</action>
<verify>
<automated>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<void> {/p' "$F" | grep -q 'tenantId: string' && sed -n '/async adminResetPassword(/,/): Promise<void> {/p' "$F" | grep -q 'tenantId: string' && sed -n '/async adminResetPassword(/,/): Promise<void> {/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.<etwas>) 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; }; }</automated>
</verify>
<done>`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.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 3: Die Mandantenquelle im Controller festnageln (neue Testdatei), alle Dokumentstellen der Klassifikation nachziehen, zwei Ledger-Eintraege anlegen, Gates falsifizieren</name>
<files>apps/api/src/auth/auth.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, .planning/WINDOWS.md</files>
<read_first>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`)</read_first>
<behavior>
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.
</behavior>
<action>
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.
</action>
<verify>
<automated>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.ts` existiert neu und deckt jeden in `<behavior>` genannten Fall ab — Mandantenquelle je Handler (das Claim; fuer die oberste Rolle der Mandant des Ziels aus dem Fan-out), unbekanntes Ziel, `null`-Durchreichung von `me`, Rollen-Metadaten und Public-Metadaten fuer alle sieben Handler; der Falsifizierungsnachweis am Fan-out-Zweig ist durchgefuehrt, zurueckgenommen und woertlich notiert, `auth.controller.ts` ist gegenueber Aufgabe 2 unveraendert. Alle handgepflegten Stellen von `docs/mandantentrennung-zugriffsklassifikation.md` sind nachgezogen und maschinell gegatet: Uebersichtszeile `auth` mit neu gemessenen Zahlen und Verweis auf die Funktions-Migration, Summenzeile, Bestandsaufnahme-Zeile `user` auf `gebunden` (keine `gemischt`-Zeile mehr fuer diese Datei), `passwordResetToken` unveraendert, Klassen-Verteilung mit 64 Paaren und Stand-Vermerk, Hintergrunddienst-Abschnitt ohne sechsten Fall, `Was diese Etappe NICHT entscheidet` mit dem Etappe-3-Punkt. `.planning/WINDOWS.md` traegt die neuen offenen Eintraege in Tabelle und JSON-Block, die Kopfzaehler stimmen. Beide Dokument-Falsifizierungen durchgefuehrt, zurueckgenommen, woertlich notiert. Werkzeug alle Pruefungen bestanden (mindestens 120), Baseline gehalten, Testzahl und Testdateizahl gestiegen, Typpruefung sauber, Schalter unveraendert aus, Funktionen unangetastet.</done>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
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.
</output>