基于 RBAC 的前端权限控制

当我们讨论到前端应用的权限控制时,不是在讨论如何去控制权限,而是在讨论如何将用户权限反映到页面元素的显隐上。如果用户没有权限访问请求,不仅会造成请求资源的浪费,还会降低用户体验。前端的权限控制就是为了解决这类问题。

RBAC 是目前普遍使用的一种权限模型。本文会讨论如何基于 RBAC 权限模型去实现前端应用的权限控制。

RBAC 简介

RBAC(Role-Based Access Control)即:基于角色的访问控制。RBAC 认为授权其实就是 who, what, how 三者之间的关系,即 who 对 what 进行 how 操作。简单来说就是某个角色 (who) 对某些资源 (what) 拥有怎样的 (how) 权限。

在 RBAC 中,用户只和角色关联,而角色对应了一组权限。通过为不同的用户分配不同的角色,从而让不同的用户拥有不同的权限。

相比于 ACL(Access Control List)直接为用户赋予权限的方式,RBAC 通过角色为用户和权限之间架起了一座桥梁,从而简化了用户和权限之间的关系,让权限配置更易于扩展和维护。

image-20191216212755925.png

前端应用的权限控制

对于前端应用来说,按照权限控制粒度的不同,可以分为组件权限和页面权限:

  1. 组件权限控制的粒度更细,可以精确地控制每个 UI 组件显示与否。
  2. 页面权限控制的粒度较粗,只能控制到页面层。本质上就是对路由进行权限控制。

组件权限控制

刚才也提到了,组件的权限控制粒度很细。因此,我们可能需要为应用中数百个组件添加权限控制。这意味着如果一开始没有设计好组件权限控制的方案,可能会导致应用难以维护,甚至造成灾难性的后果。

一个「错误」的方案

在最初设计组件权限控制的方案时,我们将组件的显示与否关联到了角色。也就是说通过配置允许访问组件的角色列表,去控制组件的显示与否。为了方便使用,我们还设计了一个公用组件,用它来包裹每个需要权限控制的组件。如下所示:

<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 权限控制的几种方案。这些方案没有绝对的对错之分,只有「适合」与「不适合」。就拿第一个「错误」的方案来说,它确实缺少了一些灵活性,但如果你项目中的角色变动很少,采用这个方案也不是不可以。只不过你需要明确这个方案带来的「利」与「弊」。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容