Skip to content

Commit

Permalink
feat: Add support for guarding based on request data (#173)
Browse files Browse the repository at this point in the history
* Adds specific and batch resource checks based on context.

* Finishing tests
  • Loading branch information
Dallin343 authored Jan 9, 2025
1 parent a68f739 commit 7ea8f53
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 27 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ AuthZModule.register(options)
- `policy` is a path string to the casbin policy file or adapter
- `enablePossession` is a boolean that enables the use of possession (`AuthPossession.(ANY|OWN|OWN_ANY)`) for actions.
- `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.
- `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`.
- `enforcerProvider` Optional enforcer provider
- `imports` Optional list of imported modules that export the providers which are required in this module.

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

An example configuration which reads user from the http request.
An example configuration which reads user and resource id from the http request.

```typescript
import { TypeOrmModule } from '@nestjs/typeorm';
Expand All @@ -66,6 +67,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
userFromContext: (ctx) => {
const request = ctx.switchToHttp().getRequest();
return request.user && request.user.username;
},
resourceFromContext: (ctx, perm) => {
const request = ctx.switchToHttp().getRequest();
return { type: perm.resource, id: request.id };
}
}),
],
Expand Down Expand Up @@ -137,6 +142,7 @@ The param of `UsePermissions` are some objects with required properties `action`
- `resource` is a resource string or object the request is accessing.
- `possession` is an enum value of `AuthPossession`. Defaults to `AuthPossession.ANY` if not defined.
- `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.
- `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.
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:
Expand All @@ -156,6 +162,20 @@ async userById(id: string) {}
async findAllUsers() {}
```
To provide access control on specific resources, `resourceFromContext` can be used:
```typescript
@UsePermissions({
action: AuthActionVerb.READ,
resource: 'User',
resourceFromContext: (ctx, perm) => {
const req = ctx.switchToHttp().getRequest();
return { type: perm.resource, id: req.id };
}
})
async userById(id: string) {}
```
You can define multiple permissions, but only when all of them satisfied, could you access the route. For example:
```
Expand Down
73 changes: 68 additions & 5 deletions src/authz.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import {
AUTHZ_MODULE_OPTIONS
} from './authz.constants';
import * as casbin from 'casbin';
import { Permission } from './interfaces/permission.interface';
import {
Permission,
PermissionData,
ResourceFromContextFn
} from './interfaces/permission.interface';
import { UnauthorizedException } from '@nestjs/common';
import { AuthPossession, AuthUser } from './types';
import { AuthPossession, AuthResource, AuthUser, BatchApproval } from './types';
import { AuthZModuleOptions } from './interfaces/authz-module-options.interface';

@Injectable()
Expand Down Expand Up @@ -45,10 +49,43 @@ export class AuthZGuard implements CanActivate {
user: AuthUser,
permission: Permission
): Promise<boolean> => {
const { possession, resource, action } = permission;
const {
possession,
resource,
action,
resourceFromContext,
batchApproval
} = permission;

let contextResource: AuthResource;
if (resourceFromContext === true) {
if (this.options.resourceFromContext) {
// Use default resourceFromContext function if provided.
contextResource = this.options.resourceFromContext(context, {
possession,
resource,
action
});
} else {
// Default to permission resource if not provided.
contextResource = resource;
}
} else {
// Use custom resourceFromContext function or default.
contextResource = (resourceFromContext as ResourceFromContextFn)(
context,
{ possession, resource, action }
);
}

const batchApprovalPolicy = batchApproval ?? this.options.batchApproval;
if (!this.options.enablePossession) {
return this.enforcer.enforce(user, resource, action);
return this.enforce(
user,
contextResource,
action,
batchApprovalPolicy
);
}

const poss = [];
Expand All @@ -63,7 +100,12 @@ export class AuthZGuard implements CanActivate {
if (p === AuthPossession.OWN) {
return (permission as any).isOwn(context);
} else {
return this.enforcer.enforce(user, resource, `${action}:${p}`);
return this.enforce(
user,
contextResource,
`${action}:${p}`,
batchApprovalPolicy
);
}
});
};
Expand All @@ -79,6 +121,27 @@ export class AuthZGuard implements CanActivate {
}
}

async enforce(
user: AuthUser,
resource: AuthResource | AuthResource[],
action: string,
batchApprovalPolicy?: BatchApproval
): Promise<boolean> {
if (Array.isArray(resource)) {
// Batch enforce according to batchApproval option.
const checks = resource.map(res => [user, res, action]);
const results = await this.enforcer.batchEnforce(checks);

if (batchApprovalPolicy === BatchApproval.ANY) {
return results.some(result => result);
}

return results.every(result => result);
}

return this.enforcer.enforce(user, resource, action);
}

static async asyncSome<T>(
array: T[],
callback: (value: T, index: number, a: T[]) => Promise<boolean>
Expand Down
18 changes: 15 additions & 3 deletions src/decorators/use-permissions.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from '../interfaces/permission.interface';
import { Permission, PermissionData } from '../interfaces/permission.interface';
import { PERMISSIONS_METADATA } from '../authz.constants';
import { ExecutionContext } from '@nestjs/common';
import { AuthPossession } from '../types';
import { AuthPossession, BatchApproval } from '../types';

const defaultIsOwn = (ctx: ExecutionContext): boolean => false;

const defaultResourceFromContext = (
ctx: ExecutionContext,
perm: PermissionData
) => perm.resource;
/**
* You can define multiple permissions, but only
* when all of them satisfied, could you access the route.
Expand All @@ -18,6 +21,15 @@ export const UsePermissions = (...permissions: Permission[]): any => {
if (!item.isOwn) {
item.isOwn = defaultIsOwn;
}

if (!item.resourceFromContext) {
item.resourceFromContext = defaultResourceFromContext;
}

if (!item.batchApproval) {
item.batchApproval = BatchApproval.ALL;
}

return item;
});

Expand Down
5 changes: 4 additions & 1 deletion src/interfaces/authz-module-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import {
ForwardReference,
Type
} from '@nestjs/common';
import { AuthUser } from '../types';
import { AuthUser, BatchApproval } from '../types';
import { ResourceFromContextFn } from './permission.interface';

export interface AuthZModuleOptions<T = any> {
model?: string;
policy?: string | Promise<T>;
enablePossession?: boolean;
userFromContext: (context: ExecutionContext) => AuthUser;
resourceFromContext?: ResourceFromContextFn;
batchApproval?: BatchApproval;
enforcerProvider?: Provider<any>;
/**
* Optional list of imported modules that export the providers which are
Expand Down
11 changes: 10 additions & 1 deletion src/interfaces/permission.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {
AuthActionVerb,
AuthPossession,
CustomAuthActionVerb,
AuthResource
AuthResource,
BatchApproval
} from '../types';
import { ExecutionContext } from '@nestjs/common';

Expand All @@ -11,4 +12,12 @@ export interface Permission {
action: AuthActionVerb | CustomAuthActionVerb;
possession?: AuthPossession;
isOwn?: (ctx: ExecutionContext) => boolean;
resourceFromContext?: boolean | ResourceFromContextFn;
batchApproval?: BatchApproval;
}

export type PermissionData = Omit<Permission, 'requestFromContext' | 'isOwn'>;
export type ResourceFromContextFn = (
context: ExecutionContext,
permission: PermissionData
) => AuthResource | AuthResource[];
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ExecutionContext } from '@nestjs/common';

export enum AuthActionVerb {
CREATE = 'create',
UPDATE = 'update',
Expand Down Expand Up @@ -30,3 +32,8 @@ export enum AuthAction {
READ_ANY = 'read:any',
READ_OWN = 'read:own'
}

export enum BatchApproval {
ANY = 'any',
ALL = 'all'
}
94 changes: 94 additions & 0 deletions test/authz.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
Permission,
AuthActionVerb,
AuthPossession,
PermissionData,
AuthZGuard,
BatchApproval
} from '../src';

describe('@AuthZGuard()', () => {
const policies = [
['user1', 'resourceType1', 'id1', AuthActionVerb.READ],
['user1', 'resourceType1', 'id2', AuthActionVerb.READ],
['user1', 'resourceType1', 'id3', AuthActionVerb.READ],
['user2', 'resourceType1', 'id1', AuthActionVerb.READ],
['user2', 'resourceType1', 'id3', AuthActionVerb.READ],
];

const mockEnforcer: any = {
enforce: (userId: string, resource: any, action: string) => {
return policies.some((p) => p[0] === userId && p[1] === resource.type && p[2] === resource.id && p[3] === action);
},
batchEnforce: (checks: string[][]) => {
return checks.map((res: any) => {
return policies.some((p) => p[0] === res[0] && p[1] === res[1].type && p[2] === res[1].id && p[3] === res[2])
});
},
};

const mockOptions: any = {
userFromContext: (ctx: any) => ctx.user.id,
}

const getMockContext = (user: string, resources: any): any => ({
getHandler: () => null,
data: {id: resources},
user: {id: user}
});

const getMockReflector = (permissions: Permission[]): any => ({
get: (meta: any, handler: any) => permissions,
});

it('should enforce specific resource', async () => {
const permission: Permission[] = [
{
resource: 'resourceType1',
action: AuthActionVerb.READ,
resourceFromContext: (ctx: any, perm: PermissionData) => ({type: perm.resource, id: ctx.data.id})
},
];

const guard = new AuthZGuard(getMockReflector(permission), mockEnforcer, mockOptions);

expect(guard.canActivate(getMockContext('user1', 'id1'))).resolves.toEqual(true);
expect(guard.canActivate(getMockContext('user2', 'id1'))).resolves.toEqual(true);
expect(guard.canActivate(getMockContext('user2', 'id2'))).resolves.toEqual(false);
});

it('should batch enforce ALL specific resources', async () => {
const permission2: Permission[] = [
{
resource: 'resourceType1',
action: AuthActionVerb.READ,
resourceFromContext: (ctx: any, perm: PermissionData) => {
return ctx.data.id.map((id: string) => ({type: perm.resource, id}))
}
},
];

const guard = new AuthZGuard(getMockReflector(permission2), mockEnforcer, mockOptions);

expect(guard.canActivate(getMockContext('user1', ['id1', 'id2', 'id3']))).resolves.toEqual(true);
expect(guard.canActivate(getMockContext('user2', ['id1', 'id3']))).resolves.toEqual(true);
expect(guard.canActivate(getMockContext('user2', ['id1', 'id2', 'id3']))).resolves.toEqual(false);
});

it('should batch enforce ANY specific resources', async () => {
const permission2: Permission[] = [
{
resource: 'resourceType1',
action: AuthActionVerb.READ,
resourceFromContext: (ctx: any, perm: PermissionData) => {
return ctx.data.id.map((id: string) => ({type: perm.resource, id}))
},
batchApproval: BatchApproval.ANY,
},
];

const guard = new AuthZGuard(getMockReflector(permission2), mockEnforcer, mockOptions);

expect(guard.canActivate(getMockContext('user2', ['id1', 'id2', 'id3']))).resolves.toEqual(true);
});
});
Loading

0 comments on commit 7ea8f53

Please sign in to comment.