# 基于 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 通过角色为用户和权限之间架起了一座桥梁,从而简化了用户和权限之间的关系,让权限配置更易于扩展和维护。
# 前端应用的权限控制
对于前端应用来说,按照权限控制粒度的不同,可以分为组件权限和页面权限:
- 组件权限控制的粒度较细,可以精确地控制每个 UI 组件显示与否。
- 页面权限控制的粒度较粗,只能控制到页面层。本质上就是对路由进行权限控制。
# 组件权限控制
刚才也提到了,组件的权限控制粒度很细。因此,我们可能需要为应用中数百个组件添加权限控制。这意味着如果一开始没有设计好组件权限控制的方案,可能会导致应用难以维护,甚至造成灾难性的后果。
# 一个「错误」的方案
在最初设计组件权限控制的方案时,我们将组件的显示与否关联到了角色。也就是说通过配置「允许访问的角色列表」,去控制组件的显示与否。为了方便使用,我们还设计了一个公用组件,用来包裹每个需要权限控制的组件。如下所示:
<OneOfAccessControl permittedRoles={["RoleA", "RoleB", "RoleC"]}>
<Button />
</OneOfAccessControl>
2
3
在上面的例子中,只要列表中的任意一个角色有权限,就会渲染按钮组件,否则什么也不做。这段代码看起来似乎没什么问题。但如果此时要新增一个角色,试想会发生什么?
我们可能需要修改上百个地方的配置,而且由于配置散落在项目的各个角落,修改起来十分困难。因此每增加一个角色或者移除一个角色,都会带来巨大成本。为了解决这个问题,我们优化了之前的方案。如下所示:
const permissionsConfig = {
canViewButton: ["RoleA", "RoleB", "RoleC"],
canEditButton: ["Role_A", "Role_B"]
canDeleteButton: ["Role_B"],
};
2
3
4
5
与前面不同的是,我们将分散的配置项集中管理起来了。在获取到当前用户所拥有的角色之后,将这份配置转换成了一组控制 UI 组件显隐的开关,并通过 Context 提供给下游组件。在使用时只需要通过一个布尔值就能控制按钮的显示和隐藏。如下所示:
const ACButton = () => {
const { canViewButton } = useContext(PermissionsContext);
return canViewButton && <Button />;
}
2
3
4
优化后的方案确实更易于维护了。但如果需求是根据动态生成的角色去控制组件的显示与否,又该如何解决呢?动态生成角色意味着角色列表可能随时发生变化。因此无法再像上面一样,通过配置「固定」的角色列表,去控制组件的显示与否。
其实问题的关键就在于:RBAC 权限模型中,角色不是固定的而是动态变化的。我们可以随时增加或者修改一个角色。因此,最好不要将组件的权限控制和角色绑定到一起。
# 不如交给 BFF 吧?
有时候一个组件的显示与否,不仅仅和权限相关,也和 API 返回的数据相关。比如:
// 有权限并且年满 18 岁的用户才能看到这个按钮
hasPermission && age >= 18 && <Button />
2
我们都知道,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
}
2
3
4
5
6
7
8
9
10
11
在一些情况下,这种方案可能有致命的缺陷。比如一个提交按钮,根据权限控制的需求,在请求数据之前就需要将其隐藏,这个时候就无法通过 BFF 在返回数据中加上开关,而再提供额外接口获取开关又显得冗余。
【进入页面时,有一个 API 专门用于获取页面上的所有 flag,可以解决某些场景下没有后端 API 调用的问题】
Attribute-Based Access Control
# 将后端权限映射到前端
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}"
]
}
2
3
4
5
6
7
8
但接口最好用 API 唯一标识替代(如 operationId
),以方便前端使用。如下所示:
{
"permissions": ["GetBook", "NewBook", "UpdateBook", "DeleteBook", "ListBook"]
}
2
3
这样,当我们需要控制 DeleteButton
是否显示时,只需要看看当前用户有没有调用 DeleteBook
接口的权限即可。如下所示:
const ACDeleteButton = () => {
const { permissions } = useContext(PermissionsContext);
return hasPermission("DeleteBook")(permissions) && <DeleteButton />;
}
2
3
4
通过可用接口列表来配置权限,在角色变化时不会造成任何负担。同时,由于不需要在接口中定义开关字段,减少了前后端的沟通成本,也避免了接口字段不断膨胀的问题。
# 权限的复用
有时候,我们需要考虑带权限组件的复用问题。比如一个组件用在了很多地方,并且每个地方需要的权限控制也相同。为了方便复用,可以把权限控制的逻辑和组件一起,封装成一个单独的组件。比如上个例子中的 ACDeleteButton
。
但是,这种场景更适合使用高阶组件。因为高阶组件采用了装饰器模式,可以增强原有组件的功能,并且不破坏它原有的特性。我们可以抽象一个不带权限控制的原始组件,再通过高阶组件去修饰它,从而得到一个带权限配置的新组件。这样,当需要复用原始组件时,也很方便。比如:
export const needOneOfPermission = (...permissionKeys: string[]) => {
const { permissions } = useContext(PermissionContext);
return (Comp) => {
return hasOneOfPermission(permissionKeys)(permissions) && <Comp />;
};
};
const ACBookList = needOneOfPermission("ListBook")(BookList);
const ACBook = needOneOfPermission("GetBook")(Book);
2
3
4
5
6
7
8
9
通过高阶组件,我们可以让一个组件在「带权限」和「不带权限」之间快速切换。
# 页面权限控制
页面的权限控制,其实就是对路由的权限控制。我们可以根据用户当前所拥有的权限,去判断他是否能访问某个页面,从而决定是否渲染某个路由导航。
与组件的权限控制类似,页面的权限控制也面临着相同的问题。如果根据角色列表去控制导航菜单的渲染,同样会遇到角色动态变化的问题。如果让 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);
2
3
4
5
6
7
8
9
10
11
12
13
14
这个方案相对来说比较简单,但是容易遗漏配置项。特别是当只要任意一个权限满足就渲染路由时,很难发现某个接口权限漏掉了。
# 组件推导
页面一定会使用组件,因此可以根据页面使用到的组件,推导出页面的权限。当然,这里需要对组件进行一些改造。比如上一小节的 DeleteButton
:
const ACDeleteButton = needPermissions("DeleteBook")(DeleteButton)
我们可以封装高阶组件(High-Order-Component)来包裹原组件, 使之在函数组件的基础上,让函数持有 shouldRender(permissions: {}) => bool
方法以便推导:
interface AccessControlComponent<TProps> {
(props: TProps) => JSX.Element | null;
shouldRender: (permissions: {}) => bool;
}
2
3
4
5
在其他组件,我们可以通过如下方式进行组合直至页面:
const ACSection = needPermissions(ACDeleteButton)(() => (
<div>
<ACDeleteButton/>
</div>
))
const ACPage = needPermissions(ACSection)(() => {}(
<div>
<ACSection/>
</div>
))
2
3
4
5
6
7
8
9
10
11
12
最后将 ACPage
注册到路由,在渲染导航菜单时,我们可以直接使用 ACPage.shouldRender
判断是否需要渲染页面对应的菜单。
组件推导的方案更适合通过 Babel 插件去自动配置。如果没有自动化工具辅助,这个方案会显得比较繁琐。
# 最后
本文讨论了前端实现 RBAC 权限控制的几种方案。其中最灵活的一种方式,就是通过当前用户可用的接口列表,去控制前端组件和路由的渲染。
这些方案没有绝对的对错之分,只有「适合」与「不适合」。就拿第一个「错误」的方案来说,它确实缺少了一些灵活性,但如果你项目中的角色变动很少,采用这个方案也不是不可以。只不过你需要明确这个方案会带来哪些「利」与「弊」。
组件权限控制可能会由几个因素共同决定:
权限。用户是否有权限调用某个 API。(只有权限交给 BFF 做也会有问题,由于 BFF 的设计有时无法返回一个 按钮的状态)
validation。后端会根据一些数据状态去校验当前用户是否应该看到某个 button。
UI 状态。前端会根据一些 UI 状态去显隐某个 Button。(交给 BFF 做有问题)
将权限标记在 HTML 上,方便 DEV 开发以及 QA 测试
是否可配置?配置共享?比如动态表单
带权限的组件复用问题。HOC over Hooks。权限的定义更适合装饰器模式,我们可以抽一个不带权限控制的原始组件,再通过高阶组件去修饰它,从而得到一个带权限配置的新组件,权限可以跟随新组件进行复用。这样,当需要复用原始组件时,也很方便。我们可以更容易的在带权限和不带权限的组件之间自由切换。
如果将权限控制的逻辑包含到组件内,那么它将随着组件进行复用。
带权限的组件。
hasPermission(xxx) &&
hasPermission(xxx) &&
所有带权限的,都需要抽组件。
带权限、可复用的时候。
如果没有自动化工具,QA 测试起来也非常困难。可以将权限渲染到 HTML 节点上,方便 QA 测试。
- 高阶组件
希望大家在指定权限控制方案时,能够清楚每种方案的利与弊。
可以通过 Babel 插件去自动添加。如果是手动加推导,可能会出现遗漏的情况,debug 起来更困难。
如果觉得很麻烦,可以在页面这一层就全部配置完成。组件推导适合通过工具来去自动配置。当页面比较复杂时,容易遗漏。
Hook ?
如何 Debug?
是否请求 welfare-home-officer 接口?由于 Internet 和 intranet 环境的用户都有可能调用这个接口,只是 intranet 的有些用户不能调用这个接口。那我们不能单纯的根据是 Internet 环境还是 intranet 环境去判断是否调用这个接口,而是根据用户所拥有的权限去判断,如果用户有调用这个接口的权限,那么就发起请求,否则就不发起请求。至于用户是否有调用这个接口的权限,我们可以通过用户所拥有的角色来判断?(同样会有前面提到的问题)
将权限和资源映射成 API。
权限控制应该对应资源和权限,而非角色。因为角色是动态创建的,对于相同的一组资源来说,不同的角色可能拥有不同的权限。因此,新建一个角色其实就是新建一组资源权限的集合。而在第一个方案中,描述的其实是某个角色是否能看到某个 UI 元素,并没有对应到资源上。因此,每新增一个角色时都会给前端带来很大成本。
如果前端能够知道某个角色能够调用哪些 API,就能够自动显示或隐藏对应的 UI 元素。
因为 RESTful API 中描述的「资源 + 对资源的操作类型」,正好对应了 RBAC 模型中的「资源 + 权限」。
因为 RESTful 是面向资源的,通过 RESTful API 我们可以知道资源以及资源的操作类型(GETPOST/PUT/PATCH/DELETE)。因此,我们可以得出这样一个结论:一个资源加上一个权限就可以对应为一个 RESTful API。
RBAC 权限模型中的「一个角色对某些资源拥有怎样的权限」,就可以翻译为「一个角色可以调用哪些 API」。
最大的问题是,组件的显示与否是强依赖于 API 响应数据的。也就是说,必须先发起 HTTP 请求才能获取控制组件显隐状态的字段。如果这里原本就需要发起一个 HTTP 请求,那么后端「顺便」将这些字段返回给前端也没有什么不妥。但是如果一个组件的显示与否,需要在发起 API 之前决定,又该怎么办呢?难道要写一个额外的 API 去处理这种情况吗?当然不是,这种情况还是应该前端去处理。
那既然组件的显示与否需要响应数据才能决定,如果由 BFF 去控制前端组件的显隐,那么当权限或数据状态发生改变时,前端无需重新部署。
我们可以通过它去控制前端组件的显隐。BFF 通过权限和数据状态判断出前端组件的显隐状态,并将其返回给前端。这样前端只需要通过布尔值去决定是否渲染组件即可。
比如管理员可以动态创建一个新的角色,那么这个新的角色同样应该参与按钮组件显隐的控制。但是由于按钮组件的显示与否是根据「固定」的角色列表来判定的,因此无法满足「动态」增加或删除角色的需求。
组件的权限控制,其实就是控制组件的显示与否。但有时候组件的显示与否,不仅仅和权限相关,也和数据状态相关:
<OneOfAccessControl permittedRoles={["RoleA", "RoleB", "RoleC"]}>
{age >= 18 && <Button />}
</OneOfAccessControl>;
2
3
在实际场景中,可能会有很多状态去控制 UI 元素是否显示,再加上权限控制,测试起来十分困难。在使用这个方案的过程中,问题也逐渐暴露出来:
- 配置
permittedRoles
可能出错,且逻辑分散在项目的各个角落,难以维护。 - 增加角色的成本很大,因为需要修改散落在各个地方的配置。
- 增加单元测试成本。为了确保每个 UI 组件都正确设置了访问权限,我们需要增加单元测试。但是测试组件是否根据
permittedRoles
和状态渲染了正确的 UI,是一笔很大的开销。 - 前后端权限控制不一致的问题。对于某个角色来说,能看到哪些 UI 元素是由前端控制的,而能够调用哪些接口是由后端决定的。有时候由于沟通协作的问题,可能会出现页面上显示了某个 UI 元素,但是却没有权限调用对应接口的问题。
- 当某个 UI 元素的权限发生变化时,需要修改前端配置并重新部署。
一个用户有哪些权限,在登录完成之后就已经确定了。也就是说,这个用户能看到页面上的哪些元素也已经确定了。因此可以将所有权限配置放到一个文件中,统一管理起来。比如:
然后,在拿到当前用户所拥有的角色之后,将这份配置转化为下面的形式提供给下游组件使用:
const accesss = {
canViewProfile: true,
canEditProfile: false,
canDeleteProfile: true,
}
2
3
4
5
在使用时,只需要通过一个布尔值去控制按钮是否显示就可以了:
const {
access: { canViewProfile },
} = useContext(AccessControlContext);
canViewProfile && <Button />;
2
3
4
5
通过这种方式将应用中所有权限控制的配置都汇总到一个地方,维护起来更加方便。同时为了保证配置的正确性,我们可以通过简单的 snapshot 测试来覆盖业务场景。在使用时因为逻辑非常简单,甚至不需要再增加额外的单元测试。这个方案只解决了我们刚才提到的两个问题,那其他问题如何解决呢?请接着往下看。
每个需要权限控制的组件,都需要增加相应逻辑。因此为了方便使用,可以通过一些公用组件来帮我们进行权限控制。如果要对一个按钮增加权限控制,只需要在它外面包裹一个组件,然后配置允许访问的角色即可。只要其中任意一个角色被允许,就渲染按钮否则就返回空。
- 减少前后端沟通成本。比如增加或减少一个用于权限控制的接口字段。
- 无需增加任何额外字段,只需要通过是否能够使用某个接口,就能判定某个元素是否显示。解决了接口无限膨胀的问题。
最终决定元素的显示与否,可能是权限 + 数据状态。
权限 + 数据状态:
1 对 多:后端
- 无限膨胀
表达式:避免服务重启。
后端动态 Render :解决了无限膨胀的问题、更灵活。但是页面元素相对固定,如果要增加或者修改都很困难。
1 对 1: 前端
- 状态单独判断。(修改、部署困难)
- 需求变更的情况。
RBAC 控制的最小单元就是接口。让接口的权限和组件的显示与否一一对应。不会存在和后端不一致的情况。
const PermissionsContext = createContext<{ permissions: { [key: string]: true }}>({})
const usePermissions = () => useContext(PermissionsContext).permissions || {}
const mustOneOfPermissions = (...requestActionCreators: { name: string }) => (
const operationIDs = requestActionCreators.map((p) => p.name)
return function<TProps>(Comp: ComponentType<TProps>) {
return (props: TProps) => {
const permissions = usePermissions()
if (some(operationIDs, (operationID) => permissions[operationID])) {
return <Comp {...props} />
}
return null
}
}
)
const mustAllOfPermissions = (...requestActionCreators: { name: string }) => (
const operationIDs = requestActionCreators.map((p) => p.name)
return function<TProps>(Comp: ComponentType<TProps>) {
return (props: TProps) => {
const permissions = usePermissions()
if (every(operationIDs, (operationID) => permissions[operationID])) {
return <Comp {...props} />
}
return null
}
}
)
const SomeComponent = () => {
}
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
# Dynamic Content
和组件化开发的思路是一样的。我们可以进一步抽象,将页面元素划分为可配置的 block,不同的 block 包含不同的类型或对应的配置值,如 Section, Image, Paragraph 等,当然每种类型的 block,是需要前端开发完备的,支持对应配置项。然后我们便可通过 JSON 来描述和存储页面,比如
const Page = (
<>
<Section>
<Paragraph content="xxxxxx"/>
<Image src="http://xxx">
</Section>
<Section>
<Paragraph content="content">
</Section>
</>
)
2
3
4
5
6
7
8
9
10
11
可以描述为
[
{
"type": "Section",
"children": [
{
"type":"Paragraph",
"content": "xxxxxx"
},
{
"type":"Image",
"src": "http://xxx"
}
]
},
{
"type": "Section",
"children": [
{
"type":"Paragraph",
"content": "content"
}
]
}
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
虽然看上去复杂了不少,但由于 JSON 无关语言,易于传输与存储的特性。我们可以这份 JSON 可存放到服务端,前端获取这一 JSON 配置,渲染最终页面。在一些特殊需求中(RBAC,定向广告),服务进行适当删减,使得显示内容得以动态。
参考: https://www.contentful.com/
# Dynamic Form
同样的,更复杂的表单,也是一样的思路,只是对于 Form,有一个额外的功能点,即表单可能随着用户的输入而变化。
既然是表单,最终是需要提交的,所以一般而言我们会为 Dynamic Form 定义两个部分。
一个是提交数据的声明结构,这个采用 JSON Schema 即可。如提交数据为
{
"name": "xxx",
"age": 1,
"gender": "male"
}
2
3
4
5
我们可以用如下声明表示
{
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "[\w]{2,}",
"x-error": "名字长度不够"
},
"gender": {
"type": "string",
"enum": ["male", "female"],
"x-enum-labels": ["男","女"]
},
"age": {
"type": "number",
"mininum": 20,
"x-error": "年龄太小"
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
然后前端根据不同的类型,渲染对应的组件,
type: "string"
我们可以用TextInput
渲染,type: "number"
我们可以用NumberInput
渲染,我们可以 JSON Schema 的 validations 属性来进行输入检查, 由于 JSON Schema 毕竟是设计来描述数据结构的,对错误的 inline error,我们可以扩展error
来描述。type: "number"
我们可以用NumberInput
渲染,enum
我们可以用SelectInput
渲染。同样的,对于选项文本的描述需要我们自己扩展,如上面例子中的x-enum-labels
,
对于响应用户输入变化,JSON Shema 有对应的方案。https://json-schema.org/understanding-json-schema/reference/conditionals.html,如下,男女法定婚龄不同,JSON Schema 可定义为,前端组件做出对应的处理即可。
{
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "[\w]{2,}",
"x-error": "名字长度不够"
},
"gender": {
"type": "string",
"enum": ["male", "female"],
"x-enum-labels": ["男","女"]
},
"age": {
"type": "number"
}
},
"if": {
"properties": { "gender": { "const": "male" } }
},
"then": {
"properties": { "age": { "mininum": 22 } }
},
"else": {
"properties": { "age": { "mininum": 20 } }
}
}
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
可是这个方案确实不够强大,对应。
这里还有另一种方式,借鉴自一个地图引擎,https://docs.mapbox.com/mapbox-gl-js/style-spec/#expressions,对于复杂场景,来定义 validation x-validation
和 condition x-when
,还可以支持复杂情况
[expression_name, argument_0, argument_1, ...]
["one", ...]
["all", ...]
["match", pattern, msg]
["get", path]
["case", defaultResult, case1, result1, ...]
[">", target, v]
[">=", target, v]
["==", target, v]
["<", target, v]
["<=", target, v]
2
3
4
5
6
7
8
9
10
11
12
{
"type": "object",
"properties": {
"name": {
"type": "string",
// if (len(field_value) == 0) return "不能为空"
// if (!(/[\w]{2,}/.text(field_value))) return "名字长度不够"
// return ""
"x-validate": [
"case",
"",
["==", ["len","$value"], 0], "不能为空",
["not", ["match", "$value", [\w]{2,}"]], "名字长度不够",
]
},
"gender": {
"type": "string",
"enum": ["male", "female"],
"x-enum-labels": ["男","女"]
},
"age": {
"type": "number",
// get(values, "gender") && <Field/>
"x-when": [
"get", "gender",
],
// if (get(values, "gender") == "male" && field_value <= 22) return "年龄太小"
// if (get(values, "gender") == "female" && field_value <= 20) return "名字长度不够"
// return ""
"x-validate": [
"case",
"",
["all",
["==", ["get", "$values", "gender"], "male"],
["<=", 22]
], "年龄太小"],
["all",
["==", ["get", "$values", "gender"], "female"],
["<=", 20]
], "年龄太小"],
]
}
}
}
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
至于如何去解析并运算表达式,这算是函数式编程的别样用法。
权限控制应该对应到它的最小单元,否则难以扩展。
权限控制:
oneOf A, B, C
allOf A,B,C
对路由的权限控制
局部 UI 组件的显示与否。如果用户没有某个权限,点击某个 button 时会抛出 error message,是否考虑 hide button 或者提示用户?
shouldShow(expr)(data) && Button
const validate = (data: any) => eval(_.template(`{{ .status }} == "AAA"`)(data))
2
3
4
表达式的问题是:后端不知道应该如何构造表达式?还是表达式有一套通用的方案?类似于一门语言?
Attribute-Based Access Control
动态表达式去决定前端元素的显示或者隐藏,主要优势在于「当前端元素时动态生成的」。特点是是否可配置,配置共享。比如 Dynamic Form。如果是相对静态的,可以不用这种方式。比如动态表单中,根据用户年龄的不同,显示不同的 input field。(小于 18 岁,需填写监护人,大于 18 岁则不需要)。
动态表达式这种方式,和 BFF 的方式有一个相同的问题,比如一个提交按钮,根据权限控制的需求,在请求数据之前就需要将其隐藏,这个时候就无法通过 BFF 在返回数据中加上开关,而再提供额外接口获取开关又显得冗余。
【进入页面时,有一个 API 专门用于获取页面上的所有 flag,可以解决某些场景下没有后端 API 调用的问题】
const fields = [
{
name: "gender",
label: "Gender",
type: "text",
value: "男"
},
{
name: "weight",
label: "Weight",
type: "text",
isShow: "get('gender', 'value')(formData)==='男'"
},
{
name: "height",
label: "Height",
type: "text",
isShow: "get('gender', 'value')(formData)==='女'", //[["get", "gender", "value"], "woman"]
}
];
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20