diff --git a/src/guards/job-access.guard.ts b/src/guards/job-access.guard.ts new file mode 100644 index 000000000..b15ea0001 --- /dev/null +++ b/src/guards/job-access.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from '../modules/jobs/entities/job.entity'; +import { User } from '@modules/user/entities/user.entity'; + +@Injectable() +export class JobAccessGuard implements CanActivate { + constructor( + @InjectRepository(Job) + private readonly jobRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const jobId = request.params.id; + const userId = request.user.sub; + + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (user?.is_superadmin) { + return true; + } + + const job = await this.jobRepository.findOne({ + where: { id: jobId }, + relations: ['user'], + }); + + if (!job) return false; + return job.user.id === userId; + } +} diff --git a/src/modules/invite/mocks/mockUser.ts b/src/modules/invite/mocks/mockUser.ts index 6e820f3b5..38e1b3bb1 100644 --- a/src/modules/invite/mocks/mockUser.ts +++ b/src/modules/invite/mocks/mockUser.ts @@ -6,6 +6,7 @@ export const mockUser: User = { first_name: 'John', last_name: 'Doe', is_active: true, + is_superadmin: false, phone: '+1234567890', status: 'Hello from the children of planet Earth', id: 'some-uuid-value-here', diff --git a/src/modules/jobs/jobs.controller.ts b/src/modules/jobs/jobs.controller.ts index 0185af653..7d772944b 100644 --- a/src/modules/jobs/jobs.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -36,6 +36,7 @@ import { JobSearchDto } from './dto/jobSearch.dto'; import { UpdateJobDto } from './dto/update-job.dto'; import { JobOwnerGuard } from '../../guards/job-owner.guard'; import { AuthGuard } from '../../guards/auth.guard'; +import { JobAccessGuard } from '@guards/job-access.guard'; @ApiTags('Jobs') @Controller('jobs') @@ -110,8 +111,7 @@ export class JobsController { } @Patch('/:id') - @UseGuards(AuthGuard) - @UseGuards(SuperAdminGuard, JobOwnerGuard) + @UseGuards(AuthGuard, JobAccessGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Update a job posting' }) @ApiResponse({ status: 200, description: 'Job updated successfully' }) diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts index 02560008b..59c4cd3ba 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -10,16 +10,15 @@ import { OrganisationUserRole } from '@modules/role/entities/organisation-user-r import { Profile } from '@modules/profile/entities/profile.entity'; import { Role } from '@modules/role/entities/role.entity'; import { UserModule } from '@modules/user/user.module'; -import { JobOwnerGuard } from '@guards/job-owner.guard'; import { AuthGuard } from '@guards/auth.guard'; -import { SuperAdminGuard } from '@guards/super-admin.guard'; +import { JobAccessGuard } from '@guards/job-access.guard'; @Module({ imports: [ TypeOrmModule.forFeature([Job, User, JobApplication, Organisation, OrganisationUserRole, Profile, Role]), UserModule, ], - providers: [JobsService, JobOwnerGuard, AuthGuard, SuperAdminGuard], + providers: [JobsService, JobAccessGuard, AuthGuard], controllers: [JobsController], }) export class JobsModule {} diff --git a/src/modules/jobs/jobs.service.ts b/src/modules/jobs/jobs.service.ts index 50c93025b..c82561909 100644 --- a/src/modules/jobs/jobs.service.ts +++ b/src/modules/jobs/jobs.service.ts @@ -169,16 +169,21 @@ export class JobsService { this.validateUserId(userId); this.validateUpdateData(updateJobDto); - const job = await this.jobRepository.findOne({ - where: { id }, - relations: ['user'], - }); + const [job, user] = await Promise.all([ + this.jobRepository.findOne({ + where: { id }, + relations: ['user'], + }), + this.userRepository.findOne({ + where: { id: userId }, + }), + ]); if (!job) { throw new CustomHttpException('Job not found', HttpStatus.NOT_FOUND); } - if (job.user.id !== userId) { + if (job.user.id !== userId && !user?.is_superadmin) { throw new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN); } diff --git a/src/modules/jobs/tests/jobs.service.spec.ts b/src/modules/jobs/tests/jobs.service.spec.ts index 542da41a0..fc93b2da5 100644 --- a/src/modules/jobs/tests/jobs.service.spec.ts +++ b/src/modules/jobs/tests/jobs.service.spec.ts @@ -343,7 +343,12 @@ describe('JobsService', () => { user: { ...mockUser, id: 'different_user_id' } as User, } as Job; + // Mock both repository calls jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + jest.spyOn(userRepository, 'findOne').mockResolvedValue({ + ...mockUser, + is_superadmin: false, + } as User); await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow( new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN) @@ -392,7 +397,12 @@ describe('JobsService', () => { user: { ...mockUser, id: 'different_user_id' } as User, } as Job; + // Mock both repository calls jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + jest.spyOn(userRepository, 'findOne').mockResolvedValue({ + ...mockUser, + is_superadmin: false, + } as User); await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow( new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN) @@ -455,5 +465,52 @@ describe('JobsService', () => { }) ); }); + + it('should allow super admin to update any job', async () => { + const superAdminUser = { + ...mockUser, + is_superadmin: true, + id: 'super_admin_id', + }; + + const jobOwnedByOtherUser = { + ...jobsMock[0], + user: { ...mockUser, id: 'other_user_id' } as User, + } as Job; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(jobOwnedByOtherUser); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(superAdminUser as User); + jest.spyOn(jobRepository, 'save').mockResolvedValue({ + ...jobOwnedByOtherUser, + ...updateDto, + } as Job); + + const result = await service.update('job-id', updateDto, 'super_admin_id'); + + expect(result.status).toBe('success'); + expect(result.status_code).toBe(200); + expect((result.data as Job).title).toBe(updateDto.title); + }); + + it('should not allow non-owner non-admin user to update job', async () => { + const regularUser = { + ...mockUser, + is_superadmin: false, + id: 'regular_user_id', + }; + + const jobOwnedByOtherUser = { + ...jobsMock[0], + user: { ...mockUser, id: 'other_user_id' } as User, + } as Job; + + // Mock both repository calls + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(jobOwnedByOtherUser); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(regularUser as User); + + await expect(service.update('job-id', updateDto, 'regular_user_id')).rejects.toThrow( + new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN) + ); + }); }); }); diff --git a/src/modules/notifications/tests/mocks/notification-repo.mock.ts b/src/modules/notifications/tests/mocks/notification-repo.mock.ts index bcb055764..8691f6dc4 100644 --- a/src/modules/notifications/tests/mocks/notification-repo.mock.ts +++ b/src/modules/notifications/tests/mocks/notification-repo.mock.ts @@ -42,6 +42,7 @@ export const mockUser: User = { status: 'Hello from the children of planet Earth', hashPassword: async () => {}, is_active: true, + is_superadmin: false, attempts_left: 3, time_left: 3600, owned_organisations: [], diff --git a/src/modules/organisations/tests/mocks/organisation.mock.ts b/src/modules/organisations/tests/mocks/organisation.mock.ts index d0e3cf33e..68ede8009 100644 --- a/src/modules/organisations/tests/mocks/organisation.mock.ts +++ b/src/modules/organisations/tests/mocks/organisation.mock.ts @@ -46,6 +46,7 @@ export const createMockOrganisation = (): Organisation => { phone: '+1234567890', hashPassword: async () => {}, is_active: true, + is_superadmin: false, attempts_left: 3, time_left: 3600, owned_organisations: [], diff --git a/src/modules/organisations/tests/mocks/user.mock.ts b/src/modules/organisations/tests/mocks/user.mock.ts index 776bc3928..1914798e5 100644 --- a/src/modules/organisations/tests/mocks/user.mock.ts +++ b/src/modules/organisations/tests/mocks/user.mock.ts @@ -33,4 +33,5 @@ export const mockUser = { comments: null, cart: [], organisations: null, + is_superadmin: false, }; diff --git a/src/modules/profile/mocks/mockUser.ts b/src/modules/profile/mocks/mockUser.ts index 1e1b291c6..490f625c5 100644 --- a/src/modules/profile/mocks/mockUser.ts +++ b/src/modules/profile/mocks/mockUser.ts @@ -10,6 +10,7 @@ export const mockUserWithProfile: User = { first_name: 'John', last_name: 'Doe', is_active: true, + is_superadmin: false, phone: '+1234567891', id: 'some-uuid-value-here', attempts_left: 2, diff --git a/src/modules/user/entities/user.entity.ts b/src/modules/user/entities/user.entity.ts index ef0e40209..5738e6e42 100644 --- a/src/modules/user/entities/user.entity.ts +++ b/src/modules/user/entities/user.entity.ts @@ -67,6 +67,9 @@ export class User extends AbstractBaseEntity { @Column({ default: false }) is_2fa_enabled: boolean; + @Column({ default: false }) + is_superadmin: boolean; + @DeleteDateColumn({ nullable: true }) deletedAt?: Date; diff --git a/src/modules/user/tests/mocks/user.mock.ts b/src/modules/user/tests/mocks/user.mock.ts index 9f9dfd58e..8c2806521 100644 --- a/src/modules/user/tests/mocks/user.mock.ts +++ b/src/modules/user/tests/mocks/user.mock.ts @@ -25,4 +25,5 @@ export const mockUser: User = { hashPassword: () => null, cart: [], organisations: null, + is_superadmin: false, };