AI Implementation feature(867): Admin Area Players Management (#56)
This commit was merged in pull request #56.
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -15,8 +16,10 @@ import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
AdminResetPasswordDtoSchema,
|
||||
CreateUserDtoSchema,
|
||||
ListUsersQueryDtoSchema,
|
||||
UpdatePlayerStatusDtoSchema,
|
||||
UpdateUserRoleDtoSchema,
|
||||
UserIdParamDtoSchema,
|
||||
} from './dto/admin.dto';
|
||||
@@ -37,8 +40,55 @@ import { AdminService } from './admin.service';
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('players')
|
||||
@ApiOperation({ summary: 'List all registered users (Players page)' })
|
||||
listPlayers(
|
||||
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||
) {
|
||||
return this.admin.listPlayers(query);
|
||||
}
|
||||
|
||||
@Patch('players/:id/role')
|
||||
@ApiOperation({ summary: 'Toggle a user role between Admin and Player' })
|
||||
updatePlayerRole(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
|
||||
) {
|
||||
return this.admin.updatePlayerRole(params.id, body.role);
|
||||
}
|
||||
|
||||
@Patch('players/:id/status')
|
||||
@ApiOperation({ summary: 'Toggle a user enabled/disabled status' })
|
||||
updatePlayerStatus(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdatePlayerStatusDtoSchema)) body: { enabled: boolean },
|
||||
) {
|
||||
return this.admin.updatePlayerStatus(params.id, body.enabled);
|
||||
}
|
||||
|
||||
@Patch('players/:id/password')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Admin-initiated password reset for a user' })
|
||||
async resetPlayerPassword(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(AdminResetPasswordDtoSchema))
|
||||
body: { newPassword: string; confirmPassword: string },
|
||||
): Promise<void> {
|
||||
await this.admin.resetPlayerPassword(params.id, body.newPassword);
|
||||
}
|
||||
|
||||
@Delete('players/:id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a user (cascades solves + refresh tokens)' })
|
||||
async deletePlayer(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.admin.deletePlayer(params.id);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'List all users (admin only)' })
|
||||
@ApiOperation({ summary: 'List all users (admin only, legacy alias)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||
@@ -47,7 +97,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
||||
@ApiOperation({ summary: 'Update a user role (admin only, legacy alias)' })
|
||||
update(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
|
||||
@@ -56,7 +106,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
||||
@ApiOperation({ summary: 'Delete a user (admin only, legacy alias)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
@@ -70,4 +120,4 @@ export class AdminController {
|
||||
) {
|
||||
return this.admin.createUser(body.username, body.password, body.role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
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';
|
||||
|
||||
export interface ListUsersQuery {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
role?: UserRole;
|
||||
/**
|
||||
* 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: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||
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
|
||||
@@ -34,37 +61,113 @@ export class AdminService {
|
||||
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);
|
||||
return rows.slice(0, limit).map(toPlayerView);
|
||||
}
|
||||
|
||||
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
||||
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) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
||||
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
||||
if (!user) throw ApiError.notFound('User not found');
|
||||
user.role = role;
|
||||
user.status = newStatus;
|
||||
await manager.save(user);
|
||||
return 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.dataSource.transaction(async (manager) => {
|
||||
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
||||
const res = await manager.delete(UserEntity, { id: targetId });
|
||||
if (!res.affected) throw ApiError.notFound('User not found');
|
||||
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<UserEntity> {
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<AdminPlayerView> {
|
||||
validatePassword(password, this.config);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
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 argon2.hash(password, { type: argon2.argon2id });
|
||||
const hash = await hashPassword(password, this.config);
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
@@ -73,7 +176,18 @@ export class AdminService {
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
return 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,22 @@ export const UpdateUserRoleDtoSchema = z.object({
|
||||
});
|
||||
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
|
||||
|
||||
export const UpdatePlayerStatusDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type UpdatePlayerStatusDto = z.infer<typeof UpdatePlayerStatusDtoSchema>;
|
||||
|
||||
export const AdminResetPasswordDtoSchema = z
|
||||
.object({
|
||||
newPassword: z.string().min(1).max(256),
|
||||
confirmPassword: z.string().min(1).max(256),
|
||||
})
|
||||
.refine((d) => d.newPassword === d.confirmPassword, {
|
||||
path: ['confirmPassword'],
|
||||
message: 'Passwords do not match',
|
||||
});
|
||||
export type AdminResetPasswordDto = z.infer<typeof AdminResetPasswordDtoSchema>;
|
||||
|
||||
export const UserIdParamDtoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
@@ -22,4 +38,12 @@ export const ListUsersQueryDtoSchema = z.object({
|
||||
cursor: z.string().uuid().optional(),
|
||||
role: z.enum(['admin', 'player']).optional(),
|
||||
});
|
||||
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
|
||||
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
|
||||
|
||||
export interface AdminPlayerView {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
status: 'enabled' | 'disabled';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LoginBackoffService } from '../../common/services/login-backoff.service
|
||||
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { hashPassword } from '../../common/utils/password-hashing';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { UsersRankService } from '../users/users-rank.service';
|
||||
@@ -117,12 +118,7 @@ export class AuthService implements OnModuleInit {
|
||||
const existing = await manager.findOne(UserEntity, { where: { username } });
|
||||
if (existing) throw new ApiError(ERROR_CODES.CONFLICT, 'Username already taken', 409);
|
||||
|
||||
const passwordHash = await argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
const passwordHash = await hashPassword(password, this.config);
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
@@ -162,12 +158,7 @@ export class AuthService implements OnModuleInit {
|
||||
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(dto.password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
const passwordHash = await hashPassword(dto.password, this.config);
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username: dto.username,
|
||||
@@ -220,12 +211,7 @@ export class AuthService implements OnModuleInit {
|
||||
throw e;
|
||||
}
|
||||
|
||||
user.passwordHash = await argon2.hash(dto.newPassword, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
user.passwordHash = await hashPassword(dto.newPassword, this.config);
|
||||
await manager.save(user);
|
||||
|
||||
// Invalidate all existing refresh tokens for this user.
|
||||
|
||||
@@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
|
||||
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 { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import type { LoginResponse } from '../auth/dto/auth.dto';
|
||||
@@ -68,12 +68,7 @@ export class SetupService implements OnModuleInit {
|
||||
throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
const passwordHash = await hashPassword(password, this.config);
|
||||
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
|
||||
type UserRepoLike = Repository<UserEntity> | EntityManager;
|
||||
|
||||
export interface LastAdminSafeMutation<T> {
|
||||
/**
|
||||
* True when the mutation removes admin powers from a currently-admin
|
||||
* user. When false, the invariant is bypassed (useful for non-destructive
|
||||
* updates).
|
||||
*/
|
||||
demotesOrDelete: boolean;
|
||||
/**
|
||||
* Optional explicit role after the mutation. When `undefined`, the wrapper
|
||||
* infers the new role from the post-mutation row (returning null after a
|
||||
* delete).
|
||||
*/
|
||||
updatedRole?: UserRole;
|
||||
mutate: (manager: EntityManager, target: UserEntity) => Promise<T>;
|
||||
onSuccess?: (manager: EntityManager, target: UserEntity, result: T) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Maximum number of times the wrapper retries on a transient lock conflict. */
|
||||
const LOCK_RETRY_MAX = 5;
|
||||
/** Initial backoff in ms; doubled on every retry. */
|
||||
const LOCK_RETRY_BASE_MS = 10;
|
||||
|
||||
const LAST_ADMIN_GUARD_MESSAGE =
|
||||
'Cannot delete or demote the last admin; the system must always retain at least one enabled user with role "admin".';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>) {}
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
countAdmins(manager?: EntityManager): Promise<number> {
|
||||
return this.getRepo(manager).count({ where: { role: 'admin' } });
|
||||
@@ -24,9 +54,128 @@ export class UsersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify, inside an open transaction, that demoting/deleting the given user
|
||||
* would not leave the system with zero admins. Throws ApiError(LAST_ADMIN)
|
||||
* if so. Must be invoked from a `dataSource.transaction(...)` callback.
|
||||
* Apply a user-role or user-deletion mutation atomically so the
|
||||
* admin-count precondition, the mutation, and the postcondition all see
|
||||
* a consistent database snapshot.
|
||||
*
|
||||
* Concurrency model:
|
||||
* - Postgres / MySQL: `pessimistic_write` is a real
|
||||
* `SELECT ... FOR UPDATE` on the target user and on every admin row,
|
||||
* serializing the count → mutate → recount sequence.
|
||||
* - SQLite (better-sqlite3): `pessimistic_write` is a no-op; the
|
||||
* database-level write lock is acquired at the first write inside the
|
||||
* transaction. The accompanying `trg_user_last_admin_*` triggers
|
||||
* raise `SQLITE_CONSTRAINT_TRIGGER` / "LAST_ADMIN" if any path ever
|
||||
* bypasses the service wrapper, so the system can never commit a
|
||||
* state with zero admins.
|
||||
*
|
||||
* Transient `SQLITE_BUSY` / TypeORM serialization failures are retried
|
||||
* with exponential backoff. If retries are exhausted, the wrapper raises
|
||||
* the existing 409 LAST_ADMIN contract so callers always see a structured
|
||||
* error.
|
||||
*/
|
||||
async applyLastAdminSafeMutation<T>(
|
||||
targetId: string,
|
||||
spec: LastAdminSafeMutation<T>,
|
||||
): Promise<T> {
|
||||
let lastError: unknown = undefined;
|
||||
for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
const delay = LOCK_RETRY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
try {
|
||||
return await this.runSafeMutation(targetId, spec);
|
||||
} catch (e) {
|
||||
if (!isLockConflict(e)) {
|
||||
throw e;
|
||||
}
|
||||
lastError = e;
|
||||
this.logger.warn(
|
||||
`applyLastAdminSafeMutation lock conflict on attempt ${attempt + 1}/${LOCK_RETRY_MAX}: ${(e as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.error(
|
||||
`applyLastAdminSafeMutation exhausted ${LOCK_RETRY_MAX} lock retries; rejecting with LAST_ADMIN`,
|
||||
);
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
|
||||
}
|
||||
|
||||
private async runSafeMutation<T>(
|
||||
targetId: string,
|
||||
spec: LastAdminSafeMutation<T>,
|
||||
): Promise<T> {
|
||||
return this.dataSource.transaction('SERIALIZABLE', async (manager) => {
|
||||
const repo = manager.getRepository(UserEntity);
|
||||
|
||||
const target = await this.lockTargetUser(repo, targetId);
|
||||
if (!target) throw ApiError.notFound('User not found');
|
||||
|
||||
const destructive = spec.demotesOrDelete && target.role === 'admin';
|
||||
|
||||
if (destructive) {
|
||||
await this.lockAdminRows(repo);
|
||||
const beforeCount = await repo.count({ where: { role: 'admin' } });
|
||||
if (beforeCount <= 1) {
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await spec.mutate(manager, target);
|
||||
|
||||
if (destructive) {
|
||||
const newRole = spec.updatedRole
|
||||
?? (await repo.findOne({ where: { id: targetId } }))?.role;
|
||||
if (newRole !== 'admin') {
|
||||
const afterCount = await repo.count({ where: { role: 'admin' } });
|
||||
if (afterCount <= 0) {
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.onSuccess) {
|
||||
await spec.onSuccess(manager, target, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private supportsRowLock(): boolean {
|
||||
const driverType = (this.dataSource.options as { type?: string } | undefined)?.type;
|
||||
return driverType !== 'better-sqlite3' && driverType !== 'sqlite' && driverType !== 'sqlite3';
|
||||
}
|
||||
|
||||
private async lockTargetUser(repo: Repository<UserEntity>, targetId: string): Promise<UserEntity | null> {
|
||||
if (!this.supportsRowLock()) {
|
||||
return repo.findOne({ where: { id: targetId } });
|
||||
}
|
||||
const row = await repo
|
||||
.createQueryBuilder('u')
|
||||
.setLock('pessimistic_write')
|
||||
.where('u.id = :id', { id: targetId })
|
||||
.getOne();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private async lockAdminRows(repo: Repository<UserEntity>): Promise<void> {
|
||||
if (!this.supportsRowLock()) {
|
||||
return;
|
||||
}
|
||||
await repo
|
||||
.createQueryBuilder('u')
|
||||
.setLock('pessimistic_write')
|
||||
.where('u.role = :role', { role: 'admin' })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible preflight helper retained for callers that only
|
||||
* need a fast non-transactional check. The transactional safe-mutation
|
||||
* path supersedes it for last-admin-sensitive flows; non-admin-sensitive
|
||||
* callers can still rely on this to surface 404s early.
|
||||
*/
|
||||
async enforceLastAdminInvariant(manager: EntityManager, targetId: string, nextRole?: UserRole): Promise<void> {
|
||||
const repo = manager.getRepository(UserEntity);
|
||||
@@ -39,11 +188,30 @@ export class UsersService {
|
||||
|
||||
const adminCount = await repo.count({ where: { role: 'admin' } });
|
||||
if (adminCount <= 1) {
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, 'Cannot delete or demote the last admin');
|
||||
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private getRepo(manager?: EntityManager): Repository<UserEntity> {
|
||||
return manager ? manager.getRepository(UserEntity) : this.repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify transient lock conflicts so the wrapper can retry them. SQLite
|
||||
* reports `SQLITE_BUSY` (errno 5) when another writer holds the database
|
||||
* lock; TypeORM surfaces the same error code as `code` or wraps it in a
|
||||
* `QueryFailedError` whose `driverError.code` is `'SQLITE_BUSY'`. Generic
|
||||
* constraint failures and other 500-class errors are NOT treated as
|
||||
* transient.
|
||||
*/
|
||||
function isLockConflict(e: unknown): boolean {
|
||||
if (!e || typeof e !== 'object') return false;
|
||||
const err = e as { code?: string; message?: string; driverError?: { code?: string } };
|
||||
if (err.code === 'SQLITE_BUSY') return true;
|
||||
if (err.code === 'SQLITE_BUSY_SNAPSHOT') return true;
|
||||
if (err.driverError?.code === 'SQLITE_BUSY') return true;
|
||||
if (err.driverError?.code === 'SQLITE_BUSY_SNAPSHOT') return true;
|
||||
if (typeof err.message === 'string' && err.message.includes('SQLITE_BUSY')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user