當我們討論到前端應用的權限控制時,不是在討論如何去控制權限,而是在討論如何將用戶權限反映到頁面元素的顯隱上。如果用戶沒有權限訪問請求,不僅會造成請求資源的浪費,還會降低用戶體驗。前端的權限控制就是為了解決這類問題。
RBAC 是目前普遍使用的一種權限模型。本文會討論如何基于 RBAC 權限模型去實現前端應用的權限控制。
RBAC 簡介
RBAC(Role-Based Access Control)即:基于角色的訪問控制。RBAC 認為授權其實就是 who, what, how 三者之間的關系,即 who 對 what 進行 how 操作。簡單來說就是某個角色 (who) 對某些資源 (what) 擁有怎樣的 (how) 權限。
在 RBAC 中,用戶只和角色關聯,而角色對應了一組權限。通過為不同的用戶分配不同的角色,從而讓不同的用戶擁有不同的權限。
相比于 ACL(Access Control List)直接為用戶賦予權限的方式,RBAC 通過角色為用戶和權限之間架起了一座橋梁,從而簡化了用戶和權限之間的關系,讓權限配置更易于擴展和維護。
前端應用的權限控制
對于前端應用來說,按照權限控制粒度的不同,可以分為組件權限和頁面權限:
- 組件權限控制的粒度更細,可以精確地控制每個 UI 組件顯示與否。
- 頁面權限控制的粒度較粗,只能控制到頁面層。本質上就是對路由進行權限控制。
組件權限控制
剛才也提到了,組件的權限控制粒度很細。因此,我們可能需要為應用中數百個組件添加權限控制。這意味著如果一開始沒有設計好組件權限控制的方案,可能會導致應用難以維護,甚至造成災難性的后果。
一個「錯誤」的方案
在最初設計組件權限控制的方案時,我們將組件的顯示與否關聯到了角色。也就是說通過配置允許訪問組件的角色列表,去控制組件的顯示與否。為了方便使用,我們還設計了一個公用組件,用它來包裹每個需要權限控制的組件。如下所示:
<OneOfAccessControl permittedRoles={["RoleA", "RoleB", "RoleC"]}>
<Button />
</OneOfAccessControl>
在上面的例子中,只要列表中的任意一個角色有權限,那么就會渲染按鈕組件,否則什么也不做。這段代碼看起來似乎沒什么問題。但如果此時要新增一個角色,試想會發生什么?
我們可能需要修改上百個地方的配置,而且由于配置散落在項目的各個角落,修改起來十分困難。因此每增加一個角色或者移除一個角色,都會帶來巨大成本。為了解決這個問題,我們優化了之前的方案。如下所示:
const permissionsConfig = {
canViewButton: ["RoleA", "RoleB", "RoleC"],
canEditButton: ["Role_A", "Role_B"]
canDeleteButton: ["Role_B"],
};
與前面不同的是,我們將分散的配置項集中管理起來了。在獲取到當前用戶所擁有的角色之后,將這份配置轉換成了一組控制 UI 組件顯隱的開關,并通過 Context 提供給下游組件。因此使用時只需要通過一個布爾值就能控制按鈕顯示和隱藏。如下所示:
const ACButton = () => {
const { canViewButton } = useContext(PermissionsContext);
return canViewButton && <Button />;
}
優化之后的方案確實更易于維護了。但如果需求是根據動態生成的角色去控制組件的顯示與否,又該如何解決呢?動態生成角色意味著角色列表可能隨時發生變化。因此無法再像上面一樣,通過配置「固定」的角色列表,去控制組件的顯示與否。
其實問題的關鍵就在于:RBAC 權限模型中,角色不是固定的而是動態變化的。我們可以隨時增加或者修改一個角色。因此,最好不要將組件的權限控制和角色綁定到一起。
不如交給 BFF 吧?
有時候一個組件的顯示與否,不僅僅和權限相關,也和 API 返回的數據相關。比如:
// 有權限并且年滿 18 歲的用戶才能看到這個按鈕
hasPermission && age >= 18 && <Button />
我們都知道,BFF(Backends For Frontends)是服務于前端的后端。既然是為前端服務的,我們可以在返回數據的同時,返回組件的開關。這樣即便權限或數據狀態發生改變,前端也無需重新部署。
不過這個方案可能會增加前后端的溝通成本。每次定義 API Schema 時,除了業務數據還需要定義一堆控制組件顯隱的開關。
隨著前端組件的變化,字段可能也需要重命名。比如原來的字段名叫 canViewProfile
,隨著需求的變化應該修改為 canViewProfileAndHistory
,否則就會產生字段不表意的問題。
另外,前端組件不斷增加,開關字段也會隨之增多。如下所示:
{
"canViewProfile": true,
"canEditProfile": false,
"canDeleteProfile": false,
"canViewHistory": true,
"canEditHistory": false,
"canDeleteHistory": false,
"canCreateReport": false,
"canViewReport": false,
"canDeleteReport": false
}
在一些情況下,這種方案可能有致命的缺陷。比如一個提交按鈕,根據權限控制的需求,在請求數據之前就需要將其隱藏,這個時候就無法通過 BFF 在返回數據中加上開關,而再提供額外接口獲取開關又顯得冗余。
將后端權限映射到前端
RESTful 是目前最流行的 API 設計規范。它的核心思想就是用「動詞 + 賓語」的結構來描述客戶端發出的數據操作指令。
動詞通常是指五種 HTTP 方法(GET, POST, PUT, PATCH, DELETE),對應接口的 CRUD 操作。賓語是指操作的資源。DELETE /book/ID
這個指令描述的就是刪除(動詞)某本書(賓語)。而 RESTful API 中的「動詞 + 賓語」,不就正好對應了 RBAC 權限模型中的「權限 + 資源」嗎?
因此,可以將權限管理與 RESTful API 關聯起來。比如,A 角色對 book
資源擁有 delete
權限,那么 A 角色就一定可以調用 DELETE /book/ID
API,自然也能看到頁面上的刪除按鈕。
我們可以讓后端返回當前用戶可用的接口列表,用于開關前端組件。比如:
{
"permissions": [
"GET,/api/books",
"POST,/api/book/{id}",
"PATCH,/api/book/{id}",
"DELETE,/api/book/{id}"
]
}
但接口最好用 API 唯一標識替代(如 operationId
),方便前端使用。如下所示:
{
"permissions": ["GetBook", "NewBook", "UpdateBook", "DeleteBook", "ListBook"]
}
這樣,當我們需要控制 DeleteButton
是否顯示時,只需要看看當前用戶有沒有調用 DeleteBook
接口的權限即可。如下所示:
const ACDeleteButton = () => {
const { permissions } = useContext(PermissionsContext);
return hasPermission("DeleteBook")(permissions) && <DeleteButton />;
}
通過可用接口列表來配置權限,在角色變化時不會造成任何負擔。同時,由于不需要在接口中定義開關字段,減少了前后端的溝通成本,也避免了接口字段不斷膨脹的問題。
頁面權限控制
頁面的權限控制,其實就是對路由的權限控制。我們可以根據用戶當前所擁有的權限,去判斷他是否能訪問某個頁面,從而決定是否渲染某個路由導航。
與組件的權限控制類似,頁面的權限控制也面臨著相同的問題。如果根據角色列表去控制導航菜單的渲染,同樣會遇到角色動態變化的問題。如果讓 BFF 返回路由開關,還需要增加一個額外的接口。因此,最好還是根據用戶可訪問的接口列表去開關路由。
配置路由
在配置路由時,我們可以增加一個狀態 visible
用于開關路由。在獲取到當前用戶可訪問的接口列表之后,再將用戶可訪問的路由過濾出來。
const routes = [
{
path: "/home",
exact: true,
visible: (permissions) => hasOneOfPermissions(["GetBook", "GetPerson"], permissions),
},
{
path: "/list",
exact: true,
visible: (permissions) => hasAllPermissions(["GetList", "ListBook"], permissions),
},
];
filterRoutesByPermissions(routes, permissions);
這個方案相對來說比較簡單,但是容易遺漏配置項。特別是當只要任意一個權限滿足就渲染路由時,很難發現某個接口權限漏掉了。
組件推導
頁面一定會使用組件,因此可以根據頁面使用到的組件推導出頁面的權限。當然這里需要對組件進行一些改造。比如上一小節的 DeleteButton
:
const ACDeleteButton = needPermissions("DeleteBook")(DeleteButton)
我們可以封裝 HOC 來包裹原組件, 使之在 Function Component 的基礎上,讓函數持有 shouldRender(permissions: {}) => bool
方法以便推導:
interface AccessControlComponent<TProps> {
(props: TProps) => JSX.Element | null;
shouldRender: (permissions: {}) => bool;
}
在其他組件,我們可以通過如下方式進行組合直至頁面:
const ACSection = needPermissions(ACDeleteButton)(() => (
<div>
<ACDeleteButton/>
</div>
))
const ACPage = needPermissions(ACSection)(() => {}(
<div>
<ACSection/>
</div>
))
最后將 ACPage
注冊到路由,在渲染導航菜單時,我們可以直接使用 ACPage.shouldRender
判斷是否需要渲染頁面對應的菜單。
組件推導的方案更適合通過 Babel 插件去自動配置。如果沒有自動化工具輔助,這個方案會顯得比較繁瑣。
最后
本文討論了前端實現 RBAC 權限控制的幾種方案。這些方案沒有絕對的對錯之分,只有「適合」與「不適合」。就拿第一個「錯誤」的方案來說,它確實缺少了一些靈活性,但如果你項目中的角色變動很少,采用這個方案也不是不可以。只不過你需要明確這個方案帶來的「利」與「弊」。