小企业信息化系统开发
小企业自建系统的权限设计实战:从“谁都能改”到 RBAC 与数据范围隔离
2026年8月23日
五张表落地 RBAC,讲清功能权限、数据范围与字段脱敏。
小企业自建系统的权限设计实战:从"谁都能改"到 RBAC 与数据范围隔离
小企业自研的业务系统几乎都有同一段黑历史:上线时人少图省事,所有人共用一个账号,或者用 is_admin 一个布尔字段区分管理员。跑上一两年,销售能看到全公司客户、仓管能改已审核的订单、离职员工账号还留着,出问题查不出是谁改的。
这篇文章讲的就是补救过程:不推翻重做,在已有系统上把权限一层层补齐。表设计基于几套真实的进销存和工单系统改造经验,可直接照搬。
先分清三种权限,别混着做
功能权限回答"能不能进这个页面、点这个按钮"。数据权限回答"同一功能里能看到哪些行",比如销售 A 只能看自己的客户,区域经理看本区域全部。字段权限回答"同一条记录里哪些列可见可改",比如普通员工看不到成本价。
三者难度递增,按顺序推进:功能权限先上,数据权限紧跟,字段权限只对少数敏感列做。一次性全做工期会失控。
表设计:五张表撑起 RBAC
RBAC 的本质是在用户和权限之间插一层角色,避免权限直接挂人。最小可用结构就五张表:
-- 权限点:细到"操作"级别
CREATE TABLE perm (
code VARCHAR(64) PRIMARY KEY, -- 如 order:view / order:audit
name VARCHAR(64) NOT NULL,
module VARCHAR(32) NOT NULL
);
-- 角色
CREATE TABLE role (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(32) NOT NULL UNIQUE,
data_scope TINYINT NOT NULL DEFAULT 1, -- 1本人 2本部门 3本部门及下级 4全部
remark VARCHAR(128)
);
CREATE TABLE role_perm (
role_id INT NOT NULL,
perm_code VARCHAR(64) NOT NULL,
PRIMARY KEY (role_id, perm_code)
);
CREATE TABLE user_role (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
-- 用户表补两个字段
ALTER TABLE user ADD dept_id INT NULL,
ADD status TINYINT NOT NULL DEFAULT 1; -- 1在职 0禁用
几个设计取舍值得说明。
权限点命名用 模块:操作 的形式,比数字 ID 可读性高得多,排查问题时看日志就懂。data_scope 直接挂在角色上而不是单独建表,是因为小企业的数据范围规则简单,四个档位覆盖九成场景,真有特例再加用户级覆盖字段。用户禁用要用 status 而不是删记录,历史单据上的经办人还要能查到名字。
校验放在哪一层:只信服务端
前端隐藏按钮只是体验优化,不是安全措施。有人把权限判断写在页面里,接口裸奔,稍懂浏览器的人改一下请求就能越权。
正确做法是接口层统一拦截。以常见的中间件写法为例:
def require(perm_code):
def deco(fn):
def wrapper(req, *a, **kw):
if perm_code not in current_perms(req.user_id):
return json_error(403, "无权限")
return fn(req, *a, **kw)
return wrapper
return deco
@require("order:audit")
def audit_order(req):
...
用户权限集合每次查库会拖慢接口,缓存到会话或 Redis,有效期十分钟,配合"改完角色主动清缓存"。这里有个必须注意的点:改了权限要能立刻生效,否则调整完员工权限还要等半小时,运维会被投诉。
数据范围隔离:在 SQL 上动手
数据权限的落地方式是给查询自动追加条件,而不是查出来再在代码里过滤。后者在数据量上来后会直接拖垮性能,还容易在分页时算错总数。
def scope_sql(user, alias="o"):
if user.scope == 4: return "1=1"
if user.scope == 1: return f"{alias}.owner_id = {user.id}"
if user.scope == 2: return f"{alias}.dept_id = {user.dept_id}"
if user.scope == 3:
ids = ",".join(map(str, dept_and_children(user.dept_id)))
return f"{alias}.dept_id IN ({ids})"
return "1=0" # 兜底:未知范围一律查不到
sql = f"SELECT * FROM orders o WHERE {scope_sql(user)} AND o.status=1"
三个实践要点。业务表必须有 owner_id 和 dept_id 两个冗余字段,否则范围过滤无从下手,老表补字段时要写脚本回填历史数据。兜底分支返回 1=0 而不是 1=1,配置出错时是"查不到"而非"全泄露",这个方向不能反。部门树查询要缓存,别每次请求都递归查数据库。
字段权限:低成本做法
不要为字段权限建复杂配置表。小企业的敏感字段通常就那么几个:成本价、进货价、员工薪资、客户手机号。做法是在返回数据前统一脱敏:
MASK = {"cost_price": "cost:view", "phone": "customer:phone"}
def mask(row, perms):
for field, need in MASK.items():
if field in row and need not in perms:
row[field] = None if field.endswith("price") else mask_phone(row[field])
return row
导出功能要单独做权限点。见过太多案例是列表页把成本价藏了,但"导出 Excel"接口忘了处理,数据整表流出。凡是导出、打印、批量接口,都要按同样规则再过一遍。
审计日志:权限的最后一道保险
权限管的是"不该做的做不了",审计管的是"做过的能查到"。小企业系统只需记录写操作即可,读操作量太大不划算。
CREATE TABLE audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
action VARCHAR(64) NOT NULL, -- order:audit
target_id VARCHAR(64),
before_js JSON, after_js JSON, -- 只存变化字段
ip VARCHAR(45),
created_at DATETIME NOT NULL,
INDEX idx_target (action, target_id),
INDEX idx_user_time (user_id, created_at)
);
只存变化的字段而不是整条记录,否则表膨胀极快。保留期设六到十二个月,配合按月分表或定期归档。审计表要禁止业务代码删除,只允许追加。
迁移策略:老系统怎么平滑加权限
硬切一定会出事故。可行的四步节奏:先梳理,把现有页面和接口列一张表,标注每个操作应属哪个权限点,让业务负责人确认;再让校验跑"观察模式",不拦截只记日志,把"某用户触发了未授权的权限点"跑一周收集漏配;然后按岗位职责批量授权,通常三到五个角色够用;最后才开启真实拦截,同时留一个应急超管账号。
数据范围放在功能权限稳定后再开,两件事同时上线会分不清问题出在哪一层。
常见坑
- 角色越建越多:出现"张三专用角色"就说明设计跑偏了,特例应该用用户级权限补充,而不是造新角色。
- 只做增删改不管查:查询接口的数据范围最容易被漏掉,尤其是统计和报表接口。
- 超管绕过一切校验:可以绕过权限,但不能绕过审计,超管的操作更要留痕。
- 离职处理不完整:应禁用账号并解绑角色,历史单据保留姓名,不要物理删除用户。
小结
小企业系统的权限不需要企业级复杂度,但必须分层清晰:功能权限拦操作、数据范围过行、字段脱敏挡列、审计日志留痕。五张表加一个中间件,两三天就能补上,换来的是数据不再裸奔、责任可以追溯。越早做成本越低——等出了纠纷再回头查记录,发现什么都没记,那就真没办法了。