From cc567b2b264a0ade90b1c3a484fa6e95dae2fc80 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Fri, 18 Jul 2025 10:20:52 -0400 Subject: [PATCH] feat(Roles): role guards et setup ownship(not implemented, yet) --- src/common/decorators/ownership.decorator.ts | 11 ++++ src/common/decorators/roles.decorators.ts | 4 +- src/common/guards/ownership.guard.ts | 51 +++++++++++++++++++ src/common/guards/roles.guard.ts | 5 +- src/main.ts | 17 ++++++- .../authentication/guards/jwt-auth.guard.ts | 17 ++++++- .../controllers/customers.controller.ts | 7 +++ .../controllers/employees.controller.ts | 9 ++++ .../controllers/leave-requests.controller.ts | 7 +++ .../oauth-access-tokens.controller.ts | 7 +++ .../controllers/shift-codes.controller.ts | 7 +++ .../shifts/controllers/shifts.controller.ts | 7 +++ .../controllers/timesheets.controller.ts | 7 +++ 13 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/common/decorators/ownership.decorator.ts create mode 100644 src/common/guards/ownership.guard.ts diff --git a/src/common/decorators/ownership.decorator.ts b/src/common/decorators/ownership.decorator.ts new file mode 100644 index 0000000..c93da9e --- /dev/null +++ b/src/common/decorators/ownership.decorator.ts @@ -0,0 +1,11 @@ +import { SetMetadata } from "@nestjs/common"; + +export const OWNER_KEY = 'ownership'; +export interface OwnershipMeta { + serviceToken: string; + idParam?: string; + ownerField?: string; +} + +export const CheckOwnership = (meta: OwnershipMeta) => + SetMetadata(OWNER_KEY, meta); \ No newline at end of file diff --git a/src/common/decorators/roles.decorators.ts b/src/common/decorators/roles.decorators.ts index 70686a5..92020c9 100644 --- a/src/common/decorators/roles.decorators.ts +++ b/src/common/decorators/roles.decorators.ts @@ -2,5 +2,7 @@ import { SetMetadata } from '@nestjs/common'; import { Roles } from '@prisma/client'; export const ROLES_KEY = 'roles'; -export const ROlesAllowed = (...roles: Roles[]) => +export const RolesAllowed = (...roles: Roles[]) => SetMetadata(ROLES_KEY, roles); + + diff --git a/src/common/guards/ownership.guard.ts b/src/common/guards/ownership.guard.ts new file mode 100644 index 0000000..9fcba18 --- /dev/null +++ b/src/common/guards/ownership.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + Injectable, + ExecutionContext, + ForbiddenException, +} from "@nestjs/common"; +import { Reflector, ModuleRef } from "@nestjs/core"; +import { OWNER_KEY, OwnershipMeta } from "../decorators/ownership.decorator"; +import { Request } from 'express'; + +interface RequestWithUser extends Request { + user: { id: string, role: string }; +} + +@Injectable() +export class OwnershipGuard implements CanActivate { + constructor( + private reflector: Reflector, + private moduleRef: ModuleRef, + ) {} + + async canActivate(context: ExecutionContext): Promise{ + const meta = this.reflector.get( + OWNER_KEY, context.getHandler(), + ); + if (!meta) + return true; + + const request = context.switchToHttp().getRequest(); + const user = request.user; + const resourceId = request.params[meta.idParam || 'id']; + + const service = this.moduleRef.get( + meta.serviceToken, + { strict: false }, + ); + const resource = await service.findOne(resourceId); + const ownerField = meta.ownerField || 'ownerId'; + + if (user.role === 'ADMIN') { + return true; + } + + if (!resource || resource[ownerField] !== user.id) { + throw new ForbiddenException( + `You do not own the rights to this resource.` + ); + } + return true; + } +} \ No newline at end of file diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts index 082f5e7..f36a0be 100644 --- a/src/common/guards/roles.guard.ts +++ b/src/common/guards/roles.guard.ts @@ -22,8 +22,9 @@ export class RolesGuard implements CanActivate { ROLES_KEY, ctx.getHandler(), ); - if (!requiredRoles) { - return true; + //for "deny-by-default" when role is wrong or unavailable + if (!requiredRoles || requiredRoles.length === 0) { + return false; } const request = ctx.switchToHttp().getRequest(); const user = request.user; diff --git a/src/main.ts b/src/main.ts index e540551..f11766c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,24 @@ import 'reflect-metadata'; -import { NestFactory } from '@nestjs/core'; +import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; +import { ValidationPipe } from '@nestjs/common'; +import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; +import { RolesGuard } from './common/guards/roles.guard'; +import { OwnershipGuard } from './common/guards/ownership.guard'; async function bootstrap() { const app = await NestFactory.create(AppModule); + //setup Reflector for Roles() + const reflector = app.get(Reflector); + + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, transform: true})); + app.useGlobalGuards( + new JwtAuthGuard(reflector), //Authentification JWT + new RolesGuard(reflector), //deny-by-default and Role-based Access Control + new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet + ); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/modules/authentication/guards/jwt-auth.guard.ts b/src/modules/authentication/guards/jwt-auth.guard.ts index 2155290..dd21e87 100644 --- a/src/modules/authentication/guards/jwt-auth.guard.ts +++ b/src/modules/authentication/guards/jwt-auth.guard.ts @@ -1,5 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} +export class JwtAuthGuard extends AuthGuard('jwt') { + + canActivate(context: ExecutionContext) { + return super.canActivate(context); + } + + handleRequest(err, user, info) { + if(err || !user) { + throw err || new UnauthorizedException(); + } + return user; + } +} diff --git a/src/modules/customers/controllers/customers.controller.ts b/src/modules/customers/controllers/customers.controller.ts index 00c212e..7b32e9b 100644 --- a/src/modules/customers/controllers/customers.controller.ts +++ b/src/modules/customers/controllers/customers.controller.ts @@ -3,27 +3,33 @@ import { CustomersService } from '../services/customers.service'; import { Customers, Employees } from '@prisma/client'; import { CreateCustomerDto } from '../dtos/create-customer'; import { UpdateCustomerDto } from '../dtos/update-customer'; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller('customers') export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) create(@Body() dto: CreateCustomerDto): Promise { return this.customersService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findAll(): Promise { return this.customersService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.customersService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCustomerDto, @@ -32,6 +38,7 @@ export class CustomersController { } @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) remove(@Param('id', ParseIntPipe) id: number): Promise{ return this.customersService.remove(id); } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index a70275c..468285a 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -13,26 +13,34 @@ import { EmployeesService } from '../services/employees.service'; import { CreateEmployeeDto } from '../dtos/create-employee.dto'; import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; +//decorators and roles imports +import { RolesAllowed } from '../../../common/decorators/roles.decorators'; +import { Roles as RoleEnum } from 'prisma/prisma-client'; + @Controller('employees') export class EmployeesController { constructor(private readonly employeesService: EmployeesService) {} @Post() + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) create(@Body() dto: CreateEmployeeDto): Promise { return this.employeesService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) findAll(): Promise { return this.employeesService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.employeesService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateEmployeeDto, @@ -41,6 +49,7 @@ export class EmployeesController { } @Delete(':id') + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) remove(@Param('id', ParseIntPipe) id: number): Promise { return this.employeesService.remove(id); } diff --git a/src/modules/leave_requests/controllers/leave-requests.controller.ts b/src/modules/leave_requests/controllers/leave-requests.controller.ts index d97dbe8..f992ee5 100644 --- a/src/modules/leave_requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave_requests/controllers/leave-requests.controller.ts @@ -3,27 +3,33 @@ import { LeaveRequestsService } from "../services/leave-request.service"; import { CreateLeaveRequestsDto } from "../dtos/create-leave-requests.dto"; import { LeaveRequests } from "@prisma/client"; import { UpdateLeaveRequestsDto } from "../dtos/update-leave-requests.dto"; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller('leave-requests') export class LeaveRequestController { constructor(private readonly leaveRequetsService: LeaveRequestsService){} @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) create(@Body() dto: CreateLeaveRequestsDto): Promise { return this. leaveRequetsService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) findAll(): Promise { return this.leaveRequetsService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.leaveRequetsService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateLeaveRequestsDto, @@ -32,6 +38,7 @@ export class LeaveRequestController { } @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) remove(@Param('id', ParseIntPipe) id: number): Promise { return this.leaveRequetsService.remove(id); } diff --git a/src/modules/oauth-access-tokens/controllers/oauth-access-tokens.controller.ts b/src/modules/oauth-access-tokens/controllers/oauth-access-tokens.controller.ts index 29d8214..edcdbde 100644 --- a/src/modules/oauth-access-tokens/controllers/oauth-access-tokens.controller.ts +++ b/src/modules/oauth-access-tokens/controllers/oauth-access-tokens.controller.ts @@ -3,32 +3,39 @@ import { OauthAccessTokensService } from '../services/oauth-access-tokens.servic import { CreateOauthAccessTokenDto } from '../dtos/create-oauth-access-token.dto'; import { OAuthAccessTokens } from '@prisma/client'; import { UpdateOauthAccessTokenDto } from '../dtos/update-oauth-access-token.dto'; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller('oauth-access-tokens') export class OauthAccessTokensController { constructor(private readonly oauthAccessTokensService: OauthAccessTokensService){} @Post() + @RolesAllowed(RoleEnum.ADMIN) create(@Body()dto: CreateOauthAccessTokenDto): Promise { return this.oauthAccessTokensService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ADMIN) findAll(): Promise { return this.oauthAccessTokensService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ADMIN) findOne(@Param('id') id: string): Promise { return this.oauthAccessTokensService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ADMIN) update(@Param('id') id: string, @Body() dto: UpdateOauthAccessTokenDto): Promise { return this.oauthAccessTokensService.update(id,dto); } @Delete(':id') + @RolesAllowed(RoleEnum.ADMIN) remove(@Param('id') id: string): Promise { return this.oauthAccessTokensService.remove(id); } diff --git a/src/modules/shift-codes/controllers/shift-codes.controller.ts b/src/modules/shift-codes/controllers/shift-codes.controller.ts index fe95532..b9ea9f8 100644 --- a/src/modules/shift-codes/controllers/shift-codes.controller.ts +++ b/src/modules/shift-codes/controllers/shift-codes.controller.ts @@ -3,33 +3,40 @@ import { ShiftCodesService } from "../services/shift-codes.service"; import { CreateShiftCodesDto } from "../dtos/create-shift-codes.dto"; import { ShiftCodes } from "@prisma/client"; import { UpdateShiftCodesDto } from "../dtos/update-shift-codes.dto"; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller() export class ShiftCodesController { constructor(private readonly shiftCodesService: ShiftCodesService) {} @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR) create(@Body()dto: CreateShiftCodesDto): Promise { return this.shiftCodesService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR) findAll(): Promise { return this.shiftCodesService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.shiftCodesService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR) update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateShiftCodesDto): Promise { return this.shiftCodesService.update(id,dto); } @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR) remove(@Param('id', ParseIntPipe)id: number): Promise { return this.shiftCodesService.remove(id); } diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index e1b1541..ad7faf7 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -3,6 +3,8 @@ import { ShiftsService } from "../services/shifts.service"; import { Shifts } from "@prisma/client"; import { CreateShiftDto } from "../dtos/create-shifts.dto"; import { UpdateShiftsDto } from "../dtos/update-shifts.dto"; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller('shifts') export class ShiftsController { @@ -10,21 +12,25 @@ export class ShiftsController { @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) create(@Body() dto: CreateShiftDto): Promise { return this.shiftsService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findAll(): Promise { return this.shiftsService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.shiftsService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateShiftsDto, @@ -33,6 +39,7 @@ export class ShiftsController { } @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) remove(@Param('id', ParseIntPipe) id: number): Promise { return this.shiftsService.remove(id); } diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 2b148c9..a8369ee 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -3,27 +3,33 @@ import { TimesheetsService } from '../services/timesheets.service'; import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; import { Timesheets } from '@prisma/client'; import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { Roles as RoleEnum } from '.prisma/client'; @Controller('timesheets') export class TimesheetsController { constructor(private readonly timesheetsService: TimesheetsService) {} @Post() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) create(@Body() dto: CreateTimesheetDto): Promise { return this.timesheetsService.create(dto); } @Get() + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findAll(): Promise { return this.timesheetsService.findAll(); } @Get(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) findOne(@Param('id', ParseIntPipe) id: number): Promise { return this.timesheetsService.findOne(id); } @Patch(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) update( @Param('id', ParseIntPipe) id:number, @Body() dto: UpdateTimesheetDto, @@ -32,6 +38,7 @@ export class TimesheetsController { } @Delete(':id') + @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) remove(@Param('id', ParseIntPipe) id: number): Promise { return this.timesheetsService.remove(id); }