This commit is contained in:
Bastian Wagner
2026-07-16 15:23:53 +02:00
parent 543e8273a7
commit c03b2e17f5
114 changed files with 7631 additions and 506 deletions

View File

@@ -1,4 +1,15 @@
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
export class UpdateUserRolesDto {
@IsArray()
@@ -18,3 +29,41 @@ export class UpdateSettingsDto {
@IsOptional()
sidebarExpanded?: boolean;
}
export type AdminUserSortField = 'name' | 'email' | 'lastLoginAt' | 'createdAt';
export type AdminUserActiveFilter = 'all' | 'active' | 'inactive';
export class AdminUserListQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsIn(['all', 'active', 'inactive'])
active: AdminUserActiveFilter = 'all';
@IsOptional()
@IsUUID()
roleId?: string;
@IsOptional()
@IsIn(['name', 'email', 'lastLoginAt', 'createdAt'])
sort: AdminUserSortField = 'createdAt';
@IsOptional()
@IsIn(['ASC', 'DESC'])
direction: 'ASC' | 'DESC' = 'DESC';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import type {
AdminUserActiveFilter,
AdminUserSortField,
} from '../dto/user.dto';
import { UserEntity } from '../entities/user.entity';
@Injectable()
@@ -9,8 +13,10 @@ export class UsersRepository {
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
) {}
findById(id: string): Promise<UserEntity | null> {
return this.repo.findOne({ where: { id } });
findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
where: { id },
});
}
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
@@ -37,7 +43,70 @@ export class UsersRepository {
.getManyAndCount();
}
async adminSearch(
query: {
search?: string;
active: AdminUserActiveFilter;
roleId?: string;
sort: AdminUserSortField;
direction: 'ASC' | 'DESC';
page: number;
pageSize: number;
},
manager?: EntityManager,
): Promise<[UserEntity[], number]> {
const repo = manager?.getRepository(UserEntity) ?? this.repo;
const qb = repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role')
.leftJoinAndSelect('role.permissions', 'permission');
if (query.search) {
qb.andWhere('user.name LIKE :search OR user.email LIKE :search', {
search: `%${query.search}%`,
});
}
if (query.active !== 'all') {
qb.andWhere('user.active = :active', {
active: query.active === 'active',
});
}
if (query.roleId) {
qb.andWhere(
'EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = user.id AND ur.role_id = :roleId)',
{ roleId: query.roleId },
);
}
return qb
.orderBy(`user.${query.sort}`, query.direction)
.skip((query.page - 1) * query.pageSize)
.take(query.pageSize)
.getManyAndCount();
}
findByIdWithRoles(
id: string,
manager?: EntityManager,
): Promise<UserEntity | null> {
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
where: { id },
relations: { roles: { permissions: true }, settings: true },
});
}
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
}
findFirstActiveAdmin(excludedUserId?: string): Promise<UserEntity | null> {
const qb = this.repo
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('role.name = :role', { role: 'admin' })
.orderBy('user.createdAt', 'ASC');
if (excludedUserId) {
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
}
return qb.getOne();
}
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes';
import { UsersService } from '../users.service';
import type { AuditService } from '../../audit/audit.service';
import type { NotificationsService } from '../../notifications/notifications.service';
import type { RolesService } from '../../roles/roles.service';
import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../repositories/users.repository';
@@ -10,25 +11,28 @@ import type { DataSource } from 'typeorm';
describe('UsersService', () => {
it('blocks changes that would remove the last active admin', async () => {
const dataSource = {
getRepository: () => ({
createQueryBuilder: () => ({
innerJoin: () => ({
where: () => ({
andWhere: () => ({
manager: {
getRepository: () => ({
createQueryBuilder: () => ({
innerJoin: () => ({
where: () => ({
andWhere: () => ({
getCount: () => Promise.resolve(0),
andWhere: () => ({
getCount: () => Promise.resolve(0),
}),
}),
}),
}),
}),
}),
}),
},
} as unknown as DataSource;
const service = new UsersService(
{} as UsersRepository,
{} as RolesService,
{} as SessionsService,
{} as AuditService,
{} as NotificationsService,
dataSource,
);

View File

@@ -1,9 +1,11 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
@@ -14,6 +16,7 @@ import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions';
import {
AdminUserListQueryDto,
UpdateSettingsDto,
UpdateUserActiveDto,
UpdateUserRolesDto,
@@ -49,6 +52,84 @@ export class UsersController {
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20));
}
@Get('admin/users')
@RequirePermissions(Permission.UsersRead)
adminList(@Query() query: AdminUserListQueryDto) {
return this.users.adminList(query);
}
@Get('admin/users/:id')
@RequirePermissions(Permission.UsersRead)
adminGet(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.users.adminGet(id, req.user?.sessionId);
}
@Patch('admin/users/:id/deactivate')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
deactivate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.users.setActive(this.requireUser(req), id, false);
}
@Patch('admin/users/:id/activate')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
activate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.users.setActive(this.requireUser(req), id, true);
}
@Post('admin/users/:id/roles/:roleId')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
assignRole(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Param('roleId') roleId: string,
) {
return this.users.assignRole(this.requireUser(req), id, roleId);
}
@Delete('admin/users/:id/roles/:roleId')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
removeRole(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Param('roleId') roleId: string,
) {
return this.users.removeRole(this.requireUser(req), id, roleId);
}
@Get('admin/users/:id/sessions')
@RequirePermissions(Permission.SessionsManage)
adminSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.users
.adminGet(id, req.user?.sessionId)
.then((user) => user.sessions);
}
@Delete('admin/users/:userId/sessions/:sessionId')
@RequirePermissions(Permission.SessionsManage)
@SensitiveRateLimit()
revokeSession(
@Req() req: AuthenticatedRequest,
@Param('userId') userId: string,
@Param('sessionId') sessionId: string,
) {
return this.users.revokeUserSession(
this.requireUser(req),
userId,
sessionId,
);
}
@Delete('admin/users/:id/sessions')
@RequirePermissions(Permission.SessionsManage)
@SensitiveRateLimit()
revokeSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.users.revokeUserSessions(this.requireUser(req), id);
}
@Patch('users/:id/active')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from './entities/user-settings.entity';
@@ -15,6 +16,7 @@ import { UsersService } from './users.service';
RolesModule,
AuditModule,
SessionsModule,
NotificationsModule,
],
controllers: [UsersController],
providers: [UsersRepository, UsersService],

View File

@@ -1,24 +1,56 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
import type { AuthenticatedUser } from '../auth/authenticated-request';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { NotificationType } from '../notifications/notification-types';
import { NotificationsService } from '../notifications/notifications.service';
import { RolesService } from '../roles/roles.service';
import { RoleEntity } from '../roles/entities/role.entity';
import { SessionsService } from '../sessions/sessions.service';
import type { AdminSessionDto } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import type { AdminUserListQueryDto } from './dto/user.dto';
import { UsersRepository } from './repositories/users.repository';
interface AdminRoleSummary {
id: string;
name: string;
description: string;
system: boolean;
}
interface AdminUserListItem {
id: string;
name: string;
email: string | null;
active: boolean;
roles: AdminRoleSummary[];
lastLoginAt: string | null;
createdAt: string;
activeSessionCount: number;
}
interface AdminUserDetail extends AdminUserListItem {
effectivePermissions: string[];
sessions: AdminSessionDto[];
settings: { tablePageSize: number; sidebarExpanded: boolean };
}
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor(
private readonly users: UsersRepository,
private readonly roles: RolesService,
private readonly sessions: SessionsService,
private readonly audit: AuditService,
private readonly notifications: NotificationsService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -27,6 +59,34 @@ export class UsersService {
return { items, total, page, pageSize };
}
async adminList(query: AdminUserListQueryDto) {
const [users, total] = await this.users.adminSearch(query);
const items = await Promise.all(
users.map(async (user) => ({
...this.toAdminListItem(user),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
})),
);
return { items, total, page: query.page, pageSize: query.pageSize };
}
async adminGet(
id: string,
currentSessionId: string | undefined,
): Promise<AdminUserDetail> {
const user = await this.getWithRoles(id);
return {
...this.toAdminListItem(user),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
effectivePermissions: this.effectivePermissions(user),
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
settings: {
tablePageSize: user.settings.tablePageSize,
sidebarExpanded: user.settings.sidebarExpanded,
},
};
}
async get(id: string): Promise<UserEntity> {
const user = await this.users.findById(id);
if (!user) {
@@ -44,22 +104,38 @@ export class UsersService {
userId: string,
active: boolean,
): Promise<UserEntity> {
const user = await this.get(userId);
if (!active) {
await this.assertAnotherActiveAdminRemains(userId);
}
user.active = active;
const saved = await this.users.save(user);
if (!active) {
await this.sessions.revokeAllForUser(userId);
}
await this.audit.record(
actor.id,
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
'user',
userId,
);
return saved;
return this.withAdminLock(async (manager) => {
const user = await this.getWithRoles(userId, manager);
if (active && user.active) {
throw new ApiError(
ErrorCode.UserAlreadyActive,
'Der Benutzer ist bereits aktiv.',
409,
);
}
if (!active && !user.active) {
throw new ApiError(
ErrorCode.UserAlreadyInactive,
'Der Benutzer ist bereits deaktiviert.',
409,
);
}
if (!active && this.hasRole(user, 'admin')) {
await this.assertAnotherActiveAdminRemains(userId, manager);
}
user.active = active;
const saved = await this.users.save(user, manager);
if (!active) {
await this.sessions.revokeAllForUser(userId);
}
await this.audit.record(
actor.id,
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
'user',
userId,
);
return saved;
});
}
async setRoles(
@@ -67,26 +143,72 @@ export class UsersService {
userId: string,
roleIds: string[],
): Promise<UserEntity> {
const user = await this.get(userId);
const previousAdmin = this.hasRole(user, 'admin');
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id)),
);
user.roles = roles;
if (previousAdmin && !this.hasRole(user, 'admin')) {
await this.assertAnotherActiveAdminRemains(userId);
return this.replaceRoles(actor, userId, roleIds);
}
async assignRole(
actor: AuthenticatedUser,
userId: string,
roleId: string,
): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (user.roles.some((role) => role.id === roleId)) {
return user;
}
const saved = await this.users.save(user);
return this.replaceRoles(actor, userId, [
...user.roles.map((role) => role.id),
roleId,
]);
}
async removeRole(
actor: AuthenticatedUser,
userId: string,
roleId: string,
): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (!user.roles.some((role) => role.id === roleId)) {
return user;
}
return this.replaceRoles(
actor,
userId,
user.roles.filter((role) => role.id !== roleId).map((role) => role.id),
);
}
async revokeUserSession(
actor: AuthenticatedUser,
userId: string,
sessionId: string,
): Promise<void> {
await this.get(userId);
await this.sessions.revokeForAdmin(userId, sessionId);
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
AuditAction.SessionRevoked,
'session',
sessionId,
{
roleIds: roleIds.join(','),
targetUserId: userId,
},
);
return saved;
}
async revokeUserSessions(
actor: AuthenticatedUser,
userId: string,
): Promise<{ revoked: number }> {
await this.get(userId);
const revoked = await this.sessions.revokeAllForUser(userId);
await this.audit.record(
actor.id,
AuditAction.AllUserSessionsRevoked,
'user',
userId,
{ revoked },
);
return { revoked };
}
async updateSettings(
@@ -103,8 +225,11 @@ export class UsersService {
return this.users.save(user);
}
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> {
const result = await this.dataSource
async assertAnotherActiveAdminRemains(
excludedUserId: string,
manager?: EntityManager,
): Promise<void> {
const result = await (manager ?? this.dataSource.manager)
.getRepository(UserEntity)
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
@@ -115,13 +240,177 @@ export class UsersService {
if (result < 1) {
throw new ApiError(
ErrorCode.LastAdminRequired,
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.',
'Mindestens ein aktiver Administrator muss erhalten bleiben.',
409,
);
}
}
private async replaceRoles(
actor: AuthenticatedUser,
userId: string,
roleIds: string[],
): Promise<UserEntity> {
return this.withAdminLock(async (manager) => {
const user = await this.getWithRoles(userId, manager);
const previousRoleNames = new Set(user.roles.map((role) => role.name));
const previousRoleIds = new Set(user.roles.map((role) => role.id));
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id, manager)),
);
const nextRoleNames = new Set(roles.map((role) => role.name));
user.roles = roles;
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
await this.assertAnotherActiveAdminRemains(userId, manager);
}
const saved = await this.users.save(user, manager);
await this.recordRoleAudit(actor, userId, previousRoleIds, roles);
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
return saved;
});
}
private async recordRoleAudit(
actor: AuthenticatedUser,
userId: string,
previousRoleIds: Set<string>,
nextRoles: RoleEntity[],
): Promise<void> {
const nextRoleIds = new Set(nextRoles.map((role) => role.id));
for (const previousRoleId of previousRoleIds) {
if (!nextRoleIds.has(previousRoleId)) {
await this.audit.record(
actor.id,
AuditAction.UserRoleRemoved,
'user',
userId,
{ roleId: previousRoleId },
);
}
}
for (const role of nextRoles) {
if (!previousRoleIds.has(role.id)) {
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
{ roleId: role.id, roleName: role.name },
);
}
}
}
private async getWithRoles(
userId: string,
manager?: EntityManager,
): Promise<UserEntity> {
const user = await this.users.findByIdWithRoles(userId, manager);
if (!user) {
throw new ApiError(
ErrorCode.UserNotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
}
return user;
}
private async withAdminLock<T>(
action: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return this.dataSource.transaction(async (manager) => {
await manager.query(
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
);
try {
return await action(manager);
} finally {
await manager.query(
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
);
}
});
}
private toAdminListItem(user: UserEntity): AdminUserListItem {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
roles: user.roles.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
system: role.protected,
})),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
activeSessionCount: 0,
};
}
private effectivePermissions(user: UserEntity): string[] {
return Array.from(
new Set(
user.roles.flatMap((role) =>
role.permissions.map((permission) => permission.id),
),
),
).sort();
}
private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName);
}
private async notifyRoleChanges(
userId: string,
previousRoleNames: Set<string>,
nextRoleNames: Set<string>,
): Promise<void> {
const added = [...nextRoleNames].filter(
(role) => !previousRoleNames.has(role),
);
const removed = [...previousRoleNames].filter(
(role) => !nextRoleNames.has(role),
);
for (const role of added) {
await this.createRoleNotification(
userId,
role,
`Ihnen wurde die Rolle "${role}" zugewiesen.`,
);
}
for (const role of removed) {
await this.createRoleNotification(
userId,
role,
`Die Rolle "${role}" wurde Ihnen entzogen.`,
);
}
}
private async createRoleNotification(
userId: string,
role: string,
message: string,
): Promise<void> {
try {
await this.notifications.createForUser({
userId,
type: NotificationType.UserRoleChanged,
title: 'Rollen geaendert',
message,
link: '/profil',
metadata: { role },
});
} catch (error) {
this.logger.error(
{ userId, role, error },
'Failed to create role change notification',
);
}
}
}