权限管理是每个后台系统都绕不开的话题。从最简单的「管理员能看到所有页面,普通用户只能看到首页」,到多租户 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 或数据库记录),前后端各自消费。