SmartfolioBackend / src /modules /users /users-operator.controller.ts
ibrahimgulbutt's picture
Claude Opus 5
deploy: operator endpoint for granting the admin role
909a81a
Raw History Blame Contribute Delete
3.55 kB
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<User>) {}
@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 };
}
}