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));

三个设计决策值得单独解释:

  1. permission 与 menu 分成两张表,而不是合一张。 合并是若依早期版本的做法(直接在 sys_menu 上放一个 perms 字段),但那样"按钮"就会变成一条没有 path 的菜单记录,菜单树里混进大量噪音。分开之后,按钮是按钮、菜单是菜单,而它们共享同一个权限码。
  2. permission.code 用冒号分层(system:user:list)。前两段是资源域和对象,最后一段是动作。这样既便于按前缀批量授权(给整个 system:user:* 开通),也便于在代码里做通配匹配。
  3. 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。三个理由:

  1. 延迟:数据库往返次数直接乘进每个请求的响应时间,且随权限点数量线性增长;
  2. 一致性:一次查询拿到的是同一个快照,逐点查可能跨越角色变更的时间点,得到"半个旧权限、半个新权限"的结果;
  3. 可缓存:集合是可以缓存的最小单元(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"的两种做法。

进入 keel 阅读