Skip to content

Commit fefba0d

Browse files
committed
feat(nestjs-acl-permissions): First step
Create library and guard service Closes: #83
1 parent a723d05 commit fefba0d

30 files changed

+754
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"extends": ["../../../.eslintrc.base.json"],
3+
"ignorePatterns": ["!**/*"],
4+
"overrides": [
5+
{
6+
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
7+
"rules": {}
8+
},
9+
{
10+
"files": ["*.ts", "*.tsx"],
11+
"rules": {}
12+
},
13+
{
14+
"files": ["*.js", "*.jsx"],
15+
"rules": {}
16+
},
17+
{
18+
"files": ["*.json"],
19+
"parser": "jsonc-eslint-parser",
20+
"rules": {
21+
"@nx/dependency-checks": "error"
22+
}
23+
}
24+
]
25+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# nestjs-acl-permissions
2+
3+
This library was generated with [Nx](https://nx.dev).
4+
5+
## Building
6+
7+
Run `nx build nestjs-acl-permissions` to build the library.
8+
9+
## Running unit tests
10+
11+
Run `nx test nestjs-acl-permissions` to execute the unit tests via [Jest](https://jestjs.io).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* eslint-disable */
2+
export default {
3+
displayName: 'nestjs-acl-permissions',
4+
preset: '../../../jest.preset.js',
5+
testEnvironment: 'node',
6+
transform: {
7+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
8+
},
9+
moduleFileExtensions: ['ts', 'js', 'html'],
10+
coverageDirectory: '../../../coverage/libs/nestjs-acl-permissions',
11+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@klerick/acl-json-api-nestjs",
3+
"version": "0.0.1",
4+
"dependencies": {
5+
"tslib": "^2.3.0"
6+
},
7+
"type": "commonjs",
8+
"main": "./src/index.js",
9+
"typings": "./src/index.d.ts"
10+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "nestjs-acl-permissions",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "libs/nestjs-acl-permissions/src",
5+
"projectType": "library",
6+
"targets": {
7+
"build": {
8+
"executor": "@nx/js:tsc",
9+
"outputs": ["{options.outputPath}"],
10+
"options": {
11+
"outputPath": "dist/libs/nestjs-acl-permissions",
12+
"tsConfig": "libs/nestjs-acl-permissions/tsconfig.lib.json",
13+
"packageJson": "libs/nestjs-acl-permissions/package.json",
14+
"main": "libs/nestjs-acl-permissions/src/index.ts",
15+
"assets": ["libs/nestjs-acl-permissions/*.md"]
16+
}
17+
},
18+
"publish": {
19+
"command": "node tools/scripts/publish.mjs nestjs-acl-permissions {args.ver} {args.tag}",
20+
"dependsOn": ["build"]
21+
}
22+
},
23+
"tags": []
24+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/nestjs-acl-permissions.module';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
Actions,
3+
Method,
4+
MethodActionMap as MethodActionMapType,
5+
} from '../types';
6+
7+
export const IS_PUBLIC_META_KEY = Symbol('IS_PUBLIC_META_KEY');
8+
export const GET_PERMISSION_RULES = Symbol('GET_PERMISSION_RULES');
9+
10+
export const MethodActionMap: MethodActionMapType = {
11+
[Method.DELETE]: Actions.delete,
12+
[Method.GET]: Actions.read,
13+
[Method.PATCH]: Actions.update,
14+
[Method.POST]: Actions.create,
15+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getPermissionRules } from './get-permission-rules.factory';
2+
import { Actions, PermissionRule } from '../types';
3+
4+
describe('UserPermissionRulesService', () => {
5+
describe('getPermissionRules method', () => {
6+
it('should return a correct set of rules', () => {
7+
const mockPermissionRule: PermissionRule = {
8+
defaultRules: {
9+
subject1: {
10+
[Actions.create]: true,
11+
[Actions.delete]: true,
12+
[Actions.update]: true,
13+
[Actions.read]: true,
14+
},
15+
},
16+
customRules: {
17+
subject1: [
18+
{
19+
permission: 'can',
20+
condition: {
21+
id: '${currentUser.id}',
22+
},
23+
action: Actions.update,
24+
},
25+
],
26+
subject2: [
27+
{
28+
permission: 'can',
29+
action: Actions.create,
30+
},
31+
],
32+
},
33+
};
34+
const rules = getPermissionRules(mockPermissionRule);
35+
expect(rules).toEqual([
36+
{ action: 'create', subject: 'subject1' },
37+
{ action: 'delete', subject: 'subject1' },
38+
{ action: 'update', subject: 'subject1' },
39+
{ action: 'read', subject: 'subject1' },
40+
{
41+
action: 'update',
42+
subject: 'subject1',
43+
conditions: { id: '${currentUser.id}' },
44+
},
45+
{ action: 'create', subject: 'subject2' },
46+
]);
47+
});
48+
});
49+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { AbilityBuilder, createMongoAbility, RawRuleOf } from '@casl/ability';
2+
import { ValueProvider } from '@nestjs/common';
3+
4+
import { AbilityRules, Actions, PermissionRule } from '../types';
5+
import { GET_PERMISSION_RULES } from '../constants';
6+
7+
export function getPermissionRules(
8+
permission: PermissionRule
9+
): RawRuleOf<AbilityRules>[] {
10+
const abilityBuilder = new AbilityBuilder<AbilityRules>(createMongoAbility);
11+
12+
const defaultRules = Object.entries(permission.defaultRules).reduce<
13+
Required<PermissionRule>['customRules']
14+
>((acum, [subject, rules]) => {
15+
acum[subject] = Object.entries(rules).map(([action, permission]) => ({
16+
permission: permission ? 'can' : 'cannot',
17+
action: action as Actions,
18+
}));
19+
return acum;
20+
}, {});
21+
22+
const resultRules = Object.entries(permission.customRules || {}).reduce(
23+
(acum, [subject, rules]) => {
24+
if (!acum[subject]) {
25+
acum[subject] = [...rules];
26+
} else {
27+
acum[subject].push(...rules);
28+
}
29+
acum[subject] = acum[subject] || [...rules];
30+
return acum;
31+
},
32+
defaultRules
33+
);
34+
35+
for (const [subject, rules] of Object.entries(resultRules)) {
36+
for (const { permission, fields, action, condition } of rules) {
37+
abilityBuilder[permission](action, subject, fields, condition);
38+
}
39+
}
40+
41+
return abilityBuilder.build().rules;
42+
}
43+
44+
export type GetPermissionRules = typeof getPermissionRules;
45+
46+
export const getPermissionRulesFactory: ValueProvider<GetPermissionRules> = {
47+
provide: GET_PERMISSION_RULES,
48+
useValue: getPermissionRules,
49+
};

libs/acl-permissions/nestjs-acl-permissions/src/lib/factory/index.ts

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
3+
@Module({
4+
controllers: [],
5+
providers: [],
6+
exports: [],
7+
})
8+
export class NestjsAclPermissionsModule {}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { Injectable } from '@nestjs/common';
2+
3+
@Injectable()
4+
export class CaslAbilityService {}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Test } from '@nestjs/testing';
2+
import { ExecutionContext } from '@nestjs/common';
3+
import { Request } from 'express';
4+
5+
import { CheckAccessService } from './check-access.service';
6+
import { JsonApi } from 'json-api-nestjs';
7+
import { RawRuleOf } from '@casl/ability';
8+
import { AbilityRules, Actions } from '../../types';
9+
10+
describe('CheckAccessService', () => {
11+
let checkAccessService: CheckAccessService;
12+
let context: ExecutionContext;
13+
14+
beforeEach(async () => {
15+
const moduleRef = await Test.createTestingModule({
16+
providers: [CheckAccessService],
17+
}).compile();
18+
19+
checkAccessService = moduleRef.get<CheckAccessService>(CheckAccessService);
20+
context = {
21+
getClass: jest.fn(),
22+
getHandler: jest.fn(),
23+
switchToHttp: jest.fn().mockReturnValue({
24+
getRequest: jest.fn(),
25+
method: 'GET',
26+
}),
27+
} as unknown as ExecutionContext;
28+
});
29+
30+
describe('validate input before checkAccess', () => {
31+
it('should return false if the request does not contain a user', async () => {
32+
const httpContext = context.switchToHttp();
33+
@JsonApi(class TestEntity {})
34+
class TestControllerJsonApi {}
35+
jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi);
36+
jest.spyOn(httpContext, 'getRequest').mockReturnValue({} as Request);
37+
const result = await checkAccessService.checkAccess(context);
38+
expect(result).toEqual(false);
39+
});
40+
41+
it('should return true, entity doesnt assign to controller', async () => {
42+
const httpContext = context.switchToHttp();
43+
jest.spyOn(httpContext, 'getRequest').mockReturnValue({} as Request);
44+
45+
jest.spyOn(context, 'getClass').mockReturnValue(class TestController {});
46+
const result = await checkAccessService.checkAccess(context);
47+
expect(result).toEqual(true);
48+
});
49+
50+
it('should throw error incorrect http methode', async () => {
51+
const permissionRules: RawRuleOf<AbilityRules>[] = [];
52+
const httpContext = context.switchToHttp();
53+
jest
54+
.spyOn(httpContext, 'getRequest')
55+
.mockReturnValue({ permissionRules, user: {} } as Request);
56+
httpContext.getRequest<Request>().method = 'incorrect';
57+
@JsonApi(class TestEntity {})
58+
class TestControllerJsonApi {}
59+
jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi);
60+
expect.assertions(1);
61+
try {
62+
await checkAccessService.checkAccess(context);
63+
} catch (e) {
64+
expect(e).toBeInstanceOf(Error);
65+
}
66+
});
67+
68+
it('return true because permissionRules empty', async () => {
69+
class TestEntity {}
70+
71+
@JsonApi(TestEntity)
72+
class TestControllerJsonApi {}
73+
74+
const permissionRules: RawRuleOf<AbilityRules>[] = [
75+
{ action: Actions.create, subject: TestEntity.name },
76+
{ action: Actions.delete, subject: TestEntity.name },
77+
{ action: Actions.update, subject: TestEntity.name },
78+
{
79+
action: Actions.update,
80+
subject: 'subject1',
81+
conditions: { id: '${currentUser.id}' },
82+
},
83+
];
84+
const httpContext = context.switchToHttp();
85+
jest.spyOn(httpContext, 'getRequest').mockReturnValue({
86+
permissionRules,
87+
method: 'GET',
88+
user: {},
89+
} as Request);
90+
91+
jest.spyOn(context, 'getClass').mockReturnValue(TestControllerJsonApi);
92+
const result = await checkAccessService.checkAccess(context);
93+
expect(result).toBe(true);
94+
});
95+
});
96+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ExecutionContext, Inject, Injectable } from '@nestjs/common';
2+
import { Logger } from '@nestjs/common';
3+
import { Request, Response } from 'express';
4+
import { entityForClass } from 'json-api-nestjs';
5+
6+
import { checkInputHttpMethod } from '../../utils';
7+
import { MethodActionMap } from '../../constants';
8+
import { Actions } from '../../types';
9+
10+
@Injectable()
11+
export class CheckAccessService {
12+
private readonly logger = new Logger(CheckAccessService.name);
13+
14+
public async checkAccess(context: ExecutionContext): Promise<boolean> {
15+
const request = context.switchToHttp().getRequest<Request>();
16+
const { method, user, permissionRules, body, url, params } = request;
17+
18+
const controller = context.getClass();
19+
const entity = entityForClass(controller);
20+
if (!entity) {
21+
this.logger.debug(
22+
'Entity doesnt assign to controller: ' + controller.name
23+
);
24+
return true;
25+
}
26+
27+
if (!user) {
28+
this.logger.debug('User doesnt assign to request');
29+
return false;
30+
}
31+
32+
if (!permissionRules) {
33+
this.logger.debug('Permission rules doesnt assign to request');
34+
return false;
35+
}
36+
37+
if (!('name' in entity)) {
38+
this.logger.debug('Entity doesnt have name');
39+
return false;
40+
}
41+
42+
checkInputHttpMethod(method);
43+
44+
const action = MethodActionMap[method];
45+
const entityName = entity.name;
46+
47+
const rulesForCurrentRequest = permissionRules.filter(
48+
(rule) => rule.action === action && rule.subject === entityName
49+
);
50+
if (rulesForCurrentRequest.length === 0) {
51+
this.logger.debug('No permission rules found for current request');
52+
return true;
53+
}
54+
55+
switch (action) {
56+
case Actions.read:
57+
case Actions.update:
58+
case Actions.create:
59+
case Actions.delete:
60+
}
61+
return true;
62+
}
63+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './permission/permission.guard';
2+
export * from './check-access/check-access.service';
3+
export * from './casl-ability/casl-ability.service';

0 commit comments

Comments
 (0)