做架构这些年,权限系统是我见过返工率很高的模块之一。不少项目为了图快,直接在用户表上加一个角色字段,甚至把一串硬编码的判断散落在各个接口里。等项目做到一半,角色从五个膨胀到十几个,各种临时开通的诉求一涌进来,代码就成了没人敢动的泥潭。
去年我们给一家三百人规模的制造企业做质量管理系统,我下决心把权限这块按标准的思路重新做一遍。这篇文章把当时的表设计、接口鉴权实现和踩过的坑完整记录下来,给遇到同样问题的朋友一个参考。
一、为什么这个项目最后还是回到了RBAC
1.1 需求从五个角色膨胀到十三个
项目启动时,客户给出的角色清单只有五个:系统管理员、质量主管、检验员、车间主任、查看者。有同事提议在用户表加一个role字段存角色编码就够了,我犹豫了一下还是没同意。调研阶段我就注意到,这家企业内部有跨车间的兼职检验员,还有人同时挂着主管和审核两个头衔。
一个用户只能绑定一个角色的设计,在这种组织结构下头一个星期就会暴露问题。果然,一个月后角色清单膨胀到十三个,还出现了质量主管只管两个车间这种按数据范围限权的诉求。单一角色字段彻底不够用了。
1.2 直接在用户表上挂权限的教训
我早年接手过一个老项目,权限判断是这么写的:每个接口开头先取用户表的isAdmin字段,不是管理员再看role字符串,等于某个值就放行。这种写法短期省事,长期是灾难。
问题在于权限逻辑和业务代码搅在一起。有一次客户要求把导出报表的权限从主管收回到管理员,我们翻了三十多个接口才把相关判断找全,还漏了两处。那次返工让我认定,权限必须收敛成独立模型加统一拦截层,业务代码里不允许出现权限判断。
二、表结构设计:五张核心表
2.1 用户、角色、权限三张主表
标准的RBAC就是用户、角色、权限三个实体,再加两张关联表。用户表只存账号基础信息,角色表存编码和名称,权限表存细粒度的权限点。核心建表语句如下:
CREATETABLEsys_user(idBIGINTPRIMARYKEYAUTO_INCREMENT,usernameVARCHAR(64)NOTNULLUNIQUE,passwordVARCHAR(128)NOTNULL,dept_idBIGINTCOMMENT'dept id for data scope',statusTINYINTDEFAULT1,created_atDATETIMEDEFAULTCURRENT_TIMESTAMP);CREATETABLEsys_role(idBIGINTPRIMARYKEYAUTO_INCREMENT,role_codeVARCHAR(64)NOTNULLUNIQUE,role_nameVARCHAR(64)NOTNULL,remarkVARCHAR(255));CREATETABLEsys_perm(idBIGINTPRIMARYKEYAUTO_INCREMENT,perm_codeVARCHAR(128)NOTNULLUNIQUE,perm_nameVARCHAR(64)NOTNULL,perm_typeTINYINTCOMMENT'1 menu 2 button 3 api');权限点编码我们定了命名规范:模块:资源:操作,比如qms:report:export。这个规范后来帮了大忙,接口鉴权和前端按钮控制用的是同一套编码,省去了两套口径对齐的成本。
2.2 两张关联表与外键取舍
用户和角色是多对多,角色和权限也是多对多,所以需要两张关联表:
CREATETABLEsys_user_role(user_idBIGINTNOTNULL,role_idBIGINTNOTNULL,PRIMARYKEY(user_id,role_id));CREATETABLEsys_role_perm(role_idBIGINTNOTNULL,perm_idBIGINTNOTNULL,PRIMARYKEY(role_id,perm_id));有个细节值得说:我们没有加物理外键,只建了二级索引。数据量上来以后,外键约束会让批量导入和角色批量调整变慢,而权限数据一般由管理端维护,一致性放在应用层保证就够。团队规范强制要求外键的话,照规范来也没问题,这不是原则问题。
2.3 数据权限的预留设计
真正让这套模型值回票价的,是数据权限的扩展。质量主管只管两个车间,这类需求光有功能权限不够,还得限制他能看到哪些数据。我们的做法是在用户表挂部门字段,另建一张角色与数据范围的配置表,鉴权时把功能权限和数据范围一起算出来。
数据范围分四档:全部数据、本部门、本部门及下级、仅本人。组织树用一张闭包表存层级关系,算下级部门时一次递归查询就能拿到。这部分强烈建议一开始就预留位置,等业务方提需求再改表,代价会大很多。
顺带提一句闭包表的性能。组织节点两千多个时,递归查询已经能感觉到延迟,我们把常用的部门下级集合做了缓存,组织调整时失效重建。这类优化不难,但要在设计时留好缓存的位置,而不是上线后打补丁。
2.4 权限表的数据量与初始化
有人担心权限点多了会拖慢系统,实际算一下就知道不必焦虑。三百个接口、平均每个接口一个权限点,加上菜单和按钮,权限表撑死一千行,角色关联表几千行,这点量级连数据库的零头都算不上。真正要控制的是聚合查询的写法,后面鉴权部分会讲。
初始化数据我们用脚本管理,角色、权限点、默认角色绑定全部写成可重复执行的脚本,随代码一起进版本库。新环境搭建时跑一遍脚本就有完整的权限骨架,避免人工在管理界面一个个点,既慢又容易漏。
三、接口鉴权的实现
3.1 统一拦截层的执行顺序
鉴权统一放在网关后面的一个中间层做,执行顺序是:先验登录态,再验功能权限,进业务代码后再按数据范围过滤。拦截器伪代码大致如下:
publicvoiddoFilter(Requestreq,Responseresp,Chainchain){LonguserId=session.getUserId(req);if(userId==null){reject(resp,401);return;}StringpermCode=routeTable.match(req.getPath());if(permCode!=null&&!permCache.hasPerm(userId,permCode)){reject(resp,403);return;}chain.next(req,resp);}路由表是启动时扫描接口注解生成的,每个接口上标一个permCode,拦截器按路径反查。权限点和接口的映射关系集中可查,彻底避免了散落各处的判断语句。
3.2 权限缓存与失效策略
权限数据读多写少,天然适合缓存。我们把用户聚合后的权限点集合压成位图存进本地缓存,过期时间十分钟,角色变更时发一条消息主动清掉对应用户的缓存。
这里踩过一个坑:管理员调整角色后,被调整的用户十分钟内权限保持不变,客户当场就打电话来了。后来改成变更即失效,通过消息广播用户id,各实例收到后删本地缓存,下次请求回源重建。宁可回源多一点,也不能让权限更新不及时。
缓存的粒度也试过几轮。起初按用户维度缓存权限点集合,管理端改角色时要挨个找出受影响的用户发失效消息,链路太长。后来把缓存改成用户聚合结果直接落一份,角色变更时按角色反查用户列表批量失效,逻辑清晰很多。权限这种数据,宁可缓存结构糙一点,也要把失效链路做短,这是实操里得出的结论。
3.3 越权访问的兜底校验
功能权限挡住了能不能调这个接口,挡不住调了接口之后改别人的数据。我们另外在业务层做了归属校验:单据更新前先查这条记录的所属部门,和当前用户的数据范围比对,不匹配直接拒绝。
这类校验很容易被图省事的同事绕过,所以代码评审清单里加了一条硬性检查:凡涉及按id操作的单据接口,必须能看到归属校验逻辑。上线三个月,越权相关的审计问题一次都没出过。
四、踩过的坑
4.1 权限点命名不统一带来的返工
早期权限点命名是各开发随手起的,有叫reportExport的,有叫export_report的,对齐规范时清理了一百多个权限点,返工整整两天。这种事一定要在项目开始就定死规范,宁可用检查脚本强制执行,也不要指望事后统一。
4.2 前端按钮权限与服务端鉴权的关系
前端按权限点控制按钮显隐,体验上没问题,但一定要让前端明白这只是展示层优化。有位新同事以为按钮藏了就安全了,接口层没做校验,被测试一笔勾出越权。从此我们立了规矩:前端权限只管看不见,服务端权限才管进不来,两边用同一套权限点编码,但互不依赖。
4.3 角色继承的诱惑
中途客户提过角色继承的需求,想要大角色自动包含小角色的全部权限。我们评估后没有做,而是用角色组合解决:给用户同时挂多个角色即可。继承树一旦深了,排查某个用户为什么有某项权限会变成噩梦,扁平组合的可解释性好得多。事后证明这个决定很明智。
五、复盘与反思
5.1 模型先行的收益
回看这个项目,权限模块真正难的不是表结构和代码,而是抵抗先简单做后面再改的诱惑。模型上的克制换来了后面几十次角色调整的轻松,五张表的方案后来沉淀成了团队标准件。其中一个客户的项目后来跑在搭贝上,我们照着同一套权限模型做配置迁移,思路一脉相承,两天就接完了。
5.2 留给下个项目的改进
还有两处没做到位。权限变更的操作日志一开始没设计,后来补的时候只能从数据库日志里捞历史;权限点文档靠人肉维护,和实际配置时有出入,理想做法是把权限清单导出进版本库,每次发版自动比对。
另外一点体会是培训和沟通比想象中重要。权限模型上线后,我们给客户的管理员做过一次两小时的培训,重点讲角色和数据范围的关系。没有这次培训,后面接到的咨询电话会成倍增加。技术人员容易高估使用者的理解程度,也容易低估一次讲解的长期价值。权限设计这件事,早一周想清楚,能省后面一个季度的纠缠,这是我最直接的体会。