From 6cf945a710d2a13d53a5ba0e6667ac3773b6ca1b Mon Sep 17 00:00:00 2001 From: Samarpan Bhattacharya Date: Wed, 17 Apr 2019 17:20:01 +0530 Subject: [PATCH] docs: add more details as per review feedback Added more details like user->role->permissions relation, authentication considerations, etc., to make it more meaningful as per @raymondfeng feedback --- docs/site/Loopback-component-authorization.md | 230 +++++++++++++++++- 1 file changed, 217 insertions(+), 13 deletions(-) diff --git a/docs/site/Loopback-component-authorization.md b/docs/site/Loopback-component-authorization.md index 17af46ce2bf9..02a1dd236057 100644 --- a/docs/site/Loopback-component-authorization.md +++ b/docs/site/Loopback-component-authorization.md @@ -15,6 +15,10 @@ This can be part of your main REST Application project or can be created as a Loopback extension for reuse in multiple projects. Latter is the better option for obvious reasons - reusability. +**Note: Loopback team is working on making authorization an out-of-the-box +feature in Loopback 4. It is a work in progress and will soon be there. Until +then, this implementation guide can be followed.** + ## The requirement 1. Every protected API end point needs to be restricted by specific permissions. @@ -25,6 +29,24 @@ for obvious reasons - reusability. 4. Publicly accessible APIs must be accessible regardless of user permissions. 5. Every user has a set of permissions. These permissions may be associated via role attached to the user or directly to the user. +6. A user can be provided additional permissions or denied some permissions over + an above its role permissions. This is considered explicit allow/deny and + always takes precedence while calculating permissions. + +## Considerations + +There are a few considerations that are taken into account before this +implementation can be done. + +1. User authentication is already implemented. You can refer to the + [@loopback/authentication](https://github.com/strongloop/loopback-next/tree/master/packages/authentication#loopbackauthentication) + guide. +2. As part of authentication, client is sent back a token (JWT or similar) which + client need to pass in every API request headers thereafter. +3. The authenticate action provider parses the token to return AuthResponse + object. +4. AuthResponse contains the logged in user information including associated + role details. ## The implementation @@ -33,27 +55,94 @@ First, let's define the types needed for this. {% include code-caption.html content="/src/authorization/types.ts" %} ```ts +import {PermissionKey} from './permission-key'; + /** * Authorize action method interface */ export interface AuthorizeFn { - (userPermissions?: string[]): Promise; + // userPermissions - Array of permission keys granted to the user + // This is actually a union of permissions picked up based on role + // attached to the user and allowed permissions at specific user level + (userPermissions: PermissionKey[]): Promise; } /** * Authorization metadata interface for the method decorator */ export interface AuthorizationMetadata { + // Array of permissions required at the method level. + // User need to have at least one of these to access the API method. permissions: string[]; } + +/** + * User Permission model + * used for explicit allow/deny any permission at user level + */ +export interface UserPermission { + permission: PermissionKey; + is_allowed: boolean; +} + +/** + * User permissions manipulation method interface. + * + * This is where we can add our business logic to read and + * union permissions associated to user via role with + * those associated directly to the user. + * + */ +export interface UserPermissionsFn { + ( + userPermissions: UserPermission[], + rolePermissions: PermissionKey[], + ): PermissionKey[]; +} ``` -We define two interfaces. +We define four interfaces. - **_AuthorizeFn_** - This is going to be the interface for authorization action business logic. - **_AuthorizationMetadata_** - This interface represents the information to be passed via decorator for each individual controller method. +- **_UserPermission_** - This is the interface to be used for associating user + level permissions. It is actually doing explicit allow/deny at user level, + over and above role permissions. +- **_UserPermissionsFn_** - This is going to be the interface for user + permissions manipulation, if required any. + +The PermissionKey is an enum containing all possible permission keys. Here is a +sample. + +{% include code-caption.html content="/src/authorization/permission-key.ts" %} + +```ts +export const enum PermissionKey { + // For accessing own (logged in user) profile + ViewOwnUser = 'ViewOwnUser', + // For accessing other users profile. + ViewAnyUser = 'ViewAnyUser', + // For creating a user + CreateAnyUser = 'CreateAnyUser', + // For updating own (logged in user) profile + UpdateOwnUser = 'UpdateOwnUser', + // For updating other users profile + UpdateAnyUser = 'UpdateAnyUser', + // For deleting a user + DeleteAnyUser = 'DeleteAnyUser', + + // For accessing a role + ViewRoles = 'ViewRoles', + // For creating a role + CreateRoles = 'CreateRoles', + // For updating a role info + UpdateRoles = 'UpdateRoles', + // For removing a role + DeleteRoles = 'DeleteRoles', +} +``` Next, we create the binding keys for each type and accessor key for method decorator. @@ -63,7 +152,7 @@ decorator. ```ts import {BindingKey} from '@loopback/context'; import {MetadataAccessor} from '@loopback/metadata'; -import {AuthorizeFn, AuthorizationMetadata} from './types'; +import {AuthorizeFn, AuthorizationMetadata, UserPermissionsFn} from './types'; /** * Binding keys used by this component. @@ -76,6 +165,10 @@ export namespace AuthorizatonBindings { export const METADATA = BindingKey.create( 'userAuthorization.operationMetadata', ); + + export const USER_PERMISSIONS = BindingKey.create( + 'userAuthorization.actions.userPermissions', + ); } /** @@ -87,13 +180,18 @@ export const AUTHORIZATION_METADATA_ACCESSOR = MetadataAccessor.create< >('userAuthorization.accessor.operationMetadata'); ``` -Now, we need to create two providers +Now, we need to create three providers - **_AuthorizationMetadataProvider_** - This will read the decorator metadata from the controller methods wherever the decorator is used. - **_AuthorizeActionProvider_** - This holds the business logic for access validation of the user based upon access permissions allowed at method level via decorator metadata above. +- **_UserPermissionsProvider_** - This is where we can add our business logic to + read and unify permissions associated to user via role, with those associated + directly to the user. In our case, an explicit allow/deny at user level takes + precendence over role permissions. But this business logic may vary + apllication to application. So, feel free to customize. {% include code-caption.html content="/src/authorization/providers/authorization-metadata.provider.ts" %} @@ -174,6 +272,50 @@ export class AuthorizeActionProvider implements Provider { } ``` +Below is the user permissions manipulation logic. If there is no requirement of +user level permissions in your application, you can skip the below. + +{% include code-caption.html content="/src/authorization/providers/user-permissions.provider.ts" %} + +```ts +import {Provider} from '@loopback/context'; + +import {PermissionKey} from '../permission-key'; +import {UserPermission, UserPermissionsFn} from '../types'; + +export class UserPermissionsProvider implements Provider { + constructor() {} + + value(): UserPermissionsFn { + return (userPermissions, rolePermissions) => + this.action(userPermissions, rolePermissions); + } + + action( + userPermissions: UserPermission[], + rolePermissions: PermissionKey[], + ): PermissionKey[] { + let perms: PermissionKey[] = []; + // First add all permissions associated with role + perms = perms.concat(rolePermissions); + // Now update permissions based on user permissions + userPermissions.forEach((userPerm: UserPermission) => { + if (userPerm.is_allowed && perms.indexOf(userPerm.permission) < 0) { + // Add permission if it is not part of role but allowed to user + perms.push(userPerm.permission); + } else if ( + !userPerm.is_allowed && + perms.indexOf(userPerm.permission) >= 0 + ) { + // Remove permission if it is disallowed for user + perms.splice(perms.indexOf(userPerm.permission), 1); + } + }); + return perms; + } +} +``` + Next, we need to expose these providers via Component to be bound to the context. @@ -181,17 +323,19 @@ context. ```ts import {Component, ProviderMap} from '@loopback/core'; -import {AuthorizatonBindings} from './keys'; -import {AuthorizeActionProvider} from './providers/authorization-action.provider'; -import {AuthorizationMetadataProvider} from './providers/authorization-metadata.provider'; +import {AuthorisatonBindings} from './keys'; +import {AuthoriseActionProvider} from './providers/authorisation-action.provider'; +import {AuthorisationMetadataProvider} from './providers/authorisation-metadata.provider'; +import {UserPermissionsProvider} from './providers/user-permissions.provider'; -export class AuthorizationComponent implements Component { +export class AuthorisationComponent implements Component { providers?: ProviderMap; constructor() { this.providers = { - [AuthorizatonBindings.AUTHORIZE_ACTION.key]: AuthorizeActionProvider, - [AuthorizatonBindings.METADATA.key]: AuthorizationMetadataProvider, + [AuthorisatonBindings.AUTHORISE_ACTION.key]: AuthoriseActionProvider, + [AuthorisatonBindings.METADATA.key]: AuthorisationMetadataProvider, + [AuthorisatonBindings.USER_PERMISSIONS.key]: UserPermissionsProvider, }; } } @@ -239,9 +383,11 @@ export * from './component'; export * from './types'; export * from './keys'; export * from './error-keys'; +export * from './permission-key'; export * from './decorators/authorize.decorator'; export * from './providers/authorization-metadata.provider'; export * from './providers/authorization-action.provider'; +export * from './providers/user-permissions.provider'; ``` That is all for the authorization component. You can create all of the above @@ -261,6 +407,54 @@ more steps to go. this.component(AuthenticationComponent); ``` +- Add permissions array to the role model. + +{% include code-caption.html content="/src/models/role.model.ts" %} + +```ts +@model({ + name: 'roles', +}) +export class Role extends Entity { + // ..... + // other attributes here + // ..... + + @property.array(String, { + required: true, + }) + permissions: PermissionKey[]; + + constructor(data?: Partial) { + super(data); + } +} +``` + +- Add user level permissions array to the user model. Do this if there is a use + case of explicit allow/deny of permissions at user-level in the application. + You can skip otherwise. + +{% include code-caption.html content="/src/models/user.model.ts" %} + +```ts +@model({ + name: 'users', +}) +export class User extends Entity { + // ..... + // other attributes here + // ..... + + @property.array(String) + permissions: UserPermission[]; + + constructor(data?: Partial) { + super(data); + } +} +``` + - Add a step in custom sequence to check for authorization whenever any end point is hit. @@ -279,6 +473,7 @@ import { SequenceHandler, HttpErrors, } from '@loopback/rest'; +import {AuthenticationBindings, AuthenticateFn} from './authenticate'; import { AuthorizatonBindings, AuthorizeFn, @@ -294,6 +489,10 @@ export class MySequence implements SequenceHandler { @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, @inject(SequenceActions.SEND) public send: Send, @inject(SequenceActions.REJECT) public reject: Reject, + @inject(AuthenticationBindings.AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + @inject(AuthorizatonBindings.USER_PERMISSIONS) + protected fetchUserPermissons: UserPermissionsFn, @inject(AuthorizatonBindings.AUTHORIZE_ACTION) protected checkAuthorization: AuthorizeFn, ) {} @@ -304,7 +503,12 @@ export class MySequence implements SequenceHandler { const route = this.findRoute(request); const args = await this.parseParams(request, route); // Do authentication of the user and fetch user permissions below - const permissions: string[] = []; + const authUser: AuthResponse = await this.authenticateRequest(request); + // Parse and calculate user permissions based on role and user level + const permissions: PermissionKey[] = this.fetchUserPermissons( + authUser.permissions, + authUser.role.permissions, + ); // This is main line added to sequence // where we are invoking the authorize action function to check for access const isAccessAllowed: boolean = await this.checkAuthorization( @@ -326,7 +530,7 @@ Now we can add access permission keys to the controller methods using authorize decorator as below. ```ts -@authorize(['CanCreateRole']) +@authorize([PermissionKey.CreateRoles]) @post(rolesPath, { responses: { [STATUS_CODE.OK]: { @@ -343,4 +547,4 @@ async create(@requestBody() role: Role): Promise { ``` This endpoint will only be accessible if logged in user has permission -'CanCreateRole'. +'CreateRoles'.