RBAC 权限模型:表结构设计与落地方案
RBAC 的核心思想是用角色做中转,不给用户直接分配权限。这篇文章拆解标准五张表的职责划分、多对多中间表的必要性,以及数据权限这类扩展场景怎么设计。
一、最朴素的做法,以及它为什么撑不住
权限控制的起点通常是这样的:
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
permissions VARCHAR(500) -- "user:list,user:add,order:view"
);
用户的权限用逗号分隔存在一个字段里。这个设计在只有三五个用户、权限几乎不变时能跑,但很快会遇到问题:
| 问题 | 具体表现 |
|---|---|
| 查询困难 | 想查"谁有 user:add 权限",只能 LIKE '%user:add%',无法走索引 |
| 改权限麻烦 | 加一个权限要遍历所有用户,拼接字符串 |
| 无法复用 | 10 个运营的权限完全相同,却要重复维护 10 份 |
| 容易出错 | 手写字符串,拼错一个字符就是个隐蔽的权限 bug |
| 没有审计 | 谁在什么时候被授予了什么权限,完全无记录 |
根子在于:它把"权限集合"当成了一个字符串,而不是一组关系。
二、RBAC 的核心思想
RBAC(Role-Based Access Control,基于角色的访问控制) 的思路是:不给用户直接分配权限,通过角色做中转。
用户 ──→ 角色 ──→ 权限
为什么加这一层中转这么重要?
| 不用角色 | 用角色 |
|---|---|
| 用户 ↔ 权限 直接多对多 | 用户 → 角色 → 权限 |
| 100 个用户 × 每个 20 条权限 = 2000 条关系 | 100 个用户 × 3 个角色 + 3 个角色 × 20 条权限 = 360 条关系 |
| 调整一类人的权限,要改 N 个用户 | 只改角色,一次生效 |
角色是对"权限集合"的命名。 有了名字,权限管理就从"N 个用户的批量操作"变成了"改一个角色"。
三、五张核心表
标准 RBAC 由五张表构成,分三组:
第 1 组:两个实体表
用户表 sys_user — 存用户基础信息
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL COMMENT '登录名',
password VARCHAR(100) NOT NULL COMMENT '加密后的密码',
nickname VARCHAR(50) COMMENT '昵称',
phone VARCHAR(20) COMMENT '手机号',
status TINYINT DEFAULT 1 COMMENT '1启用 0禁用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_deleted TINYINT DEFAULT 0 COMMENT '软删除标记',
UNIQUE KEY uk_username (username)
);
角色表 sys_role — 定义有哪些角色
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(50) NOT NULL COMMENT '角色名称,如"运营专员"',
role_code VARCHAR(50) NOT NULL COMMENT '角色编码,如 ROLE_OPERATOR',
description VARCHAR(200) COMMENT '描述',
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_role_code (role_code)
);
role_code 是程序里判断用的稳定标识,role_name 是给人看的显示名。不要把显示名当标识用——哪天产品说"运营专员"改叫"运营经理",所有硬编码了中文名的判断都会崩。
第 2 组:权限表(菜单/资源)
菜单权限表 sys_menu — 存所有可授权的资源
CREATE TABLE sys_menu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT 0 COMMENT '父级 ID,0 为顶级',
menu_name VARCHAR(50) NOT NULL COMMENT '显示名称',
perms VARCHAR(100) COMMENT '权限标识,如 sys:user:list',
path VARCHAR(200) COMMENT '前端路由地址',
component VARCHAR(200) COMMENT '前端组件路径',
menu_type TINYINT NOT NULL COMMENT '1目录 2菜单 3按钮',
icon VARCHAR(50) COMMENT '图标',
order_num INT DEFAULT 0 COMMENT '排序',
visible TINYINT DEFAULT 1 COMMENT '是否显示',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
这张表同时承载三件事,靠 menu_type 区分:
| menu_type | 含义 | 有没有 perms |
|---|---|---|
| 1 目录 | 侧边栏的一级分组,如"系统管理" | 通常没有 |
| 2 菜单 | 一个具体页面,如"用户管理" | 常有,如 sys:user:list |
| 3 按钮 | 页面上的操作按钮,如"新增" | 必有,如 sys:user:add |
按钮级权限是 RBAC 落地的关键细节。只控制到"能不能进这个页面"是不够的——同一个人可能要能看不能改。前端按 perms 决定按钮显隐,后端按 perms 拦截接口,两边都要校验。
⚠️ 前端隐藏按钮只是体验优化,不是安全措施。 用户完全可以绕过前端直接调接口,所以权限校验必须落在后端。
第 3 组:两张多对多中间表
用户角色关联表 sys_user_role
CREATE TABLE sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id),
KEY idx_user (user_id)
);
角色菜单关联表 sys_role_menu
CREATE TABLE sys_role_menu (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL,
UNIQUE KEY uk_role_menu (role_id, menu_id),
KEY idx_role (role_id)
);
一个用户可以多个角色,一个角色可以分配给多个用户——多对多关系必须用中间表,绝不能把 ID 列表塞进逗号分隔的字段。
那个联合唯一索引 uk_user_role 很关键:它保证同一个人不会被重复授予同一个角色,从数据库层面杜绝脏数据。
四、完整的表关系
sys_user ──┐
├─→ sys_user_role ←─┐
│ │
│ sys_role ──┐
│ ├─→ sys_role_menu ←─┐
│ │ │
│ │ sys_menu
│
(用户信息) (角色定义) (权限资源)
查某个人有哪些权限,需要三张表 JOIN:
SELECT DISTINCT m.perms
FROM sys_user_role ur
JOIN sys_role_menu rm ON ur.role_id = rm.role_id
JOIN sys_menu m ON rm.menu_id = m.id
WHERE ur.user_id = #{userId}
AND m.perms IS NOT NULL
AND m.perms != '';
这条 SQL 会返回类似 sys:user:list、sys:user:add、sys:order:view 的权限标识集合。
权限集合应该缓存
这条三表 JOIN 每个请求都跑一次并不划算。常见做法是登录时查一次,把权限集合放进 Redis:
key = perms:user:{userId}
value = ["sys:user:list", "sys:user:add", ...]
TTL = 30 分钟
关键是失效策略:管理员改了角色的权限,必须删掉所有相关用户的缓存 key,否则权限变更要等 TTL 过期才生效。简单做法是在角色权限变更时,删除该角色下所有用户的缓存。
五、扩展:数据权限
基础 RBAC 只回答"能不能操作这个功能"(功能权限),但真实业务还需要回答"能操作哪些数据"(数据权限)。
比如:同样是"查看订单"这个功能,华东区经理应该只能看华东的订单,而总部管理员能看全部。这在功能权限层面是完全一样的——都能调 order:list 接口。
扩展表设计
部门表 sys_dept
CREATE TABLE sys_dept (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT DEFAULT 0,
dept_name VARCHAR(50),
order_num INT DEFAULT 0,
leader VARCHAR(50)
);
角色部门关联表 sys_role_dept
CREATE TABLE sys_role_dept (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT NOT NULL,
dept_id BIGINT NOT NULL
);
用户所属部门挂在 sys_user.dept_id 上,角色能看哪些部门挂在 sys_role_dept 上。
数据权限的四种范围
| 范围 | 含义 | SQL 追加条件 |
|---|---|---|
| 全部 | 不加限制 | 无 |
| 自定义 | 看指定的几个部门 | dept_id IN (select dept_id from sys_role_dept where role_id=?) |
| 本部门 | 只看自己部门 | dept_id = (select dept_id from sys_user where id=?) |
| 本部门及以下 | 含子部门 | dept_id IN (子部门递归查询) |
| 仅本人 | 只看自己的数据 | create_by = #{userId} |
实现方式通常是 AOP 切面 + MyBatis 拦截器:在 SQL 执行前自动追加 WHERE 条件。这样业务代码里不用手写这些条件,避免遗漏。
注意:数据权限比功能权限危险得多。功能权限漏了最多是"多显示一个按钮",数据权限漏了就是越权看到别人的数据。上线前一定要专门测。
六、几个容易踩的坑
| 坑 | 说明 |
|---|---|
| 权限标识命名混乱 | 统一用 模块:资源:操作 三段式(sys:user:add),不要一会儿 addUser 一会儿 user_add |
| 超级管理员硬编码 | 通常给 admin 用户一个跳过校验的白名单,但不要用 if (username.equals("admin")) 散落在各处,应该收敛到一处 |
| 删角色不清理关联 | 删除角色时要同时清理 sys_user_role 和 sys_role_menu,否则留下孤儿数据 |
| 只做前端校验 | 前端隐藏按钮是体验,后端拦截才是安全 |
| 权限改动不刷缓存 | 权限缓存必须主动失效,不能只靠 TTL |
| 菜单树没有排序 | order_num 看着不重要,但没有它前端菜单顺序会随机 |
小结
- RBAC 核心:用户 → 角色 → 权限,不给用户直接分配权限
- 角色的价值是给权限集合起个名字,让 N 个用户的批量操作变成改一个角色
- 五张核心表:
sys_user、sys_role、sys_menu+ 两张多对多中间表sys_user_role、sys_role_menu - 中间表必须加联合唯一索引,防止重复授权
menu_type区分目录/菜单/按钮,按钮级权限是落地的关键- 前端隐藏按钮不是安全措施,校验必须在后端
- 权限集合三表 JOIN 结果要缓存到 Redis,并在权限变更时主动失效
- 扩展的数据权限(
sys_dept+sys_role_dept)控制"能看哪些数据",比功能权限危险得多,要专门测试 - 常见坑:命名混乱、admin 硬编码、删角色不清理关联、只做前端校验
权限设计看起来就是几张表的增删改查,但它是系统的安全边界。表结构设计得清楚,后面加权限、查问题都会轻松很多。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。