AI Implementation feature(867): Admin Area Players Management (#56)

This commit was merged in pull request #56.
This commit is contained in:
2026-07-23 08:08:50 +00:00
parent 705530e71f
commit e4ccf0fe13
27 changed files with 2039 additions and 218 deletions
@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
/**
* Centralized Argon2id hashing for every password-set flow in the system:
*
* - player self-registration
* - first-admin bootstrap
* - admin user creation
* - self change-password
* - admin-initiated password reset
*
* Using one helper keeps the configured cost parameters consistent across
* flows so the admin password policy behaves predictably.
*/
export async function hashPassword(password: string, config: ConfigService): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: config.get<number>('ARGON2_MEMORY_COST'),
timeCost: config.get<number>('ARGON2_TIME_COST'),
parallelism: config.get<number>('ARGON2_PARALLELISM'),
});
}
+4
View File
@@ -18,6 +18,8 @@ import { UpdateSystemCategoryKeys1700000000300 } from './migrations/170000000030
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades';
import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers';
import { DatabaseInitService } from './database-init.service';
const ENTITIES = [
@@ -39,6 +41,8 @@ const MIGRATIONS = [
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
AddUserDeleteCascades1700000000700,
AddUserLastAdminTriggers1700000000800,
];
@Global()
@@ -1,4 +1,5 @@
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('refresh_token')
export class RefreshTokenEntity {
@@ -9,6 +10,10 @@ export class RefreshTokenEntity {
@Column('text', { name: 'user_id' })
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'token_hash', unique: true })
tokenHash!: string;
@@ -8,6 +8,7 @@ import {
JoinColumn,
} from 'typeorm';
import { ChallengeEntity } from './challenge.entity';
import { UserEntity } from './user.entity';
@Entity('solve')
@Unique('uq_solve_challenge_user', ['challengeId', 'userId'])
@@ -27,6 +28,10 @@ export class SolveEntity {
@Column('text', { name: 'user_id' })
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'solved_at' })
solvedAt!: string;
@@ -0,0 +1,97 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Updates foreign-key cascade behaviour so user deletion cleans up dependent
* rows without leaving orphaned solves or refresh tokens:
*
* - `solve.user_id` and `refresh_token.user_id` both gain
* `ON DELETE CASCADE`.
*
* Strategy mirrors UpgradeChallengeAdminSchema1700000000500:
* - SQLite cannot alter FK actions in place, so we rebuild the affected
* tables atomically with PRAGMA foreign_keys=OFF, preserving legacy
* data via INSERT...SELECT.
* - Forward-only.
*/
export class AddUserDeleteCascades1700000000700 implements MigrationInterface {
name = 'AddUserDeleteCascades1700000000700';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = OFF;`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_solve_challenge_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_refresh_token_user";`);
await queryRunner.query(`ALTER TABLE "solve" RENAME TO "_solve_old";`);
await queryRunner.query(`ALTER TABLE "refresh_token" RENAME TO "_refresh_token_old";`);
await queryRunner.query(`
CREATE TABLE "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
"is_first" INTEGER NOT NULL DEFAULT 0,
"is_second" INTEGER NOT NULL DEFAULT 0,
"is_third" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "solve" (
"id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus","is_first","is_second","is_third"
)
SELECT
"id","challenge_id","user_id","solved_at",
COALESCE("points_awarded",0),
COALESCE("base_points",0),
COALESCE("rank_bonus",0),
COALESCE("is_first",0),
COALESCE("is_second",0),
COALESCE("is_third",0)
FROM "_solve_old";
`);
await queryRunner.query(`DROP TABLE "_solve_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE "refresh_token" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL UNIQUE,
"issued_at" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"revoked_at" TEXT,
CONSTRAINT "fk_refresh_token_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "refresh_token" (
"id","user_id","token_hash","issued_at","expires_at","revoked_at"
)
SELECT "id","user_id","token_hash","issued_at","expires_at","revoked_at"
FROM "_refresh_token_old";
`);
await queryRunner.query(`DROP TABLE "_refresh_token_old";`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA foreign_key_check;`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Deployment-wide safety net for the "always retain at least one admin"
* invariant. Even if a future code path bypasses
* `UsersService.applyLastAdminSafeMutation`, the database itself aborts
* the offending UPDATE/DELETE with a clear `LAST_ADMIN` error message.
*
* Triggers fire before the row mutation, so the offending statement never
* commits. The service-level wrapper still rolls back with 409 LAST_ADMIN
* in normal operation; the triggers are the belt-and-braces guard for
* cross-instance / out-of-band mistakes.
*
* Forward-only.
*/
export class AddUserLastAdminTriggers1700000000800 implements MigrationInterface {
name = 'AddUserLastAdminTriggers1700000000800';
public async up(queryRunner: QueryRunner): Promise<void> {
// UPDATE trigger: blocks any role mutation that would leave the system
// with zero admins. We use WHEN to short-circuit so the trigger body
// only fires when (a) the old role was admin, (b) the new role is no
// longer admin, and (c) no other admin row exists.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
// DELETE trigger: blocks deletion of the only remaining admin row.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
+54 -4
View File
@@ -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);
}
}
}
+137 -23
View File
@@ -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,
};
}
+25 -1
View File
@@ -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;
}
+4 -18
View File
@@ -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.
+2 -7
View File
@@ -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(),
+176 -8
View File
@@ -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;
}