From b18f8b43c51774f66e6abe450cdea71c20b9dc35 Mon Sep 17 00:00:00 2001 From: Dallin Hagman Date: Tue, 25 Jun 2024 20:04:55 +0000 Subject: [PATCH] Adds ABAC support and makes possession optional --- README.md | 44 ++++++++++++++----- src/authz.guard.ts | 19 +++++--- src/authz.module.ts | 4 ++ src/decorators/use-permissions.decorator.ts | 8 +++- .../authz-module-options.interface.ts | 4 +- src/interfaces/permission.interface.ts | 6 +-- src/types.ts | 4 ++ test/use-permissions.decorator.spec.ts | 9 +++- 8 files changed, 72 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d570a76..f636e4d 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,14 @@ AuthZModule.register(options) - `model` is a path string to the casbin model. - `policy` is a path string to the casbin policy file or adapter -- `usernameFromContext` (REQUIRED) is a function that accepts `ExecutionContext`(the param of guard method `canActivate`) as the only parameter and returns either the username as a string or null. The `AuthZGuard` uses username to determine user's permission internally. +- `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. - `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 username from the http request. +An example configuration which reads user from the http request. ```typescript import { TypeOrmModule } from '@nestjs/typeorm'; @@ -62,7 +63,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; password: 'password', database: 'nestdb' }), - usernameFromContext: (ctx) => { + userFromContext: (ctx) => { const request = ctx.switchToHttp().getRequest(); return request.user && request.user.username; } @@ -93,9 +94,12 @@ import { AUTHZ_ENFORCER } from 'nest-authz'; }, inject: [ConfigService], }, - usernameFromContext: (ctx) => { + userFromContext: (ctx) => { const request = ctx.switchToHttp().getRequest(); - return request.user && request.user.username; + return request.user && { + username: request.user.username, + isAdmin: request.user.isAdmin + }; } }), ], @@ -103,13 +107,13 @@ import { AUTHZ_ENFORCER } from 'nest-authz'; providers: [AppService] ``` -The latter one is preferred. +The latter method of configuring the enforcer is preferred. ### Checking Permissions #### Using `@UsePermissions` Decorator -The `@UserPermissions` decorator is the easiest and most common way of checking permissions. Consider the method shown below: +The `@UsePermissions` decorator is the easiest and most common way of checking permissions. Consider the method shown below: ```typescript @Get('users') @@ -127,13 +131,31 @@ The `findAllUsers` method can not be called by a user who is not granted the per The value of property `resource` is a magic string just for demonstrating. In the real-world applications you should avoid magic strings. Resources should be kept in the separated file like `resources.ts` -The param of `UsePermissions` are some objects with required properties `action`、 `resource`、 `possession` and an optional `isOwn`. +The param of `UsePermissions` are some objects with required properties `action`, and `resource`, and optionally `possession`, and `isOwn`. - `action` is an enum value of `AuthActionVerb`. -- `resource` is a resource string the request is accessing. -- `possession` is an enum value of `AuthPossession`. +- `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. +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: + +```typescript +@UsePermissions({ + action: AuthActionVerb.READ, + resource: {type: 'User', operation: 'single'}, + possession: AuthPossession.ANY +}) +async userById(id: string) {} + +@UsePermissions({ + action: AuthActionVerb.READ, + resource: {type: 'User', operation: 'batch'}, + possession: AuthPossession.ANY +}) +async findAllUsers() {} +``` + You can define multiple permissions, but only when all of them satisfied, could you access the route. For example: ``` @@ -143,7 +165,7 @@ You can define multiple permissions, but only when all of them satisfied, could possession: AuthPossession.ANY }, { action; AuthActionVerb.READ, - resource: 'USER_ROLES, + resource: 'USER_ROLES', possession: AuthPossession.ANY }) ``` diff --git a/src/authz.guard.ts b/src/authz.guard.ts index 2a0b912..1ec205e 100644 --- a/src/authz.guard.ts +++ b/src/authz.guard.ts @@ -13,7 +13,7 @@ import { import * as casbin from 'casbin'; import { Permission } from './interfaces/permission.interface'; import { UnauthorizedException } from '@nestjs/common'; -import { AuthPossession } from './types'; +import { AuthPossession, AuthUser } from './types'; import { AuthZModuleOptions } from './interfaces/authz-module-options.interface'; @Injectable() @@ -35,23 +35,28 @@ export class AuthZGuard implements CanActivate { return true; } - const username = this.options.usernameFromContext(context); + const requestUser = this.options.userFromContext(context); - if (!username) { + if (!requestUser) { throw new UnauthorizedException(); } const hasPermission = async ( - user: string, + user: AuthUser, permission: Permission ): Promise => { - const { possession, resource, action } = permission; + const { possession , resource, action } = permission; + + if (!this.options.enablePossession) { + return this.enforcer.enforce(user, resource, action); + } + const poss = []; if (possession === AuthPossession.OWN_ANY) { poss.push(AuthPossession.ANY, AuthPossession.OWN); } else { - poss.push(possession); + poss.push(possession as AuthPossession); } return AuthZGuard.asyncSome(poss, async p => { @@ -65,7 +70,7 @@ export class AuthZGuard implements CanActivate { const result = await AuthZGuard.asyncEvery( permissions, - async permission => hasPermission(username, permission) + async permission => hasPermission(requestUser, permission) ); return result; diff --git a/src/authz.module.ts b/src/authz.module.ts index 905ac5f..95133b3 100644 --- a/src/authz.module.ts +++ b/src/authz.module.ts @@ -13,6 +13,10 @@ import { AuthZRBACService, AuthZManagementService } from './services'; }) export class AuthZModule { static register(options: AuthZModuleOptions): DynamicModule { + if (options.enablePossession === undefined) { + options.enablePossession = true; + } + const moduleOptionsProvider = { provide: AUTHZ_MODULE_OPTIONS, useValue: options || {} diff --git a/src/decorators/use-permissions.decorator.ts b/src/decorators/use-permissions.decorator.ts index d6c0594..2c8f2c1 100644 --- a/src/decorators/use-permissions.decorator.ts +++ b/src/decorators/use-permissions.decorator.ts @@ -1,7 +1,8 @@ -import { SetMetadata } from '@nestjs/common'; +import { CustomDecorator, SetMetadata } from '@nestjs/common'; import { Permission } from '../interfaces/permission.interface'; import { PERMISSIONS_METADATA } from '../authz.constants'; import { ExecutionContext } from '@nestjs/common'; +import { AuthPossession } from '../types'; const defaultIsOwn = (ctx: ExecutionContext): boolean => false; @@ -9,8 +10,11 @@ const defaultIsOwn = (ctx: ExecutionContext): boolean => false; * You can define multiple permissions, but only * when all of them satisfied, could you access the route. */ -export const UsePermissions = (...permissions: Permission[]) => { +export const UsePermissions = (...permissions: Permission[]): any => { const perms = permissions.map(item => { + if (!item.possession) { + item.possession = AuthPossession.ANY; + } if (!item.isOwn) { item.isOwn = defaultIsOwn; } diff --git a/src/interfaces/authz-module-options.interface.ts b/src/interfaces/authz-module-options.interface.ts index c40b442..5dae91c 100644 --- a/src/interfaces/authz-module-options.interface.ts +++ b/src/interfaces/authz-module-options.interface.ts @@ -5,11 +5,13 @@ import { ForwardReference, Type } from '@nestjs/common'; +import { AuthUser } from '../types'; export interface AuthZModuleOptions { model?: string; policy?: string | Promise; - usernameFromContext: (context: ExecutionContext) => string; + enablePossession?: boolean; + userFromContext: (context: ExecutionContext) => AuthUser; enforcerProvider?: Provider; /** * Optional list of imported modules that export the providers which are diff --git a/src/interfaces/permission.interface.ts b/src/interfaces/permission.interface.ts index 76012d8..b68ee72 100644 --- a/src/interfaces/permission.interface.ts +++ b/src/interfaces/permission.interface.ts @@ -1,9 +1,9 @@ -import { AuthActionVerb, AuthPossession, CustomAuthActionVerb } from '../types'; +import { AuthActionVerb, AuthPossession, CustomAuthActionVerb, AuthResource } from '../types'; import { ExecutionContext } from '@nestjs/common'; export interface Permission { - resource: string; + resource: AuthResource; action: AuthActionVerb | CustomAuthActionVerb; - possession: AuthPossession; + possession?: AuthPossession; isOwn?: (ctx: ExecutionContext) => boolean; } diff --git a/src/types.ts b/src/types.ts index 4a45d43..3900825 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,10 @@ export enum AuthActionVerb { export type CustomAuthActionVerb = string; +export type AuthResource = string | Record; + +export type AuthUser = string | Record; + export enum AuthPossession { ANY = 'any', OWN = 'own', diff --git a/test/use-permissions.decorator.spec.ts b/test/use-permissions.decorator.spec.ts index 3fbf1b5..94d2e0b 100644 --- a/test/use-permissions.decorator.spec.ts +++ b/test/use-permissions.decorator.spec.ts @@ -13,12 +13,17 @@ describe('@UsePermissions()', () => { resource: 'test', action: AuthActionVerb.READ, possession: AuthPossession.ANY + }, + { + resource: {type: 'testType', id: 'testId'}, + action: AuthActionVerb.CREATE, + possession: AuthPossession.OWN, } ]; class TestController { @UsePermissions(...permissions) - getData() { - return null; + getData(): boolean { + return false; } } const res = Reflect.getMetadata(