Files
HIPCTF2/backend/src/modules/admin/admin.service.ts
T

194 lines
7.3 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { UserEntity, UserRole, UserStatus } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { UsersService } from '../users/users.service';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { ConfigService } from '@nestjs/config';
import { AdminPlayerView, ListUsersQueryDto } from './dto/admin.dto';
/**
* Every table that has a `userId` column and must be cleaned up when the
* parent `user` row is deleted. Iterated by `deleteUserOwnedRows` so future
* user-owned tables (e.g. comments, notifications) only need to be appended
* here. The ON DELETE CASCADE migration that backs this list is the
* deployment-wide safety net; the imperative delete below is what the
* admin delete path requires.
*/
const USER_OWNED_TABLES: { tableName: string; userIdColumn: string }[] = [
{ tableName: 'solve', userIdColumn: 'user_id' },
{ tableName: 'refresh_token', userIdColumn: 'user_id' },
];
async function deleteUserOwnedRows(manager: EntityManager, userId: string): Promise<Record<string, number>> {
const counts: Record<string, number> = {};
for (const { tableName, userIdColumn } of USER_OWNED_TABLES) {
const res = await manager.query(
`DELETE FROM "${tableName}" WHERE "${userIdColumn}" = ?`,
[userId],
);
counts[tableName] = Number(res?.changes ?? res?.affected ?? 0);
}
return counts;
}
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly usersService: UsersService,
private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {}
async listUsers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
return this.listPlayers(query);
}
async listPlayers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const repo = this.dataSource.getRepository(UserEntity);
const qb = repo
.createQueryBuilder('u')
.orderBy('u.createdAt', 'ASC')
.addOrderBy('u.id', 'ASC')
.limit(limit + 1);
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
const rows = await qb.getMany();
return rows.slice(0, limit).map(toPlayerView);
}
async updateUserRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
return this.updatePlayerRole(targetId, role);
}
async updatePlayerRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
if (role !== 'admin' && role !== 'player') {
throw ApiError.validation('Invalid role');
}
const updated = await this.usersService.applyLastAdminSafeMutation<AdminPlayerView>(targetId, {
demotesOrDelete: true,
updatedRole: role,
mutate: async (manager, target) => {
target.role = role;
await manager.save(target);
return toPlayerView(target);
},
});
return updated;
}
async updatePlayerStatus(targetId: string, enabled: boolean): Promise<AdminPlayerView> {
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const newStatus: UserStatus = enabled ? 'enabled' : 'disabled';
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.status = newStatus;
await manager.save(user);
if (!enabled) {
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
}
return toPlayerView(user);
});
}
async resetPlayerPassword(targetId: string, newPassword: string): Promise<void> {
if (!newPassword) {
throw ApiError.validation('New password is required');
}
validatePassword(newPassword, this.config);
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const hash = await hashPassword(newPassword, this.config);
await this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.passwordHash = hash;
await manager.save(user);
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
});
}
async deleteUser(targetId: string): Promise<void> {
return this.deletePlayer(targetId);
}
async deletePlayer(targetId: string): Promise<void> {
await this.usersService.applyLastAdminSafeMutation<null>(targetId, {
demotesOrDelete: true,
mutate: async (manager) => {
// Imperative dependent cleanup in the SAME transaction. The
// ON DELETE CASCADE migration is a safety net, but the explicit
// deletes (a) surface the affected row counts to the application,
// (b) keep deletes observable for future audit logging, and
// (c) ensure that the post-mutation admin count is the only
// invariant the caller still needs to verify.
const cleanedCounts = await deleteUserOwnedRows(manager, targetId);
const res = await manager.delete(UserEntity, { id: targetId });
if (!res.affected) {
throw ApiError.notFound('User not found');
}
// Note per-table cleanup counts; never log password hashes, token
// hashes, or the plaintext password.
this.logger.log(
`Deleted user ${targetId}; dependent rows: ${Object.entries(cleanedCounts)
.map(([t, n]) => `${t}=${n}`)
.join(', ') || 'none'}`,
);
return null;
},
});
}
async createUser(username: string, password: string, role: UserRole): Promise<AdminPlayerView> {
validatePassword(password, this.config);
const created = await this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
const hash = await hashPassword(password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash: hash,
role,
status: 'enabled',
});
await manager.save(user);
return toPlayerView(user);
});
return created;
}
}
export function toPlayerView(user: UserEntity): AdminPlayerView {
return {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
createdAt: user.createdAt,
};
}