iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Modern Web

NestJS 帶你飛!系列 第 25

[NestJS 帶你飛!] DAY25 - Authorization & RBAC

  • 分享至 

  • xImage
  •  

本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!

現在的企業會使用一些管理系統來管理人力等資源,而這些管理系統通常都會有所謂的 權限設計 (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 的方式與資料庫連動。

上一篇
[NestJS 帶你飛!] DAY24 - Authentication (下)
下一篇
[NestJS 帶你飛!] DAY26 - Swagger (上)
系列文
NestJS 帶你飛!32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言