From 813e73a0a3902a29a3ade79ed05e3ec91bbae556 Mon Sep 17 00:00:00 2001 From: underwater Date: Sat, 2 Dec 2023 10:21:19 +0100 Subject: [PATCH] Introduce HasPermission annotation (#2693) * Introduce HasPermission annotation * Update changelog --- CHANGELOG.md | 1 + .../decorators/has-permission.decorator.ts | 6 ++ .../src/guards/has-permission.guard.spec.ts | 55 +++++++++++++++++++ apps/api/src/guards/has-permission.guard.ts | 37 +++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 apps/api/src/decorators/has-permission.decorator.ts create mode 100644 apps/api/src/guards/has-permission.guard.spec.ts create mode 100644 apps/api/src/guards/has-permission.guard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a865536aa..2a5a5dc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a historical cash balances table to the account detail dialog +- Introduced a `HasPermission` annotation for endpoints ### Changed diff --git a/apps/api/src/decorators/has-permission.decorator.ts b/apps/api/src/decorators/has-permission.decorator.ts new file mode 100644 index 000000000..dc65cf82e --- /dev/null +++ b/apps/api/src/decorators/has-permission.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +export const HAS_PERMISSION_KEY = 'has_permission'; + +export function HasPermission(permission: string) { + return SetMetadata(HAS_PERMISSION_KEY, permission); +} diff --git a/apps/api/src/guards/has-permission.guard.spec.ts b/apps/api/src/guards/has-permission.guard.spec.ts new file mode 100644 index 000000000..7f5f90de9 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.spec.ts @@ -0,0 +1,55 @@ +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { HasPermissionGuard } from './has-permission.guard'; + +describe('HasPermissionGuard', () => { + let guard: HasPermissionGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HasPermissionGuard, Reflector] + }).compile(); + + guard = module.get(HasPermissionGuard); + reflector = module.get(Reflector); + }); + + function setupReflectorSpy(returnValue: string) { + jest.spyOn(reflector, 'get').mockReturnValue(returnValue); + } + + function createMockExecutionContext(permissions: string[]) { + return new ExecutionContextHost([ + { + user: { + permissions // Set user permissions based on the argument + } + } + ]); + } + + it('should deny access if the user does not have any permission', () => { + setupReflectorSpy('required-permission'); + const noPermissions = createMockExecutionContext([]); + + expect(() => guard.canActivate(noPermissions)).toThrow(HttpException); + }); + + it('should deny access if the user has the wrong permission', () => { + setupReflectorSpy('required-permission'); + const wrongPermission = createMockExecutionContext(['wrong-permission']); + + expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException); + }); + + it('should allow access if the user has the required permission', () => { + setupReflectorSpy('required-permission'); + const rightPermission = createMockExecutionContext(['required-permission']); + + expect(guard.canActivate(rightPermission)).toBe(true); + }); +}); diff --git a/apps/api/src/guards/has-permission.guard.ts b/apps/api/src/guards/has-permission.guard.ts new file mode 100644 index 000000000..298992d06 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.ts @@ -0,0 +1,37 @@ +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { hasPermission } from '@ghostfolio/common/permissions'; +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class HasPermissionGuard implements CanActivate { + public constructor(private reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredPermission = this.reflector.get( + HAS_PERMISSION_KEY, + context.getHandler() + ); + + if (!requiredPermission) { + return true; // No specific permissions required + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user || !hasPermission(user.permissions, requiredPermission)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +}