[NestJS 带你飞!] DAY25 - Authorization & RBAC

现在的企业会使用一些管理系统来管理人力等资源,而这些管理系统通常都会有所谓的 权限设计 (Permission) 来帮助企业做好权限的控管,以免发生权限过大所造成的风险问题。这里再举一个生活化的例子,我们熟悉的 YouTube 推出了 YouTube Premium 机制,只要每个月付点费用就可以 失去观看广告的资格 享受没有广告的高级体验,这也是权限设计的一种。权限设计有非常多种方法,本篇会介绍一个经典的设计 - 以角色为基础的存取控制(Role-based access control),简称 RBAC。

RBAC

RBAC 的概念很简单,以企业用的管理系统来说,很常将各个使用者赋予特定的 角色(Role),比如说:管理者、员工等,而每种角色所拥有的权限都会有些不同,比如说:管理者可以删除员工,但员工不得删除员工与管理者,这种以「角色」为基础的权限配置方式就是 RBAC。

https://ithelp.ithome.com.tw/upload/images/20210715/20119338f6a6Uojq47.png

通常在设计一套 RBAC 的系统都会依照需求而有所不同,难易度也会不同,我认为可以粗略地归类成两种:

静态权限

如果权限、角色等配置 皆不会随意改变,则属於此种设计,什麽意思呢?假设今天有一套系统,有管理员、员工这两个角色,他们能做的事情是不会随意变更的,这样的需求就会简单许多。

动态权限

如果权限、角色等是可以让使用者自行配置的,则属於此种设计,像 AWS 提供的服务就有非常复杂的权限配置,每个角色都可以透过勾选的方式来配置它的权限。

如何实作 RBAC?

实作的方式会因为需求不同而有所不同,最传统的作法就是设计资料库将使用者、角色、权限等资料做关联,当然也有非常多的套件在处理这方面的配置,而我认为 Casbin 是比较值得学习的。

Casbin

https://ithelp.ithome.com.tw/upload/images/20210716/20119338wiTsiKYwtX.png
图片来源

它是一个专门处理权限设计的函式库,可以用来设计 ACL、RBAC、ABAC 等授权机制。看到这个 Logo 可能会觉得很熟悉,没错,它与 Golang 有很大的关系,但它不限於 Golang,在 Node.js、 PHP、Python 等皆可使用,是近年来非常热门的函式库。

提醒:Casbin 对於初学者来说可能会比较难上手,这里我会尽量用最简单的方式来介绍它!

Casbin 概念

Casbin 由两部分所组成:

存取控制模型 (Access Control Model)

存取控制模型简单来说就是用来定义怎麽做验证的地方,也就是验证规则的制定。在 Casbin 我们会制作一个 model.conf 的设定档,它是基於 PERM 模型 来进行配置,让验证规则只需要用一个设定档就可以解决,那什麽是 PERM 模型呢?他们分别是这四个元素:请求 (Request)政策 (Policy)验证器 (Matcher)效果 (Effect),不过,RBAC 还会多一种叫 角色定义 (Role Definition) 的元素。

请求 (Request)

定义验证时所需使用的参数与顺序,必须包含:主题/实体 (Subject)对象/资源 (Object) 以及 操作/方式 (Action)。请求的范例格式如下:

[request_definition]
r = sub, obj, act

上述范例的各参数意义如下:

  • [request_definition]:定义请求时需要以此作为开头。
  • r:变数名称,因为定义了 [request_definition],该变数就代表了请求。
  • sub:代表主题,通常主题可以是使用者、角色等。
  • obj:代表对象,通常对象可以是资源等。
  • act:代表操作,通常操作会是针对资源所执行的动作名称。

用比较白话文的方式来解释的话,可以说成:

请求(r) 提供了「谁(sub) 想要对 什麽东西(obj)什麽动作(act)」的资讯。

我们将这段话带入 RBAC 的概念来重新解释:

请求(r) 提供了「角色(sub) 想要对 某个资源(obj)特定操作(act)」的资讯。

政策 (Policy)

定义政策模型的骨架,使未来可以依照该骨架来制定政策模型。政策的范例格式如下:

[policy_definition]
p = sub, obj, act, eft

上述范例的各参数意义如下:

  • [policy_definition]:定义政策时需要以此作为开头。
  • p:变数名称,因为定义了 [policy_definition],该变数就代表了政策。
  • sub:代表主题。
  • obj:代表对象。
  • act:代表操作。
  • eft:代表 允许(allow)拒绝(deny),非必要项目,预设值为 allow

用比较白话文的方式来解释的话,可以说成:

政策(p) 制定了「谁(sub) 可不可以(eft)什麽东西(obj)什麽动作(act)」的规则描述。

我们将这段话带入 RBAC 的概念来重新解释:

政策(p) 制定了「某个角色(sub) 可不可以(eft)某个资源(obj)特定操作(act)」的规则描述。

验证器 (Matcher)

验证请求带来的资讯是否与政策模型制定的规则吻合,是一个条件叙述式,在执行验证流程时,会将请求与政策模型的值带入进行验证。验证器的范例如下:

[matchers]
m = r.sub == p.sub && r.act == p.act && r.obj == p.obj

上述范例的各参数意义如下:

  • [matchers]:定义验证器时需要以此作为开头。
  • m:变数名称,因为定义了 [matchers],该变数就代表了验证器。
  • r:变数名称,前面已经透过 [request_definition] 将它宣告成请求。
  • p:变数名称,前面已经透过 [policy_definition] 将它宣告成政策。
  • r.sub:代表请求的主题。
  • p.sub:代表政策的主题。
  • r.obj:代表请求的对象。
  • p.obj:代表政策的对象。
  • r.act:代表请求的操作。
  • p.act:代表政策的操作。

以上方范例来说,用比较白话文的方式来解释可以说成:

请求主题(r.sub) 必须与 政策主题(p.sub) 相同、请求操作(r.act) 必须与 政策操作(p.act) 相同以及 请求对象(r.obj) 必须与 政策对象(p.obj) 相同。

效果 (Effect)

针对验证结果再进行一个额外的验证。效果的范例如下:

[policy_effect]
e = some(where (p.eft == allow))

上述范例的各参数意义如下:

  • [policy_effect]:定义效果时需要以此作为开头。
  • e:变数名称,因为定义了 [policy_effect],该变数就代表了效果。
  • p.eft:政策的许可值。
  • alloweft 的结果之一。

以上方范例来说,用比较白话文的方式来解释可以说成:

在验证结果中,只要有一个政策许可值为 allow 就表示通过。

角色定义 (Role Definition)

用来实现角色继承的定义,不是必要的配置项目。下方为角色定义的范例:

[role_definition]
g = _, _

上述范例的各参数意义如下:

  • [role_definition]:定义角色定义时需要以此作为开头。
  • g:变数名称,因为定义了 [role_definition],该变数就代表了角色定义。

在范例中可以看到 _, _ 这样的配置,这个意思是前项的角色将会继承後项角色的权限,可以运用这个方式来绑定角色和资源的关系。後面会针对这块做更完整的实作范例与解说。

政策模型 (Policy Model)

政策模型是制定角色与资源存取关系的地方,也就是哪些角色可以对哪些资源做哪些操作的明确定义。在 Casbin 中最简单的实作方法就是制定 policy.csv 档,当然,也可以透过资料库来维护这些定义,本篇将会以 csv 档的方式进行介绍与呈现。

定义模型

定义模型的方法很简单,还记得前面我们定义政策为 p 并且 p = sub, obj, act 吗?我们只要根据这个骨架进行配置即可,需特别注意的是开头必须是指定的政策变数。下方为一个简单的模型定义:

p, role:staff /todos read

可以看到我们使用政策 p 来定义模型,该模型的 subrole:staffobj/todosactread,完全呼应了 sub 为角色、obj 为资源、act 为操作资源的动作。

角色继承

如果我们有一个新的角色叫 role:manager,他同时也是 role:staff 的一份子,这要如何实现角色继承呢?前面有提到角色定义可以办到,透过使用 g 作为开头并且将继承的角色放在前项、被继承的角色放在後项:

p, role:staff /todos read
p, role:manager /todos write

g, role:manager role:staff

这样在政策模型上他们就是继承关系了,但还有一个地方需要去调整,就是我们前面提到的验证器,它也必须去调用 g 来为 sub 做匹配:

m = g(r.sub, p.sub) && r.act == p.act && r.obj == p.obj

实作 RBAC

透过 npm 安装 node-casbin

$ npm install casbin

定义规则

安装完後,我们在专案目录下新增 casbin 资料夹并建立 model.confpolicy.csv,下方为 model.conf 的内容:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == '*')

这里稍微解释一下验证器的规则,会发现有一个 keyMatch2 的函式,它主要是用来做路由资源的配对,是很好用的功能,而 p.act == '*' 则表示拥有所有操作权限,当政策模型制定该角色的 act* 时,无论请求带入的 act 是什麽,只要角色跟资源是配对的就是合法的存取。

注意:更多实用的函式可以参考官方文件

接下来设定 policy.csv 的内容:

p, role:admin, /todos, *
p, role:admin, /todos/:id, *
p, role:staff, /todos, read
p, role:staff, /todos/:id, read
p, role:manager, /todos, create
p, role:manager, /todos/:id, update

g, role:manager, role:staff

可以看到 role:admin 可以对 /todos/todos/:id 做任意操作、role:staff 只可以对 /todos/todos/:id 进行 read 操作、role:manager 继承了 role:staff 并且多了 createupdate 两个操作。

制作模组

由於 node-casbin 并没有提供 Nest Module 让我们使用,所以我们会针对其进行包装,透过 CLI 产生 AuthorizationModuleAuthorizationService

$ nest generate module common/authorization
$ nest generate service common/authorization

Casbin 无论在哪个平台上都只需要建置一个 enforcer 来套用 model.conf 以及 policy.csv 进而使用它的功能,这种第三方物件非常适合用自订 Provider 的方式进行处理,我们先在 src/common/authorization 下新增一个 constants 资料夹并建立 token.const.ts 来存放 token

export const AUTHORIZATION_ENFORCER = 'authorization_enforcer';

我会希望 model.confpolicy.csv 的路径可以从模组外部提供,所以我这里先建立一个 interface 来制定输入值。在 src/common/authorization 下新增一个 models 资料夹并建立 option.model.ts

export interface RegisterOptions {
  modelPath: string;
  policyAdapter: any;
  global?: boolean;
}

modelPathmodel.conf 的路径,比较需要注意的是 policyAdapter,由於 Casbin 是支援资料库来管理政策模型的,所以它 enforcerpolicy 可以透过资料库的 Adapter 进行串接,当然,也可以直接给它 policy.csv 的路径。

再来我们要将 AuthorizationModule 做成一个 DynamicModule,并将 enforcer 以及 AuthorizationService 进行汇出:

import { DynamicModule, Module } from '@nestjs/common';

import { newEnforcer } from 'casbin';

import { AuthorizationService } from './authorization.service';
import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { RegisterOptions } from './models/option.model';

@Module({})
export class AuthorizationModule {
  static register(options: RegisterOptions): DynamicModule {
    const { modelPath, policyAdapter, global = false } = options;
    const providers = [
      {
        provide: AUTHORIZATION_ENFORCER,
        useFactory: async () => {
          const enforcer = await newEnforcer(modelPath, policyAdapter);
          return enforcer;
        },
      },
      AuthorizationService,
    ];

    return {
      global,
      providers,
      module: AuthorizationModule,
      exports: [...providers],
    };
  }
}

现在我们需要设计一个 enum 来与我们角色的资源操作做配对,在 src/common/authorization 下新增一个 types 资料夹并建立 action.type.ts

export enum AuthorizationAction {
  CREATE = 'create',
  READ = 'read',
  UPDATE = 'update',
  DELETE = 'delete',
  NONE = 'none',
}

AuthorizationService 主要就是做权限的检查以及把 HttpMethod 转换成 AuthorizationAction,值得注意的是 enforcerenforce 方法带入的参数正对应到 model.conf 的请求:

import { Inject, Injectable } from '@nestjs/common';

import { Enforcer } from 'casbin';

import { AUTHORIZATION_ENFORCER } from './constants/token.const';
import { AuthorizationAction } from './types/action.type';

@Injectable()
export class AuthorizationService {
  constructor(
    @Inject(AUTHORIZATION_ENFORCER) private readonly enforcer: Enforcer,
  ) {}

  public checkPermission(subject: string, object: string, action: string) {
    return this.enforcer.enforce(subject, object, action);
  }

  public mappingAction(method: string): AuthorizationAction {
    switch (method.toUpperCase()) {
      case 'GET':
        return AuthorizationAction.READ;
      case 'POST':
        return AuthorizationAction.CREATE;
      case 'PATCH':
      case 'PUT':
        return AuthorizationAction.UPDATE;
      case 'DELETE':
        return AuthorizationAction.DELETE;
      default:
        return AuthorizationAction.NONE;
    }
  }
}

最後,我们将外部可能会使用到的元件统一由 index.ts 做汇出:

export { AuthorizationModule } from './authorization.module';
export { AuthorizationService } from './authorization.service';
export { AUTHORIZATION_ENFORCER } from './constants/token.const';

实作 Guard

基本上权限设计跟身分验证是脱离不了关系的,还记得 passport 在身分验证完成後会将相关资料塞入请求物件的 user 属性里吗?我们可以运用这样的方式来取得使用者的角色,进而在 RoleGuard 进行角色权限验证。我们透过 CLI 产生 RoleGuard

$ nest generate guard common/guards/role

但这里我们没有实作身分验证的功能,所以透过塞假资料来模拟 passport 验证後的情境:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';

import { Request } from 'express';
import { Observable } from 'rxjs';

import { AuthorizationService } from '../authorization';

@Injectable()
export class RoleGuard implements CanActivate {
  constructor(private readonly authorizationService: AuthorizationService) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    (request as any).user = { role: 'manager' }; // 塞假资料来实测验证功能
    const { user, path, method } = request as any;
    const action = this.authorizationService.mappingAction(method);

    if (!user) {
      throw new UnauthorizedException();
    }

    return this.authorizationService.checkPermission(
      `role:${user.role}`,
      path,
      action,
    );
  }
}

实测结果

我们建立 TodoModule 并实作几个 API 来进行测试:

$ nest generate module features/todo
$ nest generate controller features/todo
$ nest generate service features/todo

TodoService 设计一个阵列来存放资料,并提供搜寻、更新与删除的功能:

import { Injectable } from '@nestjs/common';

@Injectable()
export class TodoService {
  todos = [
    {
      id: 1,
      title: 'Ironman 13th',
      completed: false,
    },
    {
      id: 2,
      title: 'Study NestJS',
      completed: true,
    },
  ];

  findById(id: string) {
    return this.todos.find((todo) => todo.id === Number(id));
  }

  updateById(id: string, data: any) {
    const todo = this.findById(id);
    return Object.assign(todo, data);
  }

  removeById(id: string) {
    const idx = this.todos.findIndex((todo) => todo.id === Number(id));
    this.todos.splice(idx, 1);
  }
}

接着设计三个 API,调整 TodoController

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  UseGuards,
} from '@nestjs/common';
import { RoleGuard } from '../../common/guards/role.guard';
import { TodoService } from './todo.service';

@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @UseGuards(RoleGuard)
  @Get(':id')
  getTodo(@Param('id') id: string) {
    return this.todoService.findById(id);
  }

  @UseGuards(RoleGuard)
  @Patch(':id')
  updateTodo(@Param('id') id: string, @Body() body: any) {
    return this.todoService.updateById(id, body);
  }

  @UseGuards(RoleGuard)
  @Delete(':id')
  removeTodo(@Param('id') id: string) {
    this.todoService.removeById(id);
    return this.todoService.todos;
  }
}

最後,在 AppModule 使用我们制作的 AuthorizationModule

import { Module } from '@nestjs/common';

import { join } from 'path';

import { AuthorizationModule } from './common/authorization/authorization.module';

import { TodoModule } from './features/todo/todo.module';

import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    AuthorizationModule.register({
      modelPath: join(__dirname, '../casbin/model.conf'),
      policyAdapter: join(__dirname, '../casbin/policy.csv'),
      global: true,
    }),
    TodoModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

现在我们的角色为 role:manager,因为继承了 role:staff,所以拥有读取的功能:
https://ithelp.ithome.com.tw/upload/images/20210719/20119338WSgwYDtE4q.png

role:manager 本身拥有更新的功能,所以可以顺利更改资源内容:
https://ithelp.ithome.com.tw/upload/images/20210719/20119338K5QScHpaxF.png

role:manager 并没有删除资源的功能,故无法顺利删除:
https://ithelp.ithome.com.tw/upload/images/20210719/20119338vWciamAK6s.png

小结

权限设计是非常普遍的功能,绝对是值得深入学习的一环,而权限设计的方法有很多,本篇提及的 RBAC 就是非常经典的设计,相信看过这篇的各位都对它有更进一步的认识了!这里附上今天的懒人包:

  1. RBAC 是基於角色来配置不同的权限。
  2. Casbin 是一个专门处理权限设计的函式库,可以用来设计 ACL、RBAC、ABAC 等授权机制。
  3. Casbin 由存取控制模型与政策模型所组成。
  4. 存取控制模型包含了基本的四个元素:请求、政策、验证器、效果,RBAC 还有一个角色定义的元素。
  5. 整个 Casbin 都围绕着主题、资源与操作这三者。
  6. 由於 node-casbin 没有提供 Nest Module,故需要自行包装。
  7. Casbin 使用其 enforcer 物件来引入 modelpolicy
  8. policy 可以用最简单的 csv 来实作,也可以用 Adapter 的方式与资料库连动。

<<:  [Day 27] 应用二:口罩下的人脸

>>:  【Day 25】指标介绍(中)

Day23 - 使用Django-allauth整合社群登入

今天的实作内容主要根据网路资源进行。 Django并没有提供官方的社群登入整合模组,在第三方套件上,...

[Day23] 网格交易机器人-下单/取消下单/抓取库存

首先bidask那边少一个release,已经做修正 首先先用list_positions抓取手上的...

Day-30 资讯安全宣导

资讯安全宣导 tags: IT铁人 何谓资讯安全 随着资讯科技进步,资讯安全的重要程度日渐提升,以杰...

Day 13: Structural patterns - Composite

目的 将程序的组成转换成有上下阶级的结构(或称:树状结构),方便使用者不论从哪个节点、叶子使用,都可...

JavaScript 函数 | 一级函数

一级函数 (First Class Functions) Everything you can do...