diff --git a/backend/src/bounty/bounty.controller.ts b/backend/src/bounty/bounty.controller.ts index 4b61985..f3fd4be 100644 --- a/backend/src/bounty/bounty.controller.ts +++ b/backend/src/bounty/bounty.controller.ts @@ -11,6 +11,8 @@ import { Delete, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { RolesGuard } from '../common/guards/roles.guard'; import { BountyService } from './bounty.service'; import { CreateBountyDto } from './dto/create-bounty.dto'; import { UpdateBountyDto } from './dto/update-bounty.dto'; @@ -50,7 +52,8 @@ export class BountyController { return this.service.search(q, Number(page), Number(size), guildId); } - @UseGuards(JwtAuthGuard) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('ADMIN') @Patch(':id') async update( @Param('id') id: string, diff --git a/backend/src/common/decorators/roles.decorator.ts b/backend/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/backend/src/common/decorators/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/common/guards/roles.guard.spec.ts b/backend/src/common/guards/roles.guard.spec.ts new file mode 100644 index 0000000..7edd68b --- /dev/null +++ b/backend/src/common/guards/roles.guard.spec.ts @@ -0,0 +1,59 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RolesGuard } from './roles.guard'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; + +function createMockContext(user?: any): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as unknown as ExecutionContext; +} + +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should allow when no roles required', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + expect(await guard.canActivate(createMockContext({ userId: '1' }))).toBe(true); + }); + + it('should throw when no user authenticated', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['ADMIN']); + await expect(guard.canActivate(createMockContext(undefined))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('should allow when user has required role', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['ADMIN']); + expect( + await guard.canActivate(createMockContext({ userId: '1', roles: ['ADMIN'] })), + ).toBe(true); + }); + + it('should deny when user lacks required role', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['ADMIN']); + await expect( + guard.canActivate(createMockContext({ userId: '1', roles: ['USER'] })), + ).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..3b6a221 --- /dev/null +++ b/backend/src/common/guards/roles.guard.ts @@ -0,0 +1,50 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = + this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // No roles required → allow + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user) { + throw new ForbiddenException('Authentication required'); + } + + // Check if user has any of the required roles + // Supports both string[] and comma-separated string + const userRoles = Array.isArray(user.roles) + ? user.roles + : (user.roles?.split(',').map((r: string) => r.trim()) ?? []); + + const hasRole = requiredRoles.some((role) => + userRoles.includes(role), + ); + + if (!hasRole) { + throw new ForbiddenException( + `Insufficient permissions. Required role(s): ${requiredRoles.join(', ')}`, + ); + } + + return true; + } +}