[Next Note] - 在 Next/React 使用 CASL 執行乾淨俐落的權限管理方案

Introduction & 前言

CASL 權限管理系統

你的專案是否曾經碰過以下的需求:

  • 這個畫面需要管理者才能刪除,一般的用戶不能刪除
  • 我需要依照帳號角色來決定要顯示什麼
  • 這麼多地方要寫判斷能不能顯示畫面,一堆 if-else 好醜啊,怎麼美化

如果剛好你有需要做權限的需求,剛好要碰到權限判斷,那 CASL 真的是完全符合你的選擇,

接下來我將記錄一篇怎麼在 Next 使用這套 Plugins 的過程。

CASL 可以在 Server端Client端 安裝使用,如果你使用 Express、Koa、NestJS前端三大框架(Vue、React、Angular) 皆可以使用,方法也差不多。

CASL 是一個權限管理套件,它提供了一種 DSL(Domain Specific Language),讓開發者可以使用類似自然語言的方式來定義權限控制規則。它支持將權限控制的邏輯集中管理,可以在多個地方重複使用。另外,CASL 還提供了可視化的能力,可以將權限控制規則轉換為圖形化的形式,便於開發者閱讀和維護。


Summary & 摘要

本篇文章預設學習前的基本條件需求:

  • 需要會使用 Npm install
  • 需要會 JavaScript(本篇文章以 React 生態框架下去紀錄)

本篇文章將會學到:

  1. 基礎認識 - CASL 可以做什麼
  2. 開始安裝 - 環境安裝及設定
  3. 使用方法 - 套件使用方法

備註:截至文章發表前,套件目前更新至 CASL V6 版本,有些文章搜尋後點擊跳轉的連結會跑到 v5 甚至 v3 去,需要特別注意一下


為何要寫這篇

老樣子還是需要說一下為什麼需要寫這篇文章,之前工作大部分接觸的後台系統,都不太需要做權限管理,有只是簡單的 超級管理員一般用戶

也不會有太多頁面需要做隱藏顯示的判斷,剛好前陣子自己寫的 Side Project 以及自己工作上真的開始碰到了後台系統需要做權限管控,

因為之前就有從前同事口中聽過 CASL 這套插件,所以當下二話不說就直接選擇這套。

But,因為在官方的範例中 React 使用的是 useContext 的方法(非 React 使用者可以忽略這段),筆者想研究如何把這套件丟到 Redux 中,畢竟可以丟到 Redux 就可以少寫一個 ContextProvider,也方便統一管理,何樂不為呢?所以這篇紀錄文就誕生了,希望能跟相同情境的使用者交流。


基礎認識

CASL Ft. Next

在前端專案上我們要實現權限檢查很可能充斥著以下這種代碼:

1
2
3
4
5
if (user.role === 'admin' || user.id === article.authorId) {
// ... Can Do Something
} else {
// ... Hidden DOM
}

一來程式碼可能很雜亂,二來不好維護,邏輯一複雜了,可能就出現很多層的 if-else,而對於權限來說始終脫離不了 主體、角色、權限…(RBAC)

CASL DSL 介紹

CASL 提供了我們更細粒度的控制,CASL 可以讓你使用更直觀的 DSL(Domain-Specific Language) 來表示對於不同角色或者用戶的權限控制,而不是像 RBAC 一樣使用角色權限進行管理,如官方在 Define Rules 文內所述。

流程圖

可以用上面的流程圖大概想一下,如果我們有好幾個頁面都需要判斷,是不是會充斥著一大堆的 if-else,但這些程式碼都可以交由 CASL 來做到。

Basic Rules

先讓我們來一些基本認識吧!

基本規則

基本上 CASL 的核心架構脫離不了四種規則,但通常我們比較常使用到的是 User ActionSubject

  • User Action:通常用來放使用者操作的能力是什麼,例如 CRUD

  • Subject:用來檢查使用者操作的項目,通常會是一個 主題實體,例如 Post 或者 article-list(下面會在說到用法)。

  • Fields:這個可以理解成 Subject 的補充,如果 Subject 是一個大項目,例如文章列表,那 Fields 可以想成小項目,例如文章列表裡面的 TitleDescription

  • Conditions: 使用這個可以更精準的匹配使用者是否符合該權限,如果用上一個 Fields 的條件來擴充,可以在使用這個方法去做更複雜的驗證,例如我想要達到使用者只能更新自己的文章。

綜合以上四中基本規則,你會得到以下的物件格式(FieldsConditions 是非必填的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const usrAbility: [
{
action: 'Read',
subject: 'article-list',
},
{
action: 'Update',
subject: 'article-list',
fields: ['title', 'description'],
conditions: {
authorId: 1
}
}
]

根據上面的範例,代表我們賦予這個使用者的權限為可以閱讀(action: ‘Read’)文章列表(subject: ‘article-list’)、可以編輯(action: Update)作者 ID 為 1(conditions:{authorId: 1}) 文章裡的 Title(fields: [‘title’, ‘description’]) 及 Description。

Fields Patterns

當然這只是一個很簡單的範例,當中其實可以有更複雜的用法,像是 fields 可以使用 patterns,如上圖,但這邊就先不深入討論這個。

Define Rules

當我們賦予目標用戶擁有的權限後,該怎麼在套件上將這些權限做使用呢,CASL 的核心之一即是 能力(Ability)

CASL 定義能力的方式有三種,這邊我們使用第一種 defineAbility,另外兩種有興趣也可以參考官方網站 Define Rules 的介紹。

Define Rules

我們稍微修改一下官網提供的範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// ability.js
import { defineAbility } from '@casl/ability';

const usrAbility: [
{
action: 'Read',
subject: 'article-list',
},
{
action: 'Update',
subject: 'article-list',
fields: ['title', 'description'],
conditions: {
authorId: 1
}
}
]

export default defineAbility((can, cannot) => {
for (let i = 0; i < usrAbility.length; i++) {
const {
action,
subject,
fields,
conditions
} = usrAbility.length[i];
can(action, subject, fields || [], conditions || undefined);
}
});

// someComponent.js
import ability from './ability.js';

const mockArticleList = [
{
id: 1,
authorId: 1,
title: '123',
description: '456'
},
{
id: 2,
authorId: 2,
title: '123',
description: '456'
}
];

const Home = () => {
return {
<div>
{
ability.can('Read', 'article-list') ? (
<ul>
mockArticleList.map((article) => (
<li key={article.id}>
<p>Title: {article.title}</p>
<p>description: {article.description}</p>
{
ability.can('Update', 'article-list') && (
<button
onClick={() => {
// ... Do Something
}}
>Edit</button>
)
}
</li>
))
</ul>
)
}
</div>
}
};

export default Home;

上方只是一個很基礎簡單的範例,但可以明顯發現我們能力都包裹在 Ability 之中,透過 definedAbility 提供的 can() 方法,去驗證我們目前定義的能力能不能使用,該方法定義的 Type(method) can(...args: [action: any] | [action: any, subject: any, field?: string | undefined]): boolean

DefineAbilityFor

而創建 Ability 的方法也可以在包裹一層 function,透過傳入參數的方式,讓 conditions 裡面使用 user 傳遞進來的參數,例如上圖。


開始安裝

前面我們有提到,該篇文章將會以 Next 的角度下去紀錄,但其實透過上面的範例,普通的 JS 已經可以使用這個套件達到基本效果了。

提醒:這邊還是需要提醒一下,因為官方的範例是使用 useContext 的方式,而官方有提供一個 的組件可以檢查 conditions,而我自己使用的方式是類似上述範例,輸出 Ability.js 之後,使用 ability.can() 的方式,如前面提到該方法定義的 Type 為 (method) can(...args: [action: any] | [action: any, subject: any, field?: string | undefined]): boolean,因為該方法無法直接帶入 conditions,所以筆者還在研究如何帶入驗證,接下來的範例不會包含 Fields 及 Conditions 的部分,很抱歉!

React Package

CASL Install Way

首先我們到官網後,看到左側選擇你目前使用的環境,這邊筆者選擇 CASL React,接著內文其實都介紹得滿清楚的了,怎麼安裝及使用:

CASL V5

再次提醒,一定要注意看 CASL 的版本喔,如果你查看的官網為 v4v5 版本,左上角下拉點開後不會有 v6,按照官網安裝方式現在裝的一定會是 v6 版本,網址需要自己輸入 https://casl.js.org/v6/en/package/casl-react 去查看 v6 的文件,因為 v6 已經棄用 import { Ability } form '@casl/ability'; 了。

Ability Type Deprecated

如果有在使用 ChatGPT 的工程師們,一定會發現它給你的範例都是使用這個方法。

React.js With useContext and CASL

>文章的最後會有一個 Demo範例程式碼,筆者以自行理解的方式將 CASL 融入 Redux 之中,如果有誤,還請各路大神手下留情,底下可以留言告知筆者。

React 使用 CASL 官方的方法為 useContext ,我們先使用這種方式介紹:

  1. 首先創建一支檔案為 ability.ts(可是自己的情況改為 .js) ,筆者放在專案目錄 /utils/casl/ability.tsx
1
2
3
4
5
6
7
8
9
10
11
import { defineAbility } from '@casl/ability';
import { createContext } from 'react';
import { createContextualCan } from '@casl/react';

const newAbility = defineAbility((can) => {
// 基本權限 可以隨意新增 內容也可以為空
can(['Create', 'Read', 'Update', 'Delete'], 'Home');
});

export const AbilityContext = createContext(newAbility); // 此處參數也可為空
export const Can = createContextualCan(AbilityContext.Consumer);
  1. 接著修改 main.tsxapp.tsx,將 context 包裹著整個專案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AbilityContext } from '@utils/casl/ability';
import ability from '@utils/casl/config/ability';
import App from '@/App';
import '@/index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
// <React.StrictMode>
<BrowserRouter>
<AbilityContext.Provider value={ability}>
<App />
</AbilityContext.Provider>
</BrowserRouter>
// </React.StrictMode>
);
  1. 使用方式為兩種方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useEffect } from 'react';
import ability from '@utils/casl/config/ability';
import { Can } from '@casl/react';

const Home = () => {
useEffect(() => {
console.log(ability.can(['Read', 'Create'], 'Home'));
}, []);

const mockUserInfo = {
id: 1,
name: 'RexHung'
}

return (
<>
Home Page
<Can I="Edit" a="Home" ability={ability}>
<button>Edit</button>
</Can>
<Can I="Delete" a="Home" this={mockUserInfo}>
<button>Delete</button>
</Can>
<Can I="Delete" a="Home" field="article">
<button>Create</button>
</Can>
</>
)
};

export default Home;

CASL React Can Component

兩種方式分別為直接使用核心 ability.can() 及使用套件幫你包裝好的 <Can />,前面筆者有提到自行使用 Redux 定義之後,無法使用 ability.can() 帶入 conditions,這個組件可以幫你解決這個問題。

在不同的框架套件封裝的組件傳參方式都不同,例如 Vue<Can do="read" :on="post" field="title">...</Can>Angular<div *ngIf="'create' | able: 'Post'" />,但是目的都是要把 Basic Rules 那些當作條件傳遞進去給 ability 驗證是否合法,最後返回布林。

以上三個步驟是不是非常簡單呢?

Next.js With Redux and CASL

使用 Redux 主要原因為筆者覺得在最外層需要包裹兩層 Provider 有點醜,想要融合再一起,但筆者需要在聲明一次,截至文章發表前,筆者目前還沒有找到 ability.can() 可以傳遞 conditions 進去的方式。

  1. 我們跳過 Redux 的安裝,在 Redux 新增一個 authSlice.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { createSlice } from "@reduxjs/toolkit";
import { AppState } from "@/store";
import { HYDRATE } from "next-redux-wrapper";
import { AnyMongoAbility, defineAbility } from "@casl/ability";

export interface AuthState {
ability: AnyMongoAbility;
}

// Initial state
const initialState: AuthState = {
ability: defineAbility((can) => {
// 基本權限
can(['Create', 'Read', 'Update', 'Delete'], 'Home');
}),
};

interface IPermission {
action: Array<"Create" | "Read" | "Update" | "Delete">;
subject: string;
fields?: Array<string>;
conditions?: {}
}

interface IDefineAbilitiesFor {
user: IUser;
permissions: IPermission[];
}

const defineAbilitiesFor = ({user, permissions}: IDefineAbilitiesFor) => {
return defineAbility((can) => {
permissions.map((permission) => {
// 這邊如果沒有 fields 但是有定義 conditions 傳遞進來,如果 fields 沒有寫入 Undefined 的話,conditions 會存不進去,
// 所以如果沒有 fields 必須要給一個空陣列
can(permission.action, permission.subject, permission.fields || [], permission.conditions);
});
});
};

export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setAbility(state, action) {
const permission = action.payload;

// pass userInfo and get New Ability
const newAbility = defineAbilitiesFor({
permissions: permission
});

state.ability = newAbility;
},
setClearAbility(state) {
state.ability = state.ability.update([]);
},
},
// HYDRATE 這整段可以不需要管,這是因為在 Next.js 使用 Redux 需要另外加上的
extraReducers: (builder) => {
builder.addCase(HYDRATE, (state, action: any) => {
return {
...state,
...action.payload,
};
});
},
});

export const { setAbility, setClearAbility } = authSlice.actions;

export const selectAbility = (state: AppState) => state.auth.ability;

export default authSlice.reducer;
  1. 接著就可以在專案上使用了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { selectAbility } from "@/store/reducers/authSlice";

interface IArticleManagerProps {
propsPosts: IPost[];
}

const ArticleManager = ({
propsPosts
}: IArticleManagerProps) => {
const ability = useSelector(selectAbility);

useEffect(() => {
console.log(ability.can('Read', 'Home'));
}, []);

return (
<>...略</>
)
};

export default ArticleManager;

如同前面一直提到的,筆者還在研究怎麼在 can 帶入 conditions,如果有大神知道的話還麻煩下面留言告知一下,非常感謝,筆者修改後會第一時間上來更新內容。

因為少了使用 import { Can } from '@casl/react'; 這段,所以其實使用 Redux 的話可能需要自行封裝傳入 conditions 方法,這邊筆者會繼續爬文。


範例

按照慣例這邊附上實作好的 Demo:

Source Code: 傳送門

Demo: 傳送門


Conclusion & 結論

之前還沒有得知這套插件的時候還是一直使用 if-else 去判斷使用者的權限,有了這個套件,雖然免不了要在程式碼內寫一些客製化判斷,但基本上我們將邏輯都縮小到了 ability.jsdefineAbility() 的範圍內,算是方便統一維護及管理。

筆者藉著這次機會,順便第一次使用了 Next.js 來做一個簡易的後台,帳號密碼都是寫死在專案內的 API,畫面渲染可能會有點不佳,目前筆者還在持續學習當中,請各路大神手下留情,也希望這篇文章可以幫助到正在製作權限功能路上苦惱的工程師們。

備註:如果你是使用 Vue.js 也非常推薦查閱這篇文章 基于Vue.js开发的应用程序如何管理用户权限,內容非常扎實及完整,有興趣可以參考看看。

Vercel Deployment

最後離題的推薦一下,使用 Next.js 除了解決路由的一些問題外,雖然滿多地方需要摸索的,但在部署上 Vercel 真的超級方便啊,只要將程式碼更新上 Github 後,就會自動進行部署,解決了需要自己實作 CD (Continuous Deployment) 的部分。


參考網站