refactor(controllers): added ModuleAccessAllowed and Access decorators

This commit is contained in:
Matthieu Haineault 2025-12-01 16:03:48 -05:00
parent 0f509a920f
commit ebc1cd77d8
15 changed files with 156 additions and 169 deletions

View File

@ -463,6 +463,20 @@
]
}
},
"/employees/personal-profile": {
"get": {
"operationId": "EmployeesController_findOwnProfile",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Employees"
]
}
},
"/employees/profile": {
"get": {
"operationId": "EmployeesController_findProfile",

View File

@ -381,6 +381,17 @@ enum Roles {
@@map("roles")
}
enum Modules {
timesheets
timesheets_approval
employee_list
employee_management
personal_profile
dashboard
@@map("modules")
}
enum LeaveTypes {
SICK // maladie ou repos
VACATION // paye

View File

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
import { Modules } from 'src/common/mappers/module-access.mapper';
export const MODULES_KEY = 'modules';
export const ModuleAccessAllowed = (...modules: Modules[]) =>
SetMetadata(MODULES_KEY, modules);

View File

@ -1,8 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { Roles } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const RolesAllowed = (...roles: Roles[]) =>
SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MODULES_KEY } from '../decorators/modules-guard.decorators';
import { Modules, Roles } from '.prisma/client';
interface RequestWithUser extends Request {
// TODO: Create an actual user model based on OAuth signin
user: any;
}
@Injectable()
export class ModulesGuard implements CanActivate {
constructor(private reflector: Reflector) { }
canActivate(ctx: ExecutionContext): boolean {
const requiredModules = this.reflector.getAllAndOverride<Modules[]>(
MODULES_KEY,
[ctx.getHandler(), ctx.getClass()],
);
//for "deny-by-default" when role is wrong or unavailable
if (!requiredModules || requiredModules.length === 0) {
return true;
}
const request = ctx.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
return false;
}
if (!requiredModules.includes(user.role)) {
throw new ForbiddenException(
`The role ${user.role} is not authorized to access this resource.`,
);
}
return true;
}
}

View File

@ -1,61 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorators';
import { Roles } from '.prisma/client';
interface RequestWithUser extends Request {
// TODO: Create an actual user model based on OAuth signin
user: any;
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) { }
/**
* @swagger
* @function canActivate
* @description
* Authorization guard that checks whether the current user has one of the required roles
* to access a specific route handler. It uses metadata defined by the `@Roles()` decorator
* and verifies the user's role accordingly.
*
* If no roles are specified for the route, access is granted by default.
* If the user is not authenticated or does not have a required role, access is denied.
*
* @param {ExecutionContext} ctx - The current execution context, which provides access
* to route metadata and the HTTP request.
*
* @returns {boolean} - Returns `true` if access is allowed, otherwise throws a `ForbiddenException`
* or returns `false` if the user is not authenticated.
*/
canActivate(ctx: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Roles[]>(
ROLES_KEY,
[ctx.getHandler(), ctx.getClass()],
);
//for "deny-by-default" when role is wrong or unavailable
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = ctx.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
return false;
}
if (!requiredRoles.includes(user.role)) {
throw new ForbiddenException(
`The role ${user.role} is not authorized to access this resource.`,
);
}
return true;
}
}

View File

@ -4,46 +4,37 @@ import { Result } from "src/common/errors/result-error.factory";
import { EmployeeDetailedDto } from "src/identity-and-account/employees/dtos/employee-detailed.dto";
import { EmployeeDto } from "src/identity-and-account/employees/dtos/employee.dto";
import { EmployeesService } from "src/identity-and-account/employees/services/employees.service";
import { AccessGetService } from "src/identity-and-account/user-module-access/services/module-access-get.service";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
//TODO: create a custom decorator to replace the findModuleAcces call function
@Controller('employees')
export class EmployeesController {
constructor(
private readonly employeesService: EmployeesService,
private readonly accessGetService: AccessGetService,
) { }
constructor(private readonly employeesService: EmployeesService) { }
@Get('personal-profile')
@ModuleAccessAllowed(ModulesEnum.personal_profile)
async findOwnProfile(@Access('email') email: string): Promise<Result<Partial<EmployeeDetailedDto>, string>> {
return await this.employeesService.findOwnProfile(email);
}
@Get('profile')
@ModuleAccessAllowed(ModulesEnum.employee_management)
async findProfile(@Access('email') email: string, @Query('employee_email') employee_email?: string,
): Promise<Result<Partial<EmployeeDetailedDto>, string>> {
const granted_access = await this.accessGetService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
if (!granted_access.data.employee_management) {
return await this.employeesService.findOwnProfile(email);
} else if (granted_access.data.employee_management) {
return await this.employeesService.findOneDetailedProfile(email,employee_email);
} else {
return { success: false, error: 'INVALID_USER' }
}
return await this.employeesService.findOneDetailedProfile(email, employee_email);
}
@Get('employee-list')
async findListEmployees(@Access('email') email: string
): Promise<Result<EmployeeDto[], string>> {
const granted_access = await this.accessGetService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
@ModuleAccessAllowed(ModulesEnum.employee_list)
async findListEmployees(): Promise<Result<EmployeeDto[], string>> {
return this.employeesService.findListEmployees();
}
@Post()
async createEmployee(@Access('email') email: string, @Body() dto: EmployeeDetailedDto
): Promise<Result<boolean, string>> {
const granted_access = await this.accessGetService.findModuleAccess(email);
if (!granted_access.success) return { success: false, error: 'INVALID_USER' };
if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' };
@ModuleAccessAllowed(ModulesEnum.employee_management)
async createEmployee(@Body() dto: EmployeeDetailedDto): Promise<Result<boolean, string>> {
return await this.employeesService.createEmployee(dto);
}

View File

@ -1,18 +1,15 @@
import 'reflect-metadata';
//import and if case for @nestjs/schedule Cron jobs
import * as nodeCrypto from 'crypto';
if (!(globalThis as any).crypto) {
(globalThis as any).crypto = nodeCrypto;
}
import { ensureAttachmentsTmpDir } from './config/attachment.fs';
import { resolveAttachmentsRoot } from './config/attachment.config';// log to be removed post dev
import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed post dev
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { ModulesGuard } from './common/guards/modules.guard';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import * as session from 'express-session';
@ -26,11 +23,11 @@ const SESSION_TOKEN_DURATION_MINUTES = 180
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const reflector = app.get(Reflector); //setup Reflector for Roles()
const reflector = app.get(Reflector);
app.useGlobalGuards(
// new JwtAuthGuard(reflector), //Authentification JWT
new RolesGuard(reflector), //deny-by-default and Role-based Access Control
new ModulesGuard(reflector), //deny-by-default and Module-based Access Control
);
// Authentication and session

View File

@ -1,11 +1,11 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from "@nestjs/common";
import { BankCodesService } from "../services/bank-codes.service";
import { BankCodeDto } from "../dtos/bank-code.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { PAY_SERVICE } from "src/common/shared/role-groupes";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
@Controller('bank-codes')
@RolesAllowed(...PAY_SERVICE)
@ModuleAccessAllowed(ModulesEnum.employee_management)
export class BankCodesControllers {
constructor(private readonly bankCodesService: BankCodesService) {}
//_____________________________________________________________________________________________

View File

@ -1,16 +1,15 @@
import { Controller, Get, Header, Query} from "@nestjs/common";
import { CsvExportService } from "../services/csv-exports.service";
import { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { PAY_SERVICE } from "src/common/shared/role-groupes";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
@Controller('exports')
@RolesAllowed(...PAY_SERVICE)
export class CsvExportController {
constructor(private readonly csvService: CsvExportService) {}
@Get('csv')
@ModuleAccessAllowed(ModulesEnum.employee_management)
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="export.csv"')
async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise<Buffer> {

View File

@ -1,29 +1,30 @@
import { Controller, Post, Param, Body, Patch, Delete, Req, UnauthorizedException } from "@nestjs/common";
import { ExpenseUpsertService } from "src/time-and-attendance/expenses/services/expense-upsert.service";
import { ExpenseDto } from "src/time-and-attendance/expenses/dtos/expense-create.dto";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes";
import { Result } from "src/common/errors/result-error.factory";
import { Result } from "src/common/errors/result-error.factory";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
@Controller('expense')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class ExpenseController {
constructor(private readonly upsert_service: ExpenseUpsertService) { }
@Post('create')
create(@Req() req, @Body() dto: ExpenseDto): Promise<Result<ExpenseDto, string>> {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.timesheets)
create(@Access('email') email: string, @Body() dto: ExpenseDto): Promise<Result<ExpenseDto, string>> {
if (!email) throw new UnauthorizedException('Unauthorized User');
return this.upsert_service.createExpense(dto, email);
}
@Patch('update')
update(@Body() dto: ExpenseDto, @Req() req): Promise<Result<ExpenseDto, string>> {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.timesheets)
update(@Body() dto: ExpenseDto, @Access('email') email: string): Promise<Result<ExpenseDto, string>> {
return this.upsert_service.updateExpense(dto, email);
}
@Delete('delete/:expense_id')
@ModuleAccessAllowed(ModulesEnum.timesheets)
remove(@Param('expense_id') expense_id: number): Promise<Result<number, string>> {
return this.upsert_service.deleteExpense(expense_id);
}

View File

@ -1,15 +1,15 @@
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException } from "@nestjs/common";
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, UnauthorizedException } from "@nestjs/common";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PayPeriodsQueryService } from "../services/pay-periods-query.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
import { Result } from "src/common/errors/result-error.factory";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
@Controller('pay-periods')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class PayPeriodsController {
constructor(
@ -18,6 +18,7 @@ export class PayPeriodsController {
) { }
@Get('current-and-all')
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async getCurrentAndAll(@Query('date') date?: string): Promise<Result<PayPeriodBundleDto, string>> {
const current = await this.queryService.findCurrent(date);
if (!current.success) return { success: false, error: 'INVALID_PAY_PERIOD' };
@ -29,11 +30,13 @@ export class PayPeriodsController {
}
@Get("date/:date")
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async findByDate(@Param("date") date: string) {
return this.queryService.findByDate(date);
}
@Get(":year/:periodNumber")
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async findOneByYear(
@Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) period_no: number,
@ -42,26 +45,25 @@ export class PayPeriodsController {
}
@Patch("crew/pay-period-approval")
@RolesAllowed(...MANAGER_ROLES)
async bulkApproval(@Req() req, @Body() dto: BulkCrewApprovalDto) {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async bulkApproval(@Access('email') email:string, @Body() dto: BulkCrewApprovalDto) {
if (!email) throw new UnauthorizedException(`Session infos not found`);
return this.commandService.bulkApproveCrew(email, dto);
}
@Get('crew/:year/:periodNumber')
@RolesAllowed(...MANAGER_ROLES)
async getCrewOverview(@Req() req,
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async getCrewOverview(@Access('email') email:string,
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) period_no: number,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false,
): Promise<Result<PayPeriodOverviewDto, string>> {
const email = req.user?.email;
if (!email) throw new UnauthorizedException(`Session infos not found`);
return this.queryService.getCrewOverview(year, period_no, email, include_subtree);
}
@Get('overview/:year/:periodNumber')
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
async getOverviewByYear(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) period_no: number,

View File

@ -1,14 +1,14 @@
import { Controller, Param, Query, Body, Get, Post, ParseIntPipe, Delete, Patch, Req } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
import { Controller, Param, Body, Get, Post, ParseIntPipe, Delete, Patch } from "@nestjs/common";
import { SchedulePresetsDto } from "src/time-and-attendance/schedule-presets/dtos/create-schedule-presets.dto";
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
import { SchedulePresetsGetService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-get.service";
import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-create.service";
import { SchedulePresetUpdateDeleteService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-update-delete.service";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
@Controller('schedule-presets')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class SchedulePresetsController {
constructor(
private readonly createService: SchedulePresetsCreateService,
@ -19,48 +19,39 @@ export class SchedulePresetsController {
// used to create a schedule preset
@Post('create')
@RolesAllowed(...MANAGER_ROLES)
async createPreset(@Req() req, @Body() dto: SchedulePresetsDto) {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.employee_management)
async createPreset(@Access('email') email: string, @Body() dto: SchedulePresetsDto) {
return await this.createService.createPreset(email, dto);
}
//used to update an already existing schedule preset
@Patch('update/:preset_id')
@RolesAllowed(...MANAGER_ROLES)
@ModuleAccessAllowed(ModulesEnum.employee_management)
async updatePreset(
@Param('preset_id', ParseIntPipe) preset_id: number,
@Body() dto: SchedulePresetsDto,
@Req() req,
) {
const email = req.user?.email;
@Param('preset_id', ParseIntPipe) preset_id: number, @Body() dto: SchedulePresetsDto, @Access('email') email: string) {
return await this.updateDeleteService.updatePreset(preset_id, dto, email);
}
//used to delete a schedule preset
@Delete('delete/:preset_id')
@RolesAllowed(...MANAGER_ROLES)
async deletePreset(@Param('preset_id', ParseIntPipe) preset_id: number, @Req() req) {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.employee_management)
async deletePreset(
@Param('preset_id', ParseIntPipe) preset_id: number, @Access('email') email: string) {
return await this.updateDeleteService.deletePreset(preset_id, email);
}
//used to show the list of available schedule presets
@Get('find-list')
@RolesAllowed(...MANAGER_ROLES)
async findListById(@Req() req) {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.employee_management)
async findListById(@Access('email') email: string) {
return this.getService.getSchedulePresets(email);
}
//used to apply a preset to a timesheet
@Post('apply-presets')
@ModuleAccessAllowed(ModulesEnum.timesheets)
async applyPresets(
@Body('preset') preset_id: number,
@Body('start') start_date: string,
@Req() req
) {
const email = req.user?.email;
@Body('preset') preset_id: number, @Body('start') start_date: string, @Access('email') email: string) {
return this.applyPresetsService.applyToTimesheet(email, preset_id, start_date);
}
}

View File

@ -1,35 +1,35 @@
import { Body, Controller, Delete, Param, Patch, Post, Req } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GLOBAL_CONTROLLER_ROLES } from "src/common/shared/role-groupes";
import { Body, Controller, Delete, Param, Patch, Post } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { ShiftDto } from "src/time-and-attendance/shifts/dtos/shift-create.dto";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
import { ShiftsUpdateDeleteService } from "src/time-and-attendance/shifts/services/shifts-update-delete.service";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
@Controller('shift')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class ShiftController {
constructor(
constructor(
private readonly create_service: ShiftsCreateService,
private readonly update_delete_service: ShiftsUpdateDeleteService,
){}
) { }
@Post('create')
createBatch( @Req() req, @Body()dtos: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.timesheets)
createBatch(@Access('email') email: string, @Body() dtos: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
return this.create_service.createOneOrManyShifts(email, dtos)
}
@Patch('update')
updateBatch( @Body() dtos: ShiftDto[], @Req() req): Promise<Result<ShiftDto[], string>>{
const email = req.user?.email;
@ModuleAccessAllowed(ModulesEnum.timesheets)
updateBatch(@Access('email') email: string, @Body() dtos: ShiftDto[]): Promise<Result<ShiftDto[], string>> {
return this.update_delete_service.updateOneOrManyShifts(dtos, email);
}
@Delete(':shift_id')
remove(@Param('shift_id') shift_id: number ): Promise<Result<number, string>> {
@ModuleAccessAllowed(ModulesEnum.timesheets)
remove(@Param('shift_id') shift_id: number): Promise<Result<number, string>> {
return this.update_delete_service.deleteShift(shift_id);
}
}

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query, Req, UnauthorizedException } from "@nestjs/common";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { GetTimesheetsOverviewService } from "src/time-and-attendance/timesheets/services/timesheet-get-overview.service";
import { TimesheetApprovalService } from "src/time-and-attendance/timesheets/services/timesheet-approval.service";
import { GLOBAL_CONTROLLER_ROLES, MANAGER_ROLES } from "src/common/shared/role-groupes";
import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators";
import { Modules as ModulesEnum } from ".prisma/client";
import { Access } from "src/common/decorators/module-access.decorators";
@Controller('timesheets')
@RolesAllowed(...GLOBAL_CONTROLLER_ROLES)
export class TimesheetController {
constructor(
private readonly timesheetOverview: GetTimesheetsOverviewService,
@ -14,19 +14,18 @@ export class TimesheetController {
) { }
@Get(':year/:period_number')
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
getTimesheetByPayPeriod(
@Req() req,
@Access('email') email: string,
@Param('year', ParseIntPipe) year: number,
@Param('period_number', ParseIntPipe) period_number: number,
@Query('employee_email') employee_email?: string,
) {
const email = req.user?.email;
if (!email) throw new UnauthorizedException('Unauthorized User');
return this.timesheetOverview.getTimesheetsForEmployeeByPeriod(email, year, period_number, employee_email);
}
@Patch('timesheet-approval')
@RolesAllowed(...MANAGER_ROLES)
@ModuleAccessAllowed(ModulesEnum.timesheets_approval)
approveTimesheet(
@Body('timesheet_id', ParseIntPipe) timesheet_id: number,
@Body('is_approved', ParseBoolPipe) is_approved: boolean,