Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/src/bounty/bounty.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/common/decorators/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
59 changes: 59 additions & 0 deletions backend/src/common/guards/roles.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(RolesGuard);
reflector = module.get<Reflector>(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);
});
});
50 changes: 50 additions & 0 deletions backend/src/common/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(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;
}
}