import { ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { assertPasswordNotBreached } from '../common/password/password-policy'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { Prisma } from '../../generated/prisma'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { ResetUserPasswordDto } from './dto/reset-user-password.dto'; const BCRYPT_SALT_ROUNDS = 12; const USER_WITH_ROLE = { include: { role: true }, } satisfies { include: Prisma.UserInclude }; type UserWithRole = Prisma.UserGetPayload; export interface UserSummary { id: string; email: string; name: string; roleId: string; roleKey: string; roleName: string; isActive: boolean; createdAt: Date; deactivatedAt: Date | null; mfaEnabled: boolean; } @Injectable() export class UsersService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} private toSummary(user: UserWithRole): UserSummary { return { id: user.id, email: user.email, name: user.name, roleId: user.roleId, roleKey: user.role.key, roleName: user.role.name, isActive: user.isActive, createdAt: user.createdAt, deactivatedAt: user.deactivatedAt, mfaEnabled: user.mfaEnabled, }; } /** * Assigning a role is gated by its own permission, separate from * users:create/users:update - otherwise any role holding those (e.g. a * minimal "user manager" role) could hand out any role, including a * system/admin one, to itself or a colluding account. actingUserId is * only absent for non-HTTP callers (there are none today, and tests that * exercise the service directly without an actor), where there is no * caller to check permissions against. */ private async assertCanAssignRole(actingUserId?: string): Promise { if (!actingUserId) { return; } const allowed = await this.permissionResolver.hasAny(actingUserId, [ 'users:assign-role', ]); if (!allowed) { throw new ForbiddenException('Missing permission to assign roles'); } } /** * Nobody may act on - or hand out - more than they can do themselves: a * limited "user manager" holding users:update could otherwise reset an * administrator's password or MFA, change their email to one they control, * or give a colluding account the ADMIN role, and so become admin. Same * rule as RolesService.assertCanGrantPermissions. Reads the role's grants * directly (not the cached effective set), so a deactivated admin counts * as an admin too. */ private async assertWithinCallerPermissions( roleId: string, actingUserId?: string, ): Promise { if (!actingUserId) { return; } const grants = await this.prisma.rolePermission.findMany({ where: { roleId }, select: { permission: { select: { key: true } } }, }); const callerPermissions = await this.permissionResolver.getPermissions(actingUserId); const exceeds = grants.some( (grant) => !callerPermissions.has(grant.permission.key), ); if (exceeds) { throw new ForbiddenException( 'Dieses Konto hat mehr Rechte als Sie selbst und kann von Ihnen nicht verwaltet werden', ); } } private async assertActiveRole(roleId: string): Promise { const role = await this.prisma.role.findUnique({ where: { id: roleId } }); if (!role) { throw new NotFoundException('Role not found'); } if (role.status !== 'ACTIVE') { throw new ConflictException('Role is not active'); } } /** * Blocks removing (by deactivation or role reassignment) the last active * user still holding a system role (ADMIN). Without this, an operator * could lock the app out of user/role management entirely, with no * self-service recovery: prisma/seed.ts skips seeding once a user with * its configured email already exists, so it cannot restore access to a * merely demoted/deactivated account. */ private async assertNotLastSystemRoleUser(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { roleId: true, role: { select: { isSystem: true } } }, }); if (!user || !user.role.isSystem) { return; } const activeCount = await this.prisma.user.count({ where: { roleId: user.roleId, isActive: true }, }); if (activeCount <= 1) { throw new ConflictException( 'Cannot remove the last active user holding a system role', ); } } async findAll(): Promise { const users = await this.prisma.user.findMany({ ...USER_WITH_ROLE, orderBy: { createdAt: 'asc' }, }); return users.map((user) => this.toSummary(user)); } async findById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, ...USER_WITH_ROLE, }); if (!user) { throw new NotFoundException('User not found'); } return user; } async findSummaryById(id: string): Promise { return this.toSummary(await this.findById(id)); } /** Case-insensitive: "Imker@x.de" and "imker@x.de" are one account. */ async findByEmail(email: string): Promise { return this.prisma.user.findFirst({ where: { email: { equals: email.trim(), mode: 'insensitive' } }, ...USER_WITH_ROLE, }); } async findBySsoSubject(ssoSubject: string): Promise { return this.prisma.user.findUnique({ where: { ssoSubject }, ...USER_WITH_ROLE, }); } async linkSsoSubject(userId: string, ssoSubject: string): Promise { await this.prisma.user.update({ where: { id: userId }, data: { ssoSubject }, }); await this.auditLog.record({ entityType: 'User', entityId: userId, action: 'UPDATE', userId, afterData: { ssoLinked: true }, }); } async create( dto: CreateUserDto, actingUserId?: string, ): Promise { const existing = await this.findByEmail(dto.email); if (existing) { throw new ConflictException('Email already in use'); } await this.assertActiveRole(dto.roleId); await this.assertCanAssignRole(actingUserId); await this.assertWithinCallerPermissions(dto.roleId, actingUserId); await assertPasswordNotBreached(dto.password); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const user = await this.prisma.user.create({ data: { email: dto.email, passwordHash, name: dto.name, roleId: dto.roleId, }, ...USER_WITH_ROLE, }); await this.auditLog.record({ entityType: 'User', entityId: user.id, action: 'CREATE', userId: actingUserId, afterData: this.toSummary(user), }); return this.toSummary(user); } async update( id: string, dto: UpdateUserDto, actingUserId?: string, ): Promise { const before = await this.findById(id); await this.assertWithinCallerPermissions(before.roleId, actingUserId); const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId; const emailChanged = dto.email !== undefined && dto.email !== before.email; if (dto.roleId) { await this.assertActiveRole(dto.roleId); } if (roleChanged) { // Role changes for yourself always require a second party, regardless // of what permission the caller holds - otherwise anyone who can // reach this endpoint at all could grant themselves admin. if (actingUserId && id === actingUserId) { throw new ForbiddenException('Cannot change your own role'); } await this.assertCanAssignRole(actingUserId); await this.assertWithinCallerPermissions(dto.roleId!, actingUserId); await this.assertNotLastSystemRoleUser(id); } const user = await this.prisma.user.update({ where: { id }, data: { name: dto.name, email: dto.email, roleId: dto.roleId, // A new email is a new identity: the next Microsoft sign-in links // afresh instead of reusing the old account's binding. ...(emailChanged ? { ssoSubject: null } : {}), }, ...USER_WITH_ROLE, }); if (emailChanged) { // The email is the login name and the SSO link: sessions opened under // the old one end here. await this.prisma.refreshToken.updateMany({ where: { userId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); } if (roleChanged || emailChanged) { this.permissionResolver.invalidateUser(id); } await this.auditLog.record({ entityType: 'User', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } async deactivate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); await this.assertWithinCallerPermissions(before.roleId, actingUserId); await this.assertNotLastSystemRoleUser(id); const user = await this.prisma.user.update({ where: { id }, data: { isActive: false, deactivatedAt: new Date() }, ...USER_WITH_ROLE, }); this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } /** * Recovery path for a user who lost their MFA device (no email-reset flow * exists in this project). Deliberately blocked for the caller's own * account: unlike resetting someone else's MFA, self-service MFA removal * belongs behind a password check (POST /profile/mfa/disable) - otherwise * a stolen access token alone (no password) would be enough to strip the * victim's own MFA protection via this admin route. */ /** * Sets a new password for another user. Mirrors ProfileService.changePassword * in what it invalidates: every refresh token is revoked and the permission * cache is cleared, so sessions opened with the old password stop working at * once rather than living on until their access token expires. * * Your own password goes through /profile/password, which requires the * current one - otherwise anyone holding users:update could silently take * over their own account's credentials without proving they own it. */ async resetPassword( id: string, dto: ResetUserPasswordDto, actingUserId?: string, ): Promise { if (actingUserId && id === actingUserId) { throw new ForbiddenException( 'Use /profile/password to change your own password', ); } const target = await this.findById(id); await this.assertWithinCallerPermissions(target.roleId, actingUserId); await assertPasswordNotBreached(dto.password); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS); const user = await this.prisma.user.update({ where: { id }, data: { passwordHash, passwordChangedAt: new Date() }, ...USER_WITH_ROLE, }); await this.prisma.refreshToken.updateMany({ where: { userId: id, revokedAt: null }, data: { revokedAt: new Date() }, }); this.permissionResolver.invalidateUser(id); // The new password never reaches the audit log - only the fact that it // was replaced, and by whom. await this.auditLog.record({ entityType: 'User', entityId: id, action: 'UPDATE', userId: actingUserId, }); return this.toSummary(user); } async resetMfa(id: string, actingUserId?: string): Promise { if (actingUserId && id === actingUserId) { throw new ForbiddenException( 'Use /profile/mfa/disable to disable your own MFA', ); } const target = await this.findById(id); await this.assertWithinCallerPermissions(target.roleId, actingUserId); const user = await this.prisma.user.update({ where: { id }, data: { mfaEnabled: false, mfaSecret: null }, ...USER_WITH_ROLE, }); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'MFA_DISABLED', userId: actingUserId, }); return this.toSummary(user); } async activate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); await this.assertWithinCallerPermissions(before.roleId, actingUserId); if (before.isActive) { throw new ConflictException('User is already active'); } const user = await this.prisma.user.update({ where: { id }, data: { isActive: true, deactivatedAt: null }, ...USER_WITH_ROLE, }); this.permissionResolver.invalidateUser(id); await this.auditLog.record({ entityType: 'User', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: this.toSummary(user), }); return this.toSummary(user); } }