Skip to content

Commit

Permalink
feat: Add ABAC support and make custom actions more flexible (#168)
Browse files Browse the repository at this point in the history
* Adds ABAC support and makes possession optional

* removes unused import

* Fix code style with Prettier
  • Loading branch information
Dallin343 committed Jun 27, 2024
1 parent cabcfb3 commit e0bc88f
Show file tree
Hide file tree
Showing 8 changed files with 75 additions and 24 deletions.
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -93,23 +94,26 @@ 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
};
}
}),
],
controllers: [AppController],
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')
Expand All @@ -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:
```
Expand All @@ -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
})
```
Expand Down
17 changes: 11 additions & 6 deletions src/authz.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<boolean> => {
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<AuthPossession>(poss, async p => {
Expand All @@ -65,7 +70,7 @@ export class AuthZGuard implements CanActivate {

const result = await AuthZGuard.asyncEvery<Permission>(
permissions,
async permission => hasPermission(username, permission)
async permission => hasPermission(requestUser, permission)
);

return result;
Expand Down
4 changes: 4 additions & 0 deletions src/authz.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {}
Expand Down
6 changes: 5 additions & 1 deletion src/decorators/use-permissions.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { 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;

/**
* 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;
}
Expand Down
4 changes: 3 additions & 1 deletion src/interfaces/authz-module-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
ForwardReference,
Type
} from '@nestjs/common';
import { AuthUser } from '../types';

export interface AuthZModuleOptions<T = any> {
model?: string;
policy?: string | Promise<T>;
usernameFromContext: (context: ExecutionContext) => string;
enablePossession?: boolean;
userFromContext: (context: ExecutionContext) => AuthUser;
enforcerProvider?: Provider<any>;
/**
* Optional list of imported modules that export the providers which are
Expand Down
11 changes: 8 additions & 3 deletions src/interfaces/permission.interface.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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;
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export enum AuthActionVerb {

export type CustomAuthActionVerb = string;

export type AuthResource = string | Record<string, any>;

export type AuthUser = string | Record<string, any>;

export enum AuthPossession {
ANY = 'any',
OWN = 'own',
Expand Down
9 changes: 7 additions & 2 deletions test/use-permissions.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit e0bc88f

Please sign in to comment.