File size: 3,550 Bytes
909a81a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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 };
  }
}