diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 7b815e7..5bea34b 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -12,6 +13,7 @@ import { import { AuthGuard } from '@nestjs/passport'; import { Role } from '@prisma/client'; import { Request, Response } from 'express'; +import { UserService } from '../user/user.service'; import { AuthService } from './auth.service'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; @@ -23,7 +25,33 @@ import { RolesGuard } from './guards/roles.guard'; @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private userService: UserService, + ) {} + + /** + * Loest den Mandanten fuer `adminResetPassword` auf (260911-fh9, + * Praezedenzfall `user.controller.ts` `resolveTargetUser`, 260910-das): + * ein ADMIN wirkt auf seinen EIGENEN Mandanten (Claim), die oberste + * Rolle (SUPER_ADMIN) behaelt ihre uebergreifende Reichweite ueber den + * gebundenen Fan-out `UserService.findByIdForPlatformAdmin` — sonst + * saehe ein SUPER_ADMIN nur noch den eigenen Mandanten, eine stille + * Funktionsminderung (Befund D). Nicht gefunden: dieselbe + * `BadRequestException('User not found')`, die der Dienst bisher ohne + * Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode + * sieht wie vor dieser Umstellung. + */ + private async resolveTargetTenantId(currentUser: any, userId: string): Promise { + if (currentUser.role === Role.SUPER_ADMIN) { + const target = await this.userService.findByIdForPlatformAdmin(userId); + if (!target) { + throw new BadRequestException('User not found'); + } + return target.tenantId; + } + return currentUser.tenantId; + } /** * POST /auth/login @@ -55,10 +83,16 @@ export class AuthController { * GET /auth/me * Returns enriched user profile: public fields + isLocalUser + hasAvatar. * T-gbh-03: passwordHash and ldapDn are never serialised in the response. + * + * Mandant kommt ausschliesslich aus dem Sitzungsnachweis (`@CurrentUser()`, + * das Claim), NICHT aus der Anfrageobjekt-Eigenschaft, die `TenantGuard` + * fuer die oberste Rolle per Kopfzeile umschaltbar macht — ein + * umgeschalteter SUPER_ADMIN muss sich selbst weiterhin sehen (260911-fh9, + * Befund C). */ @Get('me') async me(@CurrentUser() user: any) { - return this.authService.getMe(user.id); + return this.authService.getMe(user.tenantId, user.id); } /** @@ -101,6 +135,7 @@ export class AuthController { @Res({ passthrough: true }) res: Response, ) { await this.authService.changePassword( + user.tenantId, user.id, dto.currentPassword, dto.newPassword, @@ -113,6 +148,13 @@ export class AuthController { * POST /auth/admin-reset-password/:userId * Admin resets a user's password (D-03 admin reset). * T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard. + * + * Der Mandant des Ziels kommt ausschliesslich aus dem Sitzungsnachweis + * des AUFRUFERS bzw. aus dem gebundenen Fan-out fuer die oberste Rolle + * (`resolveTargetTenantId` oben) — NICHT aus Pfad, Rumpf oder Kopfzeile + * (T-FH9-02). `AdminResetPasswordDto` traegt bewusst kein Mandantenfeld. + * Kein Frontend-Aufrufer (gemessen, 260911-fh9 Befund D); der + * Schwesterweg ist `PATCH /users/:id`. */ @Post('admin-reset-password/:userId') @Roles(Role.ADMIN, Role.SUPER_ADMIN) @@ -121,8 +163,12 @@ export class AuthController { async adminResetPassword( @Param('userId') userId: string, @Body() dto: AdminResetPasswordDto, + @CurrentUser() currentUser: any, ) { + const tenantId = await this.resolveTargetTenantId(currentUser, userId); await this.authService.adminResetPassword( + tenantId, + currentUser.role, userId, dto.newPassword, dto.mustChangePassword ?? true, diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 30bb994..5cab700 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -4,11 +4,24 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { LdapModule } from '../ldap/ldap.module'; import { MailModule } from '../mail/mail.module'; +import { UserModule } from '../user/user.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; +/** + * Importiert `UserModule` fuer `AuthController.resolveTargetTenantId` + * (260911-fh9): `adminResetPassword` loest den Mandanten der obersten + * Rolle (SUPER_ADMIN) ueber `UserService.findByIdForPlatformAdmin` auf. + * Zyklusfrei gemessen: `UserModule` importiert nur `GroupsModule`, + * `GroupsModule` importiert nichts (`grep -n "imports:" + * apps/api/src/groups/groups.module.ts`: null Treffer), und kein Modul + * ausser `AppModule` importiert `AuthModule` (`grep -rn "AuthModule" + * apps/api/src --include=*.module.ts`: nur `app.module.ts` und diese + * Datei selbst). `LdapModule`, das `AuthModule` bereits importiert, + * importiert `UserModule` unabhaengig davon selbst. + */ @Module({ imports: [ PassportModule, @@ -21,6 +34,7 @@ import { LocalStrategy } from './strategies/local.strategy'; }), MailModule, LdapModule, + UserModule, ], controllers: [AuthController], providers: [AuthService, LocalStrategy, JwtStrategy], diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index f5b2f3d..014961d 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Role } from '@prisma/client'; import { AuthService } from './auth.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; -// forTenant() gibt in diesen Tests denselben Client zurueck (tenant scoping -// ist hier nicht die Pruefung) — dasselbe Muster wie in -// ldap.service.spec.ts. +/** + * Aufgabe 2 (260911-fh9): die Identitaets-Attrappe verschwindet. Der + * Nachbau bekommt zwei UNTERSCHEIDBARE Klienten, in der Form von + * `user.service.spec.ts`/`tenant.controller.spec.ts`: `forTenant()` wird + * auf `__makeBoundClient(tenantId)` umgeleitet. Der UNGEBUNDENE Nachbau + * (der ungebundene Basisclient selbst) hat `$queryRaw` 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 hat `user`/`passwordResetToken`, aber KEIN + * `$queryRaw`: eine gebundene Anmeldesuche scheitert ebenso hart. + */ vi.mock('../prisma/prisma-tenant.extension', () => ({ - forTenant: vi.fn((p: unknown) => p), + forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), })); /** @@ -25,6 +36,122 @@ function fakeQueryRaw(resultsByCall: unknown[][]) { return { fn, calls }; } +interface FakeUserRow { + id: string; + tenantId: string; + username: string; + email?: string | null; + passwordHash: string | null; + ldapDn: string | null; + isActive: boolean; + role: string; + displayName: string | null; + mustChangePassword: boolean; + avatarPath?: string | null; + accentColor?: string | null; +} + +interface BoundCall { + tenantId: string; + model: 'user' | 'passwordResetToken'; + method: string; + args: any; +} + +/** + * Zwei-Klienten-Nachbau (Muster `user.service.spec.ts`): `users` und + * `resetTokens` sind das gemeinsame Gedaechtnis, der ungebundene Klient + * (`$queryRaw`) und der gebundene Klient (`__makeBoundClient`) greifen auf + * DIESELBEN Karten zu, protokollieren aber unterschiedlich — der + * ungebundene protokolliert nicht, der gebundene schon. + */ +function makeFakePrisma(userRows: FakeUserRow[] = [], queryRawResults: unknown[][] = [[]]) { + const users = new Map(userRows.map((u) => [u.id, { ...u }])); + const resetTokens: any[] = []; + const boundCallLog: BoundCall[] = []; + const queryRaw = fakeQueryRaw(queryRawResults); + + function throwNotFound(): never { + const err: any = new Error('Record to update not found'); + err.code = 'P2025'; + throw err; + } + + function makeScopedUser(tenantId: string) { + return { + findUnique: async ({ where, select }: any) => { + boundCallLog.push({ tenantId, model: 'user', method: 'findUnique', args: { where, select } }); + const row = users.get(where.id); + if (!row || row.tenantId !== tenantId) return null; + if (!select) return { ...row }; + const picked: any = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = (row as any)[key]; + } + return picked; + }, + update: async ({ where, data }: any) => { + boundCallLog.push({ tenantId, model: 'user', method: 'update', args: { where, data } }); + const row = users.get(where.id); + if (!row || row.tenantId !== tenantId) throwNotFound(); + const updated = { ...row, ...data }; + users.set(where.id, updated); + return updated; + }, + }; + } + + function makeScopedResetToken(tenantId: string) { + return { + create: async ({ data }: any) => { + boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'create', args: { data } }); + const record = { id: `rt-${resetTokens.length + 1}`, usedAt: null, ...data }; + resetTokens.push(record); + return record; + }, + update: async ({ where, data }: any) => { + boundCallLog.push({ tenantId, model: 'passwordResetToken', method: 'update', args: { where, data } }); + const idx = resetTokens.findIndex((t) => t.id === where.id); + if (idx === -1) throwNotFound(); + resetTokens[idx] = { ...resetTokens[idx], ...data }; + return resetTokens[idx]; + }, + }; + } + + const fake: any = { + $queryRaw: queryRaw.fn, + __users: users, + __resetTokens: resetTokens, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + return { + __isBoundClient: true, + __tenantId: tenantId, + user: makeScopedUser(tenantId), + passwordResetToken: makeScopedResetToken(tenantId), + }; + }, + }; + + return { prisma: fake, queryRaw }; +} + +function expectBoundCall( + prisma: any, + tenantId: string, + model: 'user' | 'passwordResetToken', + method: string, +) { + const found = prisma.__boundCallLog.some( + (c: BoundCall) => c.tenantId === tenantId && c.model === model && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); +} + /** * validateUser — LDAP login path (AUTH-06 follow-up): users imported from LDAP * have no local passwordHash and must be authenticated by binding as their own @@ -49,17 +176,16 @@ describe('AuthService.validateUser — LDAP login', () => { passwordHash: null, ldapDn: 'CN=alice,OU=Users,DC=ctl,DC=local', isActive: true, + role: 'USER', + displayName: null, + mustChangePassword: false, }; beforeEach(() => { vi.clearAllMocks(); - queryRaw = fakeQueryRaw([[ldapUser]]); - prisma = { - $queryRaw: queryRaw.fn, - user: { - update: vi.fn().mockResolvedValue({}), - }, - }; + const built = makeFakePrisma([ldapUser as FakeUserRow], [[ldapUser]]); + prisma = built.prisma; + queryRaw = built.queryRaw; ldapService = { verifyUserCredentials: vi.fn() }; ldapConfigService = { getConfig: vi.fn().mockResolvedValue({ @@ -92,10 +218,7 @@ describe('AuthService.validateUser — LDAP login', () => { ldapUser.ldapDn, 'ad-password', ); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: 'u1' }, - data: { lastLoginAt: expect.any(Date) }, - }); + expectBoundCall(prisma, 't1', 'user', 'update'); }); it('rejects an LDAP user when the directory bind fails', async () => { @@ -104,7 +227,7 @@ describe('AuthService.validateUser — LDAP login', () => { const result = await service.validateUser('alice', 'wrong'); expect(result).toBeNull(); - expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.__boundCallLog).toHaveLength(0); }); it('rejects an LDAP user when no active LDAP config exists', async () => { @@ -117,8 +240,8 @@ describe('AuthService.validateUser — LDAP login', () => { }); it('rejects a passwordless user that has no ldapDn (never binds)', async () => { - queryRaw = fakeQueryRaw([[{ ...ldapUser, ldapDn: null }]]); - prisma.$queryRaw = queryRaw.fn; + const built = makeFakePrisma([{ ...ldapUser, ldapDn: null } as FakeUserRow], [[{ ...ldapUser, ldapDn: null }]]); + prisma.$queryRaw = built.queryRaw.fn; const result = await service.validateUser('alice', 'pw'); @@ -153,6 +276,10 @@ describe('AuthService.validateUser — LDAP login', () => { expect(result).toBeNull(); }); + + it('scheitert an "Cannot read properties of undefined", wenn die Suche versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => { + expect(prisma.user).toBeUndefined(); + }); }); /** @@ -172,6 +299,9 @@ describe('AuthService.validateUser — lokales Kennwort', () => { passwordHash: string; ldapDn: null; isActive: boolean; + role: string; + displayName: null; + mustChangePassword: boolean; }; beforeEach(async () => { @@ -186,13 +316,14 @@ describe('AuthService.validateUser — lokales Kennwort', () => { passwordHash: await argon2.hash('correct-password'), ldapDn: null, isActive: true, + role: 'USER', + displayName: null, + mustChangePassword: false, }; - queryRaw = fakeQueryRaw([[localUser]]); - prisma = { - $queryRaw: queryRaw.fn, - user: { update: vi.fn().mockResolvedValue({}) }, - }; + const built = makeFakePrisma([localUser as FakeUserRow], [[localUser]]); + prisma = built.prisma; + queryRaw = built.queryRaw; service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); @@ -200,11 +331,24 @@ describe('AuthService.validateUser — lokales Kennwort', () => { const result = await service.validateUser('bob', 'correct-password'); expect(result).toEqual(localUser); - expect(prisma.user.update).toHaveBeenCalledWith({ + expectBoundCall(prisma, 't1', 'user', 'update'); + const updateCall = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'user' && c.method === 'update', + ); + expect(updateCall.args).toEqual({ where: { id: 'u2' }, data: { lastLoginAt: expect.any(Date) }, }); }); + + it('sucht die Anmeldedaten exakt EINMAL ungebunden ueber $queryRaw und schreibt lastLoginAt exakt EINMAL gebunden unter dem Mandanten der Funktionszeile — die Grenze zwischen Anmeldeweg und Nach-Anmeldung', async () => { + await service.validateUser('bob', 'correct-password'); + + expect(queryRaw.calls).toHaveLength(1); + expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); + expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1'); + expectBoundCall(prisma, 't1', 'user', 'update'); + }); }); describe('AuthService.requestPasswordReset', () => { @@ -213,15 +357,18 @@ describe('AuthService.requestPasswordReset', () => { let mailService: any; let queryRaw: ReturnType; - const emailUser = { id: 'u3', tenantId: 't1', email: 'bob@example.com', isActive: true }; + const emailUser = { + id: 'u3', + tenantId: 't1', + email: 'bob@example.com', + isActive: true, + }; beforeEach(() => { vi.clearAllMocks(); - queryRaw = fakeQueryRaw([[emailUser]]); - prisma = { - $queryRaw: queryRaw.fn, - passwordResetToken: { create: vi.fn().mockResolvedValue({}) }, - }; + const built = makeFakePrisma([], [[emailUser]]); + prisma = built.prisma; + queryRaw = built.queryRaw; mailService = { sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined) }; service = new AuthService(prisma, {} as any, {} as any, mailService, {} as any, {} as any); }); @@ -229,7 +376,11 @@ describe('AuthService.requestPasswordReset', () => { it('legt das Rueckstell-Token mandantengebunden an, sobald der Benutzer gefunden ist', async () => { await service.requestPasswordReset('bob@example.com'); - expect(prisma.passwordResetToken.create).toHaveBeenCalledWith({ + expectBoundCall(prisma, 't1', 'passwordResetToken', 'create'); + const createCall = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'create', + ); + expect(createCall.args).toEqual({ data: { token: expect.any(String), userId: 'u3', @@ -248,7 +399,7 @@ describe('AuthService.requestPasswordReset', () => { await service.requestPasswordReset('unknown@example.com'); - expect(prisma.passwordResetToken.create).not.toHaveBeenCalled(); + expect(prisma.__boundCallLog).toHaveLength(0); expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled(); }); }); @@ -269,23 +420,48 @@ describe('AuthService.resetPassword', () => { beforeEach(() => { vi.clearAllMocks(); - queryRaw = fakeQueryRaw([[resetTokenRow]]); - prisma = { - $queryRaw: queryRaw.fn, - user: { update: vi.fn().mockResolvedValue({}) }, - passwordResetToken: { update: vi.fn().mockResolvedValue({}) }, - }; + const built = makeFakePrisma( + [ + { + id: 'u4', + tenantId: 't1', + username: 'dave', + passwordHash: 'old-hash', + ldapDn: null, + isActive: true, + role: 'USER', + displayName: null, + mustChangePassword: true, + }, + ], + [[resetTokenRow]], + ); + prisma = built.prisma; + queryRaw = built.queryRaw; + // Das Token selbst existiert im gebundenen Nachbau nicht automatisch — + // fuer den Update-Zweig genuegt hier, dass das UPDATE ueber die + // Kennung `rt1` gelingt; deshalb wird der Datensatz vorab ueber die + // Anlage nachgebildet, bevor resetPassword() ihn aktualisiert. + prisma.__resetTokens.push({ id: 'rt1', token: 'a-uuid-token', usedAt: null }); service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); }); it('findet den passenden Rueckstell-Datensatz und aktualisiert Kennwort und Token mandantengebunden', async () => { await service.resetPassword('a-uuid-token', 'new-password'); - expect(prisma.user.update).toHaveBeenCalledWith({ + expectBoundCall(prisma, 't1', 'user', 'update'); + expectBoundCall(prisma, 't1', 'passwordResetToken', 'update'); + const userUpdate = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'user' && c.method === 'update', + ); + expect(userUpdate.args).toEqual({ where: { id: 'u4' }, data: { passwordHash: expect.any(String), mustChangePassword: false }, }); - expect(prisma.passwordResetToken.update).toHaveBeenCalledWith({ + const tokenUpdate = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'passwordResetToken' && c.method === 'update', + ); + expect(tokenUpdate.args).toEqual({ where: { id: 'rt1' }, data: { usedAt: expect.any(Date) }, }); @@ -300,3 +476,280 @@ describe('AuthService.resetPassword', () => { ); }); }); + +/** + * getMe/changePassword/adminResetPassword — bisher OHNE einen einzigen + * Testfall (Befund F). Alle drei binden seit Aufgabe 2 an den Mandanten + * aus dem Sitzungsnachweis. + */ +describe('AuthService.getMe', () => { + let service: AuthService; + let prisma: any; + + const localUserRow: FakeUserRow = { + id: 'u1', + tenantId: 't1', + username: 'alice', + passwordHash: 'a-hash', + ldapDn: null, + isActive: true, + role: 'USER', + displayName: 'Alice', + mustChangePassword: false, + avatarPath: 'avatars/u1.png', + accentColor: '#3b82f6', + }; + + const ldapUserRow: FakeUserRow = { + id: 'u2', + tenantId: 't1', + username: 'bob', + passwordHash: null, + ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local', + isActive: true, + role: 'USER', + displayName: 'Bob', + mustChangePassword: false, + avatarPath: null, + accentColor: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + const built = makeFakePrisma([localUserRow, ldapUserRow]); + prisma = built.prisma; + service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); + }); + + it('eigener Mandant, lokaler Benutzer: liefert die oeffentlichen Felder, isLocalUser true, hasAvatar true — passwordHash/ldapDn/avatarPath fehlen (T-gbh-03)', async () => { + const result = await service.getMe('t1', 'u1'); + + expect(result).toMatchObject({ + id: 'u1', + username: 'alice', + displayName: 'Alice', + role: 'USER', + tenantId: 't1', + mustChangePassword: false, + accentColor: '#3b82f6', + isLocalUser: true, + hasAvatar: true, + }); + expect(result).not.toHaveProperty('passwordHash'); + expect(result).not.toHaveProperty('ldapDn'); + expect(result).not.toHaveProperty('avatarPath'); + }); + + it('eigener Mandant, LDAP-Benutzer (kein Hash, ldapDn gesetzt): isLocalUser false', async () => { + const result = await service.getMe('t1', 'u2'); + + expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false }); + }); + + it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => { + const result = await service.getMe('t2', 'u1'); + + expect(result).toBeNull(); + }); + + it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { + vi.mocked(forTenant).mockClear(); + + await service.getMe('t1', 'u1'); + + expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); + expect(vi.mocked(forTenant).mock.calls[0][1]).toBe('t1'); + }); +}); + +describe('AuthService.changePassword', () => { + let service: AuthService; + let prisma: any; + let jwtService: any; + let configService: any; + let response: any; + let localUserRow: FakeUserRow; + let ldapUserRow: FakeUserRow; + + beforeEach(async () => { + vi.clearAllMocks(); + const argon2 = await import('argon2'); + localUserRow = { + id: 'u1', + tenantId: 't1', + username: 'alice', + passwordHash: await argon2.hash('current-password'), + ldapDn: null, + isActive: true, + role: 'USER', + displayName: 'Alice', + mustChangePassword: false, + }; + ldapUserRow = { + id: 'u2', + tenantId: 't1', + username: 'bob', + passwordHash: null, + ldapDn: 'CN=bob,OU=Users,DC=ctl,DC=local', + isActive: true, + role: 'USER', + displayName: 'Bob', + mustChangePassword: false, + }; + const built = makeFakePrisma([localUserRow, ldapUserRow]); + prisma = built.prisma; + jwtService = { sign: vi.fn().mockReturnValue('signed.jwt.token') }; + configService = { get: vi.fn().mockReturnValue('development') }; + response = { cookie: vi.fn() }; + service = new AuthService(prisma, jwtService, configService, {} as any, {} as any, {} as any); + }); + + it('eigener Mandant, richtiges aktuelles Kennwort: gebundenes update traegt neuen Hash und mustChangePassword=false, JWT signiert mit tenantId/mustChangePassword, Cookie gesetzt', async () => { + const argon2 = await import('argon2'); + + await service.changePassword('t1', 'u1', 'current-password', 'brand-new-password', response); + + const updateCall = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'user' && c.method === 'update', + ); + expect(updateCall).toBeDefined(); + expect(updateCall.args.where).toEqual({ id: 'u1' }); + expect(updateCall.args.data.mustChangePassword).toBe(false); + const isNewHashValid = await argon2.verify( + updateCall.args.data.passwordHash, + 'brand-new-password', + ); + expect(isNewHashValid).toBe(true); + + expect(jwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: 't1', mustChangePassword: false }), + ); + expect(response.cookie).toHaveBeenCalledWith( + 'session', + 'signed.jwt.token', + expect.objectContaining({ httpOnly: true }), + ); + }); + + it('FREMDER Mandant: UnauthorizedException, kein Schreibzugriff, kein Cookie', async () => { + await expect( + service.changePassword('t2', 'u1', 'current-password', 'new-password', response), + ).rejects.toThrow('User not found or has no local password'); + + expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); + expect(response.cookie).not.toHaveBeenCalled(); + }); + + it('falsches aktuelles Kennwort: Current password is incorrect, kein Schreibzugriff', async () => { + await expect( + service.changePassword('t1', 'u1', 'wrong-password', 'new-password', response), + ).rejects.toThrow('Current password is incorrect'); + + expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); + }); + + it('LDAP-Benutzer (kein Hash): UnauthorizedException, kein Schreibzugriff', async () => { + await expect( + service.changePassword('t1', 'u2', 'anything', 'new-password', response), + ).rejects.toThrow('User not found or has no local password'); + + expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); + }); + + it('erzeugt genau EINEN gebundenen Klienten je Aufruf (Suche und Schreiben auf demselben)', async () => { + vi.mocked(forTenant).mockClear(); + + await service.changePassword('t1', 'u1', 'current-password', 'new-password', response); + + expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); + }); +}); + +describe('AuthService.adminResetPassword', () => { + let service: AuthService; + let prisma: any; + let targetUserRow: FakeUserRow; + let superAdminTargetRow: FakeUserRow; + + beforeEach(() => { + vi.clearAllMocks(); + targetUserRow = { + id: 'target', + tenantId: 't1', + username: 'carol', + passwordHash: 'old-hash', + ldapDn: null, + isActive: true, + role: 'USER', + displayName: 'Carol', + mustChangePassword: false, + }; + superAdminTargetRow = { + id: 'boss', + tenantId: 't1', + username: 'dora', + passwordHash: 'old-hash', + ldapDn: null, + isActive: true, + role: 'SUPER_ADMIN', + displayName: 'Dora', + mustChangePassword: false, + }; + const built = makeFakePrisma([targetUserRow, superAdminTargetRow]); + prisma = built.prisma; + service = new AuthService(prisma, {} as any, {} as any, {} as any, {} as any, {} as any); + }); + + it('eigener Mandant, Aufrufer ADMIN, Ziel USER: gebundenes update mit neuem Hash, mustChangePassword TRUE (Vorgabe, Parameter weggelassen)', async () => { + const argon2 = await import('argon2'); + + await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password'); + + const updateCall = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'user' && c.method === 'update', + ); + expect(updateCall.args.where).toEqual({ id: 'target' }); + expect(updateCall.args.data.mustChangePassword).toBe(true); + const ok = await argon2.verify(updateCall.args.data.passwordHash, 'fresh-password'); + expect(ok).toBe(true); + }); + + it('mustChangePassword: false wird durchgereicht', async () => { + await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password', false); + + const updateCall = prisma.__boundCallLog.find( + (c: BoundCall) => c.model === 'user' && c.method === 'update', + ); + expect(updateCall.args.data.mustChangePassword).toBe(false); + }); + + it('FREMDER Mandant: BadRequestException, Meldung woertlich "User not found", KEIN Schreibzugriff — nennt weder Halter noch Mandanten', async () => { + await expect( + service.adminResetPassword('t2', Role.ADMIN, 'target', 'fresh-password'), + ).rejects.toThrow('User not found'); + + expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); + }); + + it('Aufrufer ADMIN, Ziel SUPER_ADMIN im SELBEN Mandanten: ForbiddenException (T-FH9-04), KEIN Schreibzugriff', async () => { + await expect( + service.adminResetPassword('t1', Role.ADMIN, 'boss', 'fresh-password'), + ).rejects.toThrow(); // ForbiddenException + + expect(prisma.__boundCallLog.some((c: BoundCall) => c.method === 'update')).toBe(false); + }); + + it('Aufrufer SUPER_ADMIN, Ziel SUPER_ADMIN: gelingt', async () => { + await service.adminResetPassword('t1', Role.SUPER_ADMIN, 'boss', 'fresh-password'); + + expectBoundCall(prisma, 't1', 'user', 'update'); + }); + + it('erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { + vi.mocked(forTenant).mockClear(); + + await service.adminResetPassword('t1', Role.ADMIN, 'target', 'fresh-password'); + + expect(vi.mocked(forTenant).mock.calls).toHaveLength(1); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index fe86336..0b1b508 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,11 +1,13 @@ import { BadRequestException, + ForbiddenException, Injectable, Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { Role } from '@prisma/client'; import * as argon2 from 'argon2'; import { randomUUID } from 'crypto'; import { Response } from 'express'; @@ -48,6 +50,33 @@ interface AuthLookupResetTokenRow { tenantId: string; } +/** + * Bindung an forTenant() (260911-fh9): dieser Bereich traegt die Grenze + * der gesamten Mandantentrennung. `validateUser`, `requestPasswordReset`, + * `resetPassword` suchen VOR bekanntem Mandanten — sie bleiben deshalb auf + * dem ungebundenen Klienten und laufen ueber die drei + * SECURITY-DEFINER-Funktionen aus `20260909160000_auth_lookup_functions` + * (Etappe 1, 260909-eor). `getMe`, `changePassword`, `adminResetPassword` + * laufen NACH der Anmeldung: der Mandant steht im signierten + * Sitzungsnachweis (dem JWT-Claim `tenantId`, das `login()` aus der + * Funktionszeile signiert) und wird je Methode ueber GENAU EINEN Klienten + * `tenantPrisma` gebunden. Woher der Mandant der drei gebundenen Methoden + * kommt: das Claim (`@CurrentUser().tenantId` im Controller) — NICHT die + * Anfrageobjekt-Eigenschaft, die `TenantGuard` fuer die oberste Rolle per + * Kopfzeile umschaltbar macht (die eigene Zeile liegt immer im eigenen + * Mandanten, ein umgeschalteter SUPER_ADMIN muss sich selbst sehen). Fuer + * die oberste Rolle bei `adminResetPassword` kommt der Mandant des ZIELS + * stattdessen aus dem gebundenen Fan-out `UserService.findByIdForPlatformAdmin` + * (Controller-seitig, Praezedenzfall `user.controller.ts` `resolveTargetUser`, + * 260910-das). + * + * Etappe-3-Vorbehalt: sobald Anmeldenamen je Mandant eindeutig werden, + * braucht der Anmeldeweg den Mandanten VOR der Suche — ein Umbau der drei + * Funktionen (zwei Gleichheitsbedingungen statt einer, ENGER, nicht + * weiter), nicht dieser Bereich. Die Bindung der drei Methoden hier haengt + * ausschliesslich am Claim `tenantId` und an `User.id` (plattformweite + * UUID) und bleibt davon unberuehrt. + */ @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); @@ -268,9 +297,17 @@ export class AuthService { * Return enriched profile for the currently authenticated user. * T-gbh-03: Only public fields + isLocalUser/hasAvatar returned — never * passwordHash or ldapDn. + * + * Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9): der + * Aufrufer sucht seine EIGENE Zeile, die per Definition im eigenen + * Mandanten liegt. Eine fremdmandantige Kennung (kann strukturell nicht + * vorkommen, weil der Controller ausschliesslich `user.id` aus dem Claim + * durchreicht) liefert unter dem gebundenen Klienten `null`, nicht die + * Zeile. */ - async getMe(userId: string) { - const user = await this.prisma.user.findUnique({ + async getMe(tenantId: string, userId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const user = await tenantPrisma.user.findUnique({ where: { id: userId }, select: { id: true, @@ -302,14 +339,20 @@ export class AuthService { /** * Change password for the currently logged-in user. * Verifies current password before allowing change. + * + * Bindet an den Mandanten aus dem Sitzungsnachweis (260911-fh9), EIN + * Klient `tenantPrisma` fuer Suche UND Schreiben — dieselbe Begruendung + * wie bei getMe() oben: die eigene Zeile liegt im eigenen Mandanten. */ async changePassword( + tenantId: string, userId: string, currentPassword: string, newPassword: string, response: Response, ): Promise { - const user = await this.prisma.user.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const user = await tenantPrisma.user.findUnique({ where: { id: userId }, }); @@ -323,7 +366,7 @@ export class AuthService { } const passwordHash = await argon2.hash(newPassword); - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword: false }, }); @@ -350,13 +393,29 @@ export class AuthService { /** * Admin reset of a user's password (D-03 admin reset). * T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard. + * + * Bindet an den Mandanten des ZIELS (260911-fh9), EIN Klient + * `tenantPrisma`: fuer einen ADMIN-Aufrufer ist das dessen eigener + * Mandant aus dem Sitzungsnachweis, fuer SUPER_ADMIN der ueber den + * gebundenen Fan-out (Controller, `UserService.findByIdForPlatformAdmin`) + * aufgeloeste Mandant des Ziels — beide kommen als `tenantId`-Parameter + * bereits fertig aufgeloest hier an. Ein fremdmandantiges Ziel ist unter + * dem gebundenen Klienten unsichtbar (T-FH9-01); die + * `BadRequestException` nennt weder Halter noch Mandanten. Der Riegel + * unten schliesst zusaetzlich die Rechteausweitung INNERHALB des + * Mandanten (T-FH9-04): ein Nicht-SUPER_ADMIN darf das Kennwort eines + * SUPER_ADMIN nicht setzen. Der Schwesterweg `PATCH /users/:id` hat + * dieselbe Luecke nicht geschlossen — offener Ledger-Eintrag T-FH9-05. */ async adminResetPassword( + tenantId: string, + callerRole: Role, userId: string, newPassword: string, mustChangePassword: boolean = true, ): Promise { - const user = await this.prisma.user.findUnique({ + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const user = await tenantPrisma.user.findUnique({ where: { id: userId }, }); @@ -364,8 +423,12 @@ export class AuthService { throw new BadRequestException('User not found'); } + if (user.role === Role.SUPER_ADMIN && callerRole !== Role.SUPER_ADMIN) { + throw new ForbiddenException('Cannot reset password of a SUPER_ADMIN user'); + } + const passwordHash = await argon2.hash(newPassword); - await this.prisma.user.update({ + await tenantPrisma.user.update({ where: { id: userId }, data: { passwordHash,