Skip to content

Commit 7ea8f53

Browse files
authored
feat: Add support for guarding based on request data (#173)
* Adds specific and batch resource checks based on context. * Finishing tests
1 parent a68f739 commit 7ea8f53

File tree

8 files changed

+263
-27
lines changed

8 files changed

+263
-27
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ AuthZModule.register(options)
4141
- `policy` is a path string to the casbin policy file or adapter
4242
- `enablePossession` is a boolean that enables the use of possession (`AuthPossession.(ANY|OWN|OWN_ANY)`) for actions.
4343
- `userFromContext` (REQUIRED) is a function that accepts `ExecutionContext`(the param of guard method `canActivate`) as the only parameter and returns the user as either string, object, or null. The `AuthZGuard` uses the returned user to determine their permission internally.
44+
- `resourceFromContext` (OPTIONAL) is a function that accepts `ExecutionContext` and `PermissionData` and returns an `AuthResource`. This allows the `AuthZGuard` to perform access control on specific resources found in a request. When provided, this function is used as the default for all `Permissions` with `resourceFromContext: true`.
4445
- `enforcerProvider` Optional enforcer provider
4546
- `imports` Optional list of imported modules that export the providers which are required in this module.
4647

4748
There are two ways to configure enforcer, either `enforcerProvider`(optional with `imports`) or `model` with `policy`
4849

49-
An example configuration which reads user from the http request.
50+
An example configuration which reads user and resource id from the http request.
5051

5152
```typescript
5253
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -66,6 +67,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
6667
userFromContext: (ctx) => {
6768
const request = ctx.switchToHttp().getRequest();
6869
return request.user && request.user.username;
70+
},
71+
resourceFromContext: (ctx, perm) => {
72+
const request = ctx.switchToHttp().getRequest();
73+
return { type: perm.resource, id: request.id };
6974
}
7075
}),
7176
],
@@ -137,6 +142,7 @@ The param of `UsePermissions` are some objects with required properties `action`
137142
- `resource` is a resource string or object the request is accessing.
138143
- `possession` is an enum value of `AuthPossession`. Defaults to `AuthPossession.ANY` if not defined.
139144
- `isOwn` is a function that accepts `ExecutionContext`(the param of guard method `canActivate`) as the only parameter and returns boolean. The `AuthZGuard` uses it to determine whether the user is the owner of the resource. A default `isOwn` function which returns `false` will be used if not defined.
145+
- `resourceFromContext` is either a boolean (which defaults to false) or a function that accepts `ExecutionContext` and `PermissionData` as parameters and returns an `AuthResource`. When set to true, the default `resourceFromContext` function provided during module initialization is used. When set to a function, the provided function will override the default `resourceFromContext` function. When set to false, undefined, or if a default `resourceFromContext` is not provided, the `resource` option will be used as-is for each request.
140146
141147
In order to support ABAC models which authorize based on arbitrary attributes in lieu of simple strings, you can also provide an object for the resource. For example:
142148
@@ -156,6 +162,20 @@ async userById(id: string) {}
156162
async findAllUsers() {}
157163
```
158164
165+
To provide access control on specific resources, `resourceFromContext` can be used:
166+
167+
```typescript
168+
@UsePermissions({
169+
action: AuthActionVerb.READ,
170+
resource: 'User',
171+
resourceFromContext: (ctx, perm) => {
172+
const req = ctx.switchToHttp().getRequest();
173+
return { type: perm.resource, id: req.id };
174+
}
175+
})
176+
async userById(id: string) {}
177+
```
178+
159179
You can define multiple permissions, but only when all of them satisfied, could you access the route. For example:
160180
161181
```

src/authz.guard.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import {
1111
AUTHZ_MODULE_OPTIONS
1212
} from './authz.constants';
1313
import * as casbin from 'casbin';
14-
import { Permission } from './interfaces/permission.interface';
14+
import {
15+
Permission,
16+
PermissionData,
17+
ResourceFromContextFn
18+
} from './interfaces/permission.interface';
1519
import { UnauthorizedException } from '@nestjs/common';
16-
import { AuthPossession, AuthUser } from './types';
20+
import { AuthPossession, AuthResource, AuthUser, BatchApproval } from './types';
1721
import { AuthZModuleOptions } from './interfaces/authz-module-options.interface';
1822

1923
@Injectable()
@@ -45,10 +49,43 @@ export class AuthZGuard implements CanActivate {
4549
user: AuthUser,
4650
permission: Permission
4751
): Promise<boolean> => {
48-
const { possession, resource, action } = permission;
52+
const {
53+
possession,
54+
resource,
55+
action,
56+
resourceFromContext,
57+
batchApproval
58+
} = permission;
59+
60+
let contextResource: AuthResource;
61+
if (resourceFromContext === true) {
62+
if (this.options.resourceFromContext) {
63+
// Use default resourceFromContext function if provided.
64+
contextResource = this.options.resourceFromContext(context, {
65+
possession,
66+
resource,
67+
action
68+
});
69+
} else {
70+
// Default to permission resource if not provided.
71+
contextResource = resource;
72+
}
73+
} else {
74+
// Use custom resourceFromContext function or default.
75+
contextResource = (resourceFromContext as ResourceFromContextFn)(
76+
context,
77+
{ possession, resource, action }
78+
);
79+
}
4980

81+
const batchApprovalPolicy = batchApproval ?? this.options.batchApproval;
5082
if (!this.options.enablePossession) {
51-
return this.enforcer.enforce(user, resource, action);
83+
return this.enforce(
84+
user,
85+
contextResource,
86+
action,
87+
batchApprovalPolicy
88+
);
5289
}
5390

5491
const poss = [];
@@ -63,7 +100,12 @@ export class AuthZGuard implements CanActivate {
63100
if (p === AuthPossession.OWN) {
64101
return (permission as any).isOwn(context);
65102
} else {
66-
return this.enforcer.enforce(user, resource, `${action}:${p}`);
103+
return this.enforce(
104+
user,
105+
contextResource,
106+
`${action}:${p}`,
107+
batchApprovalPolicy
108+
);
67109
}
68110
});
69111
};
@@ -79,6 +121,27 @@ export class AuthZGuard implements CanActivate {
79121
}
80122
}
81123

124+
async enforce(
125+
user: AuthUser,
126+
resource: AuthResource | AuthResource[],
127+
action: string,
128+
batchApprovalPolicy?: BatchApproval
129+
): Promise<boolean> {
130+
if (Array.isArray(resource)) {
131+
// Batch enforce according to batchApproval option.
132+
const checks = resource.map(res => [user, res, action]);
133+
const results = await this.enforcer.batchEnforce(checks);
134+
135+
if (batchApprovalPolicy === BatchApproval.ANY) {
136+
return results.some(result => result);
137+
}
138+
139+
return results.every(result => result);
140+
}
141+
142+
return this.enforcer.enforce(user, resource, action);
143+
}
144+
82145
static async asyncSome<T>(
83146
array: T[],
84147
callback: (value: T, index: number, a: T[]) => Promise<boolean>

src/decorators/use-permissions.decorator.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { SetMetadata } from '@nestjs/common';
2-
import { Permission } from '../interfaces/permission.interface';
2+
import { Permission, PermissionData } from '../interfaces/permission.interface';
33
import { PERMISSIONS_METADATA } from '../authz.constants';
44
import { ExecutionContext } from '@nestjs/common';
5-
import { AuthPossession } from '../types';
5+
import { AuthPossession, BatchApproval } from '../types';
66

77
const defaultIsOwn = (ctx: ExecutionContext): boolean => false;
8-
8+
const defaultResourceFromContext = (
9+
ctx: ExecutionContext,
10+
perm: PermissionData
11+
) => perm.resource;
912
/**
1013
* You can define multiple permissions, but only
1114
* when all of them satisfied, could you access the route.
@@ -18,6 +21,15 @@ export const UsePermissions = (...permissions: Permission[]): any => {
1821
if (!item.isOwn) {
1922
item.isOwn = defaultIsOwn;
2023
}
24+
25+
if (!item.resourceFromContext) {
26+
item.resourceFromContext = defaultResourceFromContext;
27+
}
28+
29+
if (!item.batchApproval) {
30+
item.batchApproval = BatchApproval.ALL;
31+
}
32+
2133
return item;
2234
});
2335

src/interfaces/authz-module-options.interface.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import {
55
ForwardReference,
66
Type
77
} from '@nestjs/common';
8-
import { AuthUser } from '../types';
8+
import { AuthUser, BatchApproval } from '../types';
9+
import { ResourceFromContextFn } from './permission.interface';
910

1011
export interface AuthZModuleOptions<T = any> {
1112
model?: string;
1213
policy?: string | Promise<T>;
1314
enablePossession?: boolean;
1415
userFromContext: (context: ExecutionContext) => AuthUser;
16+
resourceFromContext?: ResourceFromContextFn;
17+
batchApproval?: BatchApproval;
1518
enforcerProvider?: Provider<any>;
1619
/**
1720
* Optional list of imported modules that export the providers which are

src/interfaces/permission.interface.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import {
22
AuthActionVerb,
33
AuthPossession,
44
CustomAuthActionVerb,
5-
AuthResource
5+
AuthResource,
6+
BatchApproval
67
} from '../types';
78
import { ExecutionContext } from '@nestjs/common';
89

@@ -11,4 +12,12 @@ export interface Permission {
1112
action: AuthActionVerb | CustomAuthActionVerb;
1213
possession?: AuthPossession;
1314
isOwn?: (ctx: ExecutionContext) => boolean;
15+
resourceFromContext?: boolean | ResourceFromContextFn;
16+
batchApproval?: BatchApproval;
1417
}
18+
19+
export type PermissionData = Omit<Permission, 'requestFromContext' | 'isOwn'>;
20+
export type ResourceFromContextFn = (
21+
context: ExecutionContext,
22+
permission: PermissionData
23+
) => AuthResource | AuthResource[];

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ExecutionContext } from '@nestjs/common';
2+
13
export enum AuthActionVerb {
24
CREATE = 'create',
35
UPDATE = 'update',
@@ -30,3 +32,8 @@ export enum AuthAction {
3032
READ_ANY = 'read:any',
3133
READ_OWN = 'read:own'
3234
}
35+
36+
export enum BatchApproval {
37+
ANY = 'any',
38+
ALL = 'all'
39+
}

test/authz.guard.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
Permission,
3+
AuthActionVerb,
4+
AuthPossession,
5+
PermissionData,
6+
AuthZGuard,
7+
BatchApproval
8+
} from '../src';
9+
10+
describe('@AuthZGuard()', () => {
11+
const policies = [
12+
['user1', 'resourceType1', 'id1', AuthActionVerb.READ],
13+
['user1', 'resourceType1', 'id2', AuthActionVerb.READ],
14+
['user1', 'resourceType1', 'id3', AuthActionVerb.READ],
15+
['user2', 'resourceType1', 'id1', AuthActionVerb.READ],
16+
['user2', 'resourceType1', 'id3', AuthActionVerb.READ],
17+
];
18+
19+
const mockEnforcer: any = {
20+
enforce: (userId: string, resource: any, action: string) => {
21+
return policies.some((p) => p[0] === userId && p[1] === resource.type && p[2] === resource.id && p[3] === action);
22+
},
23+
batchEnforce: (checks: string[][]) => {
24+
return checks.map((res: any) => {
25+
return policies.some((p) => p[0] === res[0] && p[1] === res[1].type && p[2] === res[1].id && p[3] === res[2])
26+
});
27+
},
28+
};
29+
30+
const mockOptions: any = {
31+
userFromContext: (ctx: any) => ctx.user.id,
32+
}
33+
34+
const getMockContext = (user: string, resources: any): any => ({
35+
getHandler: () => null,
36+
data: {id: resources},
37+
user: {id: user}
38+
});
39+
40+
const getMockReflector = (permissions: Permission[]): any => ({
41+
get: (meta: any, handler: any) => permissions,
42+
});
43+
44+
it('should enforce specific resource', async () => {
45+
const permission: Permission[] = [
46+
{
47+
resource: 'resourceType1',
48+
action: AuthActionVerb.READ,
49+
resourceFromContext: (ctx: any, perm: PermissionData) => ({type: perm.resource, id: ctx.data.id})
50+
},
51+
];
52+
53+
const guard = new AuthZGuard(getMockReflector(permission), mockEnforcer, mockOptions);
54+
55+
expect(guard.canActivate(getMockContext('user1', 'id1'))).resolves.toEqual(true);
56+
expect(guard.canActivate(getMockContext('user2', 'id1'))).resolves.toEqual(true);
57+
expect(guard.canActivate(getMockContext('user2', 'id2'))).resolves.toEqual(false);
58+
});
59+
60+
it('should batch enforce ALL specific resources', async () => {
61+
const permission2: Permission[] = [
62+
{
63+
resource: 'resourceType1',
64+
action: AuthActionVerb.READ,
65+
resourceFromContext: (ctx: any, perm: PermissionData) => {
66+
return ctx.data.id.map((id: string) => ({type: perm.resource, id}))
67+
}
68+
},
69+
];
70+
71+
const guard = new AuthZGuard(getMockReflector(permission2), mockEnforcer, mockOptions);
72+
73+
expect(guard.canActivate(getMockContext('user1', ['id1', 'id2', 'id3']))).resolves.toEqual(true);
74+
expect(guard.canActivate(getMockContext('user2', ['id1', 'id3']))).resolves.toEqual(true);
75+
expect(guard.canActivate(getMockContext('user2', ['id1', 'id2', 'id3']))).resolves.toEqual(false);
76+
});
77+
78+
it('should batch enforce ANY specific resources', async () => {
79+
const permission2: Permission[] = [
80+
{
81+
resource: 'resourceType1',
82+
action: AuthActionVerb.READ,
83+
resourceFromContext: (ctx: any, perm: PermissionData) => {
84+
return ctx.data.id.map((id: string) => ({type: perm.resource, id}))
85+
},
86+
batchApproval: BatchApproval.ANY,
87+
},
88+
];
89+
90+
const guard = new AuthZGuard(getMockReflector(permission2), mockEnforcer, mockOptions);
91+
92+
expect(guard.canActivate(getMockContext('user2', ['id1', 'id2', 'id3']))).resolves.toEqual(true);
93+
});
94+
});

0 commit comments

Comments
 (0)