Skip to content
Merged
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
14 changes: 14 additions & 0 deletions src/rbac/roles/dto/create-role.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
14 changes: 14 additions & 0 deletions src/rbac/roles/dto/update-role.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
75 changes: 65 additions & 10 deletions src/rbac/roles/roles.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Role> {
return this.rolesService.createRole(name, description, permissionIds);
return this.rolesService.createRole(
createRoleDto.name,
createRoleDto.description,
createRoleDto.permissionIds,
this.extractContext(req),
);
}

@Get()
Expand Down Expand Up @@ -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<Role> {
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<void> {
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<void> {
Expand All @@ -82,8 +127,13 @@ export class RolesController {
async addPermission(
@Param('roleId') roleId: string,
@Param('permissionId') permissionId: string,
@Req() req: Request,
): Promise<Role> {
return this.rolesService.addPermissionToRole(roleId, permissionId);
return this.rolesService.addPermissionToRole(
roleId,
permissionId,
this.extractContext(req),
);
}

@Delete(':roleId/permissions/:permissionId')
Expand All @@ -92,7 +142,12 @@ export class RolesController {
async removePermission(
@Param('roleId') roleId: string,
@Param('permissionId') permissionId: string,
@Req() req: Request,
): Promise<Role> {
return this.rolesService.removePermissionFromRole(roleId, permissionId);
return this.rolesService.removePermissionFromRole(
roleId,
permissionId,
this.extractContext(req),
);
}
}
72 changes: 72 additions & 0 deletions test/security/roles-rbac.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading