From eaa099d0d094eb1c4200a643417576edd4cc05a1 Mon Sep 17 00:00:00 2001 From: Oseji Fabian Daniel Date: Sun, 28 Jun 2026 15:49:46 +0100 Subject: [PATCH] feat(rbac): add audit log entries for RBAC role and permission events (#833) --- src/audit-log/enums/audit-action.enum.ts | 11 + .../permissions/permissions.service.spec.ts | 149 +++++++++++ src/rbac/permissions/permissions.service.ts | 62 ++++- src/rbac/rbac.module.ts | 3 +- src/rbac/roles/roles.service.spec.ts | 250 ++++++++++++++++++ src/rbac/roles/roles.service.ts | 113 +++++++- 6 files changed, 580 insertions(+), 8 deletions(-) create mode 100644 src/rbac/permissions/permissions.service.spec.ts create mode 100644 src/rbac/roles/roles.service.spec.ts diff --git a/src/audit-log/enums/audit-action.enum.ts b/src/audit-log/enums/audit-action.enum.ts index b0227f37..94517caa 100644 --- a/src/audit-log/enums/audit-action.enum.ts +++ b/src/audit-log/enums/audit-action.enum.ts @@ -39,6 +39,17 @@ export enum AuditAction { MFA_ENABLED = 'MFA_ENABLED', MFA_DISABLED = 'MFA_DISABLED', MFA_FAILED = 'MFA_FAILED', + // RBAC events + RBAC_ROLE_ASSIGNED = 'RBAC_ROLE_ASSIGNED', + RBAC_ROLE_REVOKED = 'RBAC_ROLE_REVOKED', + RBAC_ROLE_CREATED = 'RBAC_ROLE_CREATED', + RBAC_ROLE_UPDATED = 'RBAC_ROLE_UPDATED', + RBAC_ROLE_DELETED = 'RBAC_ROLE_DELETED', + RBAC_PERMISSION_GRANTED = 'RBAC_PERMISSION_GRANTED', + RBAC_PERMISSION_REVOKED = 'RBAC_PERMISSION_REVOKED', + RBAC_PERMISSION_CREATED = 'RBAC_PERMISSION_CREATED', + RBAC_PERMISSION_UPDATED = 'RBAC_PERMISSION_UPDATED', + RBAC_PERMISSION_DELETED = 'RBAC_PERMISSION_DELETED', // Admin operations CONFIG_CHANGED = 'CONFIG_CHANGED', SETTING_UPDATED = 'SETTING_UPDATED', diff --git a/src/rbac/permissions/permissions.service.spec.ts b/src/rbac/permissions/permissions.service.spec.ts new file mode 100644 index 00000000..b913e043 --- /dev/null +++ b/src/rbac/permissions/permissions.service.spec.ts @@ -0,0 +1,149 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { PermissionsService } from './permissions.service'; +import { Permission } from '../entities/permission.entity'; +import { AuditLogService } from '../../audit-log/audit-log.service'; +import { AuditAction, AuditCategory, AuditSeverity } from '../../audit-log/enums/audit-action.enum'; + +const mockPermission: Permission = { + id: 'perm-1', + resource: 'courses', + action: 'read', + description: 'Read courses', +} as Permission; + +const mockAuditLog = { id: 'audit-1' } as any; + +describe('PermissionsService', () => { + let service: PermissionsService; + let permRepo: any; + let auditLogService: AuditLogService; + + beforeEach(async () => { + permRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionsService, + { provide: getRepositoryToken(Permission), useValue: permRepo }, + { + provide: AuditLogService, + useValue: { log: jest.fn().mockResolvedValue(mockAuditLog) }, + }, + ], + }).compile(); + + service = module.get(PermissionsService); + auditLogService = module.get(AuditLogService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ── createPermission ─────────────────────────────────────────────────────── + + describe('createPermission', () => { + it('should create a permission and emit RBAC_PERMISSION_CREATED audit log', async () => { + permRepo.create.mockReturnValue(mockPermission); + permRepo.save.mockResolvedValue(mockPermission); + + await service.createPermission('courses', 'read', 'Read courses', 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_PERMISSION_CREATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: 'actor-1', + entityType: 'Permission', + entityId: mockPermission.id, + }), + ); + }); + + it('should still create a permission without an actorId', async () => { + permRepo.create.mockReturnValue(mockPermission); + permRepo.save.mockResolvedValue(mockPermission); + + const result = await service.createPermission('courses', 'read'); + + expect(result).toEqual(mockPermission); + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ action: AuditAction.RBAC_PERMISSION_CREATED }), + ); + }); + }); + + // ── updatePermission ─────────────────────────────────────────────────────── + + describe('updatePermission', () => { + it('should update a permission and emit RBAC_PERMISSION_UPDATED audit log', async () => { + permRepo.findOneBy + .mockResolvedValueOnce(mockPermission) // initial fetch for oldValues + .mockResolvedValueOnce({ ...mockPermission, action: 'write' }); // post-update fetch + permRepo.update.mockResolvedValue({ affected: 1 }); + + await service.updatePermission('perm-1', 'courses', 'write', undefined, 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_PERMISSION_UPDATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: 'actor-1', + entityType: 'Permission', + entityId: 'perm-1', + oldValues: expect.objectContaining({ action: 'read' }), + newValues: expect.objectContaining({ action: 'write' }), + }), + ); + }); + + it('should throw NotFoundException when permission does not exist', async () => { + permRepo.findOneBy.mockResolvedValue(null); + + await expect(service.updatePermission('missing', 'x', 'y')).rejects.toThrow( + NotFoundException, + ); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); + + // ── deletePermission ─────────────────────────────────────────────────────── + + describe('deletePermission', () => { + it('should delete a permission and emit RBAC_PERMISSION_DELETED audit log', async () => { + permRepo.findOneBy.mockResolvedValue(mockPermission); + permRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.deletePermission('perm-1', 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_PERMISSION_DELETED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: 'actor-1', + entityType: 'Permission', + entityId: 'perm-1', + }), + ); + }); + + it('should throw NotFoundException when permission does not exist', async () => { + permRepo.findOneBy.mockResolvedValue(null); + + await expect(service.deletePermission('missing')).rejects.toThrow(NotFoundException); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/rbac/permissions/permissions.service.ts b/src/rbac/permissions/permissions.service.ts index ea2cde13..1a22bd5f 100644 --- a/src/rbac/permissions/permissions.service.ts +++ b/src/rbac/permissions/permissions.service.ts @@ -2,25 +2,42 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Permission } from '../entities/permission.entity'; +import { AuditLogService } from '../../audit-log/audit-log.service'; +import { AuditAction, AuditCategory, AuditSeverity } from '../../audit-log/enums/audit-action.enum'; @Injectable() export class PermissionsService { constructor( @InjectRepository(Permission) private readonly permissionRepository: Repository, + private readonly auditLogService: AuditLogService, ) {} async createPermission( resource: string, action: string, description?: string, + actorId?: string, ): Promise { const permission = this.permissionRepository.create({ resource, action, description, }); - return this.permissionRepository.save(permission); + const saved = await this.permissionRepository.save(permission); + + await this.auditLogService.log({ + action: AuditAction.RBAC_PERMISSION_CREATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: actorId, + entityType: 'Permission', + entityId: saved.id, + description: `Permission "${action}" on "${resource}" created`, + metadata: { resource, action: action, description }, + }); + + return saved; } async findAllPermissions(): Promise { @@ -40,19 +57,60 @@ export class PermissionsService { resource: string, action: string, description?: string, + actorId?: string, ): Promise { + const existing = await this.permissionRepository.findOneBy({ id }); + if (!existing) { + throw new NotFoundException(`Permission with ID ${id} not found`); + } + + const oldValues = { + resource: existing.resource, + action: existing.action, + description: existing.description, + }; + await this.permissionRepository.update(id, { resource, action, description }); const updated = await this.permissionRepository.findOneBy({ id }); if (!updated) { throw new NotFoundException(`Permission with ID ${id} not found`); } + + await this.auditLogService.log({ + action: AuditAction.RBAC_PERMISSION_UPDATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: actorId, + entityType: 'Permission', + entityId: id, + description: `Permission "${action}" on "${resource}" updated`, + oldValues, + newValues: { resource, action: action, description }, + }); + return updated; } - async deletePermission(id: string): Promise { + async deletePermission(id: string, actorId?: string): Promise { + const existing = await this.permissionRepository.findOneBy({ id }); + if (!existing) { + throw new NotFoundException(`Permission with ID ${id} not found`); + } + const result = await this.permissionRepository.delete(id); if (result.affected === 0) { throw new NotFoundException(`Permission with ID ${id} not found`); } + + await this.auditLogService.log({ + action: AuditAction.RBAC_PERMISSION_DELETED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: actorId, + entityType: 'Permission', + entityId: id, + description: `Permission "${existing.action}" on "${existing.resource}" deleted`, + metadata: { resource: existing.resource, action: existing.action }, + }); } } diff --git a/src/rbac/rbac.module.ts b/src/rbac/rbac.module.ts index 9d325450..9172b27f 100644 --- a/src/rbac/rbac.module.ts +++ b/src/rbac/rbac.module.ts @@ -6,9 +6,10 @@ import { PermissionsController } from './permissions/permissions.controller'; import { PermissionsService } from './permissions/permissions.service'; import { RolesController } from './roles/roles.controller'; import { RolesService } from './roles/roles.service'; +import { AuditLogModule } from '../audit-log/audit-log.module'; @Module({ - imports: [TypeOrmModule.forFeature([Permission, Role])], + imports: [TypeOrmModule.forFeature([Permission, Role]), AuditLogModule], controllers: [PermissionsController, RolesController], providers: [PermissionsService, RolesService], exports: [TypeOrmModule], diff --git a/src/rbac/roles/roles.service.spec.ts b/src/rbac/roles/roles.service.spec.ts new file mode 100644 index 00000000..d7ad3728 --- /dev/null +++ b/src/rbac/roles/roles.service.spec.ts @@ -0,0 +1,250 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { RolesService } from './roles.service'; +import { Role } from '../entities/role.entity'; +import { Permission } from '../entities/permission.entity'; +import { AuditLogService } from '../../audit-log/audit-log.service'; +import { AuditAction, AuditCategory, AuditSeverity } from '../../audit-log/enums/audit-action.enum'; + +const mockRole: Role = { + id: 'role-1', + name: 'admin', + description: 'Administrator role', + permissions: [], +} as Role; + +const mockPermission: Permission = { + id: 'perm-1', + resource: 'courses', + action: 'read', + description: 'Read courses', +} as Permission; + +const mockAuditLog = { id: 'audit-1' } as any; + +describe('RolesService', () => { + let service: RolesService; + let roleRepo: any; + let permRepo: any; + let auditLogService: AuditLogService; + + beforeEach(async () => { + const queryBuilderMock = { + relation: jest.fn().mockReturnThis(), + of: jest.fn().mockReturnThis(), + set: jest.fn().mockResolvedValue(undefined), + }; + + roleRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findByIds: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(queryBuilderMock), + }; + + permRepo = { + findByIds: jest.fn(), + findOneBy: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RolesService, + { provide: getRepositoryToken(Role), useValue: roleRepo }, + { provide: getRepositoryToken(Permission), useValue: permRepo }, + { + provide: AuditLogService, + useValue: { log: jest.fn().mockResolvedValue(mockAuditLog) }, + }, + ], + }).compile(); + + service = module.get(RolesService); + auditLogService = module.get(AuditLogService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ── createRole ───────────────────────────────────────────────────────────── + + describe('createRole', () => { + it('should create a role and emit RBAC_ROLE_CREATED audit log', async () => { + roleRepo.create.mockReturnValue(mockRole); + roleRepo.save.mockResolvedValue(mockRole); + + await service.createRole('admin', 'Administrator role', [], 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_ROLE_CREATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: 'actor-1', + entityType: 'Role', + entityId: mockRole.id, + }), + ); + }); + + it('should still create a role without an actorId', async () => { + roleRepo.create.mockReturnValue(mockRole); + roleRepo.save.mockResolvedValue(mockRole); + + const result = await service.createRole('admin'); + + expect(result).toEqual(mockRole); + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ action: AuditAction.RBAC_ROLE_CREATED }), + ); + }); + }); + + // ── updateRole ───────────────────────────────────────────────────────────── + + describe('updateRole', () => { + it('should update a role and emit RBAC_ROLE_UPDATED audit log', async () => { + roleRepo.findOne + .mockResolvedValueOnce({ ...mockRole, permissions: [] }) // initial fetch for oldValues + .mockResolvedValueOnce({ ...mockRole, name: 'superadmin' }); // post-update fetch + roleRepo.update.mockResolvedValue({ affected: 1 }); + + await service.updateRole('role-1', 'superadmin', undefined, undefined, 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_ROLE_UPDATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: 'actor-1', + entityType: 'Role', + entityId: 'role-1', + }), + ); + }); + + it('should throw NotFoundException when role does not exist', async () => { + roleRepo.findOne.mockResolvedValue(null); + + await expect(service.updateRole('missing', 'x')).rejects.toThrow(NotFoundException); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); + + // ── deleteRole ───────────────────────────────────────────────────────────── + + describe('deleteRole', () => { + it('should delete a role and emit RBAC_ROLE_DELETED audit log', async () => { + roleRepo.findOne.mockResolvedValue(mockRole); + roleRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.deleteRole('role-1', 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_ROLE_DELETED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: 'actor-1', + entityType: 'Role', + entityId: 'role-1', + }), + ); + }); + + it('should throw NotFoundException when role does not exist', async () => { + roleRepo.findOne.mockResolvedValue(null); + + await expect(service.deleteRole('missing')).rejects.toThrow(NotFoundException); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); + + // ── addPermissionToRole ──────────────────────────────────────────────────── + + describe('addPermissionToRole', () => { + it('should grant a permission to a role and emit RBAC_PERMISSION_GRANTED audit log', async () => { + roleRepo.findOne.mockResolvedValue({ ...mockRole, permissions: [] }); + permRepo.findOneBy.mockResolvedValue(mockPermission); + roleRepo.save.mockResolvedValue({ ...mockRole, permissions: [mockPermission] }); + + await service.addPermissionToRole('role-1', 'perm-1', 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_PERMISSION_GRANTED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: 'actor-1', + entityType: 'Role', + entityId: 'role-1', + }), + ); + }); + + it('should NOT emit audit log when permission already assigned', async () => { + roleRepo.findOne.mockResolvedValue({ ...mockRole, permissions: [mockPermission] }); + permRepo.findOneBy.mockResolvedValue(mockPermission); + + await service.addPermissionToRole('role-1', 'perm-1', 'actor-1'); + + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException when role does not exist', async () => { + roleRepo.findOne.mockResolvedValue(null); + + await expect(service.addPermissionToRole('missing', 'perm-1')).rejects.toThrow( + NotFoundException, + ); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException when permission does not exist', async () => { + roleRepo.findOne.mockResolvedValue({ ...mockRole, permissions: [] }); + permRepo.findOneBy.mockResolvedValue(null); + + await expect(service.addPermissionToRole('role-1', 'missing')).rejects.toThrow( + NotFoundException, + ); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); + + // ── removePermissionFromRole ─────────────────────────────────────────────── + + describe('removePermissionFromRole', () => { + it('should revoke a permission from a role and emit RBAC_PERMISSION_REVOKED audit log', async () => { + roleRepo.findOne.mockResolvedValue({ ...mockRole, permissions: [mockPermission] }); + roleRepo.save.mockResolvedValue({ ...mockRole, permissions: [] }); + + await service.removePermissionFromRole('role-1', 'perm-1', 'actor-1'); + + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + action: AuditAction.RBAC_PERMISSION_REVOKED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: 'actor-1', + entityType: 'Role', + entityId: 'role-1', + }), + ); + }); + + it('should throw NotFoundException when role does not exist', async () => { + roleRepo.findOne.mockResolvedValue(null); + + await expect(service.removePermissionFromRole('missing', 'perm-1')).rejects.toThrow( + NotFoundException, + ); + expect(auditLogService.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/rbac/roles/roles.service.ts b/src/rbac/roles/roles.service.ts index 3cda1be4..cf86ccd8 100644 --- a/src/rbac/roles/roles.service.ts +++ b/src/rbac/roles/roles.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Role } from '../entities/role.entity'; import { Permission } from '../entities/permission.entity'; +import { AuditLogService } from '../../audit-log/audit-log.service'; +import { AuditAction, AuditCategory, AuditSeverity } from '../../audit-log/enums/audit-action.enum'; @Injectable() export class RolesService { @@ -11,9 +13,15 @@ export class RolesService { private readonly roleRepository: Repository, @InjectRepository(Permission) private readonly permissionRepository: Repository, + private readonly auditLogService: AuditLogService, ) {} - async createRole(name: string, description?: string, permissionIds?: string[]): Promise { + async createRole( + name: string, + description?: string, + permissionIds?: string[], + actorId?: string, + ): Promise { const role = this.roleRepository.create({ name, description, @@ -24,7 +32,20 @@ export class RolesService { role.permissions = permissions; } - return this.roleRepository.save(role); + const saved = await this.roleRepository.save(role); + + await this.auditLogService.log({ + action: AuditAction.RBAC_ROLE_CREATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: actorId, + entityType: 'Role', + entityId: saved.id, + description: `Role "${name}" created`, + metadata: { roleName: name, description, permissionIds }, + }); + + return saved; } async findAllRoles(): Promise { @@ -44,7 +65,19 @@ export class RolesService { name: string, description?: string, permissionIds?: string[], + actorId?: string, ): Promise { + const existing = await this.roleRepository.findOne({ where: { id }, relations: ['permissions'] }); + if (!existing) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + + const oldValues = { + name: existing.name, + description: existing.description, + permissionIds: existing.permissions?.map((p) => p.id) ?? [], + }; + await this.roleRepository.update(id, { name, description }); if (permissionIds !== undefined) { @@ -63,17 +96,50 @@ export class RolesService { if (!updated) { throw new NotFoundException(`Role with ID ${id} not found`); } + + await this.auditLogService.log({ + action: AuditAction.RBAC_ROLE_UPDATED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: actorId, + entityType: 'Role', + entityId: id, + description: `Role "${name}" updated`, + oldValues, + newValues: { name, description, permissionIds }, + }); + return updated; } - async deleteRole(id: string): Promise { + async deleteRole(id: string, actorId?: string): Promise { + const existing = await this.roleRepository.findOne({ where: { id } }); + if (!existing) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + const result = await this.roleRepository.delete(id); if (result.affected === 0) { throw new NotFoundException(`Role with ID ${id} not found`); } + + await this.auditLogService.log({ + action: AuditAction.RBAC_ROLE_DELETED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: actorId, + entityType: 'Role', + entityId: id, + description: `Role "${existing.name}" deleted`, + metadata: { roleName: existing.name }, + }); } - async addPermissionToRole(roleId: string, permissionId: string): Promise { + async addPermissionToRole( + roleId: string, + permissionId: string, + actorId?: string, + ): Promise { const role = await this.roleRepository.findOne({ where: { id: roleId }, relations: ['permissions'], @@ -90,12 +156,32 @@ export class RolesService { if (!role.permissions.some((p) => p.id === permission.id)) { role.permissions.push(permission); await this.roleRepository.save(role); + + await this.auditLogService.log({ + action: AuditAction.RBAC_PERMISSION_GRANTED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.INFO, + userId: actorId, + entityType: 'Role', + entityId: roleId, + description: `Permission "${permission.action}" on "${permission.resource}" granted to role "${role.name}"`, + metadata: { + roleName: role.name, + permissionId, + permissionResource: permission.resource, + permissionAction: permission.action, + }, + }); } return role; } - async removePermissionFromRole(roleId: string, permissionId: string): Promise { + async removePermissionFromRole( + roleId: string, + permissionId: string, + actorId?: string, + ): Promise { const role = await this.roleRepository.findOne({ where: { id: roleId }, relations: ['permissions'], @@ -104,9 +190,26 @@ export class RolesService { throw new NotFoundException(`Role with ID ${roleId} not found`); } + const permission = role.permissions.find((p) => p.id === permissionId); role.permissions = role.permissions.filter((p) => p.id !== permissionId); await this.roleRepository.save(role); + await this.auditLogService.log({ + action: AuditAction.RBAC_PERMISSION_REVOKED, + category: AuditCategory.AUTHORIZATION, + severity: AuditSeverity.WARNING, + userId: actorId, + entityType: 'Role', + entityId: roleId, + description: `Permission "${permission?.action ?? permissionId}" on "${permission?.resource ?? 'unknown'}" revoked from role "${role.name}"`, + metadata: { + roleName: role.name, + permissionId, + permissionResource: permission?.resource, + permissionAction: permission?.action, + }, + }); + return role; } }