Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/audit-log/enums/audit-action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
149 changes: 149 additions & 0 deletions src/rbac/permissions/permissions.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(PermissionsService);
auditLogService = module.get<AuditLogService>(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();
});
});
});
62 changes: 60 additions & 2 deletions src/rbac/permissions/permissions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@
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<Permission>,
private readonly auditLogService: AuditLogService,
) {}

async createPermission(
resource: string,
action: string,
description?: string,
actorId?: string,
): Promise<Permission> {
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 },

Check warning on line 37 in src/rbac/permissions/permissions.service.ts

View workflow job for this annotation

GitHub Actions / validate

Expected property shorthand
});

return saved;
}

async findAllPermissions(): Promise<Permission[]> {
Expand All @@ -40,19 +57,60 @@
resource: string,
action: string,
description?: string,
actorId?: string,
): Promise<Permission> {
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 },

Check warning on line 88 in src/rbac/permissions/permissions.service.ts

View workflow job for this annotation

GitHub Actions / validate

Expected property shorthand
});

return updated;
}

async deletePermission(id: string): Promise<void> {
async deletePermission(id: string, actorId?: string): Promise<void> {
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 },
});
}
}
3 changes: 2 additions & 1 deletion src/rbac/rbac.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading
Loading