From a723d053a2fbd1d94d05796bd7c1813156c496aa Mon Sep 17 00:00:00 2001 From: Alex H Date: Sat, 13 Apr 2024 13:48:16 +0200 Subject: [PATCH 1/2] feat(json-api-nestjs): Add JSON_API_DECORATOR_ENTITY metadata to controller The createController now inserts a JSON_API_DECORATOR_ENTITY metadata to the controller class if it is not already present. --- .../src/lib/helper/create-controller.spec.ts | 11 ++++++++++- .../src/lib/helper/create-controller.ts | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.spec.ts b/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.spec.ts index 2759ce9c..6184a9cc 100644 --- a/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.spec.ts +++ b/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.spec.ts @@ -9,6 +9,7 @@ import { Users } from '../mock-utils'; import { JsonBaseController } from '../mixin/controller/json-base.controller'; import { JSON_API_CONTROLLER_POSTFIX, + JSON_API_DECORATOR_ENTITY, TYPEORM_SERVICE, TYPEORM_SERVICE_PROPS, } from '../constants'; @@ -48,15 +49,23 @@ describe('createController', () => { const result = createController(Users); const result2 = createController(Users, TestController); const result3 = createController(Users, TestController2); - expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result)).toBe('users'); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result)).toEqual( + Users + ); expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result2)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result2)).toBe('users'); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result2)).toEqual( + Users + ); expect(Reflect.getMetadata(CONTROLLER_WATERMARK, result3)).toBe(true); expect(Reflect.getMetadata(PATH_METADATA, result3)).toBe(overrideRoute); + expect(Reflect.getMetadata(JSON_API_DECORATOR_ENTITY, result3)).toEqual( + Users + ); }); it('Check inject typeorm, service', () => { diff --git a/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.ts b/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.ts index d4ca8078..aa651289 100644 --- a/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.ts +++ b/libs/json-api/json-api-nestjs/src/lib/helper/create-controller.ts @@ -4,6 +4,7 @@ import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-clas import { camelToKebab, getProviderName, nameIt } from './utils'; import { JSON_API_CONTROLLER_POSTFIX, + JSON_API_DECORATOR_ENTITY, JSON_API_DECORATOR_OPTIONS, TYPEORM_SERVICE, TYPEORM_SERVICE_PROPS, @@ -24,6 +25,10 @@ export function createController( JsonBaseController ); + if (!Reflect.hasMetadata(JSON_API_DECORATOR_ENTITY, controllerClass)) { + Reflect.defineMetadata(JSON_API_DECORATOR_ENTITY, entity, controllerClass); + } + const entityName = entity instanceof Function ? entity.name : entity.options.name; From fefba0dfc1bd00ded255e4e4df0d203f6a9b5bb3 Mon Sep 17 00:00:00 2001 From: Alex H Date: Sun, 21 Apr 2024 12:20:11 +0200 Subject: [PATCH 2/2] feat(nestjs-acl-permissions): First step Create library and guard service Closes: #83 --- .../nestjs-acl-permissions/.eslintrc.json | 25 ++++ .../nestjs-acl-permissions/README.md | 11 ++ .../nestjs-acl-permissions/jest.config.ts | 11 ++ .../nestjs-acl-permissions/package.json | 10 ++ .../nestjs-acl-permissions/project.json | 24 ++++ .../nestjs-acl-permissions/src/index.ts | 1 + .../src/lib/constants/index.ts | 15 +++ .../get-permission-rules.factory.spec.ts | 49 +++++++ .../factory/get-permission-rules.factory.ts | 49 +++++++ .../src/lib/factory/index.ts | 0 .../src/lib/nestjs-acl-permissions.module.ts | 8 ++ .../casl-ability/casl-ability.service.ts | 4 + .../check-access/check-access.service.spec.ts | 96 +++++++++++++ .../check-access/check-access.service.ts | 63 +++++++++ .../src/lib/service/index.ts | 3 + .../permission/permission.guard.spec.ts | 57 ++++++++ .../service/permission/permission.guard.ts | 24 ++++ .../src/lib/types/acl.ts | 46 +++++++ .../src/lib/types/index.ts | 3 + .../src/lib/types/request-types.ts | 6 + .../src/lib/types/session-users.ts | 12 ++ .../src/lib/utils/asserts-types.ts | 9 ++ .../src/lib/utils/index.ts | 1 + .../nestjs-acl-permissions/tsconfig.json | 22 +++ .../nestjs-acl-permissions/tsconfig.lib.json | 17 +++ .../nestjs-acl-permissions/tsconfig.spec.json | 14 ++ package-lock.json | 43 ++++++ package.json | 1 + test.ts | 127 ++++++++++++++++++ tsconfig.base.json | 3 + 30 files changed, 754 insertions(+) create mode 100644 libs/acl-permissions/nestjs-acl-permissions/.eslintrc.json create mode 100644 libs/acl-permissions/nestjs-acl-permissions/README.md create mode 100644 libs/acl-permissions/nestjs-acl-permissions/jest.config.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/package.json create mode 100644 libs/acl-permissions/nestjs-acl-permissions/project.json create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.spec.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/casl-ability/casl-ability.service.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.spec.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.spec.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/types/request-types.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/types/session-users.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/asserts-types.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts create mode 100644 libs/acl-permissions/nestjs-acl-permissions/tsconfig.json create mode 100644 libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json create mode 100644 libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json create mode 100644 test.ts diff --git a/libs/acl-permissions/nestjs-acl-permissions/.eslintrc.json b/libs/acl-permissions/nestjs-acl-permissions/.eslintrc.json new file mode 100644 index 00000000..c9748d24 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/README.md b/libs/acl-permissions/nestjs-acl-permissions/README.md new file mode 100644 index 00000000..fb00ecc0 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/README.md @@ -0,0 +1,11 @@ +# nestjs-acl-permissions + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build nestjs-acl-permissions` to build the library. + +## Running unit tests + +Run `nx test nestjs-acl-permissions` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/acl-permissions/nestjs-acl-permissions/jest.config.ts b/libs/acl-permissions/nestjs-acl-permissions/jest.config.ts new file mode 100644 index 00000000..196ef6da --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'nestjs-acl-permissions', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/nestjs-acl-permissions', +}; diff --git a/libs/acl-permissions/nestjs-acl-permissions/package.json b/libs/acl-permissions/nestjs-acl-permissions/package.json new file mode 100644 index 00000000..cc7e834e --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/package.json @@ -0,0 +1,10 @@ +{ + "name": "@klerick/acl-json-api-nestjs", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts" +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/project.json b/libs/acl-permissions/nestjs-acl-permissions/project.json new file mode 100644 index 00000000..8f4c433e --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/project.json @@ -0,0 +1,24 @@ +{ + "name": "nestjs-acl-permissions", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/nestjs-acl-permissions/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/nestjs-acl-permissions", + "tsConfig": "libs/nestjs-acl-permissions/tsconfig.lib.json", + "packageJson": "libs/nestjs-acl-permissions/package.json", + "main": "libs/nestjs-acl-permissions/src/index.ts", + "assets": ["libs/nestjs-acl-permissions/*.md"] + } + }, + "publish": { + "command": "node tools/scripts/publish.mjs nestjs-acl-permissions {args.ver} {args.tag}", + "dependsOn": ["build"] + } + }, + "tags": [] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/index.ts new file mode 100644 index 00000000..3df331c1 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/index.ts @@ -0,0 +1 @@ +export * from './lib/nestjs-acl-permissions.module'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts new file mode 100644 index 00000000..f170d4ca --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/constants/index.ts @@ -0,0 +1,15 @@ +import { + Actions, + Method, + MethodActionMap as MethodActionMapType, +} from '../types'; + +export const IS_PUBLIC_META_KEY = Symbol('IS_PUBLIC_META_KEY'); +export const GET_PERMISSION_RULES = Symbol('GET_PERMISSION_RULES'); + +export const MethodActionMap: MethodActionMapType = { + [Method.DELETE]: Actions.delete, + [Method.GET]: Actions.read, + [Method.PATCH]: Actions.update, + [Method.POST]: Actions.create, +}; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.spec.ts new file mode 100644 index 00000000..935174fa --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.spec.ts @@ -0,0 +1,49 @@ +import { getPermissionRules } from './get-permission-rules.factory'; +import { Actions, PermissionRule } from '../types'; + +describe('UserPermissionRulesService', () => { + describe('getPermissionRules method', () => { + it('should return a correct set of rules', () => { + const mockPermissionRule: PermissionRule = { + defaultRules: { + subject1: { + [Actions.create]: true, + [Actions.delete]: true, + [Actions.update]: true, + [Actions.read]: true, + }, + }, + customRules: { + subject1: [ + { + permission: 'can', + condition: { + id: '${currentUser.id}', + }, + action: Actions.update, + }, + ], + subject2: [ + { + permission: 'can', + action: Actions.create, + }, + ], + }, + }; + const rules = getPermissionRules(mockPermissionRule); + expect(rules).toEqual([ + { action: 'create', subject: 'subject1' }, + { action: 'delete', subject: 'subject1' }, + { action: 'update', subject: 'subject1' }, + { action: 'read', subject: 'subject1' }, + { + action: 'update', + subject: 'subject1', + conditions: { id: '${currentUser.id}' }, + }, + { action: 'create', subject: 'subject2' }, + ]); + }); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.ts new file mode 100644 index 00000000..37aeff10 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/get-permission-rules.factory.ts @@ -0,0 +1,49 @@ +import { AbilityBuilder, createMongoAbility, RawRuleOf } from '@casl/ability'; +import { ValueProvider } from '@nestjs/common'; + +import { AbilityRules, Actions, PermissionRule } from '../types'; +import { GET_PERMISSION_RULES } from '../constants'; + +export function getPermissionRules( + permission: PermissionRule +): RawRuleOf[] { + const abilityBuilder = new AbilityBuilder(createMongoAbility); + + const defaultRules = Object.entries(permission.defaultRules).reduce< + Required['customRules'] + >((acum, [subject, rules]) => { + acum[subject] = Object.entries(rules).map(([action, permission]) => ({ + permission: permission ? 'can' : 'cannot', + action: action as Actions, + })); + return acum; + }, {}); + + const resultRules = Object.entries(permission.customRules || {}).reduce( + (acum, [subject, rules]) => { + if (!acum[subject]) { + acum[subject] = [...rules]; + } else { + acum[subject].push(...rules); + } + acum[subject] = acum[subject] || [...rules]; + return acum; + }, + defaultRules + ); + + for (const [subject, rules] of Object.entries(resultRules)) { + for (const { permission, fields, action, condition } of rules) { + abilityBuilder[permission](action, subject, fields, condition); + } + } + + return abilityBuilder.build().rules; +} + +export type GetPermissionRules = typeof getPermissionRules; + +export const getPermissionRulesFactory: ValueProvider = { + provide: GET_PERMISSION_RULES, + useValue: getPermissionRules, +}; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts new file mode 100644 index 00000000..4c4f1134 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/nestjs-acl-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +@Module({ + controllers: [], + providers: [], + exports: [], +}) +export class NestjsAclPermissionsModule {} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/casl-ability/casl-ability.service.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/casl-ability/casl-ability.service.ts new file mode 100644 index 00000000..84b0364e --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/casl-ability/casl-ability.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CaslAbilityService {} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.spec.ts new file mode 100644 index 00000000..de672845 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.spec.ts @@ -0,0 +1,96 @@ +import { Test } from '@nestjs/testing'; +import { ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; + +import { CheckAccessService } from './check-access.service'; +import { JsonApi } from 'json-api-nestjs'; +import { RawRuleOf } from '@casl/ability'; +import { AbilityRules, Actions } from '../../types'; + +describe('CheckAccessService', () => { + let checkAccessService: CheckAccessService; + let context: ExecutionContext; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [CheckAccessService], + }).compile(); + + checkAccessService = moduleRef.get(CheckAccessService); + context = { + getClass: jest.fn(), + getHandler: jest.fn(), + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn(), + method: 'GET', + }), + } as unknown as ExecutionContext; + }); + + describe('validate input before checkAccess', () => { + it('should return false if the request does not contain a user', async () => { + const httpContext = context.switchToHttp(); + @JsonApi(class TestEntity {}) + class TestControllerJsonApi {} + jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi); + jest.spyOn(httpContext, 'getRequest').mockReturnValue({} as Request); + const result = await checkAccessService.checkAccess(context); + expect(result).toEqual(false); + }); + + it('should return true, entity doesnt assign to controller', async () => { + const httpContext = context.switchToHttp(); + jest.spyOn(httpContext, 'getRequest').mockReturnValue({} as Request); + + jest.spyOn(context, 'getClass').mockReturnValue(class TestController {}); + const result = await checkAccessService.checkAccess(context); + expect(result).toEqual(true); + }); + + it('should throw error incorrect http methode', async () => { + const permissionRules: RawRuleOf[] = []; + const httpContext = context.switchToHttp(); + jest + .spyOn(httpContext, 'getRequest') + .mockReturnValue({ permissionRules, user: {} } as Request); + httpContext.getRequest().method = 'incorrect'; + @JsonApi(class TestEntity {}) + class TestControllerJsonApi {} + jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi); + expect.assertions(1); + try { + await checkAccessService.checkAccess(context); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + }); + + it('return true because permissionRules empty', async () => { + class TestEntity {} + + @JsonApi(TestEntity) + class TestControllerJsonApi {} + + const permissionRules: RawRuleOf[] = [ + { action: Actions.create, subject: TestEntity.name }, + { action: Actions.delete, subject: TestEntity.name }, + { action: Actions.update, subject: TestEntity.name }, + { + action: Actions.update, + subject: 'subject1', + conditions: { id: '${currentUser.id}' }, + }, + ]; + const httpContext = context.switchToHttp(); + jest.spyOn(httpContext, 'getRequest').mockReturnValue({ + permissionRules, + method: 'GET', + user: {}, + } as Request); + + jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi); + const result = await checkAccessService.checkAccess(context); + expect(result).toBe(true); + }); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.ts new file mode 100644 index 00000000..57774078 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/check-access/check-access.service.ts @@ -0,0 +1,63 @@ +import { ExecutionContext, Inject, Injectable } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { entityForClass } from 'json-api-nestjs'; + +import { checkInputHttpMethod } from '../../utils'; +import { MethodActionMap } from '../../constants'; +import { Actions } from '../../types'; + +@Injectable() +export class CheckAccessService { + private readonly logger = new Logger(CheckAccessService.name); + + public async checkAccess(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const { method, user, permissionRules, body, url, params } = request; + + const controller = context.getClass(); + const entity = entityForClass(controller); + if (!entity) { + this.logger.debug( + 'Entity doesnt assign to controller: ' + controller.name + ); + return true; + } + + if (!user) { + this.logger.debug('User doesnt assign to request'); + return false; + } + + if (!permissionRules) { + this.logger.debug('Permission rules doesnt assign to request'); + return false; + } + + if (!('name' in entity)) { + this.logger.debug('Entity doesnt have name'); + return false; + } + + checkInputHttpMethod(method); + + const action = MethodActionMap[method]; + const entityName = entity.name; + + const rulesForCurrentRequest = permissionRules.filter( + (rule) => rule.action === action && rule.subject === entityName + ); + if (rulesForCurrentRequest.length === 0) { + this.logger.debug('No permission rules found for current request'); + return true; + } + + switch (action) { + case Actions.read: + case Actions.update: + case Actions.create: + case Actions.delete: + } + return true; + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/index.ts new file mode 100644 index 00000000..b2d0b8d6 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/index.ts @@ -0,0 +1,3 @@ +export * from './permission/permission.guard'; +export * from './check-access/check-access.service'; +export * from './casl-ability/casl-ability.service'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.spec.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.spec.ts new file mode 100644 index 00000000..3bc9fbbe --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.spec.ts @@ -0,0 +1,57 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; + +import { PermissionGuard } from './permission.guard'; +import { CheckAccessService } from '../check-access/check-access.service'; + +describe('PermissionGuard', () => { + let guard: PermissionGuard; + let reflector: Reflector; + let checkAccessService: CheckAccessService; + let context: ExecutionContext; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PermissionGuard, Reflector, CheckAccessService], + }).compile(); + + guard = moduleRef.get(PermissionGuard); + reflector = moduleRef.get(Reflector); + checkAccessService = moduleRef.get(CheckAccessService); + context = { + getClass: jest.fn(), + getHandler: jest.fn(), + } as unknown as ExecutionContext; + }); + + it('should return true when public meta key is true', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(true); + const checkAccessServiceSpy = jest + .spyOn(checkAccessService, 'checkAccess') + .mockResolvedValue(true); + const result = await guard.canActivate(context); + expect(result).toEqual(true); + expect(checkAccessServiceSpy).toHaveBeenCalledTimes(0); + }); + + it('should return false when public meta key is false', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + const checkAccessServiceSpy = jest + .spyOn(checkAccessService, 'checkAccess') + .mockResolvedValue(false); + const result = await guard.canActivate(context); + expect(result).toEqual(false); + expect(checkAccessServiceSpy).toHaveBeenCalledTimes(1); + }); + + it('should return true when public meta key is false', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + const checkAccessServiceSpy = jest + .spyOn(checkAccessService, 'checkAccess') + .mockResolvedValue(true); + const result = await guard.canActivate(context); + expect(result).toEqual(true); + expect(checkAccessServiceSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.ts new file mode 100644 index 00000000..5d78d369 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/service/permission/permission.guard.ts @@ -0,0 +1,24 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { IS_PUBLIC_META_KEY } from '../../constants'; +import { CheckAccessService } from '../check-access/check-access.service'; + +@Injectable() +export class PermissionGuard implements CanActivate { + @Inject(Reflector) private reflector!: Reflector; + @Inject(CheckAccessService) private checkAccessService!: CheckAccessService; + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride( + IS_PUBLIC_META_KEY, + [context.getClass(), context.getHandler()] + ); + return isPublic || this.checkAccessService.checkAccess(context); + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl.ts new file mode 100644 index 00000000..9102d0bc --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/acl.ts @@ -0,0 +1,46 @@ +import { MongoAbility, MongoQuery, Subject } from '@casl/ability'; + +import { Method } from './request-types'; + +export enum Actions { + read = 'read', + create = 'create', + update = 'update', + delete = 'delete', +} + +export interface BaseRules { + [Actions.read]: boolean; + [Actions.create]: boolean; + [Actions.update]: boolean; + [Actions.delete]: boolean; +} + +export type MethodActionMapType = { [key in Method]: Actions }; + +export interface MethodActionMap extends MethodActionMapType { + [Method.GET]: Actions.read; + [Method.POST]: Actions.create; + [Method.PATCH]: Actions.update; + [Method.DELETE]: Actions.delete; +} +type PermissionRuleAction = 'can' | 'cannot'; +export interface CustomRules { + action: Actions; + permission: PermissionRuleAction; + fields?: string[]; + // needRelation: string[]; + condition?: MongoQuery; +} + +export type PermissionRule = { + defaultRules: { + [key: string]: BaseRules; + }; + customRules?: { + [key: string]: CustomRules[]; + }; +}; +export type Abilities = [Actions, Subject]; + +export type AbilityRules = MongoAbility; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts new file mode 100644 index 00000000..c3e88160 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/index.ts @@ -0,0 +1,3 @@ +export * from './session-users'; +export * from './request-types'; +export * from './acl'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/request-types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/request-types.ts new file mode 100644 index 00000000..b92ddf83 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/request-types.ts @@ -0,0 +1,6 @@ +export enum Method { + GET = 'GET', + POST = 'POST', + PATCH = 'PATCH', + DELETE = 'DELETE', +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/session-users.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/session-users.ts new file mode 100644 index 00000000..103de4f0 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/types/session-users.ts @@ -0,0 +1,12 @@ +import { Express } from 'express-serve-static-core'; +import { RawRuleOf } from '@casl/ability'; + +import { AbilityRules } from './acl'; + +declare module 'express-serve-static-core' { + interface User {} + interface Request { + user?: User; + permissionRules: RawRuleOf[]; + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/asserts-types.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/asserts-types.ts new file mode 100644 index 00000000..bf7abe7c --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/asserts-types.ts @@ -0,0 +1,9 @@ +import { Method } from '../types'; + +export function checkInputHttpMethod( + httpMethod: string +): asserts httpMethod is Method { + if (!Object.values(Method).includes(httpMethod as Method)) { + throw new Error(`Invalid HTTP method: ${httpMethod}`); + } +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts new file mode 100644 index 00000000..413e2c49 --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './asserts-types'; diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json new file mode 100644 index 00000000..8122543a --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json new file mode 100644 index 00000000..71ab4b2a --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json new file mode 100644 index 00000000..9b2a121d --- /dev/null +++ b/libs/acl-permissions/nestjs-acl-permissions/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index dbb7cb41..ee9635b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@angular/platform-browser": "17.2.2", "@angular/platform-browser-dynamic": "17.2.2", "@angular/router": "17.2.2", + "@casl/ability": "^6.7.1", "@nestjs/common": "^10.3.0", "@nestjs/core": "^10.3.0", "@nestjs/platform-express": "10.3.3", @@ -3013,6 +3014,17 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.1.tgz", + "integrity": "sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -8420,6 +8432,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@verdaccio/commons-api": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@verdaccio/commons-api/-/commons-api-10.2.0.tgz", diff --git a/package.json b/package.json index a5fb64ea..45fed26e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@angular/platform-browser": "17.2.2", "@angular/platform-browser-dynamic": "17.2.2", "@angular/router": "17.2.2", + "@casl/ability": "^6.7.1", "@nestjs/common": "^10.3.0", "@nestjs/core": "^10.3.0", "@nestjs/platform-express": "10.3.3", diff --git a/test.ts b/test.ts new file mode 100644 index 00000000..e749b015 --- /dev/null +++ b/test.ts @@ -0,0 +1,127 @@ +import * as ts from 'typescript'; +import { Node, ParameterDeclaration, SyntaxKind } from 'typescript'; +import { readFileSync } from 'fs'; + +const DECORATOR_NAME = 'RpcHandler'; + +type ParamsOpenRPC = { + name: string; + description?: string; + required: boolean; + schema: { + type: string | undefined; + }; +}; + +function getDeclaration( + node: Node, + nameDeclaration: SyntaxKind, + findone: true +): undefined | Node; +function getDeclaration( + node: Node, + nameDeclaration: SyntaxKind, + findone: false +): Node[]; +function getDeclaration( + node: Node, + nameDeclaration: SyntaxKind, + findone: boolean +): undefined | Node | Node[] { + const result: Node[] = []; + function iterate(node: Node) { + if (node.kind === nameDeclaration) { + result.push(node); + } + if (findone && result.length === 1) return; + ts.forEachChild(node, iterate); + } + + iterate(node); + + return findone ? result.shift() : result; +} + +function getName(node: Node): string | null { + const identifier = getDeclaration(node, SyntaxKind.Identifier, true); + if (!identifier) return null; + return identifier.getText().replace('\n ', ''); +} + +function getMethodForClass(node: Node): Node[] { + const methodDeclaration = getDeclaration( + node, + SyntaxKind.MethodDeclaration, + false + ); + return methodDeclaration.filter((i) => { + const isPrivate = !!getDeclaration(i, SyntaxKind.PrivateKeyword, true); + if (isPrivate) return false; + const isPrivateIdentifier = !!getDeclaration( + i, + SyntaxKind.PrivateIdentifier, + true + ); + if (isPrivateIdentifier) return false; + const isProtected = !!getDeclaration(i, SyntaxKind.ProtectedKeyword, true); + return !isProtected; + }); +} + +function getParams(node: Node): ParamsOpenRPC[][] { + const methodDeclaration = getMethodForClass(node); + return methodDeclaration.map((method) => { + return getDeclaration(method, SyntaxKind.Parameter, false) + .map((i, index) => { + let param = i as ParameterDeclaration; + let type = undefined; + if (param.type) { + if (param.type.kind !== SyntaxKind.TypeReference) { + type = param.type.getText(); + } + } + return { + name: param.name.getText(), + required: !!param.questionToken, + schema: { + type, + }, + }; + }) + .filter((i): i is ParamsOpenRPC => !!i); + }); +} + +export function workWithFile(file: string) { + const code = readFileSync(file, 'utf-8'); + const sc = ts.createSourceFile('x.ts', code, ts.ScriptTarget.Latest, true); + + const classList = getDeclaration( + sc, + SyntaxKind.ClassDeclaration, + false + ).filter((item) => { + const decoratorForClass = getDeclaration(item, SyntaxKind.Decorator, true); + if (!decoratorForClass) return false; + const callExpression = getDeclaration( + decoratorForClass, + SyntaxKind.CallExpression, + true + ); + if (!callExpression) return false; + const identifier = getName(callExpression); + if (!identifier) return false; + return identifier === DECORATOR_NAME; + }); + + const methods = classList.reduce((acum, item) => { + const methods = getMethodForClass(item) + .map((i) => getName(i)) + .filter((i): i is string => !!i); + const params = getParams(item); + console.log(JSON.stringify(params)); + return acum; + }, []); +} + +workWithFile('apps/json-api-server/src/app/rpc/service/rpc.service.ts'); diff --git a/tsconfig.base.json b/tsconfig.base.json index 016220a1..b7e409c2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,6 +15,9 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { + "@klerick/nestjs-acl-permissions": [ + "libs/acl-permissions/nestjs-acl-permissions/src/index.ts" + ], "@klerick/nestjs-json-rpc": [ "libs/json-rpc/nestjs-json-rpc/src/index.ts" ],