权限控制在数栈产品的实践
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
前言
访问控制(Access control)是指对访问者向受保护资源进行访问操作的控制管理。该控制管理保证被授权者可访问受保护资源,未被授权者不能访问受保护资源。
现实生活中的访问控制可以由付费或者认证达成。例如:进电影院看电影,需要够买电影票,否则检票员就不让你进去。
访问控制有很多模型,比如:
- 自主访问控制模型 (Discretionary Access Control)
- 强制访问控制模型 (MAC: Mandatory Access Control)
- 角色访问控制模型 (RBAC: Role-based Access Control)
- 属性访问控制模型 (ABAC: Attribute-Based Access Control)
DAC
自主访问控制(DAC: Discretionary Access Control),系统会识别用户,然后根据访问对象的权限控制列表(ACL: Access Control List)或者权限控制矩阵(ACL: Access Control Matrix)的信息来决定用户是否能对其进行哪些操作,例如读取或修改。而拥有对象权限的用户,又可以将该对象的权限分配给其他用户,所以称之为“自主(Discretionary)”控制。
自主访问控制模型是一种相对比较宽松但是却很有效的保护资源不被非法访问和使用的手段。说它宽松,是因为他是自主控制的,在保护资源的时候是以个人意志为转移的;说它有效,是因为可以明确的显式的指出主体在访问或使用某个客体时究竟是以何种权限来实施的,任何超越规定权限的访问行为都会被访问控制列表判定后而被阻止。
比较典型的场景是在 Linux 的文件系统中:
系统中的每个文件(一些特殊文件可能没有,如块设备文件等)都有所有者。文件的所有者是创建这个文件的计算机的使用者(或事件,或另一个文件)。那么此文件的自主访问控制权限由它的创建者来决定如何设置和分配。文件的所有者拥有访问权限,并且可以将访问权限分配给自己及其他用户
MAC
强制访问控制(MAC: Mandatory Access Control),用于将系统中的信息分密级和类进行管理,以保证每个用户只能访问到那些被标 制访问控制下,用户(或其他主体)与文件(或其他客体)都被标记了固定的安全属性(如安全级、访问权限等),在每次访问发生时,系统检测安全属性以便确定一个用户是否有权访问该文件。
MAC 最早主要用于军方的应用中,通常与 DAC 结合使用,两种访问控制机制的过滤结果将累积,以此来达到更佳的访问控制效果。也就是说,一个主体只有通过了 DAC 限制检查与 MAC 限制检查的双重过滤装置之后,才能真正访问某个客体。一方面,用户可以利用 DAC 来防范其它用户对那些所有权归属于自己的客体的攻击;另一方面,由于用户不能直接改变 MAC 属性,所以 MAC 提供了一个不可逾越的、更强的安全保护层以防止其它用户偶然或故意地滥用 DAC。
RBAC
角色访问控制 (RBAC: Role-based Access Control),各种权限不是直接授予具体的用户,而是在用户集合与权限集合之间建立一个角色集合。 每一种角色对应一组相应的权限。 一旦用户被分配了适当的角色后,该用户就拥有此角色的所有操作权限目前来说基于角色的访问控制模型是应用较广的一个,特别是 2B 方向 SAAS 领域,应用尤其常见,角色访问也就是我们今天要介绍的重点。
RBAC 虽然简化了权限的管理,但是对于复杂场景的角色管理,它依然不够灵活。比如主体和客体之间的权限复杂多变,可能就需要维护大量的角色及其授权关系;新增客体也需要对所有相关角色进行处理。基于属性的角色访问控制就是为了解决这个问题。
ABAC
属性访问控制(Attributes-based Access Control)是一种非常灵活的访问控制模型。属性包括请求主体的属性、请求客体的属性、请求上下文的属性、操作的属性等。如身为班主任(主体的属性)的老张在上课(上下文的属性)时可以踢(操作属性)身为普通学生(客体的属性)的小明一脚。可以看到,只要对属性进行精确定义及划分,ABAC可以实现非常复杂的权限控制。
比如:大二(年级)计科(专业)二班(班级)的班干(职位)可以在学校内网(环境)上传(操作)班级的照片。
但是由于 ABAC 比较复杂,对于目前的 SAAS 领域,就显得有点大材小用了,所以在 SAAS 领域很少见到有使用ABAC 的平台,目前使用 ABAC 比较多的就是一些云服务。
数栈中的 RBAC
我们产品中采用的是 RBAC 的权限方案,所以我们目前只对 RBAC 进行分析。
RBAC 是角色访问控制,那么首先我们需要知道的是用户的角色,在这个方面,我们项目中存在了用户管理以及角色管理两个模块。
用户管理
在登陆门户的用户管理中提供用户账户的创建、编辑和删除等功能。
在数栈的产品中,存在租户的概念,每个租户下都有一个自己的用户管理,对租户内的用户进行管理。能够设置当前用户的角色,这些角色包括租户所有者、项目所有者和项目管理者等。
角色管理
在角色管理中可以看到角色的定义,以及它所拥有的访问权限。
我们通过在用户管理和角色管理中的用户定义,可以得到当前用户完整的产品访问权限,当用户进入某个功能时,我们就可以通过当前的准入权限以及用户的访问权限,进行比较,进而得出是否准入的结论。
对于我们前端开发者而言,我们需要的其实就是
- 用户具体的角色权限
- 通过用户具体的角色权限, 对权限进行校验
那我们来看看 ant design pro 的权限方案是如何处理的。
ant design pro 中的权限方案
业界比较通用的 ant design pro 中的权限方案是如何设计的呢?
获取用户角色权限
一开始在进入页面的同时,会进行登陆校验。如果未登录会跳转到登录页面,进行登陆操作,登陆成功后,会把当前用户的角色数据通过 setAuthority
方法存进 localStorage
中,方便我们重新进入页面时获取。
而对于已经登录校验通过的,会直接进入项目中,进行渲染页面基础布局 BasicLayout 组件,在 BasicLayout 组件中我们使用到了Authorized
组件,在挂载Authorized
的时候,触发renderAuthorize
给CURRENT
进行赋值。后续的权限校验都会使用CURRENT
,比较关键。
下面是这两种情况的方法调用流程图:
renderAuthorize
方法是一个柯里化函数,在内部使用getAuthority
获取到角色数据时对 CURRENT
进行赋值。
let CURRENT: string | string[] = 'NULL';
type CurrentAuthorityType = string | string[] | (() => typeof CURRENT);
/**
* use authority or getAuthority
* @param {string|()=>String} currentAuthority
*/
const renderAuthorize = (Authorized: any) => (currentAuthority: CurrentAuthorityType) => {
if (currentAuthority) {
if (typeof currentAuthority === 'function') {
CURRENT = currentAuthority();
}
if (
Object.prototype.toString.call(currentAuthority) === '[object String]' ||
Array.isArray(currentAuthority)
) {
CURRENT = currentAuthority as string[];
}
} else {
CURRENT = 'NULL';
}
return Authorized;
};
export { CURRENT };
export default (Authorized: any) => renderAuthorize(Authorized);
到这,项目的权限获取以及更新就完成了。接下来就是权限的校验了
校验权限
对于权限校验,需要以下环境参数:
- authority:当前访问权限也就是准入权限
- currentAuthority:当前用户的角色,也就是 CURRENT
- target:校验成功展示的组件
- Exception:校验失败展示的组件
对于需要进行权限校验的组件,使用Authorized
组件进行组合,在Authorized
组件内部,实现了checkPermissions
方法,用来校验当前用户角色,是否有权限的进行访问。如果有权限,则直接展示当前的组件,如果没有则展示无权限等消息。
Authorized
组件的实现,
type IAuthorizedType = React.FunctionComponent<AuthorizedProps> & {
Secured: typeof Secured;
check: typeof check;
AuthorizedRoute: typeof AuthorizedRoute;
};
const Authorized: React.FunctionComponent<AuthorizedProps> = ({
children,
authority,
noMatch = (
<Result
status="403"
title="403"
subTitle="Sorry, you are not authorized to access this page."
/>
),
}) => {
const childrenRender: React.ReactNode = typeof children === 'undefined' ? null : children;
const dom = check(authority, childrenRender, noMatch);
return <>{dom}</>;
};
function check<T, K>(authority: IAuthorityType, target: T, Exception: K): T | K | React.ReactNode {
return checkPermissions<T, K>(authority, CURRENT, target, Exception);
}
/**
* 通用权限检查方法
* Common check permissions method
* @param { 权限判定 | Permission judgment } authority
* @param { 你的权限 | Your permission description } currentAuthority
* @param { 通过的组件 | Passing components } target
* @param { 未通过的组件 | no pass components } Exception
*/
const checkPermissions = <T, K>(
authority: IAuthorityType,
currentAuthority: string | string[],
target: T,
Exception: K,
): T | K | React.ReactNode => {
// 没有判定权限.默认查看所有
// Retirement authority, return target;
if (!authority) {
return target;
}
// 数组处理
if (Array.isArray(authority)) {
if (Array.isArray(currentAuthority)) {
if (currentAuthority.some((item) => authority.includes(item))) {
return target;
}
} else if (authority.includes(currentAuthority)) {
return target;
}
return Exception;
}
// string 处理
if (typeof authority === 'string') {
if (Array.isArray(currentAuthority)) {
if (currentAuthority.some((item) => authority === item)) {
return target;
}
} else if (authority === currentAuthority) {
return target;
}
return Exception;
}
// Promise 处理
if (authority instanceof Promise) {
return <PromiseRender<T, K> ok={target} error={Exception} promise={authority} />;
}
// Function 处理
if (typeof authority === 'function') {
const bool = authority(currentAuthority);
// 函数执行后返回值是 Promise
if (bool instanceof Promise) {
return <PromiseRender<T, K> ok={target} error={Exception} promise={bool} />;
}
if (bool) {
return target;
}
return Exception;
}
throw new Error('unsupported parameters');
};
使用 Authorized 组件
在页面上使用则非常的方便,对需要进行权限管控的组件,使用 Authorized
组件进行组合即可。
function NoMatch = () => {
return <div>404</div>
}
<Authorized authority={'admin'} noMatch={NoMatch}>
{children}
</Authorized>
我们还可以利用路由进行组件的匹配。
<Authorized
authority={authority}
noMatch={<Route {...rest} render={() => <Redirect to={{ pathname: redirectPath }} />} />}
>
<Route
{...rest}
render={(props: any) => (Component ? <Component {...props} /> : render(props))}
/>
</Authorized>
我们的权限方案
旧权限方案
在旧方案中,通过接口请求后端维护的权限数据,这部分权限数据只维护了菜单这一级别。将请求到的数据存入缓存中,便于后续的使用。
在我们内部的业务工具包中监听页面地址的改变,根据缓存的数据判断是否有进入当前页面的权限,根据结果来进行相应的处理,实际就是做了个路由守卫的功能。
而在子产品中,根据缓存的数据来判断是否显示当前的菜单入口。这两者组合,形成了我们旧方案。
随着数栈的成长,旧方案慢慢的也暴露出了许多的问题。
- 对权限控制的范围太小,我们只控制到了菜单这一级别,而对于特殊页面和某些场景下需要对功能的控制(如:编辑,新增、删除等),目前只有后端接口进行限制,页面上并没有进行限制,如果需要实现这个功能,就需要添加额外的接口和处理逻辑,
- 我们把权限的处理分成两部分,业务工具包和子产品中,但是两者间的耦合度是非常高的,往往改动了一个地方,另一个也需要跟着更改。
- 我们在研发过程中,每当需要增加一个菜单,就需要增加一条对应的菜单处理逻辑,增加一个产品,就需要增加这个产品对应的所有菜单逻辑,目前数栈的子产品已经超过了 10+ ,可以想象这部分处理逻辑是有多么的臃肿。
- ......
实际的问题不止以上列的三点,但是这三点就足够我们进行新的权限方案的探索。
新权限方案
在新方案中,业务工具包只保留权限的公共方法,把页面权限判断的逻辑进行的下放,子产品自己维护自己的权限判断逻辑,修改一条权限的逻辑也非常的容易
更改后的流程如下:
相比起 ant design pro 中通过角色进行判断,新方案中我们把角色权限的判断逻辑移交给了后端,后端经过了相应的处理后,返回对应的 code 码集合。
我们为每个需要设置准入权限的模块,定义一个 code 码,去比较后端返回的集合中,是否能够找到相同的 code,如果能找到说明就有访问当前模块的权限,反之则没有。
经过这样处理后,我们只需要关心是否能够进入。
在获取到权限点的时候,还会根据这个权限点,去缓存有权限访问的路由列表,当路由改变时,就可以去有权的路由列表里进行查找,如果没有找到就进行重定向之类的操作,也就是路由守卫的功能。
总结
经过上面的介绍,我们对权限方案已经有所了解,主要分为两个阶段:
- 获取权限阶段:在获取权限阶段,往往是用户登入或进入项目时,第一时间根据用户信息获取相对应的权限
- 校验权限阶段:通过用户的权限,与当前模块的准入权限进行比对,在根据结果进行操作
知道了这些之后,就可以结合自身的场景,制定出相应的权限方案。