import { Body, Controller, Logger, NotFoundException, Post, UseGuards, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { IsEmail, IsIn } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { User } from './user.entity'; import { AdminKeyGuard } from '../auth/admin-key.guard'; import { RateLimit, RateLimitGuard } from '../../common/guards/rate-limit.guard'; export class SetRoleDto { @ApiProperty({ example: 'operator@example.com' }) @IsEmail() email!: string; @ApiProperty({ enum: ['candidate', 'admin'] }) @IsIn(['candidate', 'admin']) role!: 'candidate' | 'admin'; } /** * Granting and removing the admin role. * * There is no self-serve path to admin and there should not be, so the role has * to be set by an operator. Doing that through direct database access works * exactly once and then becomes a reason to keep production credentials on * somebody's laptop — which is a worse security position than this endpoint. * * Guarded by AdminKeyGuard, the same shared-secret guard the scraper * maintenance routes already use. That guard fails CLOSED (an unset * ADMIN_API_KEY makes this unreachable rather than open) and compares in * constant time, so the key cannot be recovered by timing. The rate limit is * what makes guessing it impractical rather than merely expensive. * * Deliberately NOT reachable with a JWT, admin or otherwise. An admin who could * mint other admins turns one compromised session into permanent access; this * way the blast radius of any stolen token stops at what that one account can * already do. * * The users module has no other controller on purpose — its docblock records * that the old /users routes returned the full entity, password and token * hashes included. So this returns four named fields and nothing else. */ @ApiTags('Operator') @ApiSecurity('admin-key') @Controller('admin/users') @UseGuards(AdminKeyGuard, RateLimitGuard) export class UsersOperatorController { private readonly logger = new Logger(UsersOperatorController.name); constructor(@InjectRepository(User) private readonly users: Repository) {} @Post('role') @RateLimit({ limit: 5, windowMs: 60_000 }) @ApiOperation({ summary: 'Grant or remove the admin role', description: 'Requires the x-admin-key header. Not reachable with a JWT: an admin who could ' + 'mint other admins would turn one stolen session into permanent access.', }) async setRole(@Body() dto: SetRoleDto) { const email = dto.email.trim().toLowerCase(); const user = await this.users .createQueryBuilder('u') .where('LOWER(u.email) = :email', { email }) .getOne(); if (!user) { // The account has to exist first: roles are granted to people who have // signed up, not reserved for addresses that might one day register. throw new NotFoundException( `No account for ${email}. Sign up on the site first, then grant the role.`, ); } const previous = user.role; user.role = dto.role; await this.users.save(user); // Audit trail. A privilege change with no record of it is the thing you // most want to look back at and cannot. this.logger.warn( `ROLE CHANGE: ${user.email} (${user.id}) ${previous} -> ${dto.role}`, ); return { id: user.id, email: user.email, name: user.name, role: user.role }; } }