diff --git a/packages/nestjs-access-control/package.json b/packages/nestjs-access-control/package.json index 2190cfd04..e7f06da2e 100644 --- a/packages/nestjs-access-control/package.json +++ b/packages/nestjs-access-control/package.json @@ -16,10 +16,14 @@ "@nestjs/common": "^10.4.1", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.1", - "accesscontrol": "^2.2.1" + "accesscontrol": "^2.2.1", + "rxjs": "^7.8.1" }, "devDependencies": { + "@nestjs/swagger": "^7.4.0", "@nestjs/testing": "^10.4.1", - "jest-mock-extended": "^2.0.9" + "@types/supertest": "^2.0.16", + "jest-mock-extended": "^2.0.9", + "supertest": "^6.3.4" } } 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 22f10c8f4..62b2b3e9e 100644 --- a/packages/nestjs-access-control/src/access-control.module-definition.ts +++ b/packages/nestjs-access-control/src/access-control.module-definition.ts @@ -1,4 +1,4 @@ -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigurableModuleBuilder, DynamicModule, @@ -16,6 +16,7 @@ import { AccessControlSettingsInterface } from './interfaces/access-control-sett import { AccessControlGuard } from './access-control.guard'; import { AccessControlService } from './services/access-control.service'; import { accessControlDefaultConfig } from './config/acess-control-default.config'; +import { AccessControlFilter } from './filter/access-control.filter'; const RAW_OPTIONS_TOKEN = Symbol('__ACCESS_CONTROL_MODULE_RAW_OPTIONS_TOKEN__'); @@ -77,6 +78,7 @@ export function createAccessControlExports() { return [ ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, AccessControlService, + AccessControlFilter, AccessControlGuard, ]; } @@ -90,6 +92,8 @@ export function createAccessControlProviders(options: { createAccessControlSettingsProvider(options.overrides), createAccessControlServiceProvider(options.overrides), createAccessControlAppGuardProvider(options.overrides), + createAccessControlAppFilterProvider(options.overrides), + AccessControlFilter, AccessControlGuard, ]; } @@ -145,3 +149,28 @@ export function createAccessControlAppGuardProvider( }, }; } + +export function createAccessControlAppFilterProvider( + optionsOverrides?: AccessControlOptions, +): Provider { + return { + provide: APP_INTERCEPTOR, + inject: [RAW_OPTIONS_TOKEN, AccessControlFilter], + useFactory: async ( + options: AccessControlOptionsInterface, + defaultFilter: AccessControlFilter, + ) => { + // get app filter from the options + const appFilter = optionsOverrides?.appFilter ?? options?.appFilter; + + // is app filter explicitly false? + if (appFilter === false) { + // yes, don't set a filter + return null; + } else { + // return app filter if set, or fall back to default + return appFilter ?? defaultFilter; + } + }, + }; +} diff --git a/packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts b/packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts new file mode 100644 index 000000000..59fae02bd --- /dev/null +++ b/packages/nestjs-access-control/src/filter/access-control.filter.e2e-spec.ts @@ -0,0 +1,246 @@ +import { + Controller, + ExecutionContext, + Get, + INestApplication, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AccessControl } from 'accesscontrol'; +import supertest from 'supertest'; +import { AccessControlModule } from '../access-control.module'; +import { ACCESS_CONTROL_MODULE_SETTINGS_TOKEN } from '../constants'; +import { AccessControlReadOne } from '../decorators/access-control-read-one.decorator'; +import { AccessControlOptionsInterface } from '../interfaces/access-control-options.interface'; +import { AccessControlServiceInterface } from '../interfaces/access-control-service.interface'; +import { AccessControlService } from '../services/access-control.service'; + +describe('AccessControlFilter', () => { + const resourceGetAll = 'resource_get_all'; + const resourceGetOne = 'resource_get_one'; + const USER_1 = { + firstName: 'John', + lastName: 'Doe', + phone: '(407) 123 1234', + dob: '12/01/1988', + }; + const USER_2 = { + firstName: 'Jane', + lastName: 'Doe', + phone: '(407) 123 1234', + dob: '12/01/1988', + }; + + class TestUser { + constructor(public id: number) {} + } + + @ApiTags('users') + @Controller('users') + class UserController { + @Get('') + @AccessControlReadOne(resourceGetAll) + @ApiResponse({}) + getAny() { + return [USER_1, USER_2]; + } + + @Get(':id') + @ApiResponse({}) + @AccessControlReadOne(resourceGetOne) + getOwn() { + return USER_1; + } + } + + let app: INestApplication; + let reflector: Reflector; + const createTestModule = async (rules: AccessControl, roles: string[]) => { + class TestAccessService implements AccessControlServiceInterface { + async getUser(_context: ExecutionContext): Promise { + return new TestUser(1234); + } + async getUserRoles( + _context: ExecutionContext, + ): Promise { + return roles; + } + } + const testService = new TestAccessService(); + const moduleConfig: AccessControlOptionsInterface = { + settings: { rules: rules }, + service: testService, + }; + reflector = new Reflector(); + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AccessControlModule.forRoot({ + service: testService, + settings: { + rules: rules, + }, + }), + ], + controllers: [UserController], + providers: [ + { provide: Reflector, useValue: reflector }, + { + provide: ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, + useValue: moduleConfig.settings, + }, + { + provide: AccessControlService, + useValue: testService, + }, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }; + + afterEach(async () => { + await app.close(); + }); + + it('should return filtered data combined', async () => { + const rules = new AccessControl(); + rules.grant('role2').readAny(resourceGetAll, ['phone']); + rules.grant('role1').readAny(resourceGetAll, ['firstName', 'lastName']); + + await createTestModule(rules, ['role1', 'role2']); + await supertest(app.getHttpServer()) + .get('/users') + .expect(200) + .expect([ + { + firstName: USER_1.firstName, + lastName: USER_1.lastName, + phone: USER_1.phone, + }, + { + firstName: USER_2.firstName, + lastName: USER_2.lastName, + phone: USER_2.phone, + }, + ]); + }); + + it('should return filtered data based any possession as priority', async () => { + const rules = new AccessControl(); + rules.grant('manager').readAny(resourceGetOne, ['firstName', 'lastName']); + rules + .grant('user') + .readOwn(resourceGetOne, ['firstName', 'lastName', 'phone']); + + await createTestModule(rules, ['manager', 'user']); + await supertest(app.getHttpServer()).get('/users/1').expect(200).expect({ + firstName: USER_1.firstName, + lastName: USER_1.lastName, + }); + }); + + it('should return filtered data based on combine attributes', async () => { + const rules = new AccessControl(); + rules.grant('manager').readOwn(resourceGetOne, ['firstName', 'lastName']); + rules + .grant('user') + .readOwn(resourceGetOne, ['firstName', 'lastName', 'phone']); + + await createTestModule(rules, ['manager', 'user']); + await supertest(app.getHttpServer()).get('/users/1').expect(200).expect({ + firstName: USER_1.firstName, + lastName: USER_1.lastName, + phone: USER_1.phone, + }); + }); + + it('should return filtered data based on one role', async () => { + const rules = new AccessControl(); + rules.grant('role1').read(resourceGetOne, ['phone']); + + await createTestModule(rules, ['role1']); + await supertest(app.getHttpServer()).get('/users/1').expect(200).expect({ + phone: USER_1.phone, + }); + }); + + it('should return filtered data based on one role as Any', async () => { + const rules = new AccessControl(); + rules.grant('manager').readAny(resourceGetOne); + + await createTestModule(rules, ['manager']); + await supertest(app.getHttpServer()) + .get('/users/1') + .expect(200) + .expect(USER_1); + }); + + it('should return filtered data based to show all attributes', async () => { + const rules = new AccessControl(); + rules.grant('manager').readAny(resourceGetOne, ['*']); + rules.grant('manager').readOwn(resourceGetOne, []); + + await createTestModule(rules, ['manager']); + await supertest(app.getHttpServer()) + .get('/users/1') + .expect(200) + .expect(USER_1); + }); + + it('should return empty objects when no fields are allowed', async () => { + const rules = new AccessControl(); + rules.grant('role1').readAny(resourceGetAll, []); + + await createTestModule(rules, ['role1']); + await supertest(app.getHttpServer()).get('/users').expect(403); + }); + + it('should return empty objects when no fields are allowed', async () => { + const rules = new AccessControl(); + rules.grant('role1').readAny(resourceGetAll, []); + rules.grant('role1').readOwn(resourceGetAll, ['*', '!phone']); + + await createTestModule(rules, ['role1']); + await supertest(app.getHttpServer()) + .get('/users') + .expect(200) + .expect([ + { + firstName: USER_1.firstName, + lastName: USER_1.lastName, + dob: USER_1.dob, + }, + { + firstName: USER_2.firstName, + lastName: USER_2.lastName, + dob: USER_2.dob, + }, + ]); + }); + + it('should all objects when second role has permission', async () => { + const rules = new AccessControl(); + rules.grant('role1').readAny(resourceGetAll, []); + rules.grant('role2').readOwn(resourceGetAll, ['*']); + + await createTestModule(rules, ['role1', 'role2']); + await supertest(app.getHttpServer()) + .get('/users') + .expect(200) + .expect([USER_1, USER_2]); + }); + + it('should all objects when second role has permission', async () => { + const rules = new AccessControl(); + rules.grant('role1').readAny(resourceGetOne, []); + rules.grant('role2').readAny(resourceGetAll); + + await createTestModule(rules, ['role2']); + await supertest(app.getHttpServer()) + .get('/users') + .expect(200) + .expect([USER_1, USER_2]); + }); +}); diff --git a/packages/nestjs-access-control/src/filter/access-control.filter.ts b/packages/nestjs-access-control/src/filter/access-control.filter.ts new file mode 100644 index 000000000..024ca5b01 --- /dev/null +++ b/packages/nestjs-access-control/src/filter/access-control.filter.ts @@ -0,0 +1,85 @@ +import { + CallHandler, + ExecutionContext, + Inject, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IQueryInfo } from 'accesscontrol'; +import { map } from 'rxjs/operators'; +import { + ACCESS_CONTROL_MODULE_GRANT_METADATA, + ACCESS_CONTROL_MODULE_SETTINGS_TOKEN, +} from '../constants'; +import { PossessionEnum } from '../enums/possession.enum'; +import { AccessControlGrantOptionInterface } from '../interfaces/access-control-grant-option.interface'; +import { AccessControlServiceInterface } from '../interfaces/access-control-service.interface'; +import { AccessControlSettingsInterface } from '../interfaces/access-control-settings.interface'; +import { AccessControlService } from '../services/access-control.service'; + +@Injectable() +export class AccessControlFilter implements NestInterceptor { + constructor( + @Inject(ACCESS_CONTROL_MODULE_SETTINGS_TOKEN) + private readonly settings: AccessControlSettingsInterface, + @Inject(AccessControlService) + private readonly service: AccessControlServiceInterface, + private readonly reflector: Reflector, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler) { + // get my method and its metadata + const acGrants = this.reflector.get( + ACCESS_CONTROL_MODULE_GRANT_METADATA, + context.getHandler(), + ); + + if (!acGrants || !Array.isArray(acGrants)) { + return next.handle(); + } + // get roles of my user, they will be used to see if they have access + // and how attribute filter should be applied + const userRoles = await this.service.getUserRoles(context); + + // today, we can define what permission, so lets check for both + return next.handle().pipe( + map((data) => { + for (const grant of acGrants) { + // filter based on any + let permission = this.getPermission( + userRoles, + grant, + PossessionEnum.ANY, + ); + if (permission.granted && permission.attributes) + return permission.filter(data); + + // filter based on own + permission = this.getPermission(userRoles, grant, PossessionEnum.OWN); + if (permission.granted && permission.attributes) + return permission.filter(data); + + // if none just return data + return data; + } + }), + ); + } + + private getPermission( + userRoles: string | string[], + grant: AccessControlGrantOptionInterface, + possession: PossessionEnum, + ) { + const rules = this.settings.rules; + const query: IQueryInfo = { + role: userRoles, + action: grant.action, + resource: grant.resource, + possession, + }; + // get permission object + return rules.permission(query); + } +} diff --git a/packages/nestjs-access-control/src/index.ts b/packages/nestjs-access-control/src/index.ts index 09ba4edea..bd20d1038 100644 --- a/packages/nestjs-access-control/src/index.ts +++ b/packages/nestjs-access-control/src/index.ts @@ -1,4 +1,5 @@ export * from './access-control.guard'; +export { AccessControlFilter } from './filter/access-control.filter'; export * from './access-control.module'; export { AccessControlContext } from './access-control.context'; export { AccessControlService } from './services/access-control.service'; 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 39f875a5a..4903599fc 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,4 +1,4 @@ -import { CanActivate } from '@nestjs/common'; +import { CanActivate, NestInterceptor } from '@nestjs/common'; import { AccessControlServiceInterface } from './access-control-service.interface'; import { AccessControlSettingsInterface } from './access-control-settings.interface'; @@ -6,4 +6,5 @@ export interface AccessControlOptionsInterface { settings: AccessControlSettingsInterface; service?: AccessControlServiceInterface; appGuard?: CanActivate | false; + appFilter?: NestInterceptor | false; } diff --git a/yarn.lock b/yarn.lock index 797ec5ac1..c3aba12c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -677,9 +677,13 @@ __metadata: "@nestjs/common": "npm:^10.4.1" "@nestjs/config": "npm:^3.2.3" "@nestjs/core": "npm:^10.4.1" + "@nestjs/swagger": "npm:^7.4.0" "@nestjs/testing": "npm:^10.4.1" + "@types/supertest": "npm:^2.0.16" accesscontrol: "npm:^2.2.1" jest-mock-extended: "npm:^2.0.9" + rxjs: "npm:^7.8.1" + supertest: "npm:^6.3.4" languageName: unknown linkType: soft