本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
現在的企業會使用一些管理系統來管理人力等資源,而這些管理系統通常都會有所謂的 權限設計 (Permission) 來幫助企業做好權限的控管,以免發生權限過大所造成的風險問題。這裡再舉一個生活化的例子,我們熟悉的 YouTube 推出了 YouTube Premium 機制,只要每個月付點費用就可以 失去觀看廣告的資格 享受沒有廣告的高級體驗,這也是權限設計的一種。權限設計有非常多種方法,本篇會介紹一個經典的設計 - 以角色為基礎的存取控制(Role-based access control),簡稱 RBAC。
RBAC 的概念很簡單,以企業用的管理系統來說,很常將各個使用者賦予特定的 角色(Role),比如說:管理者、員工等,而每種角色所擁有的權限都會有些不同,比如說:管理者可以刪除員工,但員工不得刪除員工與管理者,這種以「角色」為基礎的權限配置方式就是 RBAC。
通常在設計一套 RBAC 的系統都會依照需求而有所不同,難易度也會不同,我認為可以粗略地歸類成兩種:
如果權限、角色等配置 皆不會隨意改變,則屬於此種設計,什麼意思呢?假設今天有一套系統,有管理員、員工這兩個角色,他們能做的事情是不會隨意變更的,這樣的需求就會簡單許多。
如果權限、角色等是可以讓使用者自行配置的,則屬於此種設計,像 AWS 提供的服務就有非常複雜的權限配置,每個角色都可以透過勾選的方式來配置它的權限。
實作的方式會因為需求不同而有所不同,最傳統的作法就是設計資料庫將使用者、角色、權限等資料做關聯,當然也有非常多的套件在處理這方面的配置,而我認為 Casbin 是比較值得學習的。
它是一個專門處理權限設計的函式庫,可以用來設計 ACL、RBAC、ABAC 等授權機制。看到這個 Logo 可能會覺得很熟悉,沒錯,它與 Golang 有很大的關係,但它不限於 Golang,在 Node.js、 PHP、Python 等皆可使用,是近年來非常熱門的函式庫。
提醒:Casbin 對於初學者來說可能會比較難上手,這裡我會盡量用最簡單的方式來介紹它!
Casbin 由兩部分所組成:
存取控制模型簡單來說就是用來定義怎麼做驗證的地方,也就是驗證規則的制定。在 Casbin 我們會製作一個 model.conf
的設定檔,它是基於 PERM 模型 來進行配置,讓驗證規則只需要用一個設定檔就可以解決,那什麼是 PERM 模型呢?他們分別是這四個元素:請求 (Request)、政策 (Policy)、驗證器 (Matcher)、效果 (Effect),不過,RBAC 還會多一種叫 角色定義 (Role Definition) 的元素。
定義驗證時所需使用的參數與順序,必須包含:主題/實體 (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_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)」的規則描述。
驗證請求帶來的資訊是否與政策模型制定的規則吻合,是一個條件敘述式,在執行驗證流程時,會將請求與政策模型的值帶入進行驗證。驗證器的範例如下:
[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) 相同。
針對驗證結果再進行一個額外的驗證。效果的範例如下:
[policy_effect]
e = some(where (p.eft == allow))
上述範例的各參數意義如下:
[policy_effect]
:定義效果時需要以此作為開頭。e
:變數名稱,因為定義了 [policy_effect]
,該變數就代表了效果。p.eft
:政策的許可值。allow
:eft
的結果之一。以上方範例來說,用比較白話文的方式來解釋可以說成:
在驗證結果中,只要有一個政策許可值為
allow
就表示通過。
用來實現角色繼承的定義,不是必要的配置項目。下方為角色定義的範例:
[role_definition]
g = _, _
上述範例的各參數意義如下:
[role_definition]
:定義角色定義時需要以此作為開頭。g
:變數名稱,因為定義了 [role_definition]
,該變數就代表了角色定義。在範例中可以看到 _, _
這樣的配置,這個意思是前項的角色將會繼承後項角色的權限,可以運用這個方式來綁定角色和資源的關係。後面會針對這塊做更完整的實作範例與解說。
政策模型是制定角色與資源存取關係的地方,也就是哪些角色可以對哪些資源做哪些操作的明確定義。在 Casbin 中最簡單的實作方法就是制定 policy.csv
檔,當然,也可以透過資料庫來維護這些定義,本篇將會以 csv
檔的方式進行介紹與呈現。
定義模型的方法很簡單,還記得前面我們定義政策為 p
並且 p = sub, obj, act
嗎?我們只要根據這個骨架進行配置即可,需特別注意的是開頭必須是指定的政策變數。下方為一個簡單的模型定義:
p, role:staff /todos read
可以看到我們使用政策 p
來定義模型,該模型的 sub
為 role:staff
、obj
為 /todos
、act
為 read
,完全呼應了 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
透過 npm
安裝 node-casbin:
$ npm install casbin
安裝完後,我們在專案目錄下新增 casbin
資料夾並建立 model.conf
與 policy.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
並且多了 create
與 update
兩個操作。
由於 node-casbin
並沒有提供 Nest Module 讓我們使用,所以我們會針對其進行包裝,透過 CLI 產生 AuthorizationModule
與 AuthorizationService
:
$ 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.conf
與 policy.csv
的路徑可以從模組外部提供,所以我這裡先建立一個 interface
來制定輸入值。在 src/common/authorization
下新增一個 models
資料夾並建立 option.model.ts
:
export interface RegisterOptions {
modelPath: string;
policyAdapter: any;
global?: boolean;
}
modelPath
為 model.conf
的路徑,比較需要注意的是 policyAdapter
,由於 Casbin 是支援資料庫來管理政策模型的,所以它 enforcer
的 policy
可以透過資料庫的 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
,值得注意的是 enforcer
的 enforce
方法帶入的參數正對應到 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';
基本上權限設計跟身分驗證是脫離不了關係的,還記得 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
,所以擁有讀取的功能:
role:manager
本身擁有更新的功能,所以可以順利更改資源內容:
但 role:manager
並沒有刪除資源的功能,故無法順利刪除:
權限設計是非常普遍的功能,絕對是值得深入學習的一環,而權限設計的方法有很多,本篇提及的 RBAC 就是非常經典的設計,相信看過這篇的各位都對它有更進一步的認識了!這裡附上今天的懶人包:
node-casbin
沒有提供 Nest Module,故需要自行包裝。enforcer
物件來引入 model
與 policy
。policy
可以用最簡單的 csv
來實作,也可以用 Adapter 的方式與資料庫連動。