diff --git a/src/rbac/roles/dto/create-role.dto.ts b/src/rbac/roles/dto/create-role.dto.ts new file mode 100644 index 00000000..70242b33 --- /dev/null +++ b/src/rbac/roles/dto/create-role.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; + +export class CreateRoleDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID(undefined, { each: true }) + @IsOptional() + permissionIds?: string[]; +} diff --git a/src/rbac/roles/dto/update-role.dto.ts b/src/rbac/roles/dto/update-role.dto.ts new file mode 100644 index 00000000..a9dbb846 --- /dev/null +++ b/src/rbac/roles/dto/update-role.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; + +export class UpdateRoleDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID(undefined, { each: true }) + @IsOptional() + permissionIds?: string[]; +} diff --git a/src/rbac/roles/roles.controller.ts b/src/rbac/roles/roles.controller.ts index d33c343f..e49db5ee 100644 --- a/src/rbac/roles/roles.controller.ts +++ b/src/rbac/roles/roles.controller.ts @@ -1,3 +1,27 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Put, + Delete, + UseGuards, + Req, +} from '@nestjs/common'; +import { Request } from 'express'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { RolesService } from './roles.service'; +import { Role } from '../entities/role.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') import { Controller, Get, Post, Body, Param, Put, Delete, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { RolesService } from './roles.service'; @@ -23,15 +47,29 @@ import { UserRole } from '../../users/entities/user.entity'; export class RolesController { constructor(private readonly rolesService: RolesService) {} + private extractContext(req: Request) { + const user: any = req.user || {}; + return { + actorId: user.id || user.sub, + actorEmail: user.email, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }; + } + @Post() @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Create a new role (Admin only)' }) async create( - @Body('name') name: string, - @Body('description') description?: string, - @Body('permissionIds') permissionIds?: string[], + @Body() createRoleDto: CreateRoleDto, + @Req() req: Request, ): Promise { - return this.rolesService.createRole(name, description, permissionIds); + return this.rolesService.createRole( + createRoleDto.name, + createRoleDto.description, + createRoleDto.permissionIds, + this.extractContext(req), + ); } @Get() @@ -62,14 +100,21 @@ export class RolesController { @ApiOperation({ summary: 'Update a role (Admin only)' }) async update( @Param('id') id: string, - @Body('name') name: string, - @Body('description') description?: string, - @Body('permissionIds') permissionIds?: string[], + @Body() updateRoleDto: UpdateRoleDto, + @Req() req: Request, ): Promise { - return this.rolesService.updateRole(id, name, description, permissionIds); + return this.rolesService.updateRole( + id, + updateRoleDto.name, + updateRoleDto.description, + updateRoleDto.permissionIds, + this.extractContext(req), + ); } @Delete(':id') + async remove(@Param('id') id: string, @Req() req: Request): Promise { + return this.rolesService.deleteRole(id, this.extractContext(req)); @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Delete a role (Admin only)' }) async remove(@Param('id') id: string): Promise { @@ -82,8 +127,13 @@ export class RolesController { async addPermission( @Param('roleId') roleId: string, @Param('permissionId') permissionId: string, + @Req() req: Request, ): Promise { - return this.rolesService.addPermissionToRole(roleId, permissionId); + return this.rolesService.addPermissionToRole( + roleId, + permissionId, + this.extractContext(req), + ); } @Delete(':roleId/permissions/:permissionId') @@ -92,7 +142,12 @@ export class RolesController { async removePermission( @Param('roleId') roleId: string, @Param('permissionId') permissionId: string, + @Req() req: Request, ): Promise { - return this.rolesService.removePermissionFromRole(roleId, permissionId); + return this.rolesService.removePermissionFromRole( + roleId, + permissionId, + this.extractContext(req), + ); } } diff --git a/test/security/roles-rbac.e2e-spec.ts b/test/security/roles-rbac.e2e-spec.ts new file mode 100644 index 00000000..691a668c --- /dev/null +++ b/test/security/roles-rbac.e2e-spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ExecutionContext } from '@nestjs/common'; +import request from 'supertest'; +import { AppModule } from '../../src/app.module'; +import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../src/auth/guards/roles.guard'; + +describe('RolesController RBAC Security (e2e)', () => { + let app: INestApplication; + + const mockJwtAuthGuard = { + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + const auth = req.headers.authorization; + if (!auth) return false; + if (auth === 'Bearer admin-token') { + req.user = { id: 'admin-1', email: 'admin@test.com', roles: ['admin'] }; + return true; + } + if (auth === 'Bearer user-token') { + req.user = { id: 'user-1', email: 'user@test.com', roles: ['user'] }; + return true; + } + return false; + }, + }; + + const mockRolesGuard = { + canActivate: (context: ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + const user = req.user; + if (!user) return false; + if (user.roles?.includes('admin')) return true; + return false; + }, + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtAuthGuard) + .overrideGuard(RolesGuard) + .useValue(mockRolesGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + it('should return 401 Unauthorized for unauthenticated POST /roles', async () => { + return request(app.getHttpServer()) + .post('/roles') + .send({ name: 'hacker-role', description: 'Malicious role' }) + .expect(401); + }); + + it('should return 403 Forbidden for authenticated non-admin POST /roles', async () => { + return request(app.getHttpServer()) + .post('/roles') + .set('Authorization', 'Bearer user-token') + .send({ name: 'hacker-role', description: 'Malicious role' }) + .expect(403); + }); +});