diff --git a/migrations/1616694341420-extended-player-roles.js b/migrations/1616694341420-extended-player-roles.js new file mode 100644 index 000000000..256a8cbf0 --- /dev/null +++ b/migrations/1616694341420-extended-player-roles.js @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +'use strict' + +// +// Remove Player.role, use Player.roles instead. +// + +const { config } = require('dotenv'); +const { MongoClient } = require('mongodb'); + +module.exports.up = next => { + config(); + + let credentials = ''; + if (process.env.MONGODB_USERNAME) { + if (process.env.MONGODB_PASSWORD) { + credentials = `${process.env.MONGODB_USERNAME}:${process.env.MONGODB_PASSWORD}@`; + } else { + credentials = `${process.env.MONGODB_USERNAME}@`; + } + } + + const uri = `mongodb://${credentials}${process.env.MONGODB_HOST}:${process.env.MONGODB_PORT}/${process.env.MONGODB_DB}`; + + MongoClient.connect(uri, { useUnifiedTopology: true }) + .then(client => client.db()) + .then(db => db.collection('players')) + .then(collection => Promise.all([collection, collection.updateMany( + { + role: 'super-user', + }, + { + $set: { roles: ['admin', 'super user'] }, + $unset: { role: 1 }, + }, + )])) + .then(([ collection ]) => Promise.all([collection, collection.updateMany( + { + role: 'admin', + }, + { + $set: { roles: ['admin'] }, + $unset: { role: 1 }, + }, + )])) + .then(([ collection ]) => collection.updateMany( + { + role: 'bot', + }, + { + $set: { roles: ['bot'] }, + $unset: { role: 1 }, + }, + )) + .then(() => next()); +} diff --git a/src/auth/guards/role.guard.spec.ts b/src/auth/guards/role.guard.spec.ts index e23dd1f20..b6cd995a5 100644 --- a/src/auth/guards/role.guard.spec.ts +++ b/src/auth/guards/role.guard.spec.ts @@ -1,10 +1,12 @@ import { RoleGuard } from './role.guard'; import { TestingModule, Test } from '@nestjs/testing'; import { Reflector } from '@nestjs/core'; +import { PlayerRole } from '@/players/models/player-role'; +import { UnauthorizedException } from '@nestjs/common'; const context = { getHandler: () => null, - switchToHttp: () => null, + switchToHttp: jest.fn(), }; describe('RoleGuard', () => { @@ -27,26 +29,26 @@ describe('RoleGuard', () => { }); it('should allow when the user has the required role', () => { - jest.spyOn(reflector, 'get').mockImplementation(() => ['super-user']); - jest.spyOn(context, 'switchToHttp').mockImplementation(() => ({ + jest.spyOn(reflector, 'get').mockImplementation(() => [ PlayerRole.superUser ]); + context.switchToHttp.mockImplementation(() => ({ getRequest: () => ({ user: { - role: 'super-user', + roles: [ PlayerRole.superUser ] }, }), })); expect(guard.canActivate(context as any)).toBe(true); }); - it('should dany when the user does not have the required role', () => { - jest.spyOn(reflector, 'get').mockImplementation(() => ['super-user']); - jest.spyOn(context, 'switchToHttp').mockImplementation(() => ({ + it('should deny when the user does not have the required role', () => { + jest.spyOn(reflector, 'get').mockImplementation(() => [ PlayerRole.superUser ]); + context.switchToHttp.mockImplementation(() => ({ getRequest: () => ({ user: { - role: 'admin', + roles: [ PlayerRole.admin ], }, }), })); - expect(() => guard.canActivate(context as any)).toThrow(/*new UnauthorizedException()*/); + expect(() => guard.canActivate(context as any)).toThrow(UnauthorizedException); }); }); diff --git a/src/auth/guards/role.guard.ts b/src/auth/guards/role.guard.ts index 8cb14831f..0656768e4 100644 --- a/src/auth/guards/role.guard.ts +++ b/src/auth/guards/role.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PlayerRole } from '@/players/models/player-role'; +import { Player } from '@/players/models/player'; @Injectable() export class RoleGuard implements CanActivate { @@ -11,10 +12,11 @@ export class RoleGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const roles = this.reflector.get('roles', context.getHandler()); - if (roles && roles.length) { + if (roles?.length) { const request = context.switchToHttp().getRequest(); - const user = request.user; - if (!(user && user.role && roles.includes(user.role))) { + const user = request.user as Player; + + if (!user || !roles.some(r => user.roles.includes(r))) { throw new UnauthorizedException(); } } diff --git a/src/configuration/controllers/configuration.controller.ts b/src/configuration/controllers/configuration.controller.ts index 139b4c9ee..752c34b62 100644 --- a/src/configuration/controllers/configuration.controller.ts +++ b/src/configuration/controllers/configuration.controller.ts @@ -1,4 +1,5 @@ import { Auth } from '@/auth/decorators/auth.decorator'; +import { PlayerRole } from '@/players/models/player-role'; import { Body, ClassSerializerInterceptor, Controller, Get, Put, UseInterceptors, ValidationPipe } from '@nestjs/common'; import { DefaultPlayerSkill } from '../dto/default-player-skill'; import { WhitelistId } from '../dto/whitelist-id'; @@ -18,7 +19,7 @@ export class ConfigurationController { } @Put('default-player-skill') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UseInterceptors(ClassSerializerInterceptor) async setDefaultPlayerSkill( @Body(new ValidationPipe({ transform: true })) { value }: DefaultPlayerSkill, @@ -33,7 +34,7 @@ export class ConfigurationController { } @Put('whitelist-id') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UseInterceptors(ClassSerializerInterceptor) async setWhitelistId(@Body(new ValidationPipe()) { value }: WhitelistId) { return new WhitelistId(await this.configurationService.setWhitelistId(value)); diff --git a/src/documents/controllers/documents.controller.ts b/src/documents/controllers/documents.controller.ts index a1aa699f9..442df673e 100644 --- a/src/documents/controllers/documents.controller.ts +++ b/src/documents/controllers/documents.controller.ts @@ -1,4 +1,5 @@ import { Auth } from '@/auth/decorators/auth.decorator'; +import { PlayerRole } from '@/players/models/player-role'; import { DocumentNotFoundFilter } from '@/shared/filters/document-not-found.filter'; import { Body, ClassSerializerInterceptor, Controller, DefaultValuePipe, Get, Param, Put, Query, UseFilters, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common'; import { Document } from '../models/document'; @@ -24,7 +25,7 @@ export class DocumentsController { @Put(':name') @UseInterceptors(ClassSerializerInterceptor) @UsePipes(ValidationPipe) - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) async saveDocument( @Param('name') name: string, @Query('language', new DefaultValuePipe('en')) language: string, diff --git a/src/game-servers/controllers/game-server-diagnostics.controller.ts b/src/game-servers/controllers/game-server-diagnostics.controller.ts index e7cac6910..b2f3a2d93 100644 --- a/src/game-servers/controllers/game-server-diagnostics.controller.ts +++ b/src/game-servers/controllers/game-server-diagnostics.controller.ts @@ -1,11 +1,11 @@ import { Auth } from '@/auth/decorators/auth.decorator'; +import { PlayerRole } from '@/players/models/player-role'; import { DocumentNotFoundFilter } from '@/shared/filters/document-not-found.filter'; import { ObjectIdValidationPipe } from '@/shared/pipes/object-id-validation.pipe'; import { ClassSerializerInterceptor, Controller, Get, Param, UseFilters, UseInterceptors } from '@nestjs/common'; import { GameServerDiagnosticRun } from '../models/game-server-diagnostic-run'; import { GameServerDiagnosticsService } from '../services/game-server-diagnostics.service'; -@Auth('super-user') @Controller('game-server-diagnostics') export class GameServerDiagnosticsController { @@ -14,7 +14,7 @@ export class GameServerDiagnosticsController { ) { } @Get(':id') - @Auth('super-user') + @Auth(PlayerRole.superUser) @UseInterceptors(ClassSerializerInterceptor) @UseFilters(DocumentNotFoundFilter) async getDiagnosticRun(@Param('id', ObjectIdValidationPipe) id: string): Promise { diff --git a/src/game-servers/controllers/game-servers.controller.ts b/src/game-servers/controllers/game-servers.controller.ts index 5461788d5..43b261200 100644 --- a/src/game-servers/controllers/game-servers.controller.ts +++ b/src/game-servers/controllers/game-servers.controller.ts @@ -9,6 +9,7 @@ import { GameServerDiagnosticsService } from '../services/game-server-diagnostic import { Environment } from '@/environment/environment'; import { User } from '@/auth/decorators/user.decorator'; import { Player } from '@/players/models/player'; +import { PlayerRole } from '@/players/models/player-role'; @Controller('game-servers') export class GameServersController { @@ -33,7 +34,7 @@ export class GameServersController { } @Post() - @Auth('super-user') + @Auth(PlayerRole.superUser) @UsePipes(ValidationPipe) @UseInterceptors(ClassSerializerInterceptor) async addGameServer(@Body() gameServer: AddGameServer, @User() admin: Player) { @@ -41,13 +42,13 @@ export class GameServersController { } @Delete(':id') - @Auth('super-user') + @Auth(PlayerRole.superUser) async removeGameServer(@Param('id', ObjectIdValidationPipe) gameServerId: string, @User() admin: Player) { await this.gameServersService.removeGameServer(gameServerId, admin.id); } @Post(':id/diagnostics') - @Auth('super-user') + @Auth(PlayerRole.superUser) @HttpCode(202) async runDiagnostics(@Param('id', ObjectIdValidationPipe) gameServerId: string) { const id = await this.gameServerDiagnosticsService.runDiagnostics(gameServerId); diff --git a/src/games/controllers/games.controller.ts b/src/games/controllers/games.controller.ts index e56b1895e..a47f530aa 100644 --- a/src/games/controllers/games.controller.ts +++ b/src/games/controllers/games.controller.ts @@ -8,6 +8,7 @@ import { IsOneOfPipe } from '@/shared/pipes/is-one-of.pipe'; import { Game } from '../models/game'; import { User } from '@/auth/decorators/user.decorator'; import { Player } from '@/players/models/player'; +import { PlayerRole } from '@/players/models/player-role'; const sortOptions: string[] = [ 'launched_at', @@ -74,7 +75,7 @@ export class GamesController { } @Get(':id/skills') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) async getGameSkills(@Param('id', ObjectIdValidationPipe) gameId: string) { const game = await this.gamesService.getById(gameId); if (game) { @@ -85,7 +86,7 @@ export class GamesController { } @Post(':id') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @HttpCode(200) async takeAdminAction( @Param('id', ObjectIdValidationPipe) gameId: string, diff --git a/src/players/controllers/players.controller.ts b/src/players/controllers/players.controller.ts index b9e2659c8..68a62dbf7 100644 --- a/src/players/controllers/players.controller.ts +++ b/src/players/controllers/players.controller.ts @@ -13,6 +13,7 @@ import { Tf2ClassName } from '@/shared/models/tf2-class-name'; import { DocumentNotFoundFilter } from '@/shared/filters/document-not-found.filter'; import { PlayerStats } from '../dto/player-stats'; import { ForceCreatePlayer } from '../dto/force-create-player'; +import { PlayerRole } from '../models/player-role'; @Controller('players') @UseInterceptors(CacheInterceptor) @@ -39,7 +40,7 @@ export class PlayersController { } @Post() - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UsePipes(ValidationPipe) @UseInterceptors(ClassSerializerInterceptor) async forceCreatePlayer(@Body() player: ForceCreatePlayer) { @@ -47,10 +48,10 @@ export class PlayersController { } @Patch(':id') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UseInterceptors(ClassSerializerInterceptor) - async updatePlayer(@Param('id', ObjectIdValidationPipe) playerId: string, @Body() player: Partial, @User() user: Player) { - return await this.playersService.updatePlayer(playerId, player, user.id); + async updatePlayer(@Param('id', ObjectIdValidationPipe) playerId: string, @Body() player: Partial, @User() admin: Player) { + return await this.playersService.updatePlayer(playerId, player, admin.id); } @Get(':id/games') @@ -89,13 +90,13 @@ export class PlayersController { } @Get('/all/skill') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) async getAllPlayerSkills() { return await this.playerSkillService.getAll(); } @Get(':id/skill') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) async getPlayerSkill(@Param('id', ObjectIdValidationPipe) playerId: string) { const skill = await this.playerSkillService.getPlayerSkill(playerId); if (skill) { @@ -106,7 +107,7 @@ export class PlayersController { } @Put(':id/skill') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) // todo validate skill async setPlayerSkill( @Param('id', ObjectIdValidationPipe) playerId: string, @@ -118,14 +119,14 @@ export class PlayersController { } @Get(':id/bans') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UseInterceptors(ClassSerializerInterceptor) async getPlayerBans(@Param('id', ObjectIdValidationPipe) playerId: string) { return await this.playerBansService.getPlayerBans(playerId); } @Post(':id/bans') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UsePipes(ValidationPipe) @UseInterceptors(ClassSerializerInterceptor) async addPlayerBan(@Body() playerBan: PlayerBan, @User() user: Player) { @@ -136,7 +137,7 @@ export class PlayersController { } @Post(':playerId/bans/:banId') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) @UseInterceptors(ClassSerializerInterceptor) @HttpCode(200) async updatePlayerBan(@Param('playerId', ObjectIdValidationPipe) playerId: string, @Param('banId', ObjectIdValidationPipe) banId: string, diff --git a/src/players/models/player-role.ts b/src/players/models/player-role.ts index e57ad788c..b2254dbdc 100644 --- a/src/players/models/player-role.ts +++ b/src/players/models/player-role.ts @@ -1 +1,6 @@ -export type PlayerRole = 'admin' | 'super-user' | 'bot'; +export enum PlayerRole { + superUser = 'super user', + admin = 'admin', + bot = 'bot', +} + diff --git a/src/players/models/player.ts b/src/players/models/player.ts index 87e739a62..e52969ab5 100644 --- a/src/players/models/player.ts +++ b/src/players/models/player.ts @@ -25,8 +25,8 @@ export class Player extends MongooseDocument { @prop() avatar?: PlayerAvatar; - @prop() - role?: PlayerRole; + @prop({ type: () => [String], enum: PlayerRole, default: [] }) + roles?: PlayerRole[]; @Exclude({ toPlainOnly: true }) @prop({ default: false }) diff --git a/src/players/services/players.service.spec.ts b/src/players/services/players.service.spec.ts index c94d6a5f2..182b7f78c 100644 --- a/src/players/services/players.service.spec.ts +++ b/src/players/services/players.service.spec.ts @@ -25,6 +25,7 @@ import { WebsocketEvent } from '@/websocket-event'; import { Socket } from 'socket.io'; import { InsufficientTf2InGameHoursError } from '../errors/insufficient-tf2-in-game-hours.error'; import { Tf2InGameHoursVerificationError } from '../errors/tf2-in-game-hours-verification.error'; +import { PlayerRole } from '../models/player-role'; jest.mock('./etf2l-profile.service'); jest.mock( './online-players.service'); @@ -195,7 +196,7 @@ describe('PlayersService', () => { it('should find the bot', async () => { expect(await service.findBot()).toMatchObject({ name: 'FAKE_BOT_NAME', - role: 'bot', + roles: [ PlayerRole.bot ], }); }); }); @@ -261,7 +262,7 @@ describe('PlayersService', () => { medium: 'FAKE_MEDIUM_AVATAR_URL', large: 'FAKE_FULL_AVATAR_URL', }, - role: null, + roles: [], etf2lProfileId: 112758, }); }); @@ -269,7 +270,7 @@ describe('PlayersService', () => { it('should assign the super-user role', async () => { environment.superUser = 'FAKE_STEAM_ID_2'; const ret = await service.createPlayer(mockSteamProfile); - expect(ret.role).toEqual('super-user'); + expect(ret.roles.includes(PlayerRole.superUser)).toBe(true); }); it('should emit the playerRegisters event', async () => new Promise(resolve => { @@ -416,12 +417,12 @@ describe('PlayersService', () => { expect(ret.name).toEqual('NEW_NAME'); }); - it('should update player role', async () => { - const ret1 = await service.updatePlayer(mockPlayer.id, { role: 'admin' }, admin.id); - expect(ret1.role).toEqual('admin'); + it('should update player roles', async () => { + const ret1 = await service.updatePlayer(mockPlayer.id, { roles: [ PlayerRole.admin ] }, admin.id); + expect(ret1.roles).toEqual([ PlayerRole.admin ]); - const ret2 = await service.updatePlayer(mockPlayer.id, { role: null }, admin.id); - expect(ret2.role).toBe(null); + const ret2 = await service.updatePlayer(mockPlayer.id, { roles: [] }, admin.id); + expect(ret2.roles).toEqual([]); }); it('should emit updated player over websocket', async () => { diff --git a/src/players/services/players.service.ts b/src/players/services/players.service.ts index f632c7255..e0f78ff23 100644 --- a/src/players/services/players.service.ts +++ b/src/players/services/players.service.ts @@ -22,6 +22,7 @@ import { UpdateQuery } from 'mongoose'; import { Tf2InGameHoursVerificationError } from '../errors/tf2-in-game-hours-verification.error'; import { AccountBannedError } from '../errors/account-banned.error'; import { InsufficientTf2InGameHoursError } from '../errors/insufficient-tf2-in-game-hours.error'; +import { PlayerRole } from '../models/player-role'; type ForceCreatePlayerOptions = Pick; @@ -47,7 +48,7 @@ export class PlayersService implements OnModuleInit { if (error instanceof mongoose.Error.DocumentNotFoundError) { await this.playerModel.create({ name: this.environment.botName, - role: 'bot', + roles: [ PlayerRole.bot ], }); } else { throw error; @@ -64,7 +65,7 @@ export class PlayersService implements OnModuleInit { } async getAll(): Promise { - return plainToClass(Player, await this.playerModel.find({ role: { $ne: 'bot' } }).lean().exec()); + return plainToClass(Player, await this.playerModel.find({ roles: { $ne: PlayerRole.bot } }).lean().exec()); } async getById(id: string | ObjectId): Promise { @@ -117,7 +118,7 @@ export class PlayersService implements OnModuleInit { steamId: steamProfile.id, name, avatar, - role: this.environment.superUser === steamProfile.id ? 'super-user' : null, + roles: this.environment.superUser === steamProfile.id ? [ PlayerRole.superUser, PlayerRole.admin ] : [], etf2lProfileId: etf2lProfile?.id, hasAcceptedRules: false, }))._id; diff --git a/src/plugins/discord/services/admin-notifications.service.ts b/src/plugins/discord/services/admin-notifications.service.ts index 8fee514b2..d67e886d7 100644 --- a/src/plugins/discord/services/admin-notifications.service.ts +++ b/src/plugins/discord/services/admin-notifications.service.ts @@ -74,8 +74,12 @@ export class AdminNotificationsService implements OnModuleInit { if (oldPlayer.name !== newPlayer.name) { changes.name = { old: oldPlayer.name, new: newPlayer.name }; } - if (oldPlayer.role !== newPlayer.role) { - changes.role = { old: oldPlayer.role, new: newPlayer.role }; + + const oldRoles = oldPlayer.roles.join(', '); + const newRoles = oldPlayer.roles.join(', '); + + if (oldRoles !== newRoles) { + changes.role = { old: oldRoles, new: newRoles }; } if (Object.keys(changes).length === 0) { diff --git a/src/queue/controllers/queue.controller.ts b/src/queue/controllers/queue.controller.ts index 9bc8037ba..465d5b95d 100644 --- a/src/queue/controllers/queue.controller.ts +++ b/src/queue/controllers/queue.controller.ts @@ -9,6 +9,7 @@ import { PlayerPopulatorService } from '../services/player-populator.service'; import { MapPoolService } from '../services/map-pool.service'; import { Auth } from '@/auth/decorators/auth.decorator'; import { Map } from '../models/map'; +import { PlayerRole } from '@/players/models/player-role'; @Controller('queue') export class QueueController { @@ -57,7 +58,7 @@ export class QueueController { } @Put('map_vote_results/scramble') - @Auth('admin', 'super-user') + @Auth(PlayerRole.admin) scrambleMaps() { return this.mapVoteService.scramble(); } @@ -78,20 +79,20 @@ export class QueueController { } @Post('maps') - @Auth('super-user', 'admin') + @Auth(PlayerRole.admin) @UsePipes(ValidationPipe) async addMap(@Body() map: Map) { return await this.mapPoolService.addMap(map); } @Delete('maps/:name') - @Auth('super-user', 'admin') + @Auth(PlayerRole.admin) async deleteMap(@Param('name') name: string) { return await this.mapPoolService.removeMap(name); } @Put('maps') - @Auth('super-user', 'admin') + @Auth(PlayerRole.admin) @UsePipes(ValidationPipe) async setMaps(@Body() maps: Map[]) { return await this.mapPoolService.setMaps(maps);