4c3172b5a5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
1075 lines
101 KiB
Markdown
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>
|
|
|