Skip to content

Commit

Permalink
Merge pull request #288 from tnramalho/feature/access-control-filter
Browse files Browse the repository at this point in the history
chore: add filter for response payload
  • Loading branch information
MrMaz authored Oct 10, 2024
2 parents f1085c2 + a684cf3 commit 9974bf2
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 4 deletions.
8 changes: 6 additions & 2 deletions packages/nestjs-access-control/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import {
ConfigurableModuleBuilder,
DynamicModule,
Expand All @@ -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__');

Expand Down Expand Up @@ -77,6 +78,7 @@ export function createAccessControlExports() {
return [
ACCESS_CONTROL_MODULE_SETTINGS_TOKEN,
AccessControlService,
AccessControlFilter,
AccessControlGuard,
];
}
Expand All @@ -90,6 +92,8 @@ export function createAccessControlProviders(options: {
createAccessControlSettingsProvider(options.overrides),
createAccessControlServiceProvider(options.overrides),
createAccessControlAppGuardProvider(options.overrides),
createAccessControlAppFilterProvider(options.overrides),
AccessControlFilter,
AccessControlGuard,
];
}
Expand Down Expand Up @@ -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;
}
},
};
}
Original file line number Diff line number Diff line change
@@ -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<TestUser> {
return new TestUser(1234);
}
async getUserRoles(
_context: ExecutionContext,
): Promise<string | string[]> {
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]);
});
});
Loading

0 comments on commit 9974bf2

Please sign in to comment.