后台管理系统的权限角色系统设计

在重构一个历史遗留系统的时候,我发现权限角色和用户授权系统,实现得稀烂。权限角色系统,是所有后台管理系统都绕不开的一个重要模块,但是,因为用户需求的灵活性要求,系统管理的高效性要求,实际业务的复杂性要求,鲜少看见一个高度成熟,开箱即用的权限角色系统。不是需要大量的二次开发,就是配置复杂无比。

而又因为权限角色系统抽象的复杂性,学习曲线陡峭,导致开发者无法简单上手,所以,虽然有了很强大的 RBAC 模型,以及很多实现的范本,但是,常见的情况仍然是各种后台管理系统的权限角色体系,甚至包括操作界面,都实现得差强人意。

RBAC 体系

我们最熟悉的就是 Role-based-access-control 系统,简称是 RBAC。主要是基于角色去进行访问控制。

在这套体系里,控制的最小颗粒是“权限”这个抽象对象,权限可以组合成“角色”,用户则拥有角色。

判定一个用户能否进行访问的时候,我们一般先拉出用户的所有角色,然后分解成权限,看看里面是否包含当前要访问的权限。

这种类型的抽象设计里,如果要增加灵活性的话,可能会允许“角色”里,既可以包含其他“角色”,也可以包含单个的“权限”。在赋权的时候,允许给用户赋予“角色”或者直接赋予“权限”。

不过在这种灵活提升的同时,代码的实现复杂度必然变高,需要区分当前读取的列表里包含的对象的类型是权限还是角色。

好,到这里还没有什么问题,因为这个原理是很简单的。具体到了实现的时候,请问,应该怎么去设置一个系统的权限呢?

以前,我用得最多的是 Yii 框架,在 Yii 框架里,默认情况下,每个 Controller 的 Action 会在系统里自动成为一个 Route,而一个 Route 的访问,则自动有一个与之对应的权限。有些 /controller/action 是一个页面,有些是 REST 的 API,他们无一例外都被自动生成了权限了。

然后,如果一个程序员偷懒的话,那么就不会有新的权限设置了。页面和 API 级别的控制,已经够用了。而且,往往程序员都是偷懒的。也就是访问控制系统的全部了。

这里,我们发现一个真正的难题,虽然有了 RBAC,但是权限列表的维护绝对是一个困难的事情,至少是一个繁琐的事情,一般的程序员是绝不愿意去做的,除非有明确的需求的情况下。比如在你开发一个新的页面或者 API 的时候,你会记得去权限角色列表里,注册一个新的权限么?尤其可怕的是,当去更新历史存在的页面或者 API 的时候,如果对应的权限范围或者定义有所区别的时候,如何去维护权限角色列表呢?

因为这个一致性问题的存在,还有就是开发者和线上运维者,身份并不总是统一的情况,所以维护线上的权限列表,重新 review 权限角色分配,历来都是各个后台管理系统的难题。我层间见到过,人都离职很多年了,系统里还能查到这个人名下的权限,其身份仍然在系统里保留。也时常看到业务人员离职后,其账号不注销,直接连通用户名密码,给接手同事使用,这给企业的管理和安全都带来了很大的挑战。

RBAC 的维护

我觉得 Yii 框架的做法,对我是一个巨大的启发,也应该成为一般的权限角色系统的标杆。系统应该自动取生成一些权限项,减少程序员的工作。这样才能让需求的实现更容易。

不过在一个前后端分离系统,尤其前台使用 Vue 开发的 SPA 中,这种把菜单路由设置为一个权限,同时控制菜单可见性和访问权限的做法,是不太方便的。因为前台只有一个页面了。用路由区分页面上呈现的组件。

基于 Yii 的做法的灵感,我认为,应该这样去处理前端站点的权限角色系统的问题。

菜单和路由

在 Vue 3 的站点里,我们会为每个页面配置一个路由表,指明 path 和对应的 component,这是 Vue 3 的站点组织信息架构的一种方式。

如果你在一个前端系统里,开发了一些页面,你需要通过系统的配置菜单命令去注册你添加的页面,以确保用户发出指令的情况下, Vue 能自动渲染对应的组件。

这个配置也叫路由配置。进行路由配置的时候,其实可以将 Vue 的配置信息,加工成菜单的访问权限。这样,可以以菜单同名的权限名,控制菜单的可访问性和可见性。

这样,仿照后端的做法,我们就有了前端页面和菜单的访问控制的功能,权限被维护起来了。

后端 API

如果还是使用 Yii 框架,还是可以像从前那样,将 /controller/action 的 API 路径加入到权限中去,如果不是用 PHP,比如我们用 Golang,也应该是在构建的时候,完成这个过程。将需要加入到数据库的权限提炼出来,准备好。

不过,相比起 PHP 来说,这么做要难得多,因为是构建和部署分离的,你在本地构建,也不可能连接到线上数据库去持久化权限的。

我想到的方法,可能是需要构建期,生成一个新增权限的列表,上线后,启动时,将其一次性加载入数据库进行持久化。注意去重即可。

不属于菜单和 API 的权限

这类一般都是命名权限,就是这个权限,即不是一个前台页面,也不是一个后台 API,可能是他们的结合体,也可能是若干个 API 的结合体,也可能是别的什么。

这是不,我们都要人工注册命名权限。那么我这种自动生成的体系里,怎么支持这一种,或者是否需要支持这一种呢?是否支持,我觉得还是应该支持的,毕竟这种功能,可以保留一些灵活性,因为有时候,你虽然在 API 上进行了控制,可以保证用户不会越权调用。但是,我们可能有更高要求,比如某个页面显示或者隐藏某个按钮,根据权限来甄别。

那这时候用户还没有调用 API,你还想控制按钮的可见性,是不是就不太方便,除非按钮的功能很单一,点下去必然是只跟特定 API 有关,你可以代替。不然还是自己命名一个权限方便点。

我想这种情况下,可以特定的语法在注释中声明这个权限。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script lang="ts" setup>
// @permission show_create_button
const showButton = ref(true)
function getPermissionFromApi() {
}

getPermissionFromApi().then( res => {
showButton.value = hasPermission('show_create_button')
}
</script>
<template>
<button v-if="showButton"></button>
</template>

这里,我们就用一个注释声明了一种权限。在构建期,我们通过扫描,将声明的 permisson 生成一个注册文件,在系统上线的时候,将其持久化到数据库中。然后代码里自然就可以从数据库读取刚声明的权限。

上线后,可以通过角色权限的分配界面,将自动注册的权限配置给登录用户即可。

后台也可以用这种声明使用的方法,来声明权限。并利用自动脚本持久化这种声明出来的权限。

在我的这种设计中,基础的权限几乎不会来自于 runtime 时期使用者手动的注册,这其实本来也不现实。

使用者唯一要完成的工作是,将每个零散的权限,封装成一些固定的角色。这些固定的角色,甚至都可以在系统初始化的时候,由初始安装包提供初始化。

一些问题

角色的维护和变更

角色因为是可以随意定义的,所以是在系统的运行时去维护的,通过配置。而系统可以给出初始的角色列表。

权限的维护和变更

如果我们新开发了代码,声明了新的权限,那么就会自动被注册到数据库里去,不过,多个地方用到同一个权限,到底是是否出现权限重名,还是本就该是同一个权限,这个代码很难界定,必须程序员自己小心了。

如果你修改一个权限的名字,则系统没法自动地删除数据库里已经注册持久化的权限。这就需要权限维护层面,提供删除权限的功能。

不过你如果误删了一个权限,对应代码没有改名或者删除的话,下次构建,权限又会被重新加入到数据库中。这也是这套体系带来的问题。不过往往都是 trade-off 而已。

当然,也可以约定,就完全不允许删除一个权限即可。那也没什么。

总结

我给出了一套维护系统权限角色的闭环方法。这套方法里,不用手动去设置一个个权限,毕竟系统的功能都是代码实现的,你手动设置的权限,也都是要靠代码去实现生效的,所以说,还不如在构建时系统自动取维护权限列表。只把角色的封装和分配交给最后的用户。

对我来说,这种方案基本满足了我现在的要求。