KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 若依式权限从零手搓:五张表与三层授权 — keel 龙骨
若依(RuoYi)及其同类框架把国内后台系统的权限模型固化成了几乎统一的形状。这一章不依赖框架,从零把它手搓一遍:表怎么设计、权限点怎么命名、三层授权各自查哪张表,以及"菜单可见 ≠ 接口可调"这个最经典的漏洞是怎么产生的。
若依(RuoYi)及其同类框架把国内后台系统的权限模型固化成了几乎统一的形状。这一章不依赖框架,从零把它手搓一遍:表怎么设计、权限点怎么命名、三层授权各自查哪张表,以及"菜单可见 ≠ 接口可调"这个最经典的漏洞是怎么产生的。
一、现场:菜单做了权限,接口没做
一个后台系统按若依的样子配了角色:运营角色能看见"用户管理"菜单,于是所有人认为权限体系已经做完了。验收时运营试着点"删除用户",前端把按钮藏了,看起来没问题。
但只要把浏览器换成 curl:
curl -X DELETE https://example.com/api/users/u_bob -H "Authorization: Bearer <运营的 token>"
# 200 {"deleted": "u_bob"}
按钮只是前端隐藏,后端接口没有任何校验。这就是国内后台系统最常见、也最致命的一类漏洞——它的危险之处在于"看起来做过权限了"。
二、概念边界:三层授权,各查不同的东西
若依式 RBAC 的核心是三张关联表撑起三层授权:
用户 sys_user
└─ sys_user_role(多对多)→ 角色 sys_role
├─ sys_role_menu(多对多)→ 菜单 sys_menu ← 菜单权限
└─ sys_role_permission → 权限点 sys_permission ← 接口权限
角色上还挂一个 data_scope ← 数据权限
| 层 | 回答的问题 | 查询来源 | 少了会怎样 |
|---|---|---|---|
| 菜单权限 | 左侧导航看得见什么 | role_menu |
用户找不到功能(体验问题) |
| 接口权限 | 后端接口能不能调 | role_permission |
越权(安全问题) |
| 数据权限 | 列表能查到哪些行 | role.data_scope |
横向越权(数据泄露) |
三层的失效后果完全不同,这是本章最需要记住的判断:菜单缺失是体验问题,接口缺失是安全事故,数据范围缺失是静默泄露。
三、一次完整运行:把表建出来
配套项目的 project/schema.sql 是完整版,这里只留与本课相关的核心部分:
-- 权限点:后端权威,菜单和接口都引用它
CREATE TABLE permission (
code TEXT PRIMARY KEY, -- 形如 system:user:list,冒号分层
name TEXT NOT NULL,
kind TEXT NOT NULL -- api | button
);
-- 菜单树:前端可见性单独一张表
CREATE TABLE menu (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES menu(id),
name TEXT NOT NULL,
path TEXT,
icon TEXT,
permission_code TEXT REFERENCES permission(code), -- 绑到后端权限点;NULL = 仅目录
sort INTEGER NOT NULL DEFAULT 0
);
-- 角色:租户内自定义,带数据范围
CREATE TABLE role (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant(id),
code TEXT NOT NULL,
name TEXT NOT NULL,
data_scope TEXT NOT NULL DEFAULT 'self', -- all | org | org_and_child | self
builtin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE role_permission (role_id TEXT, permission_code TEXT, PRIMARY KEY (role_id, permission_code));
CREATE TABLE role_menu (role_id TEXT, menu_id TEXT, PRIMARY KEY (role_id, menu_id));
CREATE TABLE member_role (member_id TEXT, role_id TEXT, PRIMARY KEY (member_id, role_id));
三个设计决策值得单独解释:
permission与menu分成两张表,而不是合一张。 合并是若依早期版本的做法(直接在sys_menu上放一个perms字段),但那样"按钮"就会变成一条没有 path 的菜单记录,菜单树里混进大量噪音。分开之后,按钮是按钮、菜单是菜单,而它们共享同一个权限码。permission.code用冒号分层(system:user:list)。前两段是资源域和对象,最后一段是动作。这样既便于按前缀批量授权(给整个system:user:*开通),也便于在代码里做通配匹配。role带tenant_id且有唯一约束(tenant_id, code)。 角色是租户内数据——A 租户可以有"运营",B 租户可以有完全不同的"运营"。第 04 章会展开这一点。
四、一次完整运行:跑一遍三层判定
配套项目的 project/rbac.py 实现了三层判定,用演示数据跑出来是这样(python demo.py 实测):
=== 02 菜单可见 ≠ 接口可调(carol:运营角色) ===
身份: m_carol_acme | 角色: ('ops',)
可见菜单: ['/system', '/system/user']
有 system:user:list ? True
有 system:user:delete ? False
调删除接口 -> PermissionDenied | missing_permission:system:user:delete | HTTP 403
这四行就是本课的核心对照:菜单里有"用户管理"(可见),有列表权限(能查),但没有删除权限(不能删)。同一份数据、三个不同结论。
对应的三条 SQL(role_permission_codes / visible_menu_paths / has_permission):
-- 接口权限:经角色拿到全部权限点
SELECT DISTINCT rp.permission_code
FROM member_role mr
JOIN role_permission rp ON rp.role_id = mr.role_id
WHERE mr.member_id = ?;
-- 菜单权限:经角色拿到可见菜单
SELECT DISTINCT m.path
FROM role_menu rm
JOIN menu m ON m.id = rm.menu_id
JOIN member_role mr ON mr.role_id = rm.role_id
WHERE mr.member_id = ?
AND m.path IS NOT NULL;
-- 单点判定:权限点是否在集合内
SELECT 1 WHERE 'system:user:delete' IN (...);
注意第三条:实际代码里不会发 SQL 去查,而是先把权限点集合查出来在内存里判断(role_permission_codes 返回一个 set)。这是一个重要的性能与设计选择——每个请求查一次权限集合,而不是每个权限点查一次数据库(N+1 陷阱,02 章末尾有实测)。
五、失败注入:把三层分别拆掉
用配套项目做三个实验,每个只拆一层(对应 00 章的实验 A/B/C):
实验 A:删掉接口层校验。把 api.py 里 delete_user 的依赖 needs("system:user:delete") 去掉,重跑 api_probe.py:
运营角色删用户(越权) DELETE /users/u_bob -> 200 {'deleted': 'u_bob', 'by_member': 'm_carol_acme'}
403 变 200。这就是"只做菜单权限"的后果。
实验 B:把 permission 表清空。所有角色都拿不到权限点,全员 403——包括管理员(tenant_admin 走的是"全部权限"分支,也返回空集)。这说明权限点是硬依赖:新增接口时忘了在表里登记,接口会对所有人关闭。
实验 C:把角色码写错(ops 改成 operation)。角色仍然存在、菜单仍然可见,但接口权限全丢。这是最隐蔽的一类 bug:数据看起来都在,就是不生效。
三个实验的共同点:没有一处会抛异常。权限系统是"沉默"的,测试环境里最容易通过。
六、误判澄清
| 误解 | 核对 | 结论 |
|---|---|---|
| "隐藏按钮就是权限控制" | 用 curl 直接打接口返回 200 | 前端隐藏是体验,后端校验才是安全边界 |
| "菜单表就是权限表" | 演示数据里菜单 3 行、权限点 5 行 | 按钮不是菜单;两者共享权限码但是不同的东西 |
| "权限点在代码里定义就行" | test_tenant_admin_bypasses_rbac_only 里管理员绕过逻辑读的就是表 |
权限点必须落库,否则新增接口无法授权 |
| "角色是全局的" | role 表有 tenant_id 且 (tenant_id, code) 唯一 |
角色是租户内数据(04 章) |
| "每个权限点查一次数据库最保险" | 见下面的性能实测 | 查一次集合在内存判断,比 N 次查询快且更安全 |
七、一次实测:把两种写法量出来
配套项目里两种写法都跑得起来。用 sqlite3.Connection.set_trace_callback 数一次判定里的真实 SQL 次数(判定 5 个权限点,演示数据里的 carol):
判定 5 个权限点
写法 A 逐点判断 : 5 次 SQL
写法 B 集合判断 : 1 次 SQL 权限点集合大小=2 结果=False
配套项目的 role_permission_codes 用的是写法 B。三个理由:
- 延迟:数据库往返次数直接乘进每个请求的响应时间,且随权限点数量线性增长;
- 一致性:一次查询拿到的是同一个快照,逐点查可能跨越角色变更的时间点,得到"半个旧权限、半个新权限"的结果;
- 可缓存:集合是可以缓存的最小单元(07 章的主题),逐点查没法缓存。
上面那行 结果=False 也顺带说明了一件事:carol 的权限集合里只有 2 个点,另外 3 个判定为 False——
集合大小远小于权限点总数,正是"集合化"的前提。如果一个角色的集合膨胀到几百个,
就该怀疑角色设计有问题(角色爆炸,06 章)。
代价是集合要一次性取回,需要控制角色数量——这就是"角色爆炸"的早期信号。
八、生产环境怎样替换
| 教学替身 | 生产替换 | 要点 |
|---|---|---|
硬编码角色码 'tenant_admin' |
角色数据落库,管理员通过角色授予 | 硬编码管理员判断会让"谁都能当管理员"的开关难以审计 |
| 权限集合每次查库 | 权限集合进缓存 + 失效机制 | 07 章展开 |
菜单 path 硬编码 |
菜单与路由元数据同源 | 前后端各维护一份路由表是长期 drift 的来源 |
| 无审计 | 角色/权限变更写审计日志 | 权限系统自身的变更必须可追溯 |
九、练习与验收
练习 1:在你的项目里列出所有"前端会隐藏但后端没校验"的接口(用 grep 找前端权限判断,对照后端路由)。
练习 2:给配套项目新增一个权限点 system:user:export 和对应的接口,然后分别验证:管理员可调、运营不可调、菜单按需挂上。
练习 3:跑实验 A/B/C,把三个现象(200 / 全员 403 / 权限全丢)分别归因到"哪一层被拆掉了"。
验收点:不看资料,说出若依式 RBAC 的三张关联表与三层授权;解释为什么菜单表和权限表要分开;说明"权限点查集合而不是逐点查"的三个理由。
现在能解释什么:若依式权限的表结构与三层授权;"菜单可见 ≠ 接口可调"的成因与修法;权限点命名与集合化判定的性能与一致性优势。
下一步:第 02 章讲三层里最容易漏的那层——数据权限,以及"把权限塞进 SQL"的两种做法。