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