如何设计一个灵活的权限管理系统

权限管理是每个后台系统都绕不开的话题。从最简单的「管理员能看到所有页面,普通用户只能看到首页」,到多租户 SaaS 里「用户 A 在项目 1 是管理员、在项目 2 是只读」——权限模型的复杂度是业务规模决定的。本文从模型演进开始,拆解一个灵活权限系统的设计。

一、权限模型的演进

每种权限模型的出现都在解决前一模型的特定缺陷。理解这种演进关系,比记住每种模型的定义更有用。

1.1 ACL:最直接的思路

User A → [Read /file/1, Write /file/1, Read /file/2]
User B → [Read /file/2]

访问控制列表(ACL)直接把「谁对什么资源能做什么操作」写成一条条记录。这在小规模场景下完全够用,Linux 文件权限本质上就是 ACL。

问题:当用户数量从 10 个增长到 1000 个,资源从 10 个增长到 10000 个,ACL 条目数是 M×N 级别。更致命的是——当组织规定「所有编辑人员都能编辑所有文章」时,你需要给 50 个编辑 × 5000 篇文章一一配置权限。

1.2 RBAC0:引入中间层

User → Role → Permission
       ↑
     这座桥是 RBAC 的精华

RBAC 在用户和权限之间插入角色层。「编辑」角色拥有编辑文章的权限,把 50 个用户分配「编辑」角色即可。修改权限时改一个角色就行,不用逐个用户处理。

三个核心实体:

实体 说明
User 使用者,可以拥有多个角色
Role 权限的集合,可以是「管理员」「编辑」「只读」
Permission 对资源的操作许可,如「文章:删除」「用户:查看」

1992 年 Ferraiolo 和 Kuhn 提出的 RBAC96 模型奠定了这一框架。它的核心价值不是技术上的,而是管理上的——它把权限管理从「IT 部门的操作」变成了「业务方可以理解的抽象」。

1.3 RBAC1:角色继承

超级管理员
  └── 部门管理员
        ├── 项目管理员
        │     └── 项目成员(只读)
        └── 审计员(只读)

RBAC1 在 RBAC0 基础上增加了角色继承。上级角色自动包含下级角色的所有权限。建模时不需要给「部门管理员」重复配置「项目成员」已有的那 20 个权限——继承链自动完成。

一个容易踩的坑:角色继承不是组织架构的映射。组织架构里的「上级」不一定比「下级」有更多权限——CTO 的运维权限可能不如运维工程师。角色继承是一种权限复用机制,不是层级关系的模拟。

1.4 RBAC2:约束

RBAC2 增加了职责分离规则。最典型的约束是互斥角色——一个人不能同时拥有「采购申请」和「采购审批」两个角色。这是金融和审计场景的刚需。

其他约束包括:

  • 基数约束:一个角色最多分配给 N 个用户,或一个用户最多拥有 N 个角色
  • 先决条件约束:用户想获得高级角色,必须先拥有低级角色

1.5 当一个 Role 不够:资源维度的引入

上面所有模型的假设是:角色 = 一组固定的权限。这在单系统里没问题。但到了 SaaS 多租户场景,问题就暴露了:

用户 Tom 在「项目A」是管理员,在「项目B」是只读成员
用户 Jerry 在「项目A」是只读成员,在「项目B」是编辑

如果用传统 RBAC,你需要建 4 个角色:「项目A管理员」「项目A只读」「项目B编辑」「项目B只读」。每新增一个项目就多一组角色——角色表随项目数线性膨胀。

根本原因是:权限不只是「角色→操作」这一维,还有「资源」这一维。 权限实际上是一个三元组:<主体, 资源, 操作>。角色是主体的快捷方式,但资源没有同等的快捷方式。

这就是基于资源的 RBAC(Resource-Based RBAC)要解决的问题。下面开始讨论具体设计。

二、数据库设计

2.1 传统 RBAC 的五张表

users                 roles                  permissions
+----+------+        +----+--------+        +----+----------------+
| id | name |        | id | name   |        | id | action         |
+----+------+        +----+--------+        +----+----------------+
| 1  | Tom  |        | 1  | admin  |        | 1  | article:read   |
| 2  | Jerry|        | 2  | editor |        | 2  | article:write  |
+----+------+        +----+--------+        | 3  | article:delete |
                       +---- +--------+        +----+----------------+

user_roles             role_permissions
+---------+---------+  +---------+-------------+
| user_id | role_id |  | role_id | permission_id |
+---------+---------+  +---------+-------------+
| 1       | 1       |  | 1       | 1           |
| 2       | 2       |  | 1       | 2           |
+---------+---------+  | 1       | 3           |
                       | 2       | 1           |
                       | 2       | 2           |
                       +---------+-------------+

鉴权时查询路径是:user_id → user_roles → role_permissions → permission。3 次 JOIN。

这套设计在绝大多数场景下是够用的。但一旦引入资源维度,就需要扩展。

2.2 引入资源组

当权限需要绑定到具体资源时,直接给 permission 表加 resource_type 和 resource_id 字段会导致角色数量爆炸。

更好的方式是为资源也建立分组:

resource_groups
+----+---------------+-----------+
| id | resource_type | group_key |
+----+---------------+-----------+
| 1  | project       | proj_101  |
| 2  | project       | proj_202  |
+----+---------------+-----------+

user_resource_roles
+---------+----------+-------------------+
| user_id | group_id | role (权限级别)    |
+---------+----------+-------------------+
| 1       | 1        | admin             |
| 1       | 2        | viewer            |
| 2       | 1        | viewer            |
| 2       | 2        | editor            |
+---------+----------+-------------------+

权限计算流程变成:

1. 根据 user_id 查 user_roles → 全局角色(如「系统管理员」)
2. 根据 user_id + resource_id 查 user_resource_roles → 资源级角色
3. 合并两个维度的权限,资源级覆盖全局级

这样角色表只存系统级的预定义角色(admin、editor、viewer),不会随项目增加而膨胀。

多对多关联的量级问题:当用户 10 万、资源 1 万时,user_resource_roles 表理论上可达 10 亿条(全交叉)。实际业务中每个用户通常只参与 5-20 个资源,实际量级是百万级,但仍需注意索引策略——(user_id, group_id) 的联合索引是必须的,鉴权查询通常带这两个条件。

三、菜单权限 vs 数据权限

权限在实际系统中分为两个层次:

层次 控制什么 典型实现 失败后果
菜单权限 用户能看到哪些页面和按钮 路由守卫 + 动态菜单渲染 看不到按钮(前端体验)
数据权限 用户能看到哪些数据行 SQL 行级过滤或 API 层拦截 看到别人的数据(安全事故)

菜单权限在前端做第一道过滤就够了——它本质上是 UX 优化,真正的防线是后端。把菜单藏起来不让用户点,但接口不做校验的话,curl 一下照样能拿到数据。

数据权限的核心是行级过滤。两种常见实现:

SQL 注入式:在查询时自动拼接 WHERE 条件:

-- 原始 SQL
SELECT * FROM orders

-- 注入数据权限后
SELECT * FROM orders WHERE project_id IN (
  SELECT group_id FROM user_resource_roles WHERE user_id = ?
)

优点是透明——业务代码不需要感知权限逻辑。缺点是子查询在高数据量下会成为性能瓶颈,需要缓存用户-资源关系。

中间件拦截式:在 ORM 层或 Repository 层统一注入过滤条件。从 ThreadLocal 中获取当前用户的数据权限范围并改写 SQL。适合复杂查询场景。

四、鉴权链路

一个完整的鉴权请求经过以下环节:

请求到达 → 认证拦截器(获取 userId)
         → 权限缓存检查(命中则直接返回)
         → 查用户全局角色(user_roles JOIN roles)
         → 查用户资源角色(user_resource_roles)
         → 合并权限集合
         → 策略匹配(判断当前请求的 {userId, resourceId, action} 是否在权限集合中)
         → 写入缓存
         → 允许/拒绝

缓存粒度是这里的关键决策:

  • 用户级缓存:cacheKey = userId,一次查询覆盖所有后续请求。缺点:权限变更时需要清掉整份缓存。
  • 用户×资源级缓存:cacheKey = userId:resourceId,粒度细、失效范围小。缺点:同用户访问不同资源产生多次缓存查询。

推荐用户级缓存 + 短 TTL(30-60 秒)。权限变更不是高频操作,鉴权是高频操作——短 TTL 在一致性和性能之间折中。

五、Casbin:策略驱动的权限引擎

Casbin 是一个开源访问控制框架,核心思想是把权限管理的「模型」和「策略」分离。

5.1 模型文件(model.conf)

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _
g2 = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && g2(r.obj, p.obj) && regexMatch(r.act, p.act)

和传统的 RBAC 表设计完全不同——没有固定的表结构,匹配逻辑由你定义的匹配器决定。

5.2 策略文件(policy.csv)

p, admin, project, (read|write|delete)
p, editor, project, (read|write)
p, viewer, project, (read)

g, tom, admin
g, jerry, editor

g2, proj_101, admin
g2, proj_101, editor
g2, proj_202, admin
g2, proj_202, viewer

当请求 tom 对 proj_101 执行 read 时,Casbin 执行匹配器:

1. g(tom, admin) → true (tom 属于 admin 角色)
2. g2(proj_101, admin) → true (proj_101 属于 admin 资源组)
3. regexMatch(read, (read|write|delete)) → true
→ 允许

5.3 Casbin 替代了什么

传统方案需要 5-7 张表加 3-4 次 JOIN。Casbin 把它们压缩到规则定义:

传统方案 Casbin 对应
user_roles 表 g 规则(用户到角色的映射)
resource_groups + user_resource_roles g2 规则(资源到资源组的映射 + 角色权限)
role_permissions 表 p 规则(角色对资源的操作许可)

Casbin 将策略全部加载到内存中,鉴权是纯内存操作。适合策略规则在万级到十万级的场景。如果达到百万级需评估内存开销,但绝大多数业务系统中用户×资源的关系量级在十万级以内。

六、权限中心的架构决策

当系统从单应用变成微服务体系时,权限管理是做成 SDK Library 还是独立的权限中心?

维度 SDK Library 权限中心(独立服务)
延迟 本地调用,零网络开销 每次鉴权一次 RPC
一致性 各服务独立加载,存在更新时差 中心化存储,强一致
耦合 规则变更需要重新部署所有服务 规则热更新,不影响业务
故障隔离 不依赖外部服务 权限中心挂掉需降级策略

决策逻辑:

  • 服务 ≤ 5 个、鉴权 QPS ≤ 1000 → SDK Library
  • 服务 > 5 个、需要动态调整规则 → 权限中心独立部署
  • 高可用要求 → Hybrid 模式:权限中心维护规则,通过消息队列推送到各服务的本地策略缓存,鉴权走本地

七、落地建议

组织架构不等于角色体系。 HR 系统里的部门树是管理关系,权限系统里的角色是功能关系。两者通过映射关联而不应该直接耦合——一个人调岗不应该导致角色体系的重新设计。

先做菜单权限,再做数据权限。 菜单权限是二元的(能看到/不能看到),实现简单,快速见效。数据权限涉及行级过滤和查询改写,复杂度高。

鉴权放 Guard 层,不要放 Middleware 层。 Middleware 不知道当前请求对应哪个 Handler,无法读取方法级权限注解。权限判断需要 ExecutionContext,这是 Guard 的能力边界。

权限配置的操作审计:谁在什么时候给谁授予了什么权限、谁撤销了什么权限——这些记录出现安全事故时是排查依据,也是合规审计的需求。user_resource_roles 表应该用软删除 + 操作日志表。

API 权限与 UI 权限的一致性:前端的动态菜单和后端的接口权限要同源——都从同一个权限配置中推导。权限定义集中管理(一份 JSON 或数据库记录),前后端各自消费。