Skip to content

BasicAI 多租户权限系统设计

Cover

BasicAI 作为一个支持多租户的 SaaS 平台,拥有比较复杂的权限控制系统,并且允许租户自定义角色,本文对其权限系统设计进行了介绍。

功能权限

术语定义

权限系统中有些术语翻译成中文后含义比较类似,容易搞混,这里先统一说明一下,特别要注意 Permission 和 Privilege 的区别。

  • Role 角色,对应现实中的身份,比如经理、主管和员工
  • Feature 功能,从产品角度理解的系统功能,可能与具体实现的页面结构不是完全对应
  • Permission 权限,包含动作和客体,比如“创建任务”,从开发角度理解的系统功能,通常按照前端页面结构来划分,如果页面粒度太粗,还可再进一步细分到页面内的操作
  • Resource 资源,程序中的对象,比如菜单、按钮、链接、API 等
  • Privilege 权利,包含主体、动作和客体,比如“小王可以创建任务”

架构设计

Architecture

系统的权限划分粒度可粗可细,对用户来说只关心自己是什么角色,对产品人员来说希望控制某个用户可以使用哪些功能,而对开发人员来说则要在代码里校验某个用户是否能执行某个资源操作。为了便于用户、产品人员和开发人员都能从各自的角度去理解权限,从上到下,由粗到细,整个权限系统可划分为 Role、Function、Permission 和 Resource。层与层之间均为多对多关系,每层都按自己的粒度划分权限,如有变动只需调整映射关系,无需修改权限验证代码。权限管理和配置比较复杂,特别是各层之间的映射关系,最好有配套的管理后台。

如果不追求灵活性,为了简单,可以移除 Resource 层,直接在代码里每个资源操作(点击按钮、调用 API)的地方判断当前用户是否具备相应的 Permission。注意,一个资源操作可能会关联到多个 Permissions,用户只需要具备其中之一即可。但如果希望集中校验所有资源操作(比如后端在网关里统一验证 API 权限),那么就需要保留 Resource 层,验证时首先根据 Permission 和 Resource 的映射关系动态获取哪些 Permissions 能够操作当前资源,然后判断当前用户是否拥有这些 Permissions 之一。

库表设计

UML 模型

UML model

  1. 每个 Team 可以自定义 Role,没有关联到任何 Team 的 Role 为默认 Role,所有团队共用;
  2. Function 和 Permission 表的结构完全一样,只是划分维度不一样,如果产品和开发能达成一致,那么可以去掉 Function 表,Role 直接关联到 Permission,以简化实现;
  3. Permission 按照前端页面结构来划分,如果有导航菜单,直接按导航菜单划分就可以,需要的话也可以细到页面内的操作。为了更好控制导航菜单的隐藏或现实,也可为导航菜单定义专门的 Permissions;
  4. 这里假设前端没有集中校验权限的需求,只有后端 API 需要在网关里集中校验所有 API 请求,因此 Resource 表退化为了 Api 表。前端如果要集中校验权限,也可以把 Permission 跟 Resource 的映射关系保存在代码里,这样维护起来更方便,只是没法动态调整了;
  5. 为了展示更清晰,除了 Role,其它层都设计为了树形结构。为了简化连表查询和权限验证时的匹配规则,各层之间可以只在叶子结点进行关联,非叶子结点仅用于树形展示和选择,只是这样就没法实现在新增功能时自动赋给现有 Role,需要手动添加;

示例数据

Role
idteam_idordernamedescription
1null1TEAM_OWNER团队拥有者
2null2TEAM_ADMIN团队管理员
3null3TEAM_MEMBER团队成员
Feature
idparent_idordernamedescription
1null1功能权限
211数据集功能
321数据集创建
422数据集修改
523数据集查看
624数据集删除
7null2管理权限
871团队成员管理
981团队成员邀请
1082团队成员移除
Permission
idparent_idordernamedescription
1null1dataset:*数据集模块
211dataset:dataset:*数据集功能
321dataset:dataset:create数据集创建
422dataset:dataset:edit数据集修改
523dataset:dataset:view数据集查看
624dataset:dataset:delete数据集删除
712dataset:data:*数据功能
871dataset:data:upload数据上传
972dataset:data:delete数据删除
1013dataset:ontology:*本体功能
11101dataset:ontology:createClass 创建
12102dataset:ontology:deleteClass 删除
Api
idparent_idorderpathdescription
1null1null数据集微服务
211null数据集模块
321/dataset/dataset/create数据集创建
422/dataset/dataset/edit/{1}数据集修改
523/dataset/dataset/list数据集列表
624/dataset/dataset/info/{1}数据集查看
725/dataset/dataset/delete/{1}数据集删除

数据权限

功能权限 vs. 数据权限

数据权限用于限制用户能够访问的数据,同一个页面,拥有同样功能权限但不通数据权限的用户进入后看到的内容不一样。相比于静态不变的功能,数据是动态变化的,并且数据权限通常跟业务逻辑紧密耦合,很多时候会隐含在业务 SQL 查询语句中,因此很难抽象出一种统一的机制来实施数据权限。

功能权限和数据权限容易搞混,有时单独使用,有时又结合使用。有些时候数据权限隐含在功能权限里,如果没有访问某个页面或者调用某个接口的功能权限,那么自然就无法看到相关数据。有些时候功能是开放的,所有人都可以访问某个页面或调用某个接口,但后端接口会依据当前用户的数据权限来决定返回哪些记录或字段。

以 Team 和 Task 的权限需求为例,这里面既涉及到功能权限,又涉及到数据权限。Team 内包含 Admin 和 Member 两种角色,Worker 能查看 Team 下的所有 Datasets,以及分配给他的 Tasks,Admin 在 Member 基础上增加了 Dataset 和 Task 的管理权限。分配某个 Task 给某个 Worker 时,会指定该 Member 在该 Task 内的角色,可以是 Manager 或 Worker,Worker 只能执行领取数据等普通操作,而 Manager 还能执行一些管理操作。

  1. Team Member 相关功能通过数据权限来限制即可,无需限制功能,功能都是开放的;
  2. Team Admin 相关功能要管理所有数据,与具体数据无关,因此无需数据权限,但需要通过功能权限来限制;
  3. Task Manager 比较特殊,这里需要结合数据权限和功能权限,首先用户需要有某个 Task 的数据权限(分配了该 Task 的某种角色)才能看到和进入该 Task,然后再依据用户在该 Task 内的角色来决定可以执行哪些操作;
  4. 各个角色之间尽量保持独立,不要互相依赖,比如让 Team Admin 直接包含 Task Manager 的所有权限,如果 Team Admin 需要管理某个 Task,可以将其加入该 Task 并赋予 Manager 权限,这样实现起来更简单,Team Admin 可以执行一些针对 Task 整体的操作,比如新建 Task、删除 Task 等;

租户数据权限

租户(团队)之间的数据需要相互隔离,这个是在数据访问层强制给所有 SQL 查询语句加上租户 ID 条件来实现的。

共享任务数据权限

共享任务列表数据权限

共享任务列表使用功能权限即可,给需要的团队成员分配该功能权限后该成员即可查看当前团队的所有共享任务。

单个共享任务数据权限

受限于租户数据隔离,被共享者(外部租户成员)无法访问共享者所共享的任务,因为被共享者不在任务所属团队里,需要使用某种机制来绕开此限制,可选的机制有:

  1. 在校验过共享任务的数据权限之后,就不再校验租户数据权限,可通过请求头或请求参数临时关闭当前请求的租户数据权限校验;
  2. 在校验过共享任务的数据权限之后,把被共享者的当前团队切换为任务所属团队,这样可以把外部团队成员临时伪装成任务所属团队成员,从而共用一套接口。注意这里需要清空外部团队成员在外部团队里的角色(权限),并根据外部团队角色来设置相应的共享任务角色(比如 SharedTaskManager、SharedTaskWorker),否则会放大外部团队成员在任务所属团队里的权限。自动切换团队后,有些场景还需要被共享者的原始团队(比如记录绩效),因此最好把原始团队也保留下来。

由于任务切换不像团队切换那样需要用户显示操作,而是跟随请求随时发生变化,所以不适合像当前团队那样在上下文里(登录 Token)保存一个当前任务,需要从请求中获取当前任务,可采用下面几种方式:

  1. 通过路径参数传递,需要所有 Task 接口遵循约定的路径格式(比如 /task/{id}/xxx/yyy);
  2. 通过 Query 参数传递,比如 taskId=xxx。
  3. 通过请求头传递,需要在所有 Task 接口请求时额外增加一个请求头(比如 X-Task-Id)来传递当前访问的 Task,这种方式会增加请求者的工作并且后端需要校验请求头和请求参数里的 Task 是否一致,不推荐使用;