diff --git a/packages/nestjs-access-control/README.md b/packages/nestjs-access-control/README.md index 2a9c6d9b1..850ea2ca4 100644 --- a/packages/nestjs-access-control/README.md +++ b/packages/nestjs-access-control/README.md @@ -147,103 +147,89 @@ import { acRules } from './app.acl'; export class AppModule {} ``` -### Implement on your controller (nestjsx CRUD module with Passport guard example) +### Implement on your controller (Passport guard example) ```typescript -import { Controller, UseGuards } from '@nestjs/common'; -import { Crud, CrudController } from '@nestjsx/crud'; -import { User } from './user.entity'; -import { UserService } from './user.service'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { AuthGuard } from '@nestjs/passport'; -import { AppResource } from '../../app.acl'; +import { ApiTags } from '@nestjs/swagger'; +import { Controller, Body, Query, Param } from '@nestjs/common'; import { + AccessControlCreateMany, AccessControlCreateOne, AccessControlDeleteOne, - AccessControlGuard, AccessControlReadMany, AccessControlReadOne, + AccessControlRecoverOne, AccessControlUpdateOne, - UseAccessControl, } from '@concepta/nestjs-access-control'; -import { UserDto, CreateUserDto, UpdateUserDto } from './dto'; -import { UserAccessControlFilterService } from './user-access-control-filter.service'; - -@ApiTags(AppResource.User) -@ApiBearerAuth() -@Crud({ - model: { - type: UserDto, - }, - dto: { - create: CreateUserDto, - update: UpdateUserDto, - }, - routes: { - only: [ - 'getManyBase', - 'getOneBase', - 'createOneBase', - 'updateOneBase', - 'deleteOneBase', - ], - getManyBase: { - decorators: [ - ApiOperation({ - operationId: 'user_getMany', - }), - AccessControlReadMany(AppResource.UserList), - ], - }, - getOneBase: { - decorators: [ - ApiOperation({ - operationId: 'user_getOne', - }), - AccessControlReadOne( - AppResource.User, - async ( - params: { id: string }, - user: User, - service: UserAccessControlFilterService, - ): Promise => { - return ( - params.id === user.id && true === service.userCanRead(id, user) - ); - }, - ), - ], - }, - createOneBase: { - decorators: [ - ApiOperation({ - operationId: 'user_createOne', - }), - AccessControlCreateOne(AppResource.User), - ], - }, - updateOneBase: { - decorators: [ - ApiOperation({ - operationId: 'user_updateOne', - }), - AccessControlUpdateOne(AppResource.User), - ], - }, - deleteOneBase: { - decorators: [ - ApiOperation({ - operationId: 'user_deleteOne', - }), - AccessControlDeleteOne(AppResource.User), - ], - }, - }, -}) -@Controller(AppResource.User) -@UseGuards(AuthGuard(), AccessControlGuard) -@UseAccessControl({ service: UserAccessControlFilterService }) -export class UserController implements CrudController { - constructor(public service: UserService) {} + +import { UserResource } from './user.types'; +import { UserCreateDto } from './dto/user-create.dto'; +import { UserCreateManyDto } from './dto/user-create-many.dto'; +import { UserUpdateDto } from './dto/user-update.dto'; + +/** + * User controller. + */ +@Controller('user') +@ApiTags('user') +export class UserController { + /** + * Get many + */ + @AccessControlReadMany(UserResource.Many) + async getMany(@Query() query: unknown) { + // ... + } + + /** + * Get one + */ + @AccessControlReadOne(UserResource.One) + async getOne(@Param('id') id: string) { + // ... + } + + /** + * Create many + */ + @AccessControlCreateMany(UserResource.Many) + async createMany(@Body() userCreateManyDto: UserCreateManyDto) { + // ... + } + + /** + * Create one + */ + @AccessControlCreateOne(UserResource.One) + async createOne(@Body() userCreateDto: UserCreateDto) { + // ... + } + + /** + * Update one + */ + @AccessControlUpdateOne(UserResource.One) + async updateOne( + @Param('id') userId: string, + @Body() userUpdateDto: UserUpdateDto, + ) { + // ... + } + + /** + * Delete one + */ + @AccessControlDeleteOne(UserResource.One) + async deleteOne(@Param('id') id: string) { + // ... + } + + /** + * Recover one + */ + @AccessControlRecoverOne(UserResource.One) + async recoverOne(@Param('id') id: string) { + // ... + } } ``` diff --git a/packages/nestjs-access-control/package.json b/packages/nestjs-access-control/package.json index 20703d26c..69d936151 100644 --- a/packages/nestjs-access-control/package.json +++ b/packages/nestjs-access-control/package.json @@ -13,6 +13,7 @@ ], "dependencies": { "@concepta/nestjs-common": "^4.0.0-alpha.37", + "@concepta/ts-common": "^4.0.0-alpha.37", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", diff --git a/packages/nestjs-access-control/src/access-control.context.spec.ts b/packages/nestjs-access-control/src/access-control.context.spec.ts new file mode 100644 index 000000000..f5302174c --- /dev/null +++ b/packages/nestjs-access-control/src/access-control.context.spec.ts @@ -0,0 +1,58 @@ +import { AccessControl } from 'accesscontrol'; +import { mock } from 'jest-mock-extended'; +import { Controller } from '@nestjs/common'; +import { ExecutionContext, HttpArgumentsHost } from '@nestjs/common/interfaces'; +import { AccessControlContext } from './access-control.context'; +import { AccessControlReadOne } from './decorators/access-control-read-one.decorator'; +import { ActionEnum } from './enums/action.enum'; +import { PossessionEnum } from './enums/possession.enum'; + +describe(AccessControlContext.name, () => { + it('should return expected values', () => { + @Controller() + @AccessControlReadOne('a_resource_name') + class TestController {} + + const rules = new AccessControl(); + rules.grant('role1').readAny('a_resource_name'); + + const argsHost = mock(); + argsHost.getRequest.mockReturnValue({ body: { b1: 'xyz' } }); + + const context = mock(); + context.getClass.mockReturnValue(TestController); + context.switchToHttp.mockReturnValue(argsHost); + + const expectedAccessControlContext = new AccessControlContext({ + request: { + body: { b1: 'xyz' }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_create_own', + action: ActionEnum.READ, + }, + accessControl: rules, + executionContext: context, + }); + + expect(expectedAccessControlContext.getRequest()).toEqual({ + body: { b1: 'xyz' }, + }); + expect(expectedAccessControlContext.getRequest('body')).toEqual({ + b1: 'xyz', + }); + expect(expectedAccessControlContext.getRequest('not-a-real-prop')).toEqual( + undefined, + ); + expect(expectedAccessControlContext.getUser()).toEqual({ id: 1234 }); + expect(expectedAccessControlContext.getQuery()).toEqual({ + possession: PossessionEnum.OWN, + resource: 'resource_create_own', + action: ActionEnum.READ, + }); + expect(expectedAccessControlContext.getAccessControl()).toEqual(rules); + expect(expectedAccessControlContext.getExecutionContext()).toEqual(context); + }); +}); diff --git a/packages/nestjs-access-control/src/access-control.context.ts b/packages/nestjs-access-control/src/access-control.context.ts new file mode 100644 index 000000000..17be8360d --- /dev/null +++ b/packages/nestjs-access-control/src/access-control.context.ts @@ -0,0 +1,43 @@ +import { AccessControl, IQueryInfo } from 'accesscontrol'; +import { ExecutionContext } from '@nestjs/common'; +import { AccessControlContextArgsInterface } from './interfaces/access-control-context-args.interface'; +import { AccessControlContextInterface } from './interfaces/access-control-context.interface'; + +export class AccessControlContext implements AccessControlContextInterface { + constructor(private readonly ctxArgs: AccessControlContextArgsInterface) {} + + protected hasProp( + obj: unknown, + key: K, + ): obj is Record { + return ( + key !== null && obj !== null && typeof obj === 'object' && key in obj + ); + } + + protected getProp(obj: unknown, prop: string) { + return this.hasProp(obj, prop) ? obj[prop] : undefined; + } + + getRequest(property?: string): unknown { + return property?.length + ? this.getProp(this.ctxArgs.request, property) + : this.ctxArgs.request; + } + + getUser(): unknown { + return this.ctxArgs.user; + } + + getQuery(): IQueryInfo { + return this.ctxArgs.query; + } + + getAccessControl(): AccessControl { + return this.ctxArgs.accessControl; + } + + getExecutionContext(): ExecutionContext { + return this.ctxArgs.executionContext; + } +} diff --git a/packages/nestjs-access-control/src/access-control.guard.spec.ts b/packages/nestjs-access-control/src/access-control.guard.spec.ts index 4f2a040d5..b2d13520b 100644 --- a/packages/nestjs-access-control/src/access-control.guard.spec.ts +++ b/packages/nestjs-access-control/src/access-control.guard.spec.ts @@ -1,33 +1,33 @@ +import { AccessControl } from 'accesscontrol'; +import { mock } from 'jest-mock-extended'; +import { Reflector } from '@nestjs/core'; +import { Controller, ExecutionContext, Injectable } from '@nestjs/common'; +import { HttpArgumentsHost } from '@nestjs/common/interfaces'; +import { Test, TestingModule } from '@nestjs/testing'; import { - ACCESS_CONTROL_MODULE_CTLR_METADATA, - ACCESS_CONTROL_MODULE_FILTERS_METADATA, + ACCESS_CONTROL_MODULE_QUERY_METADATA, ACCESS_CONTROL_MODULE_GRANT_METADATA, ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, } from './constants'; -import { - AccessControlFilterCallback, - AccessControlFilterOption, -} from './interfaces/access-control-filter-option.interface'; -import { Controller, ExecutionContext } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { AccessControl } from 'accesscontrol'; -import { AccessControlAction } from './enums/access-control-action.enum'; -import { AccessControlCreateOne } from './decorators/access-control-create-one.decorator'; -import { AccessControlFilterService } from './interfaces/access-control-filter-service.interface'; -import { AccessControlFilterType } from './enums/access-control-filter-type.enum'; -import { AccessControlGrantOption } from './interfaces/access-control-grant-option.interface'; -import { AccessControlGuard } from './access-control.guard'; -import { AccessControlService } from './services/access-control.service'; +import { ActionEnum } from './enums/action.enum'; +import { PossessionEnum } from './enums/possession.enum'; + +import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; +import { AccessControlContextInterface } from './interfaces/access-control-context.interface'; import { AccessControlOptionsInterface } from './interfaces/access-control-options.interface'; -import { AccessControlMetadataInterface } from './interfaces/access-control-metadata.interface'; +import { AccessControlQueryOptionInterface } from './interfaces/access-control-query-option.interface'; +import { AccessControlGrantOptionInterface } from './interfaces/access-control-grant-option.interface'; +import { CanAccess } from './interfaces/can-access.interface'; + +import { AccessControlCreateOne } from './decorators/access-control-create-one.decorator'; +import { AccessControlQuery } from './decorators/access-control-query.decorator'; import { AccessControlReadMany } from './decorators/access-control-read-many.decorator'; import { AccessControlReadOne } from './decorators/access-control-read-one.decorator'; -import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; -import { HttpArgumentsHost } from '@nestjs/common/interfaces'; -import { Reflector } from '@nestjs/core'; -import { UseAccessControl } from './decorators/use-access-control.decorator'; -import { mock } from 'jest-mock-extended'; + +import { AccessControlGuard } from './access-control.guard'; +import { AccessControlService } from './services/access-control.service'; +import { AccessControlContext } from './access-control.context'; describe('AccessControlModule', () => { const resourceNoAccess = 'protected_resource_no_access'; @@ -36,26 +36,26 @@ describe('AccessControlModule', () => { const resourceGetOneOwn = 'resource_get_one_own'; const resourceCreateOwn = 'resource_create_own'; - const filterFail: AccessControlFilterCallback = jest - .fn() - .mockResolvedValue(false); - - const filterPass: AccessControlFilterCallback = jest - .fn() - .mockResolvedValue(true); - class TestUser { constructor(public id: number) {} } - class TestFilterService implements AccessControlFilterService { - async anyMethodName() { + @Injectable() + class TestQueryServicePass implements CanAccess { + async canAccess(_context: AccessControlContextInterface) { return true; } } + @Injectable() + class TestQueryServiceFail implements CanAccess { + async canAccess(_context: AccessControlContextInterface) { + return false; + } + } + class TestAccessService implements AccessControlServiceInterface { - async getUser(_context: ExecutionContext): Promise { + async getUser(_context: ExecutionContext): Promise { return new TestUser(1234); } async getUserRoles(_context: ExecutionContext): Promise { @@ -64,7 +64,6 @@ describe('AccessControlModule', () => { } @Controller() - @UseAccessControl() class TestController { getOpen() { return undefined; @@ -81,35 +80,29 @@ describe('AccessControlModule', () => { getOwn() { return undefined; } - @AccessControlReadMany(resourceGetOwn, filterFail) - getOwnFilterFail() { - return undefined; - } - @AccessControlReadMany(resourceGetOwn, filterPass) - getOwnFilterPass() { + @AccessControlReadMany(resourceGetOwn) + @AccessControlQuery({ service: TestQueryServiceFail }) + getOwnQueryFail() { return undefined; } - @AccessControlCreateOne(resourceCreateOwn, filterPass) - createOwnFilterPass() { + @AccessControlReadMany(resourceGetOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + getOwnQueryPass() { return undefined; } - @AccessControlReadOne(resourceGetOneOwn, filterPass) - getOneOwnFilterPass() { + @AccessControlCreateOne(resourceCreateOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + createOwnQueryPass() { return undefined; } - } - - @Controller() - @UseAccessControl({ service: TestFilterService }) - class TestControllerWithService { - @AccessControlReadMany(resourceGetOwn, filterPass) - getOwnFilterPass() { + @AccessControlReadOne(resourceGetOneOwn) + @AccessControlQuery({ service: TestQueryServicePass }) + getOneOwnQueryPass() { return undefined; } } - const controller = new TestController(); - const controllerWithService = new TestControllerWithService(); + let controller: TestController; const rules = new AccessControl(); rules.grant('role1').readAny(resourceGetAny); @@ -120,18 +113,24 @@ describe('AccessControlModule', () => { let moduleRef: TestingModule; let guard: AccessControlGuard; - const reflector: Reflector = new Reflector(); - const moduleConfig: AccessControlOptionsInterface = { - settings: { rules: rules }, - service: new TestAccessService(), - }; + let testQueryServicePass: TestQueryServicePass; + let testQueryServiceFail: TestQueryServiceFail; + let reflector: Reflector; + + beforeEach(async () => { + const moduleConfig: AccessControlOptionsInterface = { + settings: { rules: rules }, + service: new TestAccessService(), + }; + + reflector = new Reflector(); - beforeAll(async () => { moduleRef = await Test.createTestingModule({ providers: [ AccessControlGuard, TestAccessService, - TestFilterService, + TestQueryServicePass, + TestQueryServiceFail, { provide: AccessControlService, useClass: TestAccessService, @@ -144,7 +143,16 @@ describe('AccessControlModule', () => { ], }).compile(); + controller = new TestController(); guard = moduleRef.get(AccessControlGuard); + testQueryServicePass = + moduleRef.get(TestQueryServicePass); + testQueryServiceFail = + moduleRef.get(TestQueryServiceFail); + }); + + afterEach(async () => { + jest.clearAllMocks(); }); describe('guard provider', () => { @@ -153,17 +161,6 @@ describe('AccessControlModule', () => { }); }); - describe('access filter service', () => { - it('should be configured', async () => { - const config: AccessControlMetadataInterface = reflector.get( - ACCESS_CONTROL_MODULE_CTLR_METADATA, - TestControllerWithService, - ); - - expect(config.service).toEqual(TestFilterService); - }); - }); - describe('access grants', () => { it('should not have any grants set for getOpen', async () => { const grants = reflector.get( @@ -180,9 +177,9 @@ describe('AccessControlModule', () => { controller.getNoAccess, ); - expect(grants).toEqual([ + expect(grants).toEqual([ { - action: AccessControlAction.READ, + action: ActionEnum.READ, resource: resourceNoAccess, }, ]); @@ -194,9 +191,9 @@ describe('AccessControlModule', () => { controller.getAny, ); - expect(grants).toEqual([ + expect(grants).toEqual([ { - action: AccessControlAction.READ, + action: ActionEnum.READ, resource: resourceGetAny, }, ]); @@ -208,40 +205,38 @@ describe('AccessControlModule', () => { controller.getOwn, ); - expect(grants).toEqual([ + expect(grants).toEqual([ { - action: AccessControlAction.READ, + action: ActionEnum.READ, resource: resourceGetOwn, }, ]); }); }); - describe('access filters', () => { - it('should have filters set for getOwnFilterFail', async () => { - const filters = reflector.get( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getOwnFilterFail, + describe('access queries', () => { + it('should have queries set for getOwnQueryFail', async () => { + const queries = reflector.get( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + controller.getOwnQueryFail, ); - expect(filters).toEqual([ + expect(queries).toEqual([ { - type: AccessControlFilterType.QUERY, - filter: filterFail, + service: TestQueryServiceFail, }, ]); }); - it('should have filters set for getOwnFilterPass', async () => { - const filters = reflector.get( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getOwnFilterPass, + it('should have query set for getOwnQueryPass', async () => { + const queries = reflector.get( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + controller.getOwnQueryPass, ); - expect(filters).toEqual([ + expect(queries).toEqual([ { - type: AccessControlFilterType.QUERY, - filter: filterPass, + service: TestQueryServicePass, }, ]); }); @@ -280,109 +275,134 @@ describe('AccessControlModule', () => { expect(canActivate).toEqual(true); }); - it('should NOT allow activation, filter type not found on request', async () => { + it('should NOT allow activation, request not found on args host', async () => { const argsHost = mock(); - argsHost.getRequest.mockReturnValue({ not_a_valide_type: {} }); + argsHost.getRequest.mockReturnValue(null); const context = mock(); context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnFilterPass); + context.getHandler.mockReturnValue(controller.getOwnQueryPass); context.switchToHttp.mockReturnValue(argsHost); + const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); + const canActivate: boolean = await guard.canActivate(context); - expect(filterPass).not.toHaveBeenCalled(); + expect(querySpy).not.toHaveBeenCalled(); expect(canActivate).toEqual(false); }); - it('should NOT allow activation, query filtered', async () => { + it('should NOT allow activation, query string data', async () => { const argsHost = mock(); argsHost.getRequest.mockReturnValue({ query: { foo: 'bar' } }); const context = mock(); context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnFilterFail); + context.getHandler.mockReturnValue(controller.getOwnQueryFail); context.switchToHttp.mockReturnValue(argsHost); + const querySpy = jest.spyOn(testQueryServiceFail, 'canAccess'); + const canActivate: boolean = await guard.canActivate(context); - expect(filterFail).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledTimes(1); expect(canActivate).toEqual(false); }); - it('should allow activation, query filtered', async () => { + it('should allow activation, query string data', async () => { const argsHost = mock(); + argsHost.getRequest.mockReturnValue({ query: { q1: 'abc' } }); const context = mock(); context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOwnFilterPass); + context.getHandler.mockReturnValue(controller.getOwnQueryPass); context.switchToHttp.mockReturnValue(argsHost); + const expectedAccessControlContext = new AccessControlContext({ + request: { + query: { + q1: 'abc', + }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_get_own', + action: ActionEnum.READ, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); + + const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); + const canActivate: boolean = await guard.canActivate(context); - expect(filterPass).toHaveBeenCalledTimes(1); - expect(filterPass).toHaveBeenCalledWith( - { q1: 'abc' }, - { id: 1234 }, - undefined, - ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); expect(canActivate).toEqual(true); }); - it('should allow activation, body filtered', async () => { + it('should allow activation, body data', async () => { const argsHost = mock(); argsHost.getRequest.mockReturnValue({ body: { b1: 'xyz' } }); const context = mock(); context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.createOwnFilterPass); + context.getHandler.mockReturnValue(controller.createOwnQueryPass); context.switchToHttp.mockReturnValue(argsHost); + const expectedAccessControlContext = new AccessControlContext({ + request: { + body: { b1: 'xyz' }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_create_own', + action: ActionEnum.CREATE, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); + + const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); + const canActivate: boolean = await guard.canActivate(context); - expect(filterPass).toHaveBeenCalledTimes(1); - expect(filterPass).toHaveBeenCalledWith( - { b1: 'xyz' }, - { id: 1234 }, - undefined, - ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); expect(canActivate).toEqual(true); }); - it('should allow activation, path filtered', async () => { + it('should allow activation, path data', async () => { const argsHost = mock(); argsHost.getRequest.mockReturnValue({ params: { id: 7890 } }); const context = mock(); context.getClass.mockReturnValue(TestController); - context.getHandler.mockReturnValue(controller.getOneOwnFilterPass); + context.getHandler.mockReturnValue(controller.getOneOwnQueryPass); context.switchToHttp.mockReturnValue(argsHost); - const canActivate: boolean = await guard.canActivate(context); - expect(filterPass).toHaveBeenCalledTimes(1); - expect(filterPass).toHaveBeenCalledWith( - { id: 7890 }, - { id: 1234 }, - undefined, - ); - expect(canActivate).toEqual(true); - }); - - it('should allow activation, query filtered, with configured filter service', async () => { - const argsHost = mock(); - argsHost.getRequest.mockReturnValue({ query: { foo: 'bar' } }); + const querySpy = jest.spyOn(testQueryServicePass, 'canAccess'); - const context = mock(); - context.getClass.mockReturnValue(TestControllerWithService); - context.getHandler.mockReturnValue( - controllerWithService.getOwnFilterPass, - ); - context.switchToHttp.mockReturnValue(argsHost); + const expectedAccessControlContext = new AccessControlContext({ + request: { + params: { id: 7890 }, + }, + user: { id: 1234 }, + query: { + possession: PossessionEnum.OWN, + resource: 'resource_get_one_own', + action: ActionEnum.READ, + role: ['role1'], + }, + accessControl: rules, + executionContext: context, + }); const canActivate: boolean = await guard.canActivate(context); - expect(filterPass).toHaveBeenCalledTimes(1); - expect(filterPass).toHaveBeenCalledWith( - { foo: 'bar' }, - { id: 1234 }, - moduleRef.get(TestFilterService), - ); + expect(querySpy).toHaveBeenCalledTimes(1); + expect(querySpy).toHaveBeenCalledWith(expectedAccessControlContext); expect(canActivate).toEqual(true); }); }); diff --git a/packages/nestjs-access-control/src/access-control.guard.ts b/packages/nestjs-access-control/src/access-control.guard.ts index 0f896e2f7..3c6d487b4 100644 --- a/packages/nestjs-access-control/src/access-control.guard.ts +++ b/packages/nestjs-access-control/src/access-control.guard.ts @@ -1,25 +1,28 @@ +import { IQueryInfo } from 'accesscontrol'; +import { ModuleRef, Reflector } from '@nestjs/core'; import { CanActivate, ExecutionContext, Inject, Injectable, } from '@nestjs/common'; -import { ModuleRef, Reflector } from '@nestjs/core'; -import { IQueryInfo } from 'accesscontrol'; -import { Possession } from 'accesscontrol/lib/enums'; + import { - ACCESS_CONTROL_MODULE_CTLR_METADATA, - ACCESS_CONTROL_MODULE_FILTERS_METADATA, + ACCESS_CONTROL_MODULE_QUERY_METADATA, ACCESS_CONTROL_MODULE_GRANT_METADATA, ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, } from './constants'; -import { AccessControlMetadataInterface } from './interfaces/access-control-metadata.interface'; -import { AccessControlFilterOption } from './interfaces/access-control-filter-option.interface'; -import { AccessControlGrantOption } from './interfaces/access-control-grant-option.interface'; + +import { PossessionEnum } from './enums/possession.enum'; + +import { CanAccess } from './interfaces/can-access.interface'; +import { AccessControlQueryOptionInterface } from './interfaces/access-control-query-option.interface'; +import { AccessControlGrantOptionInterface } from './interfaces/access-control-grant-option.interface'; import { AccessControlServiceInterface } from './interfaces/access-control-service.interface'; -import { AccessControlFilterService } from './interfaces/access-control-filter-service.interface'; import { AccessControlSettingsInterface } from './interfaces/access-control-settings.interface'; + import { AccessControlService } from './services/access-control.service'; +import { AccessControlContext } from './access-control.context'; @Injectable() export class AccessControlGuard implements CanActivate { @@ -41,7 +44,8 @@ export class AccessControlGuard implements CanActivate { context: ExecutionContext, ): Promise { const rules = this.settings.rules; - const acGrants = this.reflector.get( + + const acGrants = this.reflector.get( ACCESS_CONTROL_MODULE_GRANT_METADATA, context.getHandler(), ); @@ -53,97 +57,113 @@ export class AccessControlGuard implements CanActivate { } const userRoles = await this.service.getUserRoles(context); - - // do they have at least one ANY permission? - const hasAnyPermission = acGrants.some((acGrant) => { - const query: IQueryInfo = { - role: userRoles, - possession: Possession.ANY, - ...acGrant, - }; - const permission = rules.permission(query); - return permission.granted; - }); - - // have a match? - if (hasAnyPermission) { - // yes, skip remaining checks (even filters) - return true; + const possessions = [PossessionEnum.ANY, PossessionEnum.OWN]; + const queriesPermitted: IQueryInfo[] = []; + + // loop each grant + loopGrants: for (const acGrant of acGrants) { + // loop each possession + for (const possession of possessions) { + // build up the query + const query: IQueryInfo = { + role: userRoles, + possession, + ...acGrant, + }; + // get permission object + const permission = rules.permission(query); + // has permission? + if (permission.granted) { + queriesPermitted.push(query); + break loopGrants; + } + } } - const hasOwnPermission = acGrants.some((acGrant) => { - const query: IQueryInfo = { - role: userRoles, - possession: Possession.OWN, - ...acGrant, - }; - const permission = rules.permission(query); - return permission.granted; - }); - - // have a match? - if (hasOwnPermission) { - // yes, now we have to check filters - return this.checkAccessFilters(context); - } else { - // no access - return false; + // any permitted queries? + if (queriesPermitted.length) { + // yes, check access queries + return this.checkAccessQueries(context, queriesPermitted); } + + // no permissions via grants + return false; } - protected async checkAccessFilters( + protected async checkAccessQueries( context: ExecutionContext, + queriesPermitted: IQueryInfo[], ): Promise { - // get access filters configuration for handler - const acFilters = this.reflector.get( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - context.getHandler(), + const targets = [context.getClass(), context.getHandler()]; + + const acQueries = this.reflector.getAllAndMerge< + AccessControlQueryOptionInterface[] + >( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + targets.filter((t) => t), ); // get anything? - if (!acFilters || !Array.isArray(acFilters)) { + if (!acQueries || !Array.isArray(acQueries) || !acQueries.length) { // no, nothing to check return true; } - const req = context.switchToHttp().getRequest(); - const service = this.getFilterService(context); + const request: unknown = context.switchToHttp().getRequest(); + + // did we get a request? + if (!request || typeof request !== 'object') { + // no, impossible to query + return false; + } + const user = await this.service.getUser(context); - let authorized = true; - for (const acFilter of acFilters) { - // maybe apply - if (req[acFilter.type]) { - authorized = await acFilter.filter(req[acFilter.type], user, service); - } else { - // no parameters found on request?! - // impossible to apply filter - authorized = false; - } + // authorized by default + let authorized = true; - // lost access? - if (!authorized) { - // yes, don't bother checking anything else - break; + // loop all ac queries + loopQueries: for await (const acQuery of acQueries) { + // get the query service + const service = await this.getQueryService(acQuery); + + // loop all queries permitted + for await (const query of queriesPermitted) { + // yes, new access control context instance + const accessControlContext = new AccessControlContext({ + request, + user, + query, + accessControl: this.settings.rules, + executionContext: context, + }); + + // call query service + authorized = await service.canAccess(accessControlContext); + + // lost access? + if (authorized) { + // yes, don't bother checking anything else + break loopQueries; + } } } return authorized; } - private getFilterService( - context: ExecutionContext, - ): AccessControlFilterService | undefined { - const controllerClass = context.getClass(); - const config: AccessControlMetadataInterface = this.reflector.get( - ACCESS_CONTROL_MODULE_CTLR_METADATA, - controllerClass, - ); + private async getQueryService( + queryOption: AccessControlQueryOptionInterface, + ): Promise { + // get the query class instance + const queryService = this.moduleRef.resolve(queryOption.service); - if (config.service) { - return this.moduleRef.get(config.service); + if (queryService) { + return queryService; } else { - return; + throw new Error( + `Access control guard was unable to resolve service ${queryOption.service.name}`, + ); } } } diff --git a/packages/nestjs-access-control/src/access-control.module-definition.ts b/packages/nestjs-access-control/src/access-control.module-definition.ts index e308a4977..22f10c8f4 100644 --- a/packages/nestjs-access-control/src/access-control.module-definition.ts +++ b/packages/nestjs-access-control/src/access-control.module-definition.ts @@ -1,3 +1,4 @@ +import { APP_GUARD } from '@nestjs/core'; import { ConfigurableModuleBuilder, DynamicModule, @@ -6,12 +7,15 @@ import { import { ConfigModule } from '@nestjs/config'; import { createSettingsProvider } from '@concepta/nestjs-common'; -import { AccessControlOptionsInterface } from './interfaces/access-control-options.interface'; import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from './constants'; + +import { AccessControlOptionsInterface } from './interfaces/access-control-options.interface'; import { AccessControlOptionsExtrasInterface } from './interfaces/access-control-options-extras.interface'; -import { accessControlDefaultConfig } from './config/acess-control-default.config'; import { AccessControlSettingsInterface } from './interfaces/access-control-settings.interface'; + +import { AccessControlGuard } from './access-control.guard'; import { AccessControlService } from './services/access-control.service'; +import { accessControlDefaultConfig } from './config/acess-control-default.config'; const RAW_OPTIONS_TOKEN = Symbol('__ACCESS_CONTROL_MODULE_RAW_OPTIONS_TOKEN__'); @@ -33,6 +37,7 @@ export type AccessControlOptions = Omit< typeof ACCESS_CONTROL_OPTIONS_TYPE, 'global' >; + export type AccessControlAsyncOptions = Omit< typeof ACCESS_CONTROL_ASYNC_OPTIONS_TYPE, 'global' @@ -43,13 +48,15 @@ function definitionTransform( extras: AccessControlOptionsExtrasInterface, ): DynamicModule { const { providers = [] } = definition; - const { global = false, imports } = extras; + const { global = false, imports, queryServices = [] } = extras; return { ...definition, global, imports: createAccessControlImports({ imports }), - providers: createAccessControlProviders({ providers }), + providers: createAccessControlProviders({ + providers: [...providers, ...queryServices], + }), exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAccessControlExports()], }; } @@ -67,17 +74,23 @@ export function createAccessControlImports( } export function createAccessControlExports() { - return [ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, AccessControlService]; + return [ + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + AccessControlService, + AccessControlGuard, + ]; } -export function createAccessControlProviders(overrides: { - options?: AccessControlOptions; +export function createAccessControlProviders(options: { + overrides?: AccessControlOptions; providers?: Provider[]; }): Provider[] { return [ - ...(overrides.providers ?? []), - createAccessControlSettingsProvider(overrides.options), - createAccessControlServiceProvider(overrides.options), + ...(options.providers ?? []), + createAccessControlSettingsProvider(options.overrides), + createAccessControlServiceProvider(options.overrides), + createAccessControlAppGuardProvider(options.overrides), + AccessControlGuard, ]; } @@ -107,3 +120,28 @@ export function createAccessControlServiceProvider( new AccessControlService(), }; } + +export function createAccessControlAppGuardProvider( + optionsOverrides?: AccessControlOptions, +): Provider { + return { + provide: APP_GUARD, + inject: [RAW_OPTIONS_TOKEN, AccessControlGuard], + useFactory: async ( + options: AccessControlOptionsInterface, + defaultGuard: AccessControlGuard, + ) => { + // get app guard from the options + const appGuard = optionsOverrides?.appGuard ?? options?.appGuard; + + // is app guard explicitly false? + if (appGuard === false) { + // yes, don't set a guard + return null; + } else { + // return app guard if set, or fall back to default + return appGuard ?? defaultGuard; + } + }, + }; +} diff --git a/packages/nestjs-access-control/src/access-control.module.ts b/packages/nestjs-access-control/src/access-control.module.ts index 6498c957d..5e416edef 100644 --- a/packages/nestjs-access-control/src/access-control.module.ts +++ b/packages/nestjs-access-control/src/access-control.module.ts @@ -30,7 +30,7 @@ export class AccessControlModule extends AccessControlModuleClass { return { module: AccessControlModule, imports: createAccessControlImports(options), - providers: createAccessControlProviders({ options }), + providers: createAccessControlProviders({ overrides: options }), exports: createAccessControlExports(), }; } diff --git a/packages/nestjs-access-control/src/constants.spec.ts b/packages/nestjs-access-control/src/constants.spec.ts index 827dcbd10..596c8168f 100644 --- a/packages/nestjs-access-control/src/constants.spec.ts +++ b/packages/nestjs-access-control/src/constants.spec.ts @@ -1,8 +1,7 @@ import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN, - ACCESS_CONTROL_MODULE_CTLR_METADATA, - ACCESS_CONTROL_MODULE_FILTERS_METADATA, + ACCESS_CONTROL_MODULE_QUERY_METADATA, ACCESS_CONTROL_MODULE_GRANT_METADATA, } from './constants'; @@ -14,11 +13,8 @@ describe('Constants', () => { expect(ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN).toEqual( 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN', ); - expect(ACCESS_CONTROL_MODULE_CTLR_METADATA).toEqual( - 'ACCESS_CONTROL_MODULE_CTLR_METADATA', - ); - expect(ACCESS_CONTROL_MODULE_FILTERS_METADATA).toEqual( - 'ACCESS_CONTROL_MODULE_FILTERS_METADATA', + expect(ACCESS_CONTROL_MODULE_QUERY_METADATA).toEqual( + 'ACCESS_CONTROL_MODULE_QUERY_METADATA', ); expect(ACCESS_CONTROL_MODULE_GRANT_METADATA).toEqual( 'ACCESS_CONTROL_MODULE_GRANT_METADATA', diff --git a/packages/nestjs-access-control/src/constants.ts b/packages/nestjs-access-control/src/constants.ts index 15c027b9d..5841b3e03 100644 --- a/packages/nestjs-access-control/src/constants.ts +++ b/packages/nestjs-access-control/src/constants.ts @@ -1,10 +1,11 @@ export const ACCESS_CONTROL_MODULE_SETTINGS_TOKEN = 'ACCESS_CONTROL_MODULE_SETTINGS_TOKEN'; + export const ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN = 'ACCESS_CONTROL_MODULE_DEFAULT_SETTINGS_TOKEN'; -export const ACCESS_CONTROL_MODULE_CTLR_METADATA = - 'ACCESS_CONTROL_MODULE_CTLR_METADATA'; + export const ACCESS_CONTROL_MODULE_GRANT_METADATA = 'ACCESS_CONTROL_MODULE_GRANT_METADATA'; -export const ACCESS_CONTROL_MODULE_FILTERS_METADATA = - 'ACCESS_CONTROL_MODULE_FILTERS_METADATA'; + +export const ACCESS_CONTROL_MODULE_QUERY_METADATA = + 'ACCESS_CONTROL_MODULE_QUERY_METADATA'; diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts index 7ffa3e1d6..11b46dea7 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlCreateMany } from './access-control-create-many.decorator'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlCreateMany(resource) createMany() { return null; } - @AccessControlCreateMany(resource, filterCallback) - createManyFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlCreateOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.createMany, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.createManyFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.createManyFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.BODY, - filter: filterCallback, + action: ActionEnum.CREATE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts index 8b955e35b..b975aeadf 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-create-many.decorator.ts @@ -1,14 +1,9 @@ -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; import { AccessControlCreateOne } from './access-control-create-one.decorator'; /** - * Create many resource filter shortcut. + * Create many resource grant shortcut. * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. */ -export const AccessControlCreateMany = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -) => AccessControlCreateOne(resource, paramFilter); +export const AccessControlCreateMany = (resource: string) => + AccessControlCreateOne(resource); diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts index 0a4846221..f8f323f52 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlCreateOne } from './access-control-create-one.decorator'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlCreateOne(resource) createOne() { return null; } - @AccessControlCreateOne(resource, filterCallback) - createOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlCreateOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.createOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.createOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.createOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.BODY, - filter: filterCallback, + action: ActionEnum.CREATE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts index f19eb0ff1..ba0b1156c 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-create-one.decorator.ts @@ -1,36 +1,17 @@ import { AccessControlGrant } from './access-control-grant.decorator'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ActionEnum } from '../enums/action.enum'; import { applyDecorators } from '@nestjs/common'; -import { AccessControlFilter } from './access-control-filter.decorator'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; /** - * Create one resource filter shortcut. + * Create one resource grant shortcut. * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. * @returns {ReturnType} Decorator function */ export const AccessControlCreateOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -): ReturnType => { - const acFilter = AccessControlGrant({ + resource: string, +): ReturnType => + AccessControlGrant({ resource: resource, - action: AccessControlAction.CREATE, + action: ActionEnum.CREATE, }); - - if (paramFilter) { - return applyDecorators( - acFilter, - AccessControlFilter({ - type: AccessControlFilterType.BODY, - filter: paramFilter, - }), - ); - } else { - return acFilter; - } -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts index 2775c04fa..82a1173bd 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlDeleteOne } from './access-control-delete-one.decorator'; describe('@AccessControlDeleteOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlDeleteOne(resource) deleteOne() { return null; } - @AccessControlDeleteOne(resource, filterCallback) - deleteOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlDeleteOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.DELETE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.deleteOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.deleteOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.DELETE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.deleteOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.PATH, - filter: filterCallback, + action: ActionEnum.DELETE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts index a111abc83..740a553fc 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-delete-one.decorator.ts @@ -1,36 +1,17 @@ import { AccessControlGrant } from './access-control-grant.decorator'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; import { applyDecorators } from '@nestjs/common'; -import { AccessControlFilter } from './access-control-filter.decorator'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; +import { ActionEnum } from '../enums/action.enum'; /** - * Delete one resource filter shortcut + * Delete one resource grant shortcut * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. * @returns {ReturnType} Decorator function */ export const AccessControlDeleteOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -): ReturnType => { - const acFilter = AccessControlGrant({ + resource: string, +): ReturnType => + AccessControlGrant({ resource: resource, - action: AccessControlAction.DELETE, + action: ActionEnum.DELETE, }); - - if (paramFilter) { - return applyDecorators( - acFilter, - AccessControlFilter({ - type: AccessControlFilterType.PATH, - filter: paramFilter, - }), - ); - } else { - return acFilter; - } -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.spec.ts deleted file mode 100644 index 1866c1eea..000000000 --- a/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { ACCESS_CONTROL_MODULE_FILTERS_METADATA } from '../constants'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; -import { AccessControlFilter } from './access-control-filter.decorator'; - -describe('@AccessControlFilter', () => { - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - - @Controller() - class TestController { - @AccessControlFilter({ - type: AccessControlFilterType.BODY, - filter: filterCallback, - }) - createOne() { - return null; - } - @AccessControlFilter({ - type: AccessControlFilterType.QUERY, - filter: filterCallback, - }) - getList() { - return null; - } - @AccessControlFilter({ - type: AccessControlFilterType.PATH, - filter: filterCallback, - }) - getOne() { - return null; - } - } - - const controller = new TestController(); - - describe('enhance controller methods with access control filters', () => { - it('createOne should have filter on BODY metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.createOne, - ); - - expect(grants).toEqual([ - { - type: AccessControlFilterType.BODY, - filter: filterCallback, - }, - ]); - }); - - it('getList should have filter on QUERY metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getList, - ); - - expect(grants).toEqual([ - { - type: AccessControlFilterType.QUERY, - filter: filterCallback, - }, - ]); - }); - - it('getList should have filter on PATH metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getOne, - ); - - expect(grants).toEqual([ - { - type: AccessControlFilterType.PATH, - filter: filterCallback, - }, - ]); - }); - }); -}); diff --git a/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.ts deleted file mode 100644 index af281ab3f..000000000 --- a/packages/nestjs-access-control/src/decorators/access-control-filter.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { AccessControlFilterOption } from '../interfaces/access-control-filter-option.interface'; -import { ACCESS_CONTROL_MODULE_FILTERS_METADATA } from '../constants'; - -/** - * Define access filters required for this route. - * - * @param {AccessControlFilterOption[]} acFilters Array of access control filters. - * @returns {ReturnType} Decorator function. - */ -export const AccessControlFilter = ( - ...acFilters: AccessControlFilterOption[] -): ReturnType => { - return SetMetadata(ACCESS_CONTROL_MODULE_FILTERS_METADATA, acFilters); -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts index 6b2882008..4270c4c2a 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.spec.ts @@ -1,6 +1,6 @@ import { Controller } from '@nestjs/common'; import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlGrant } from './access-control-grant.decorator'; describe('@AccessControlGrant', () => { @@ -10,28 +10,28 @@ describe('@AccessControlGrant', () => { class TestController { @AccessControlGrant({ resource: resource, - action: AccessControlAction.CREATE, + action: ActionEnum.CREATE, }) createOne() { return null; } @AccessControlGrant({ resource: resource, - action: AccessControlAction.READ, + action: ActionEnum.READ, }) getOne() { return null; } @AccessControlGrant({ resource: resource, - action: AccessControlAction.UPDATE, + action: ActionEnum.UPDATE, }) updateOne() { return null; } @AccessControlGrant({ resource: resource, - action: AccessControlAction.DELETE, + action: ActionEnum.DELETE, }) deleteOne() { return null; @@ -50,7 +50,7 @@ describe('@AccessControlGrant', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.CREATE, + action: ActionEnum.CREATE, }, ]); }); @@ -64,7 +64,7 @@ describe('@AccessControlGrant', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.READ, + action: ActionEnum.READ, }, ]); }); @@ -78,7 +78,7 @@ describe('@AccessControlGrant', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.UPDATE, + action: ActionEnum.UPDATE, }, ]); }); @@ -92,7 +92,7 @@ describe('@AccessControlGrant', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.DELETE, + action: ActionEnum.DELETE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts index aeaabc3f2..4fc5d62ff 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-grant.decorator.ts @@ -1,15 +1,15 @@ import { SetMetadata } from '@nestjs/common'; -import { AccessControlGrantOption } from '../interfaces/access-control-grant-option.interface'; +import { AccessControlGrantOptionInterface } from '../interfaces/access-control-grant-option.interface'; import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; /** - * Define access control filters required for this route. + * Define access control grants required for this route. * - * @param {AccessControlGrantOption[]} acFilters Array of access control filters. + * @param {AccessControlGrantOptionInterface[]} acGrants Array of access control grants. * @returns {ReturnType} Decorator function. */ export const AccessControlGrant = ( - ...acFilters: AccessControlGrantOption[] + ...acGrants: AccessControlGrantOptionInterface[] ): ReturnType => { - return SetMetadata(ACCESS_CONTROL_MODULE_GRANT_METADATA, acFilters); + return SetMetadata(ACCESS_CONTROL_MODULE_GRANT_METADATA, acGrants); }; diff --git a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts new file mode 100644 index 000000000..a63e31343 --- /dev/null +++ b/packages/nestjs-access-control/src/decorators/access-control-query.decorator.spec.ts @@ -0,0 +1,40 @@ +import { Controller } from '@nestjs/common'; +import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../constants'; +import { CanAccess } from '../interfaces/can-access.interface'; +import { AccessControlQuery } from './access-control-query.decorator'; +import { AccessControlContextInterface } from '../interfaces/access-control-context.interface'; + +describe('@AccessControlQuery', () => { + class TestQueryService implements CanAccess { + async canAccess(_context: AccessControlContextInterface): Promise { + return true; + } + } + + @Controller() + class TestController { + @AccessControlQuery({ + service: TestQueryService, + }) + createOne() { + return null; + } + } + + const controller = new TestController(); + + describe('enhance controller methods with access control query', () => { + it('createOne should have query on metadata', () => { + const grants = Reflect.getMetadata( + ACCESS_CONTROL_MODULE_QUERY_METADATA, + controller.createOne, + ); + + expect(grants).toEqual([ + { + service: TestQueryService, + }, + ]); + }); + }); +}); diff --git a/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts new file mode 100644 index 000000000..d824e9729 --- /dev/null +++ b/packages/nestjs-access-control/src/decorators/access-control-query.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common'; +import { AccessControlQueryOptionInterface } from '../interfaces/access-control-query-option.interface'; +import { ACCESS_CONTROL_MODULE_QUERY_METADATA } from '../constants'; + +/** + * Define access query options for this route. + * + * @param {AccessControlQueryOptionInterface[]} queryOptions Array of access control query options. + * @returns {ReturnType} Decorator function. + */ +export const AccessControlQuery = ( + ...queryOptions: AccessControlQueryOptionInterface[] +): ReturnType => { + return SetMetadata(ACCESS_CONTROL_MODULE_QUERY_METADATA, queryOptions); +}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts index 7cba2858e..27b6bf8b9 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlReadMany } from './access-control-read-many.decorator'; describe('@AccessControlReadMany', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlReadMany(resource) getList() { return null; } - @AccessControlReadMany(resource, filterCallback) - getListFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlReadMany', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.READ, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getList, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getListFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.READ, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getListFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.QUERY, - filter: filterCallback, + action: ActionEnum.READ, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts index 2b648da27..13aeae78c 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-read-many.decorator.ts @@ -1,36 +1,17 @@ -import { AccessControlGrant } from './access-control-grant.decorator'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; import { applyDecorators } from '@nestjs/common'; -import { AccessControlFilter } from './access-control-filter.decorator'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; +import { ActionEnum } from '../enums/action.enum'; +import { AccessControlGrant } from './access-control-grant.decorator'; /** - * Read many resource filter shortcut. + * Read many resource grant shortcut. * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. * @returns {ReturnType} Decorator function */ export const AccessControlReadMany = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -): ReturnType => { - const acFilter = AccessControlGrant({ + resource: string, +): ReturnType => + AccessControlGrant({ resource: resource, - action: AccessControlAction.READ, + action: ActionEnum.READ, }); - - if (paramFilter) { - return applyDecorators( - acFilter, - AccessControlFilter({ - type: AccessControlFilterType.QUERY, - filter: paramFilter, - }), - ); - } else { - return acFilter; - } -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts index 54428be0b..946e616d6 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlReadOne } from './access-control-read-one.decorator'; describe('@AccessControlReadOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlReadOne(resource) getOne() { return null; } - @AccessControlReadOne(resource, filterCallback) - getOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlReadOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.READ, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.getOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.READ, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.getOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.PATH, - filter: filterCallback, + action: ActionEnum.READ, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts index e566dd167..e4cdeabf7 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-read-one.decorator.ts @@ -1,36 +1,17 @@ -import { AccessControlGrant } from './access-control-grant.decorator'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; import { applyDecorators } from '@nestjs/common'; -import { AccessControlFilter } from './access-control-filter.decorator'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; +import { ActionEnum } from '../enums/action.enum'; +import { AccessControlGrant } from './access-control-grant.decorator'; /** - * Read one resource filter shortcut + * Read one resource grant shortcut * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. * @returns {ReturnType} Decorator function */ export const AccessControlReadOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -): ReturnType => { - const acFilter = AccessControlGrant({ + resource: string, +): ReturnType => + AccessControlGrant({ resource: resource, - action: AccessControlAction.READ, + action: ActionEnum.READ, }); - - if (paramFilter) { - return applyDecorators( - acFilter, - AccessControlFilter({ - type: AccessControlFilterType.PATH, - filter: paramFilter, - }), - ); - } else { - return acFilter; - } -}; diff --git a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts index 402c50c1d..0f21ce381 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlRecoverOne } from './access-control-recover-one.decorator'; describe('@AccessControlCreateOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlRecoverOne(resource) recoverOne() { return null; } - @AccessControlRecoverOne(resource, filterCallback) - recoverOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlCreateOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.recoverOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.recoverOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.CREATE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.recoverOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.BODY, - filter: filterCallback, + action: ActionEnum.CREATE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts index 737087905..549f33aaf 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-recover-one.decorator.ts @@ -1,14 +1,9 @@ -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; import { AccessControlCreateOne } from './access-control-create-one.decorator'; /** - * Recover one resource filter shortcut. + * Recover one resource grant shortcut. * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. */ -export const AccessControlRecoverOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -) => AccessControlCreateOne(resource, paramFilter); +export const AccessControlRecoverOne = (resource: string) => + AccessControlCreateOne(resource); diff --git a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts index 01444a1f4..f87390262 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlReplaceOne } from './access-control-replace-one.decorator'; describe('@AccessControlUpdateOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlReplaceOne(resource) replaceOne() { return null; } - @AccessControlReplaceOne(resource, filterCallback) - replaceOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlUpdateOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.UPDATE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.replaceOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.replaceOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.UPDATE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.replaceOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.PATH, - filter: filterCallback, + action: ActionEnum.UPDATE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts index 13ff45959..07c64290f 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-replace-one.decorator.ts @@ -1,14 +1,9 @@ -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; import { AccessControlUpdateOne } from './access-control-update-one.decorator'; /** - * Update one resource filter shortcut + * Update one resource grant shortcut * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. */ -export const AccessControlReplaceOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -) => AccessControlUpdateOne(resource, paramFilter); +export const AccessControlReplaceOne = (resource: string) => + AccessControlUpdateOne(resource); diff --git a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts index 263dabac0..8b2b14cb2 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.spec.ts @@ -1,34 +1,17 @@ import { Controller } from '@nestjs/common'; -import { - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - ACCESS_CONTROL_MODULE_GRANT_METADATA, -} from '../constants'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; +import { ACCESS_CONTROL_MODULE_GRANT_METADATA } from '../constants'; +import { ActionEnum } from '../enums/action.enum'; import { AccessControlUpdateOne } from './access-control-update-one.decorator'; describe('@AccessControlUpdateOne', () => { const resource = 'a_protected_resource'; - const filterCallback: AccessControlFilterCallback = ( - _data, - _user, - _service, - ) => { - return Promise.resolve(false); - }; - @Controller() class TestController { @AccessControlUpdateOne(resource) updateOne() { return null; } - @AccessControlUpdateOne(resource, filterCallback) - updateOneFiltered() { - return null; - } } const controller = new TestController(); @@ -43,46 +26,7 @@ describe('@AccessControlUpdateOne', () => { expect(grants).toEqual([ { resource: resource, - action: AccessControlAction.UPDATE, - }, - ]); - }); - - it('should NOT have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.updateOne, - ); - - expect(filters).toBeUndefined(); - }); - }); - - describe('enhance controller method with access control and filter', () => { - it('should have grants metadata', () => { - const grants = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_GRANT_METADATA, - controller.updateOneFiltered, - ); - - expect(grants).toEqual([ - { - resource: resource, - action: AccessControlAction.UPDATE, - }, - ]); - }); - - it('should have filters metadata', () => { - const filters = Reflect.getMetadata( - ACCESS_CONTROL_MODULE_FILTERS_METADATA, - controller.updateOneFiltered, - ); - - expect(filters).toEqual([ - { - type: AccessControlFilterType.PATH, - filter: filterCallback, + action: ActionEnum.UPDATE, }, ]); }); diff --git a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts b/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts index 67d40d3ac..ef094b668 100644 --- a/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts +++ b/packages/nestjs-access-control/src/decorators/access-control-update-one.decorator.ts @@ -1,36 +1,17 @@ -import { AccessControlGrant } from './access-control-grant.decorator'; -import { AccessControlFilterCallback } from '../interfaces/access-control-filter-option.interface'; import { applyDecorators } from '@nestjs/common'; -import { AccessControlFilter } from './access-control-filter.decorator'; -import { AccessControlGrantResource } from '../interfaces/access-control-grant-option.interface'; -import { AccessControlAction } from '../enums/access-control-action.enum'; -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; +import { ActionEnum } from '../enums/action.enum'; +import { AccessControlGrant } from './access-control-grant.decorator'; /** - * Update one resource filter shortcut + * Update one resource grant shortcut * - * @param {AccessControlGrantResource} resource The grant resource. - * @param {AccessControlFilterCallback} paramFilter An optional param filter. + * @param string resource The grant resource. * @returns {ReturnType} Decorator function */ export const AccessControlUpdateOne = ( - resource: AccessControlGrantResource, - paramFilter?: AccessControlFilterCallback, -): ReturnType => { - const acFilter = AccessControlGrant({ + resource: string, +): ReturnType => + AccessControlGrant({ resource: resource, - action: AccessControlAction.UPDATE, + action: ActionEnum.UPDATE, }); - - if (paramFilter) { - return applyDecorators( - acFilter, - AccessControlFilter({ - type: AccessControlFilterType.PATH, - filter: paramFilter, - }), - ); - } else { - return acFilter; - } -}; diff --git a/packages/nestjs-access-control/src/decorators/use-access-control.decorator.spec.ts b/packages/nestjs-access-control/src/decorators/use-access-control.decorator.spec.ts deleted file mode 100644 index a15ec9957..000000000 --- a/packages/nestjs-access-control/src/decorators/use-access-control.decorator.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { UseAccessControl } from './use-access-control.decorator'; -import { ACCESS_CONTROL_MODULE_CTLR_METADATA } from '../constants'; -import { AccessControlFilterService } from '../interfaces/access-control-filter-service.interface'; - -describe('@UseAccessControl', () => { - class TestFilterService implements AccessControlFilterService {} - - @Controller() - @UseAccessControl({ service: TestFilterService }) - class TestController {} - - it('should enhance class with expected use access control metadata', () => { - const metadata = Reflect.getOwnMetadata( - ACCESS_CONTROL_MODULE_CTLR_METADATA, - TestController, - ); - expect(metadata).toEqual( - expect.objectContaining({ service: TestFilterService }), - ); - }); -}); diff --git a/packages/nestjs-access-control/src/decorators/use-access-control.decorator.ts b/packages/nestjs-access-control/src/decorators/use-access-control.decorator.ts deleted file mode 100644 index af6152e11..000000000 --- a/packages/nestjs-access-control/src/decorators/use-access-control.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { AccessControlMetadataInterface } from '../interfaces/access-control-metadata.interface'; -import { ACCESS_CONTROL_MODULE_CTLR_METADATA } from '../constants'; - -/** - * Define access control filters required for this route. - * - * @param options Access control options. - * @returns Decorator function. - */ -export const UseAccessControl = ( - options: AccessControlMetadataInterface = {}, -): ReturnType => { - return SetMetadata(ACCESS_CONTROL_MODULE_CTLR_METADATA, options); -}; diff --git a/packages/nestjs-access-control/src/enums/access-control-action.enum.spec.ts b/packages/nestjs-access-control/src/enums/access-control-action.enum.spec.ts deleted file mode 100644 index 34517d4a6..000000000 --- a/packages/nestjs-access-control/src/enums/access-control-action.enum.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AccessControlAction } from './access-control-action.enum'; - -describe('Access control action enumeration', () => { - it('should match specification', () => { - expect(AccessControlAction).toEqual({ - CREATE: 'CREATE', - READ: 'READ', - UPDATE: 'UPDATE', - DELETE: 'DELETE', - }); - }); -}); diff --git a/packages/nestjs-access-control/src/enums/access-control-action.enum.ts b/packages/nestjs-access-control/src/enums/access-control-action.enum.ts deleted file mode 100644 index adc521761..000000000 --- a/packages/nestjs-access-control/src/enums/access-control-action.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum AccessControlAction { - CREATE = 'CREATE', - READ = 'READ', - UPDATE = 'UPDATE', - DELETE = 'DELETE', -} diff --git a/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.spec.ts b/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.spec.ts deleted file mode 100644 index fbf3d5d22..000000000 --- a/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AccessControlFilterType } from './access-control-filter-type.enum'; - -describe('Access control action enumeration', () => { - it('should match specification', () => { - expect(AccessControlFilterType).toEqual({ - QUERY: 'query', - BODY: 'body', - PATH: 'params', - }); - }); -}); diff --git a/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.ts b/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.ts deleted file mode 100644 index 24726092c..000000000 --- a/packages/nestjs-access-control/src/enums/access-control-filter-type.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum AccessControlFilterType { - QUERY = 'query', - BODY = 'body', - PATH = 'params', -} diff --git a/packages/nestjs-access-control/src/enums/action.enum.spec.ts b/packages/nestjs-access-control/src/enums/action.enum.spec.ts new file mode 100644 index 000000000..7685c3a53 --- /dev/null +++ b/packages/nestjs-access-control/src/enums/action.enum.spec.ts @@ -0,0 +1,13 @@ +import { Action } from 'accesscontrol/lib/enums'; +import { ActionEnum } from './action.enum'; + +describe('Access control action enumeration', () => { + it('should match specification', () => { + expect(ActionEnum).toEqual({ + CREATE: Action.CREATE, + READ: Action.READ, + UPDATE: Action.UPDATE, + DELETE: Action.DELETE, + }); + }); +}); diff --git a/packages/nestjs-access-control/src/enums/action.enum.ts b/packages/nestjs-access-control/src/enums/action.enum.ts new file mode 100644 index 000000000..85b646950 --- /dev/null +++ b/packages/nestjs-access-control/src/enums/action.enum.ts @@ -0,0 +1,6 @@ +export enum ActionEnum { + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/packages/nestjs-access-control/src/enums/possession.enum.spec.ts b/packages/nestjs-access-control/src/enums/possession.enum.spec.ts new file mode 100644 index 000000000..2926d5941 --- /dev/null +++ b/packages/nestjs-access-control/src/enums/possession.enum.spec.ts @@ -0,0 +1,11 @@ +import { Possession } from 'accesscontrol/lib/enums'; +import { PossessionEnum } from './possession.enum'; + +describe('Access control possession enumeration', () => { + it('should match specification', () => { + expect(PossessionEnum).toEqual({ + ANY: Possession.ANY, + OWN: Possession.OWN, + }); + }); +}); diff --git a/packages/nestjs-access-control/src/enums/possession.enum.ts b/packages/nestjs-access-control/src/enums/possession.enum.ts new file mode 100644 index 000000000..9a687e226 --- /dev/null +++ b/packages/nestjs-access-control/src/enums/possession.enum.ts @@ -0,0 +1,4 @@ +export enum PossessionEnum { + ANY = 'any', + OWN = 'own', +} diff --git a/packages/nestjs-access-control/src/index.spec.ts b/packages/nestjs-access-control/src/index.spec.ts index 6d37d055a..8ecb2983c 100644 --- a/packages/nestjs-access-control/src/index.spec.ts +++ b/packages/nestjs-access-control/src/index.spec.ts @@ -1,19 +1,20 @@ import { - AccessControlAction, + ActionEnum, + PossessionEnum, AccessControlCreateMany, AccessControlCreateOne, AccessControlDeleteOne, - AccessControlFilter, - AccessControlFilterType, + AccessControlQuery, AccessControlGrant, AccessControlGuard, AccessControlModule, + AccessControlContext, AccessControlReadMany, AccessControlReadOne, AccessControlRecoverOne, AccessControlReplaceOne, AccessControlUpdateOne, - UseAccessControl, + AccessControlService, } from './index'; describe('Index', () => { @@ -21,6 +22,11 @@ describe('Index', () => { it('All exported modules should be imported', () => { expect(AccessControlGuard).toEqual(expect.any(Function)); expect(AccessControlModule).toEqual(expect.any(Function)); + expect(AccessControlContext).toEqual(expect.any(Function)); + }); + + it('All exported services should be imported', () => { + expect(AccessControlService).toEqual(expect.any(Function)); }); // decorators @@ -28,19 +34,18 @@ describe('Index', () => { expect(AccessControlCreateMany).toEqual(expect.any(Function)); expect(AccessControlCreateOne).toEqual(expect.any(Function)); expect(AccessControlDeleteOne).toEqual(expect.any(Function)); - expect(AccessControlFilter).toEqual(expect.any(Function)); + expect(AccessControlQuery).toEqual(expect.any(Function)); expect(AccessControlGrant).toEqual(expect.any(Function)); expect(AccessControlReadMany).toEqual(expect.any(Function)); expect(AccessControlReadOne).toEqual(expect.any(Function)); expect(AccessControlUpdateOne).toEqual(expect.any(Function)); expect(AccessControlRecoverOne).toEqual(expect.any(Function)); expect(AccessControlReplaceOne).toEqual(expect.any(Function)); - expect(UseAccessControl).toEqual(expect.any(Function)); }); // enums it('All exported enums should be imported', () => { - expect(AccessControlAction).toEqual(expect.any(Object)); - expect(AccessControlFilterType).toEqual(expect.any(Object)); + expect(ActionEnum).toEqual(expect.any(Object)); + expect(PossessionEnum).toEqual(expect.any(Object)); }); }); diff --git a/packages/nestjs-access-control/src/index.ts b/packages/nestjs-access-control/src/index.ts index 43bfce386..09ba4edea 100644 --- a/packages/nestjs-access-control/src/index.ts +++ b/packages/nestjs-access-control/src/index.ts @@ -1,21 +1,28 @@ export * from './access-control.guard'; export * from './access-control.module'; -export * from './decorators/use-access-control.decorator'; +export { AccessControlContext } from './access-control.context'; +export { AccessControlService } from './services/access-control.service'; export * from './decorators/access-control-create-many.decorator'; export * from './decorators/access-control-create-one.decorator'; export * from './decorators/access-control-delete-one.decorator'; -export * from './decorators/access-control-filter.decorator'; +export * from './decorators/access-control-query.decorator'; export * from './decorators/access-control-grant.decorator'; export * from './decorators/access-control-read-many.decorator'; export * from './decorators/access-control-read-one.decorator'; export * from './decorators/access-control-recover-one.decorator'; export * from './decorators/access-control-replace-one.decorator'; export * from './decorators/access-control-update-one.decorator'; -export * from './enums/access-control-action.enum'; -export * from './enums/access-control-filter-type.enum'; -export * from './interfaces/access-control-filter-option.interface'; -export * from './interfaces/access-control-filter-service.interface'; +export { ActionEnum } from './enums/action.enum'; +export { PossessionEnum } from './enums/possession.enum'; +export { CanAccess } from './interfaces/can-access.interface'; +export { AccessControlContextInterface } from './interfaces/access-control-context.interface'; +export * from './interfaces/access-control-query-option.interface'; export * from './interfaces/access-control-grant-option.interface'; export * from './interfaces/access-control-options.interface'; export * from './interfaces/access-control-metadata.interface'; export * from './interfaces/access-control-service.interface'; + +/** + * COMPAT + */ +export { ActionEnum as AccessControlAction } from './enums/action.enum'; diff --git a/packages/nestjs-access-control/src/interfaces/access-control-async-options.ts b/packages/nestjs-access-control/src/interfaces/access-control-async-options.ts deleted file mode 100644 index 088e49ab0..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-async-options.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FactoryProvider, ModuleMetadata } from '@nestjs/common/interfaces'; -import { AccessControlMetadataInterface } from './access-control-metadata.interface'; - -export interface AccessControlAsyncOptions - extends Pick, - Pick< - FactoryProvider< - AccessControlMetadataInterface | Promise - >, - 'useFactory' | 'inject' - > {} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts new file mode 100644 index 000000000..766218ab2 --- /dev/null +++ b/packages/nestjs-access-control/src/interfaces/access-control-context-args.interface.ts @@ -0,0 +1,10 @@ +import { AccessControl, IQueryInfo } from 'accesscontrol'; +import { ExecutionContext } from '@nestjs/common'; + +export interface AccessControlContextArgsInterface { + request: unknown; + user: unknown; + query: IQueryInfo; + accessControl: AccessControl; + executionContext: ExecutionContext; +} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts new file mode 100644 index 000000000..b229fecf6 --- /dev/null +++ b/packages/nestjs-access-control/src/interfaces/access-control-context.interface.ts @@ -0,0 +1,10 @@ +import { AccessControl, IQueryInfo } from 'accesscontrol'; +import { ExecutionContext } from '@nestjs/common'; + +export interface AccessControlContextInterface { + getRequest(property?: string): unknown; + getUser(): unknown; + getQuery(): IQueryInfo; + getAccessControl(): AccessControl; + getExecutionContext(): ExecutionContext; +} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-filter-option.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-filter-option.interface.ts deleted file mode 100644 index 3bdf44e66..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-filter-option.interface.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { AccessControlFilterType } from '../enums/access-control-filter-type.enum'; -import { AccessControlFilterService } from './access-control-filter-service.interface'; - -export interface AccessControlFilterOption { - /** - * Which request data to check. - */ - type: AccessControlFilterType; - - /** - * Callback function used for advanced validation - */ - filter: AccessControlFilterCallback; -} - -export type AccessControlFilterCallback< - D = unknown, - U = unknown, - S = unknown, -> = ( - data: D, - user?: U, - acService?: S & AccessControlFilterService, -) => Promise; diff --git a/packages/nestjs-access-control/src/interfaces/access-control-filter-service.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-filter-service.interface.ts deleted file mode 100644 index 11c657825..000000000 --- a/packages/nestjs-access-control/src/interfaces/access-control-filter-service.interface.ts +++ /dev/null @@ -1 +0,0 @@ -export interface AccessControlFilterService {} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts index 275462ab1..5728abd73 100644 --- a/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts +++ b/packages/nestjs-access-control/src/interfaces/access-control-grant-option.interface.ts @@ -1,8 +1,6 @@ -import { AccessControlAction } from '../enums/access-control-action.enum'; +import { ActionEnum } from '../enums/action.enum'; -export interface AccessControlGrantOption { - resource: AccessControlGrantResource; - action: AccessControlAction; +export interface AccessControlGrantOptionInterface { + resource: string; + action: ActionEnum; } - -export type AccessControlGrantResource = string; diff --git a/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts index 980bbd834..8e1405f0e 100644 --- a/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts +++ b/packages/nestjs-access-control/src/interfaces/access-control-metadata.interface.ts @@ -1,6 +1,6 @@ -import { AccessControlFilterService } from './access-control-filter-service.interface'; import { Type } from '@nestjs/common'; +import { AccessControlServiceInterface } from './access-control-service.interface'; export interface AccessControlMetadataInterface { - service?: Type; + service?: Type; } diff --git a/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts index d6380bf40..fca2ac904 100644 --- a/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts +++ b/packages/nestjs-access-control/src/interfaces/access-control-options-extras.interface.ts @@ -1,4 +1,7 @@ -import { DynamicModule } from '@nestjs/common'; +import { DynamicModule, Provider } from '@nestjs/common'; +import { CanAccess } from './can-access.interface'; export interface AccessControlOptionsExtrasInterface - extends Pick {} + extends Pick { + queryServices?: Provider[]; +} diff --git a/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts index 3abcaa400..39f875a5a 100644 --- a/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts +++ b/packages/nestjs-access-control/src/interfaces/access-control-options.interface.ts @@ -1,7 +1,9 @@ +import { CanActivate } from '@nestjs/common'; import { AccessControlServiceInterface } from './access-control-service.interface'; import { AccessControlSettingsInterface } from './access-control-settings.interface'; export interface AccessControlOptionsInterface { settings: AccessControlSettingsInterface; service?: AccessControlServiceInterface; + appGuard?: CanActivate | false; } diff --git a/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts b/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts new file mode 100644 index 000000000..dd5a58eae --- /dev/null +++ b/packages/nestjs-access-control/src/interfaces/access-control-query-option.interface.ts @@ -0,0 +1,9 @@ +import { Type } from '@nestjs/common'; +import { CanAccess } from './can-access.interface'; + +export interface AccessControlQueryOptionInterface { + /** + * Service used for advanced validation + */ + service: Type; +} diff --git a/packages/nestjs-access-control/src/interfaces/can-access.interface.ts b/packages/nestjs-access-control/src/interfaces/can-access.interface.ts new file mode 100644 index 000000000..0d8c40e56 --- /dev/null +++ b/packages/nestjs-access-control/src/interfaces/can-access.interface.ts @@ -0,0 +1,5 @@ +import { AccessControlContextInterface } from './access-control-context.interface'; + +export interface CanAccess { + canAccess(context: AccessControlContextInterface): Promise; +} diff --git a/packages/nestjs-auth-github/package.json b/packages/nestjs-auth-github/package.json index 1340c2b58..3615da05b 100644 --- a/packages/nestjs-auth-github/package.json +++ b/packages/nestjs-auth-github/package.json @@ -29,6 +29,7 @@ "@concepta/nestjs-auth-jwt": "^4.0.0-alpha.37", "@concepta/nestjs-crud": "^4.0.0-alpha.37", "@concepta/nestjs-jwt": "^4.0.0-alpha.37", + "@concepta/nestjs-password": "^4.0.0-alpha.37", "@concepta/nestjs-typeorm-ext": "^4.0.0-alpha.37", "@concepta/nestjs-user": "^4.0.0-alpha.37", "@nestjs/testing": "^9.0.0", diff --git a/packages/nestjs-password/src/config/password-default.config.ts b/packages/nestjs-password/src/config/password-default.config.ts index f8366f93b..b24115ed8 100644 --- a/packages/nestjs-password/src/config/password-default.config.ts +++ b/packages/nestjs-password/src/config/password-default.config.ts @@ -8,9 +8,6 @@ import { PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN } from '../password.constants'; export const passwordDefaultConfig = registerAs( PASSWORD_MODULE_DEFAULT_SETTINGS_TOKEN, (): PasswordSettingsInterface => ({ - /** - * Get log levels from environment variables - */ maxPasswordAttempts: process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS ? Number.parseInt(process.env.PASSWORD_MAX_PASSWORD_ATTEMPTS) : 3, @@ -20,5 +17,8 @@ export const passwordDefaultConfig = registerAs( : process.env?.NODE_ENV === 'production' ? 8 : 0, + + requireCurrentToUpdate: + process.env?.PASSWORD_REQUIRE_CURRENT_TO_UPDATE === 'true' ? true : false, }), ); diff --git a/packages/nestjs-password/src/index.ts b/packages/nestjs-password/src/index.ts index 87b19988f..a788a73c9 100644 --- a/packages/nestjs-password/src/index.ts +++ b/packages/nestjs-password/src/index.ts @@ -12,3 +12,4 @@ export * from './interfaces/password-storage.interface'; export * from './interfaces/password-storage-service.interface'; export * from './interfaces/password-validation-service.interface'; export * from './interfaces/password-creation-service.interface'; +export { PasswordCreateObjectOptionsInterface } from './interfaces/password-create-object-options.interface'; diff --git a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts new file mode 100644 index 000000000..5290f2a28 --- /dev/null +++ b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts @@ -0,0 +1,11 @@ +export interface PasswordCreateObjectOptionsInterface { + /** + * Optional salt. If not provided, one will be generated. + */ + salt?: string; + + /** + * Set to true if password is required. + */ + required?: boolean; +} diff --git a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts index d8cdaff28..7192bc50a 100644 --- a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts @@ -1,5 +1,7 @@ import { PasswordPlainInterface } from '@concepta/ts-common'; import { PasswordStorageInterface } from './password-storage.interface'; +import { PasswordCurrentPasswordInterface } from './password-current-password.interface'; +import { PasswordCreateObjectOptionsInterface } from './password-create-object-options.interface'; /** * Password Creation Service Interface @@ -9,35 +11,38 @@ export interface PasswordCreationServiceInterface { * Create password for an object (optionally). * * @param object An object containing the new password to hash. - * @param options.salt Optional salt. If not provided, one will be generated. - * @param options.required Set to true if password is required. - * @param options.currentPassword Optional current password object to validate. + * @param options Password create options. * @returns A new object with the password hashed, with salt added. */ createObject( object: T, - options?: { - salt?: string; - required?: boolean; - currentPassword?: { - password: string; - object: PasswordStorageInterface; - }; - }, + options?: PasswordCreateObjectOptionsInterface, ): Promise< Omit | (Omit & PasswordStorageInterface) >; /** - * Check if attempt is valid + * Validate the current password for the targeted object. + * + * @param options Validate current options. + * @returns boolean + */ + validateCurrent: ( + options: Partial, + ) => Promise; + + /** + * Check if attempt is valid. + * * @returns Number of attempts user has to try */ checkAttempt(numOfAttempts: number): boolean; /** * Check number of attempts of using password + * * @param numOfAttempts number of attempts - * @returns + * @returns number of attempts left */ checkAttemptLeft(numOfAttempts: number): number; } diff --git a/packages/nestjs-password/src/interfaces/password-current-password.interface.ts b/packages/nestjs-password/src/interfaces/password-current-password.interface.ts new file mode 100644 index 000000000..07ddb90d6 --- /dev/null +++ b/packages/nestjs-password/src/interfaces/password-current-password.interface.ts @@ -0,0 +1,6 @@ +import { PasswordStorageInterface } from './password-storage.interface'; + +export interface PasswordCurrentPasswordInterface { + password: string; + target: PasswordStorageInterface; +} diff --git a/packages/nestjs-password/src/interfaces/password-settings.interface.ts b/packages/nestjs-password/src/interfaces/password-settings.interface.ts index 0221d0a0f..2b8ee0e6e 100644 --- a/packages/nestjs-password/src/interfaces/password-settings.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-settings.interface.ts @@ -13,4 +13,9 @@ export interface PasswordSettingsInterface { * Max number of password attempts allowed */ maxPasswordAttempts?: number; + + /** + * Require current password to update + */ + requireCurrentToUpdate?: boolean; } diff --git a/packages/nestjs-password/src/services/password-creation.service.spec.ts b/packages/nestjs-password/src/services/password-creation.service.spec.ts index fec38df1b..937f55a0b 100644 --- a/packages/nestjs-password/src/services/password-creation.service.spec.ts +++ b/packages/nestjs-password/src/services/password-creation.service.spec.ts @@ -9,20 +9,22 @@ import { PasswordStrengthService } from './password-strength.service'; import { PasswordValidationService } from './password-validation.service'; describe(PasswordCreationService, () => { + let config: PasswordSettingsInterface; let passwordCreationService: PasswordCreationService; let passwordStorageService: PasswordStorageService; let passwordValidationService: PasswordValidationService; let passwordStrengthService: PasswordStrengthService; - const config: PasswordSettingsInterface = { - maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.Medium, - }; - const PASSWORD_WEAK = 'secret'; const PASSWORD_MEDIUM = 'F*h#1d*fQ@XB'; beforeEach(async () => { + config = { + maxPasswordAttempts: 5, + minPasswordStrength: PasswordStrengthEnum.Medium, + requireCurrentToUpdate: false, + }; + passwordStorageService = new PasswordStorageService(); passwordValidationService = new PasswordValidationService(); passwordStrengthService = new PasswordStrengthService(config); @@ -46,7 +48,10 @@ describe(PasswordCreationService, () => { // encrypt password const passwordStorageObject: TestOut = - await passwordCreationService.createObject({ foo: 'bar' }); + await passwordCreationService.createObject( + { foo: 'bar' }, + { required: false }, + ); expect(passwordStorageObject).toEqual({ foo: 'bar' }); }); @@ -62,67 +67,64 @@ describe(PasswordCreationService, () => { expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); }); - it('should create a password on object WITH a VALID current password requirement', async () => { + it('should NOT create a password on object WITH a WEAK password', async () => { + const t = async () => { + // try to create on object with a weak password + await passwordCreationService.createObject({ + password: PASSWORD_WEAK, + }); + }; + + await expect(t).rejects.toThrow(Error); + await expect(t).rejects.toThrow('Password is not strong enough'); + }); + }); + + describe(PasswordCreationService.prototype.validateCurrent, () => { + it('should be validated', async () => { // encrypt "current" password const passwordStorageObjectCurrent: PasswordStorageInterface = await passwordStorageService.hashObject({ password: 'current-password-string', }); - const passwordStorageObject: PasswordStorageInterface = - await passwordCreationService.createObject( - { - password: PASSWORD_MEDIUM, - }, - { - currentPassword: { - password: 'current-password-string', - object: passwordStorageObjectCurrent, - }, - }, - ); + const isValid = await passwordCreationService.validateCurrent({ + password: 'current-password-string', + target: passwordStorageObjectCurrent, + }); - expect(typeof passwordStorageObject.passwordHash).toEqual('string'); - expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); + expect(isValid).toEqual(true); }); - it('should NOT create a password on object WITH an INVALID current password requirement', async () => { - const t = async () => { - // encrypt "current" password - const passwordStorageObjectCurrent: PasswordStorageInterface = - await passwordStorageService.hashObject({ - password: 'current-password-string', - }); - - await passwordCreationService.createObject( - { - password: PASSWORD_MEDIUM, - }, - { - currentPassword: { - password: 'bad-current-password-string', - object: passwordStorageObjectCurrent, - }, - }, - ); - }; + it('should NOT be validated', async () => { + // encrypt "current" password + const passwordStorageObjectCurrent: PasswordStorageInterface = + await passwordStorageService.hashObject({ + password: 'current-password-string', + }); - await expect(t).rejects.toThrow(Error); - await expect(t).rejects.toThrow( - 'Current password that was supplied is not valid', - ); + const isValid = await passwordCreationService.validateCurrent({ + password: 'bad-current-password-string', + target: passwordStorageObjectCurrent, + }); + + expect(isValid).toEqual(false); }); - it('should NOT create a password on object WITH a WEAK password', async () => { + it('should NOT throw an error due to required current password setting', async () => { + const isValid = await passwordCreationService.validateCurrent({}); + expect(isValid).toEqual(true); + }); + + it('should throw an error due to required current password setting', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = true; + const t = async () => { - // try to create on object with a weak password - await passwordCreationService.createObject({ - password: PASSWORD_WEAK, - }); + await passwordCreationService.validateCurrent({}); }; await expect(t).rejects.toThrow(Error); - await expect(t).rejects.toThrow('Password is not strong enough'); + await expect(t).rejects.toThrow('Current password is required'); }); }); diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index ca49748e3..9c987661c 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -8,6 +8,8 @@ import { PasswordPlainInterface } from '@concepta/ts-common'; import { PasswordStorageInterface } from '../interfaces/password-storage.interface'; import { PasswordStorageService } from './password-storage.service'; import { PasswordValidationService } from './password-validation.service'; +import { PasswordCreateObjectOptionsInterface } from '../interfaces/password-create-object-options.interface'; +import { PasswordCurrentPasswordInterface } from '../interfaces/password-current-password.interface'; /** * Service with functions related to password creation @@ -33,42 +35,24 @@ export class PasswordCreationService * Create password for an object. * * @param object An object containing the new password to hash. - * @param options.salt Optional salt. If not provided, one will be generated. - * @param options.required Set to true if password is required. - * @param options.currentPassword Optional current password object to validate. + * @param options Password create options. * @returns A new object with the password hashed, with salt added. */ async createObject( object: T, - options?: { - salt?: string; - required?: boolean; - currentPassword?: { - password: string; - object: PasswordStorageInterface; - }; - }, + options?: PasswordCreateObjectOptionsInterface, ): Promise & PasswordStorageInterface>; /** * Create password for an object. * * @param object An object containing the new password to hash. - * @param options.salt Optional salt. If not provided, one will be generated. - * @param options.required Set to true if password is required. - * @param options.currentPassword Optional current password object to validate. + * @param options Password create options. * @returns A new object with the password hashed, with salt added. */ async createObject>( - object: Partial, - options?: { - salt?: string; - required?: boolean; - currentPassword?: { - password: string; - object: PasswordStorageInterface; - }; - }, + object: T, + options?: PasswordCreateObjectOptionsInterface, ): Promise< | Omit | (Omit & Partial) @@ -78,55 +62,53 @@ export class PasswordCreationService * Create password for an object. * * @param object An object containing the new password to hash. - * @param options.salt Optional salt. If not provided, one will be generated. - * @param options.required Set to true if password is required. - * @param options.currentPassword Optional current password object to validate. + * @param options Password create options. * @returns A new object with the password hashed, with salt added. */ async createObject( object: T, - options?: { - salt?: string; - required?: boolean; - currentPassword?: { - password: string; - object: PasswordStorageInterface; - }; - }, + options?: PasswordCreateObjectOptionsInterface, ): Promise< Omit | (Omit & PasswordStorageInterface) > { // extract properties const { password } = object; - const { currentPassword } = options ?? {}; // is the password in the object? if (typeof password === 'string') { - // maybe check current password - if (currentPassword) { - // call validation service - const currentPasswordIsValid = - await this.passwordValidationService.validateObject( - currentPassword.password, - currentPassword.object, - ); - - // is it valid? - if (!currentPasswordIsValid) { - // current password they supplied is not valid - throw new Error('Current password that was supplied is not valid'); - } - } - // check strength if (!this.passwordStrengthService.isStrong(password)) { throw new Error('Password is not strong enough'); } + } - return this.passwordStorageService.hashObject(object, options); + // finally hash it + return this.passwordStorageService.hashObject(object, options); + } + + public async validateCurrent( + options: Partial, + ): Promise { + const { password, target: object } = options || { + password: undefined, + target: undefined, + }; + + // make sure the password is a string with some length + if (typeof password === 'string' && password.length > 0 && object) { + // validate it + return this.passwordValidationService.validateObject(password, object); + } else { + // settings say that current password is required? + if (this.settings?.requireCurrentToUpdate === true) { + // TODO: should be a password exception class + // reqs not met, throw exception + throw new Error('Current password is required'); + } } - return object; + // valid by default + return true; } /** diff --git a/packages/nestjs-user/package.json b/packages/nestjs-user/package.json index 65e071929..9dc8c8c43 100644 --- a/packages/nestjs-user/package.json +++ b/packages/nestjs-user/package.json @@ -27,10 +27,14 @@ "@nestjs/swagger": "^6.0.0" }, "devDependencies": { + "@concepta/nestjs-authentication": "^4.0.0-alpha.37", + "@concepta/nestjs-auth-jwt": "^4.0.0-alpha.37", + "@concepta/nestjs-jwt": "^4.0.0-alpha.37", "@concepta/typeorm-seeding": "^4.0.0-beta.0", "@faker-js/faker": "^6.0.0-alpha.6", "@nestjs/testing": "^9.0.0", "@nestjs/typeorm": "^9.0.0", + "accesscontrol": "^2.2.1", "jest-mock-extended": "^2.0.4", "supertest": "^6.1.6" }, diff --git a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts index ba476cdb3..acd4b221e 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts @@ -1,24 +1,48 @@ +import { AccessControl } from 'accesscontrol'; import { Module } from '@nestjs/common'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { PasswordModule } from '@concepta/nestjs-password'; +import { AuthenticationModule } from '@concepta/nestjs-authentication'; +import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; +import { JwtModule } from '@concepta/nestjs-jwt'; +import { AccessControlModule } from '@concepta/nestjs-access-control'; import { CrudModule } from '@concepta/nestjs-crud'; import { EventModule } from '@concepta/nestjs-event'; -import { PasswordModule } from '@concepta/nestjs-password'; -import { createUserRepositoryFixture } from './create-user-repository.fixture'; import { UserModule } from '../user.module'; +import { createUserRepositoryFixture } from './create-user-repository.fixture'; import { UserModuleCustomFixture } from './user.module.custom.fixture'; import { UserLookupCustomService } from './services/user-lookup.custom.service'; import { ormConfig } from './ormconfig.fixture'; import { UserEntityFixture } from './user.entity.fixture'; +import { UserResource } from '../user.types'; + +const rules = new AccessControl(); +rules + .grant('user') + .resource(UserResource.One) + .createOwn() + .readOwn() + .updateOwn() + .deleteOwn(); @Module({ imports: [ + UserModuleCustomFixture, TypeOrmExtModule.forRoot(ormConfig), CrudModule.forRoot({}), EventModule.forRoot({}), + JwtModule.forRoot({}), + AuthJwtModule.forRootAsync({ + inject: [UserLookupCustomService], + useFactory: (userLookupService: UserLookupCustomService) => ({ + userLookupService, + }), + }), + AuthenticationModule.forRoot({}), PasswordModule.forRoot({}), + AccessControlModule.forRoot({ settings: { rules } }), UserModule.forRootAsync({ - imports: [UserModuleCustomFixture], inject: [UserLookupCustomService], useFactory: async (userLookupService: UserLookupCustomService) => ({ userLookupService, diff --git a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts index 5a2b66ce4..b8c9eb6e7 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts @@ -1,21 +1,51 @@ +import { AccessControl } from 'accesscontrol'; import { Module } from '@nestjs/common'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; -import { CrudModule } from '@concepta/nestjs-crud'; +import { AuthenticationModule } from '@concepta/nestjs-authentication'; +import { AccessControlModule } from '@concepta/nestjs-access-control'; +import { JwtModule } from '@concepta/nestjs-jwt'; +import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; import { PasswordModule } from '@concepta/nestjs-password'; +import { CrudModule } from '@concepta/nestjs-crud'; import { EventModule } from '@concepta/nestjs-event'; import { UserModule } from '../user.module'; -import { ormConfig } from './ormconfig.fixture'; -import { UserEntityFixture } from './user.entity.fixture'; +import { UserResource } from '../user.types'; import { InvitationAcceptedEventAsync } from './events/invitation-accepted.event'; import { InvitationGetUserEventAsync } from './events/invitation-get-user.event'; +import { UserLookupService } from '../services/user-lookup.service'; +import { UserAccessQueryService } from '../services/user-access-query.service'; + +import { ormConfig } from './ormconfig.fixture'; +import { UserEntityFixture } from './user.entity.fixture'; + +const rules = new AccessControl(); +rules + .grant('user') + .resource(UserResource.One) + .createOwn() + .readOwn() + .updateOwn() + .deleteOwn(); @Module({ imports: [ TypeOrmExtModule.forRoot(ormConfig), CrudModule.forRoot({}), EventModule.forRoot({}), + JwtModule.forRoot({}), + AuthJwtModule.forRootAsync({ + inject: [UserLookupService], + useFactory: (userLookupService: UserLookupService) => ({ + userLookupService, + }), + }), + AuthenticationModule.forRoot({}), PasswordModule.forRoot({}), + AccessControlModule.forRoot({ + settings: { rules }, + queryServices: [UserAccessQueryService], + }), UserModule.forRoot({ settings: { invitationRequestEvent: InvitationAcceptedEventAsync, diff --git a/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts b/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts index 48c160d07..9df882c4d 100644 --- a/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/user.module.custom.fixture.ts @@ -1,6 +1,7 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { UserLookupCustomService } from './services/user-lookup.custom.service'; +@Global() @Module({ providers: [UserLookupCustomService], exports: [UserLookupCustomService], diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts index 78f6cc7e5..7a8e957be 100644 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ b/packages/nestjs-user/src/config/user-default.config.ts @@ -1,5 +1,5 @@ import { registerAs } from '@nestjs/config'; -import { UserOptionsInterface } from '../interfaces/user-options.interface'; +import { UserSettingsInterface } from '../interfaces/user-settings.interface'; import { USER_MODULE_DEFAULT_SETTINGS_TOKEN } from '../user.constants'; /** @@ -7,5 +7,5 @@ import { USER_MODULE_DEFAULT_SETTINGS_TOKEN } from '../user.constants'; */ export const userDefaultConfig = registerAs( USER_MODULE_DEFAULT_SETTINGS_TOKEN, - (): UserOptionsInterface => ({}), + (): UserSettingsInterface => ({}), ); diff --git a/packages/nestjs-user/src/dto/user-password-update.dto.ts b/packages/nestjs-user/src/dto/user-password-update.dto.ts new file mode 100644 index 000000000..192f1d0dd --- /dev/null +++ b/packages/nestjs-user/src/dto/user-password-update.dto.ts @@ -0,0 +1,23 @@ +import { Exclude, Expose } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { PasswordPlainCurrentInterface } from '@concepta/ts-common'; +import { UserPasswordDto } from './user-password.dto'; + +/** + * User update password DTO + */ +@Exclude() +export class UserPasswordUpdateDto + extends UserPasswordDto + implements PasswordPlainCurrentInterface +{ + @Expose({ toClassOnly: true }) + @ApiProperty({ + type: 'string', + description: 'Current password to validate', + }) + @IsOptional() + @IsString() + passwordCurrent!: string; +} diff --git a/packages/nestjs-user/src/dto/user-password.dto.ts b/packages/nestjs-user/src/dto/user-password.dto.ts index 4f2993486..143f6cb07 100644 --- a/packages/nestjs-user/src/dto/user-password.dto.ts +++ b/packages/nestjs-user/src/dto/user-password.dto.ts @@ -1,19 +1,18 @@ import { Exclude, Expose } from 'class-transformer'; -import { IsOptional, IsString } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; import { PasswordPlainInterface } from '@concepta/ts-common'; /** * User plain password DTO */ @Exclude() -export class UserPasswordDto implements Partial { +export class UserPasswordDto implements PasswordPlainInterface { @Expose({ toClassOnly: true }) - @ApiPropertyOptional({ + @ApiProperty({ type: 'string', description: 'Plain text password to set', }) - @IsOptional() @IsString() - password?: string; + password!: string; } diff --git a/packages/nestjs-user/src/dto/user-update.dto.ts b/packages/nestjs-user/src/dto/user-update.dto.ts index 36b4e835f..5a81fdc06 100644 --- a/packages/nestjs-user/src/dto/user-update.dto.ts +++ b/packages/nestjs-user/src/dto/user-update.dto.ts @@ -3,6 +3,7 @@ import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; import { UserUpdatableInterface } from '@concepta/ts-common'; import { UserDto } from './user.dto'; import { UserPasswordDto } from './user-password.dto'; +import { UserPasswordUpdateDto } from './user-password-update.dto'; /** * User Update DTO @@ -12,5 +13,6 @@ export class UserUpdateDto extends IntersectionType( PartialType(PickType(UserDto, ['email', 'active'] as const)), PartialType(UserPasswordDto), + PartialType(UserPasswordUpdateDto), ) implements UserUpdatableInterface {} diff --git a/packages/nestjs-user/src/index.ts b/packages/nestjs-user/src/index.ts index 4cf517efa..a81c38f85 100644 --- a/packages/nestjs-user/src/index.ts +++ b/packages/nestjs-user/src/index.ts @@ -5,17 +5,21 @@ export { UserSqliteEntity } from './entities/user-sqlite.entity'; export { UserLookupService } from './services/user-lookup.service'; export { UserMutateService } from './services/user-mutate.service'; +export { UserPasswordService } from './services/user-password.service'; +export { UserAccessQueryService } from './services/user-access-query.service'; export { UserCrudService } from './services/user-crud.service'; export { UserController } from './user.controller'; export { UserEntityInterface } from './interfaces/user-entity.interface'; export { UserLookupServiceInterface } from './interfaces/user-lookup-service.interface'; export { UserMutateServiceInterface } from './interfaces/user-mutate-service.interface'; +export { UserPasswordServiceInterface } from './interfaces/user-password-service.interface'; export { UserCreateManyDto } from './dto/user-create-many.dto'; export { UserCreateDto } from './dto/user-create.dto'; export { UserPaginatedDto } from './dto/user-paginated.dto'; export { UserPasswordDto } from './dto/user-password.dto'; +export { UserPasswordUpdateDto } from './dto/user-password-update.dto'; export { UserUpdateDto } from './dto/user-update.dto'; export { UserDto } from './dto/user.dto'; diff --git a/packages/nestjs-user/src/interfaces/user-options.interface.ts b/packages/nestjs-user/src/interfaces/user-options.interface.ts index 7433d66f7..dc79aa75a 100644 --- a/packages/nestjs-user/src/interfaces/user-options.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-options.interface.ts @@ -1,9 +1,13 @@ +import { CanAccess } from '@concepta/nestjs-access-control'; +import { UserSettingsInterface } from './user-settings.interface'; import { UserLookupServiceInterface } from './user-lookup-service.interface'; import { UserMutateServiceInterface } from './user-mutate-service.interface'; -import { UserSettingsInterface } from './user-settings.interface'; +import { UserPasswordServiceInterface } from './user-password-service.interface'; export interface UserOptionsInterface { settings?: UserSettingsInterface; userLookupService?: UserLookupServiceInterface; userMutateService?: UserMutateServiceInterface; + userPasswordService?: UserPasswordServiceInterface; + userAccessQueryService?: CanAccess; } diff --git a/packages/nestjs-user/src/interfaces/user-password-service.interface.ts b/packages/nestjs-user/src/interfaces/user-password-service.interface.ts new file mode 100644 index 000000000..e95b3f473 --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-password-service.interface.ts @@ -0,0 +1,49 @@ +import { ReferenceId, ReferenceIdInterface } from '@concepta/ts-core'; +import { + PasswordPlainCurrentInterface, + PasswordPlainInterface, +} from '@concepta/ts-common'; +import { + PasswordCreationService, + PasswordStorageInterface, +} from '@concepta/nestjs-password'; + +export interface UserPasswordServiceInterface { + /** + * Should return true if the user can update their password. + * + * @param {ReferenceIdInterface} userToUpdate The user that is being updated + * @param {ReferenceIdInterface} authenticatedUser The user that is currently authenticated + * @returns {Promise} true if user can update password + */ + canUpdate: ( + userToUpdate: ReferenceIdInterface, + authenticatedUser: ReferenceIdInterface, + ) => Promise; + + /** + * Get the user being updated by id. + * + * Object must have reference id and password storage interface. + * + * @param {ReferenceId} userId The id of the user that is being updated + * @returns {Promise} The user being updated + */ + getUserById: ( + userId: ReferenceId, + ) => Promise; + + /** + * Set the password (hash) on the user object. + * + * @param passwordDto The object containing the password, and optionally the current password. + * @param userId The id of the user being updated. + * @returns {ReturnType} + */ + setPassword: ( + passwordDto: Partial< + PasswordPlainInterface & PasswordPlainCurrentInterface + >, + userId?: ReferenceId, + ) => ReturnType; +} diff --git a/packages/nestjs-user/src/services/user-access-query.service.ts b/packages/nestjs-user/src/services/user-access-query.service.ts new file mode 100644 index 000000000..36fecae75 --- /dev/null +++ b/packages/nestjs-user/src/services/user-access-query.service.ts @@ -0,0 +1,46 @@ +import { plainToInstance } from 'class-transformer'; +import { Inject, Injectable } from '@nestjs/common'; +import { + CanAccess, + AccessControlContext, + ActionEnum, +} from '@concepta/nestjs-access-control'; +import { UserPasswordService } from './user-password.service'; +import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; +import { UserDto } from '../dto/user.dto'; +import { UserResource } from '../user.types'; + +@Injectable() +export class UserAccessQueryService implements CanAccess { + constructor( + @Inject(UserPasswordService) + private readonly userPasswordService: UserPasswordServiceInterface, + ) {} + + async canAccess(context: AccessControlContext): Promise { + return this.canUpdatePassword(context); + } + + protected async canUpdatePassword( + context: AccessControlContext, + ): Promise { + const { resource, action } = context.getQuery(); + + if (resource === UserResource.One && action === ActionEnum.UPDATE) { + const userAuthorizedDto = plainToInstance(UserDto, context.getUser()); + + const params = context.getRequest('params'); + const userParamDto = plainToInstance(UserDto, params); + + if (userParamDto.id) { + return await this.userPasswordService.canUpdate( + userParamDto, + userAuthorizedDto, + ); + } + } + + // does not apply + return true; + } +} diff --git a/packages/nestjs-user/src/services/user-mutate.service.ts b/packages/nestjs-user/src/services/user-mutate.service.ts index 14a0a8fe7..14a7c2d9b 100644 --- a/packages/nestjs-user/src/services/user-mutate.service.ts +++ b/packages/nestjs-user/src/services/user-mutate.service.ts @@ -6,9 +6,9 @@ import { UserCreatableInterface, UserUpdatableInterface, } from '@concepta/ts-common'; -import { PasswordCreationService } from '@concepta/nestjs-password'; import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { UserPasswordService } from './user-password.service'; import { USER_MODULE_USER_ENTITY_KEY } from '../user.constants'; import { UserEntityInterface } from '../interfaces/user-entity.interface'; import { UserMutateServiceInterface } from '../interfaces/user-mutate-service.interface'; @@ -39,7 +39,7 @@ export class UserMutateService constructor( @InjectDynamicRepository(USER_MODULE_USER_ENTITY_KEY) repo: Repository, - private passwordCreationService: PasswordCreationService, + private userPasswordService: UserPasswordService, ) { super(repo); } @@ -50,9 +50,7 @@ export class UserMutateService // do we need to hash the password? if ('password' in user && typeof user.password === 'string') { // yes, hash it - return this.passwordCreationService.createObject(user, { - required: false, - }); + return this.userPasswordService.setPassword(user); } else { // no changes return user; diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts new file mode 100644 index 000000000..7db5f7f6e --- /dev/null +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -0,0 +1,136 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ReferenceId, ReferenceIdInterface } from '@concepta/ts-core'; +import { + PasswordPlainCurrentInterface, + PasswordPlainInterface, +} from '@concepta/ts-common'; +import { + PasswordCreationService, + PasswordCreationServiceInterface, + PasswordStorageInterface, +} from '@concepta/nestjs-password'; + +import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; +import { UserLookupServiceInterface } from '../interfaces/user-lookup-service.interface'; +import { UserLookupService } from './user-lookup.service'; +import { UserException } from '../exceptions/user-exception'; +import { UserNotFoundException } from '../exceptions/user-not-found-exception'; + +/** + * User password service + */ +@Injectable() +export class UserPasswordService implements UserPasswordServiceInterface { + /** + * Constructor + * + * @param userLookupService user lookup service + * @param passwordCreationService password creation service + */ + constructor( + @Inject(UserLookupService) + private userLookupService: UserLookupServiceInterface, + @Inject(PasswordCreationService) + private passwordCreationService: PasswordCreationServiceInterface, + ) {} + + async setPassword( + passwordDto: Partial< + PasswordPlainInterface & PasswordPlainCurrentInterface + >, + userId?: ReferenceId, + ): ReturnType { + // are we updating? + if (userId) { + // yes, get the user + const userToUpdate = await this.getUserById(userId); + + // is the user updating their own password? + if (userToUpdate.id === userId) { + // call current password validation helper + await this.validateCurrent(userToUpdate, passwordDto?.passwordCurrent); + } + } + + // break out the password + const { password } = passwordDto ?? {}; + + // is current valid (this will be true if not required) + if (typeof password === 'string') { + // create safe object + const targetSafe = { ...passwordDto, password }; + // call the password creation service + return await this.passwordCreationService.createObject(targetSafe, { + required: false, + }); + } + + // return the object untouched + return passwordDto; + } + + async canUpdate( + userToUpdate: ReferenceIdInterface, + authenticatedUser: ReferenceIdInterface, + ): Promise { + // by default, only authenticated user can update their own password + return userToUpdate.id === authenticatedUser.id; + } + + async getUserById( + userId: ReferenceId, + ): Promise { + let user: (ReferenceIdInterface & Partial) | null; + + try { + // try to lookup the user + user = await this.userLookupService.byId(userId); + } catch (e: unknown) { + throw new UserException( + 'Cannot update password, error while getting user by id', + e, + ); + } + + // did we get a user? + if (user) { + // break out the stored password + const { passwordHash, passwordSalt } = user; + + // type guards + if ( + typeof passwordHash === 'string' && + typeof passwordSalt === 'string' + ) { + // return the user with asserted storage types + return { ...user, passwordHash, passwordSalt }; + } else { + throw new UserException( + 'User object is missing some or all password storage properties', + ); + } + } + + // throw an exception by default + throw new UserNotFoundException( + 'Impossible to update password if user is not found', + ); + } + + protected async validateCurrent( + target: PasswordStorageInterface, + password?: string, + ): Promise { + // call current password validation helper + const currentIsValid = await this.passwordCreationService.validateCurrent({ + password, + target, + }); + + if (currentIsValid) { + return true; + } else { + throw new UserException(`Current password is not valid`); + } + } +} diff --git a/packages/nestjs-user/src/user.controller.e2e-spec.ts b/packages/nestjs-user/src/user.controller.e2e-spec.ts index 3b9c76263..180bcb7b8 100644 --- a/packages/nestjs-user/src/user.controller.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.e2e-spec.ts @@ -3,16 +3,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { getDataSourceToken } from '@nestjs/typeorm'; import { SeedingSource } from '@concepta/typeorm-seeding'; +import { AccessControlGuard } from '@concepta/nestjs-access-control'; +import { AuthJwtGuard } from '@concepta/nestjs-auth-jwt'; import { UserFactory } from './user.factory'; import { UserSeeder } from './user.seeder'; + import { AppModuleFixture } from './__fixtures__/app.module.fixture'; import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; -describe('AppController (e2e)', () => { - describe('Authentication', () => { +describe('UserController (e2e)', () => { + describe('Normal CRUD flow', () => { let app: INestApplication; let seedingSource: SeedingSource; + let authJwtGuard: AuthJwtGuard; + let accessControlGuard: AccessControlGuard; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -21,6 +26,12 @@ describe('AppController (e2e)', () => { app = moduleFixture.createNestApplication(); await app.init(); + authJwtGuard = app.get(AuthJwtGuard); + jest.spyOn(authJwtGuard, 'canActivate').mockResolvedValue(true); + + accessControlGuard = app.get(AccessControlGuard); + jest.spyOn(accessControlGuard, 'canActivate').mockResolvedValue(true); + seedingSource = new SeedingSource({ dataSource: app.get(getDataSourceToken()), }); diff --git a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts new file mode 100644 index 000000000..291d8f347 --- /dev/null +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -0,0 +1,177 @@ +import supertest from 'supertest'; +import { randomUUID } from 'crypto'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { IssueTokenService } from '@concepta/nestjs-authentication'; +import { AccessControlService } from '@concepta/nestjs-access-control'; +import { + PasswordCreationService, + PasswordStorageService, + PasswordValidationService, +} from '@concepta/nestjs-password'; +import { SeedingSource } from '@concepta/typeorm-seeding'; + +import { UserFactory } from './user.factory'; +import { UserLookupService } from './services/user-lookup.service'; + +import { AppModuleFixture } from './__fixtures__/app.module.fixture'; +import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; + +describe('User Controller (password e2e)', () => { + describe('Password Update Flow', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let fakeUser: UserEntityFixture; + let authToken: string; + let passwordValidationService: PasswordValidationService; + let passwordStorageService: PasswordStorageService; + let passwordCreationService: PasswordCreationService; + let userLookupService: UserLookupService; + let issueTokenService: IssueTokenService; + let accessControlService: AccessControlService; + + const userId = randomUUID(); + const userPassword = 'Test1234'; + const userNewPassword = 'Test6789'; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + await initSeeding(); + await setVariables(); + await createFakeUsers(); + }); + + async function initSeeding() { + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + } + + async function setVariables() { + passwordValidationService = app.get(PasswordValidationService); + passwordStorageService = app.get(PasswordStorageService); + passwordCreationService = app.get(PasswordCreationService); + userLookupService = app.get(UserLookupService); + issueTokenService = app.get(IssueTokenService); + accessControlService = app.get(AccessControlService); + authToken = await issueTokenService.accessToken({ sub: userId }); + + // spys + jest + .spyOn(accessControlService, 'getUserRoles') + .mockResolvedValue(['user']); + } + + async function createFakeUsers() { + const userFactory = new UserFactory({ + seedingSource: seedingSource, + entity: UserEntityFixture, + }); + + fakeUser = await userFactory.create( + await passwordStorageService.hashObject({ + id: userId, + password: userPassword, + }), + ); + + await userFactory.save(fakeUser); + } + + afterEach(async () => { + jest.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + describe(`UserPasswordController WITH current password required`, () => { + it('Should update password', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = true; + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + passwordCurrent: userPassword, + }) + .expect(200); + + const updatedUser = await userLookupService.byId(userId); + expect(updatedUser).toBeInstanceOf(UserEntityFixture); + + if (updatedUser) { + const isPasswordValid = + await passwordValidationService.validateObject( + userNewPassword, + updatedUser, + ); + expect(isPasswordValid).toEqual(true); + } else { + fail('User not found'); + } + }); + + it('Should fail to update password', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = true; + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + passwordCurrent: 'wrong password', + }) + .expect(400); + + const unchangedUser = await userLookupService.byId(userId); + expect(unchangedUser).toBeInstanceOf(UserEntityFixture); + + if (unchangedUser) { + expect(unchangedUser.passwordHash?.length).toBeGreaterThan(0); + expect(unchangedUser.passwordHash).toEqual( + fakeUser?.passwordHash, + ); + } else { + fail('User not found'); + } + }); + }); + + describe(`UserPasswordController WITHOUT current password required`, () => { + it('Should update password', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(200); + + const updatedUser = await userLookupService.byId(userId); + expect(updatedUser).toBeInstanceOf(UserEntityFixture); + + if (updatedUser) { + const isPasswordValid = + await passwordValidationService.validateObject( + userNewPassword, + updatedUser, + ); + expect(isPasswordValid).toEqual(true); + } else { + fail('User not updated'); + } + }); + }); + }); +}); diff --git a/packages/nestjs-user/src/user.controller.ts b/packages/nestjs-user/src/user.controller.ts index bb75788ff..375a9d3f5 100644 --- a/packages/nestjs-user/src/user.controller.ts +++ b/packages/nestjs-user/src/user.controller.ts @@ -1,3 +1,4 @@ +import { BadRequestException, Param } from '@nestjs/common'; import { CrudBody, CrudCreateOne, @@ -12,29 +13,33 @@ import { CrudReadMany, CrudRecoverOne, } from '@concepta/nestjs-crud'; +import { PasswordStorageInterface } from '@concepta/nestjs-password'; import { ApiTags } from '@nestjs/swagger'; import { UserCreatableInterface, UserUpdatableInterface, } from '@concepta/ts-common'; -import { PasswordCreationService } from '@concepta/nestjs-password'; import { AccessControlCreateMany, AccessControlCreateOne, AccessControlDeleteOne, + AccessControlQuery, AccessControlReadMany, AccessControlReadOne, AccessControlRecoverOne, AccessControlUpdateOne, } from '@concepta/nestjs-access-control'; + +import { UserResource } from './user.types'; import { UserCrudService } from './services/user-crud.service'; +import { UserEntityInterface } from './interfaces/user-entity.interface'; import { UserDto } from './dto/user.dto'; import { UserCreateDto } from './dto/user-create.dto'; import { UserCreateManyDto } from './dto/user-create-many.dto'; import { UserUpdateDto } from './dto/user-update.dto'; import { UserPaginatedDto } from './dto/user-paginated.dto'; -import { UserEntityInterface } from './interfaces/user-entity.interface'; -import { UserResource } from './user.types'; +import { UserAccessQueryService } from './services/user-access-query.service'; +import { UserPasswordService } from './services/user-password.service'; /** * User controller. @@ -46,6 +51,9 @@ import { UserResource } from './user.types'; paginatedType: UserPaginatedDto, }, }) +@AccessControlQuery({ + service: UserAccessQueryService, +}) @ApiTags('user') export class UserController implements @@ -59,11 +67,11 @@ export class UserController * Constructor. * * @param userCrudService instance of the user crud service - * @param passwordCreationService instance of password creation service + * @param userPasswordService instance of user password service */ constructor( private userCrudService: UserCrudService, - private passwordCreationService: PasswordCreationService, + private userPasswordService: UserPasswordService, ) {} /** @@ -106,11 +114,7 @@ export class UserController // loop all dtos for (const userCreateDto of userCreateManyDto.bulk) { // hash it - hashed.push( - await this.passwordCreationService.createObject(userCreateDto, { - required: false, - }), - ); + hashed.push(await this.userPasswordService.setPassword(userCreateDto)); } // call crud service to create @@ -132,9 +136,7 @@ export class UserController // call crud service to create return this.userCrudService.createOne( crudRequest, - await this.passwordCreationService.createObject(userCreateDto, { - required: false, - }), + await this.userPasswordService.setPassword(userCreateDto), ); } @@ -149,13 +151,20 @@ export class UserController async updateOne( @CrudRequest() crudRequest: CrudRequestInterface, @CrudBody() userUpdateDto: UserUpdateDto, + @Param('id') userId?: string, ) { - return this.userCrudService.updateOne( - crudRequest, - await this.passwordCreationService.createObject(userUpdateDto, { - required: false, - }), - ); + let hashedObject: Partial; + + try { + hashedObject = await this.userPasswordService.setPassword( + userUpdateDto, + userId, + ); + } catch (e) { + throw new BadRequestException(e); + } + + return this.userCrudService.updateOne(crudRequest, hashedObject); } /** diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 6ca6f6cb8..93980d8bf 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -26,10 +26,13 @@ import { UserEntityInterface } from './interfaces/user-entity.interface'; import { UserCrudService } from './services/user-crud.service'; import { UserLookupService } from './services/user-lookup.service'; import { UserMutateService } from './services/user-mutate.service'; +import { UserPasswordService } from './services/user-password.service'; import { UserController } from './user.controller'; import { InvitationAcceptedListener } from './listeners/invitation-accepted-listener'; import { InvitationGetUserListener } from './listeners/invitation-get-user.listener'; import { userDefaultConfig } from './config/user-default.config'; +import { UserLookupServiceInterface } from './interfaces/user-lookup-service.interface'; +import { UserAccessQueryService } from './services/user-access-query.service'; const RAW_OPTIONS_TOKEN = Symbol('__USER_MODULE_RAW_OPTIONS_TOKEN__'); @@ -91,6 +94,8 @@ export function createUserProviders(options: { createUserSettingsProvider(options.overrides), createUserLookupServiceProvider(options.overrides), createUserMutateServiceProvider(options.overrides), + createUserPasswordServiceProvider(options.overrides), + createUserAccessQueryServiceProvider(options.overrides), ]; } @@ -102,6 +107,8 @@ export function createUserExports(): Required< UserLookupService, UserMutateService, UserCrudService, + UserPasswordService, + UserAccessQueryService, ]; } @@ -151,15 +158,48 @@ export function createUserMutateServiceProvider( inject: [ RAW_OPTIONS_TOKEN, getDynamicRepositoryToken(USER_MODULE_USER_ENTITY_KEY), - PasswordCreationService, + UserPasswordService, ], useFactory: async ( options: UserOptionsInterface, userRepo: Repository, - passwordCreationService: PasswordCreationService, + userPasswordService: UserPasswordService, ) => optionsOverrides?.userMutateService ?? options.userMutateService ?? - new UserMutateService(userRepo, passwordCreationService), + new UserMutateService(userRepo, userPasswordService), + }; +} + +export function createUserPasswordServiceProvider( + optionsOverrides?: UserOptions, +): Provider { + return { + provide: UserPasswordService, + inject: [RAW_OPTIONS_TOKEN, UserLookupService, PasswordCreationService], + useFactory: async ( + options: UserOptionsInterface, + userLookUpService: UserLookupServiceInterface, + passwordCreationService: PasswordCreationService, + ) => + optionsOverrides?.userPasswordService ?? + options.userPasswordService ?? + new UserPasswordService(userLookUpService, passwordCreationService), + }; +} + +export function createUserAccessQueryServiceProvider( + optionsOverrides?: UserOptions, +): Provider { + return { + provide: UserAccessQueryService, + inject: [RAW_OPTIONS_TOKEN, UserPasswordService], + useFactory: async ( + options: UserOptionsInterface, + userPasswordService: UserPasswordService, + ) => + optionsOverrides?.userAccessQueryService ?? + options.userAccessQueryService ?? + new UserAccessQueryService(userPasswordService), }; } diff --git a/packages/nestjs-user/src/user.module.custom.spec.ts b/packages/nestjs-user/src/user.module.custom.spec.ts index ee0febb40..3983973a9 100644 --- a/packages/nestjs-user/src/user.module.custom.spec.ts +++ b/packages/nestjs-user/src/user.module.custom.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserCrudService } from './services/user-crud.service'; import { UserMutateService } from './services/user-mutate.service'; +import { UserPasswordService } from './services/user-password.service'; import { UserController } from './user.controller'; import { AppModuleCustomFixture } from './__fixtures__/app.module.custom.fixture'; @@ -13,6 +14,7 @@ describe('AppModule', () => { let userCrudService: UserCrudService; let userController: UserController; let userMutateService: UserMutateService; + let userPasswordService: UserPasswordService; beforeEach(async () => { const testModule: TestingModule = await Test.createTestingModule({ @@ -26,6 +28,8 @@ describe('AppModule', () => { UserLookupCustomService, ); userMutateService = testModule.get(UserMutateService); + userPasswordService = + testModule.get(UserPasswordService); userCrudService = testModule.get(UserCrudService); userController = testModule.get(UserController); }); @@ -39,6 +43,7 @@ describe('AppModule', () => { expect(userModule).toBeInstanceOf(UserModuleCustomFixture); expect(userLookupService).toBeInstanceOf(UserLookupCustomService); expect(userMutateService).toBeInstanceOf(UserMutateService); + expect(userPasswordService).toBeInstanceOf(UserPasswordService); expect(userCrudService).toBeInstanceOf(UserCrudService); expect(userController).toBeInstanceOf(UserController); }); diff --git a/packages/nestjs-user/src/user.module.spec.ts b/packages/nestjs-user/src/user.module.spec.ts index ef320800c..674584005 100644 --- a/packages/nestjs-user/src/user.module.spec.ts +++ b/packages/nestjs-user/src/user.module.spec.ts @@ -1,6 +1,7 @@ import { Repository } from 'typeorm'; import { Test, TestingModule } from '@nestjs/testing'; import { getDynamicRepositoryToken } from '@concepta/nestjs-typeorm-ext'; +import { PasswordCreationService } from '@concepta/nestjs-password'; import { USER_MODULE_USER_ENTITY_KEY } from './user.constants'; import { UserModule } from './user.module'; @@ -8,6 +9,9 @@ import { UserCrudService } from './services/user-crud.service'; import { UserController } from './user.controller'; import { UserLookupService } from './services/user-lookup.service'; import { UserMutateService } from './services/user-mutate.service'; +import { UserPasswordService } from './services/user-password.service'; +import { UserAccessQueryService } from './services/user-access-query.service'; + import { AppModuleFixture } from './__fixtures__/app.module.fixture'; import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; @@ -16,6 +20,8 @@ describe('AppModule', () => { let userLookupService: UserLookupService; let userMutateService: UserMutateService; let userCrudService: UserCrudService; + let userPasswordService: UserPasswordService; + let userAccessQueryService: UserAccessQueryService; let userController: UserController; let userRepo: Repository; @@ -30,6 +36,11 @@ describe('AppModule', () => { ); userLookupService = testModule.get(UserLookupService); userMutateService = testModule.get(UserMutateService); + userPasswordService = + testModule.get(UserPasswordService); + userAccessQueryService = testModule.get( + UserAccessQueryService, + ); userCrudService = testModule.get(UserCrudService); userController = testModule.get(UserController); }); @@ -49,6 +60,20 @@ describe('AppModule', () => { expect(userMutateService).toBeInstanceOf(UserMutateService); expect(userMutateService['repo']).toBeInstanceOf(Repository); expect(userMutateService['repo'].find).toBeInstanceOf(Function); + expect(userMutateService['userPasswordService']).toBeInstanceOf( + UserPasswordService, + ); + expect(userPasswordService).toBeInstanceOf(UserPasswordService); + expect(userPasswordService['userLookupService']).toBeInstanceOf( + UserLookupService, + ); + expect(userPasswordService['passwordCreationService']).toBeInstanceOf( + PasswordCreationService, + ); + expect(userAccessQueryService).toBeInstanceOf(UserAccessQueryService); + expect(userAccessQueryService['userPasswordService']).toBeInstanceOf( + UserPasswordService, + ); expect(userController).toBeInstanceOf(UserController); }); }); diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index bbbc44b96..2d571fb8b 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -11,6 +11,7 @@ export { AuthenticatedUserInterface } from './authentication/interfaces/authenti export { AuthorizationPayloadInterface } from './authorization/interfaces/authorization-payload.interface'; export { PasswordPlainInterface } from './password/interfaces/password-plain.interface'; +export { PasswordPlainCurrentInterface } from './password/interfaces/password-plain-current.interface'; export { OrgInterface } from './org/interfaces/org.interface'; export { OrgOwnerInterface } from './org/interfaces/org-owner.interface'; diff --git a/packages/ts-common/src/password/interfaces/password-plain-current.interface.ts b/packages/ts-common/src/password/interfaces/password-plain-current.interface.ts new file mode 100644 index 000000000..449a79637 --- /dev/null +++ b/packages/ts-common/src/password/interfaces/password-plain-current.interface.ts @@ -0,0 +1,9 @@ +/** + * Password current text interface + */ +export interface PasswordPlainCurrentInterface { + /** + * Current Password + */ + passwordCurrent: string; +} diff --git a/packages/ts-common/src/user/interfaces/user-updatable.interface.ts b/packages/ts-common/src/user/interfaces/user-updatable.interface.ts index 7f18f7654..a96d76e9e 100644 --- a/packages/ts-common/src/user/interfaces/user-updatable.interface.ts +++ b/packages/ts-common/src/user/interfaces/user-updatable.interface.ts @@ -1,6 +1,8 @@ import { UserCreatableInterface } from './user-creatable.interface'; +import { PasswordPlainCurrentInterface } from '../../password/interfaces/password-plain-current.interface'; export interface UserUpdatableInterface extends Partial< - Pick - > {} + Pick + >, + Partial {}