Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ABAC support and make custom actions more flexible #168

Merged
merged 3 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading