ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零设计权限系统:RBAC数据模型、角色层级与按钮级权限拦截实践

从零设计权限系统:RBAC数据模型、角色层级与按钮级权限拦截实践 简介用户权限管理系统需求分析文档是一份面向系统架构师、后端开发与项目管理人员的完整需求规格说明目标是构建可复用的通用权限控制模块避免各应用系统重复设计。文档从系统目标、性能指标、B/S架构、功能构成、接口与数据设计、错误处理与安全机制等维度展开覆盖系统设置、用户管理、角色管理、组织管理、资源管理、日志管理及IP黑名单管理等功能模块并配有管理员与一般用户的用例关系图。整份资料为1个doc文档大小约287KB内容完整、章节清晰便于直接查阅和二次整理。目前已有84人学习/下载。读者可借助其中的目标说明、功能分析和架构设计快速搭建自己的权限管理需求规格也可参考其中的模块关系与用例图来完善项目文档或毕业设计。1. 将组织从权限体系剥离需求文档里最值得抄的设计决策这份需求分析最反直觉的地方是把「组织管理」设计成了一个只读的视图模块组织可以无限级挂子节点可以查看组织下的员工但权限判断完全不经过组织关系。很多权限系统一上来就是「用户-部门-角色-权限」层层关联最后部门和角色的权限范围纠缠在一起每次调整组织架构都要连带改授权。文档直接把组织抽出去权限只认角色组织的唯一作用就是分组归类和管理参考。整份文档把人、角色、操作、资源四类实体当作权限控制的全部要素资源又细分成菜单、页面、按钮、图片、附件这些具体对象登录前还加了一道 IP 黑名单过滤。对正在设计权限中台、或者要从零搭一套可复用权限模块的团队来说这几个取舍比「用户-角色-菜单」老三样要实用得多。下面从数据模型、授权链路、边界约束、验证方式几个层面把它拆开。2. 权限数据模型用户、角色、资源、操作四类实体怎么落表2.1 需求里的功能模块如何映射到数据库表需求文本给出的功能模块有系统设置、用户管理、角色管理、组织管理、资源管理、日志管理、IP 黑名单管理。去掉系统设置这种通用项真正参与权限判断的实体只有四个用户、角色、资源、操作。组织虽然出现在模块列表里但文档明确写了它只提供组织视图不参与权限的控制管理。技术底座方面系统采用 B/S 架构基于 BNFW 框架开发服务层封装对后台数据操作的细节Web 应用通过安全接口访问服务数据库用 SQL ServerWeb 服务器用 Tomcat支持部署在 Linux 和 Windows 环境下数据模型设计必须兼容这两类部署场景。这个约束直接决定了表结构的方向用户表保留一个组织编号作为冗余信息但不需要做「组织-权限」关联表也不需要查询数据时往组织维度扩散。角色和用户是多对多关系一个用户可以有多个角色一个角色也能授予多个用户。资源与操作是一对多关系一个菜单或页面下可挂多个按钮操作。角色授权时授的不是资源整体而是「资源操作」的细粒度组合这一点是整套权限体系的最小控制单元。2.2 核心表的建表脚本与字段说明以下脚本按 SQL Server 语法编写与需求里指定的数据库保持一致CREATE TABLE sys_user ( user_id INT IDENTITY(1,1) PRIMARY KEY, username NVARCHAR(50) NOT NULL UNIQUE, password NVARCHAR(64) NOT NULL, real_name NVARCHAR(50), org_id INT, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT GETDATE() ); CREATE TABLE sys_role ( role_id INT IDENTITY(1,1) PRIMARY KEY, role_name NVARCHAR(50) NOT NULL, parent_role_id INT, description NVARCHAR(255), create_time DATETIME DEFAULT GETDATE() ); CREATE TABLE sys_user_role ( user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE TABLE sys_resource ( resource_id INT IDENTITY(1,1) PRIMARY KEY, resource_name NVARCHAR(50) NOT NULL, resource_code NVARCHAR(100) NOT NULL, resource_url NVARCHAR(255), parent_id INT, resource_type TINYINT NOT NULL, sort_order INT DEFAULT 0 ); CREATE TABLE sys_operation ( operation_id INT IDENTITY(1,1) PRIMARY KEY, operation_name NVARCHAR(50) NOT NULL, operation_code NVARCHAR(100) NOT NULL, resource_id INT NOT NULL ); CREATE TABLE sys_role_operation ( role_id INT NOT NULL, resource_id INT NOT NULL, operation_id INT NOT NULL, PRIMARY KEY (role_id, resource_id, operation_id) ); CREATE TABLE sys_login_log ( log_id INT IDENTITY(1,1) PRIMARY KEY, user_id INT, user_name NVARCHAR(50), login_ip NVARCHAR(50), login_time DATETIME DEFAULT GETDATE(), status TINYINT ); CREATE TABLE ip_blacklist ( id INT IDENTITY(1,1) PRIMARY KEY, ip_address NVARCHAR(50) NOT NULL, create_time DATETIME DEFAULT GETDATE() );字段设计上有两个点值得展开。sys_role 里的 parent_role_id 对应需求中「角色具有上下级关系」的约束系统管理员给下级角色授权时授权范围不能超出自己所在角色拥有的资源集这个自关联字段在第四章会讲它如何参与授权拦截。sys_resource 表的 resource_type 用 TINYINT 区分菜单、页面、按钮、图片附件等资源类别比如 0 代表菜单、1 代表页面、2 代表按钮、3 代表图片或附件授权界面上按类型筛选时直接查这个字段。resource_code 是程序里写死的行为标识后端 Controller 方法上标注的注解用的就是它避免在代码里硬编码资源的显示名称或 URL 路径。sys_role_operation 把角色、资源、操作三方关联在同一个表里查询按钮权限时一次连接就能返回结果。这里没有拆成「角色-资源表」和「角色-操作表」两张是因为需求文档把「资源访问按钮操作」看成同一个授权动作里不可拆分的两个维度用户能访问某个菜单页面不代表他能点页面里的删除按钮两个行为必须同时在校验逻辑里出现。2.3 组织在数据层为什么可以只做视图组织表是权限体系里的弱实体用户挂一个组织编号组织提供树形结构仅用于列表展示和组织员工查询。设计上只需要一张自关联表parent_id 指向上级组织。这里要提醒一点不要因为组织有层级就给角色加 org_scope 字段。一旦加了角色的数据权限就会扩散到某个组织及其子树后续所有查询都要叠加组织范围的 SQL 条件校验分支越来越多与文档「组织不参与权限控制」的设定直接冲突。组织建模越简单权限逻辑越好维护。3. 从登录到按钮级拦截授权链路的完整实现3.1 登录阶段的三道校验需求里把登录过程拆成三道检查IP 黑名单过滤、用户名密码校验、按角色加载工作界面。第一道在 Web 容器层用 Filter 完成比在业务代码里做更干净所有请求在进入 Controller 之前就被拦截public class IpBlacklistFilter implements Filter { private final SetString blacklist loadFromIpBlacklistTable(); public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { String ip req.getRemoteAddr(); if (blacklist.contains(ip)) { resp.setStatus(403); return; } chain.doFilter(req, resp); } }这段代码的作用是应用启动时从 ip_blacklist 表加载全部 IP 到内存 Set请求进来先比对来源地址命中就直接返回 403不再进入后续的密码校验环节。黑名单集合可用定时任务刷新缓存列表变动时不需要重启应用。IP 过滤只做粗粒度防护不替代密码校验它解决的是「某些恶意来源连验证密码的机会都不给」的场景。第二道用户名密码校验通过后系统要加载当前用户的全部角色再根据角色去 sys_role_operation 表聚合资源与操作编码生成的权限集合同时用于菜单树初始化和后端接口拦截。查询权限集合最常见的方式是登录成功后一次查出全部授权范围先关联 sys_user_role 得到角色 ID再关联 sys_role_operation 得到资源与操作编码SQL Server 场景下用一条 join 完成避免循环查库SELECT DISTINCT res.resource_code, op.operation_code FROM sys_user_role ur JOIN sys_role_operation ro ON ur.role_id ro.role_id JOIN sys_resource res ON ro.resource_id res.resource_id JOIN sys_operation op ON ro.operation_id op.operation_id WHERE ur.user_id userId;这条查询返回的是「当前用户能执行哪些按钮行为」的完整集合比如 user_manage:view、user_manage:delete、role_manage:grant。resource_code 与 operation_code 拼接起来的字符串集合就是后面 AOP 切面做匹配的数据源。需要关注的是 DISTINCT 关键字一个用户挂多个角色时不同角色可能重复授权同一操作去重后权限集合才不会出现冗余项。3.2 后端接口的 AOP 按钮级拦截菜单隐藏只是界面表现层面的控制真正防止越权必须回到后端。常见做法是定义一个操作注解标注在 Controller 方法上再用 AOP 切面做统一校验Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireOperation { String resourceCode(); String operationCode(); }Controller 方法上用这个注解声明它属于哪个资源下的哪个按钮行为DeleteMapping(/user/{userId}) RequireOperation(resourceCode user_manage, operationCode delete) public Result deleteUser(PathVariable Long userId) { userService.delete(userId); return Result.success(); }切面拦截注解标注的方法在方法执行前比对当前用户的权限集合Aspect Component public class PermissionCheckAspect { Before(annotation(requireOperation)) public void check(JoinPoint joinPoint, RequireOperation requireOperation) { Long userId SecurityContextHolder.getUserId(); boolean allowed PermissionService.hasPermission( userId, requireOperation.resourceCode(), requireOperation.operationCode() ); if (!allowed) { throw new PermissionDeniedException(无操作权限); } } }切面的核心逻辑在 PermissionService.hasPermission从当前登录上下文拿到用户 ID再通过本文前面那条 SQL 查出的权限集合做匹配。实际部署时这个权限集合一般放在缓存里每次请求只做一次内存查询不走数据库。AOP 方案的关键优势是统一出口新增接口时只要标注注解拦截逻辑自动生效不会出现某个 Controller 忘了写权限判断的漏洞。3.3 菜单树构建与资源层级约束需求里强调菜单、页面这类资源必须有父子关系而且父子层次结构最好不超过 3 层。这意味着 sys_resource 表 parent_id 自关联的深度是受限的一级是系统模块二级是页面入口三级是按钮或功能点。三层以内前端菜单可以一次加载全部授权节点用递归组件或三层嵌套循环都能处理超过三层左侧菜单的折叠交互、面包屑导航和后端递归查询的复杂度都会明显上升。限制层级本质上是控制界面渲染路径和查询深度。菜单初始化时按 sort_order 排序只展示当前用户有权限的节点。这里有个容易被忽视的细节如果用户有三级按钮的权限那也必须拥有对应二级页面的权限否则按钮渲染不出来接口也进不去。授权时需要做祖先节点联动把页面的父级资源一并授权给角色否则会出现「菜单里找不到入口但 URL 能访问」的越权空洞。4. 角色上下级授权防越权的精细化校验4.1 角色自关联字段的语义sys_role 表里的 parent_role_id 是这张表中约束力最强的字段。访客、用户、管理员、系统管理员这一串角色的排列对应的是需求里「角色具有上下级关系下级角色的权限范围只能在上级权限范围内实施授权操作」。区别于扁平 RBAC 中角色只是权限标签集合这里的自关联把角色变成了树每棵子树的授权动作只能由父级角色的持有者发起形成一条收缩式的授权链。举例来说系统管理员拥有全部资源权限他给某管理员角色授予「用户管理」模块的查看权限这个管理员后续再创建下级角色时能选择授权的资源范围只能是「用户管理」模块内的查看行为其他模块的资源在授权界面上直接置灰。要做到这一点单靠 parent_role_id 存进表里不够必须在授权接口做双重校验一是操作者角色自身的权限范围二是目标角色与操作者的角色层级关系。4.2 授权范围校验的实现4.2.1 操作者权限集合的提取授权接口要做的第一件事是查出当前操作者所有角色下已授权的资源与操作 ID。角色多、资源多的场景下不能在业务代码里逐条迭代替换SQL Server 里用一条关联查询取集合操作者整个权限集合可以缓存在会话中public SetLong getOwnedOperationIds(Long operatorUserId) { String sql SELECT DISTINCT ro.operation_id FROM sys_user_role ur JOIN sys_role_operation ro ON ur.role_id ro.role_id WHERE ur.user_id ? ; return jdbcTemplate.query(sql, (rs, i) - rs.getLong(1), operatorUserId); }这个集合代表操作者能授出的权限上限。不管当前操作者是不是系统管理员授权都基于这个集合做减法。用 operation_id 而不是 resource_id 做集合元素是因为同一资源下可能有多个按钮行为按操作维度控制能把授权粒度精确到单个按钮如果按资源维度控制页面里两个按钮的权限只能捆绑授予又退回菜单级权限的粗粒度。4.2.2 授权写入前的越权检查拿到操作者权限集合后把本次要授予目标角色的 grant 列表做差集校验grant 集合必须是 owned 集合的子集校验通过后再写入 sys_role_operation同时记一条授权日志public void grantToRole(Long operatorUserId, Long targetRoleId, ListRoleOperation grants) { SetLong owned getOwnedOperationIds(operatorUserId); SetLong grantIds grants.stream() .map(RoleOperation::getOperationId) .collect(Collectors.toSet()); if (!owned.containsAll(grantIds)) { throw new PermissionDeniedException(授权范围超出自身权限); } roleOperationDao.batchInsert(targetRoleId, grants); grantLogDao.record(operatorUserId, targetRoleId, grants); }代码里的核心判断是 owned.containsAll(grantIds)如果操作者自身没有某个删除操作的授权那么他给任何下级角色授权时都不能勾选这个删除操作。授权日志必须记录操作者用户 ID、目标角色 ID、被授予的操作 ID 列表和操作时间用于后续审计追溯。这里容易漏掉的场景是操作者想分配的两项权限确实都有但分别挂在自己的两个不同角色上。此时 owned 集合是所有角色权限的并集按并集校验会放行。是否允许跨角色汇聚权限取决于系统对权限隔离强度的要求。需求文档没有明确这一点实践当中我一般会从严处理只取操作者当前上下文角色的权限集合避免授权者靠多角色叠加攒出超出单一身份的权限范围。4.3 与扁平角色管理的差异引入 parent_role_id 之后角色删除、用户角色变更都会触发对新约束的校验不能像扁平 RBAC 那样直接删角色。删除一个角色前先检查它下面有没有子角色有子角色时要么拒绝删除要么把子角色的 parent_role_id 重定向到被删角色的父级。授权接口也不能只判断「操作者是否为管理员」否则系统里出现多个管理员时管理员 A 就可以给管理员 B 的下级角色随意授权角色树形约束形同虚设。4.4 角色子树的递归查询角色层级在业务上不允许无限深通常控制在三四层以内与组织的无限级形成对比。需要导出角色体系或判断目标角色是否在操作者角色子树内时用递归 CTE 查询WITH role_tree AS ( SELECT role_id, role_name, parent_role_id, 0 AS depth FROM sys_role WHERE role_id currentRoleId UNION ALL SELECT r.role_id, r.role_name, r.parent_role_id, rt.depth 1 FROM sys_role r INNER JOIN role_tree rt ON r.parent_role_id rt.role_id ) SELECT * FROM role_tree;这个查询返回从当前角色出发的全部下级角色链服务端拿到结果后可以做两件事一是在运维后台渲染角色树二是做授权范围判定确认目标角色确实在当前操作者的子树范围内防止跨角色分支越权授权。组织表同样支持递归查询但它只影响视图展示结果到不了权限判断链路组织层级深一点浅一点不影响安全。5. IP 黑名单、并发限制与需求验收方式5.1 黑名单与并发限制的落地IP 黑名单在需求里是登录前的第一道防护。落地时除了登录接口要过滤登录后的敏感操作接口也可以复用同一个 Filter因为用户登录后可能带着相同来源 IP 执行操作敏感操作同样需要来源校验。黑名单的数据量一般不会太大几百条到几千条属于常见的量级加载到内存做集合判断足够如果黑名单达到十万级则要考虑用前缀匹配或网段匹配但那是运维防火墙的范畴权限系统里一般不承担这种规模的过滤。「管理者与用户不能同时操作」这个并发约束在需求里属于异常严格的场景。生产环境里通常的落地方式是一张资源锁表管理员登录时写入一把全局锁普通用户登录前检查锁状态锁存在就提示稍后重试。这种设计牺牲了一部分并发能力换来的是权限变更期间数据的一致性和审计溯源的清晰度。权限调整频率不高的内部管理系统接受这个约束是合理的但如果接入第三方开放平台管理员与调用方不能同时操作会阻塞业务需要重新评估。5.2 把需求条目转成可执行的验收用例需求文档常见的毛病是目标写得很满验收时却无从下手。解决方式是提前把需求条目映射成测试用例评审时逐条过。针对这份需求分析可以抽取这样的用例表用例编号需求条目操作场景预期结果TC-LOGIN-001系统登录-IP校验黑名单 IP 发起登录请求请求被 Filter 直接拦截返回 403TC-AUTH-001资源权限控制无删除权限的用户调用删除接口接口返回无操作权限异常数据未被删除TC-ROLE-001角色上下级关系仅有查询权限的管理员给下级角色授予删除权限授权接口拒绝提示授权范围超出自身权限TC-RES-001资源层级约束尝试创建四级子资源节点系统提示资源层级不允许超过 3 层TC-ORG-001组织管理视图组织节点挂多级子节点并查看组织员工组织树正常展示权限判断不受组织影响TC-PERF-001多用户并发操作100 个用户同时登录并访问菜单平均响应时间不高于 2 秒无 500 错误这张用例表把需求文档里的功能语句和非功能语句都映射成了可执行动作。TC-ROLE-001 专门验证第三章设计的授权范围校验逻辑它确保角色上下级约束不只是数据库里的一个自关联字段而是真正拦截任何越权写入动作TC-ORG-001 则验证组织视图和数据权限的彻底隔离防止后续迭代把组织维度重新引入权限判断。5.3 日志与审计的补充建议日志管理模块至少应该记录三类事件登录失败与成功、授权变更、敏感操作。日志表里的 user_name 建议冗余保存用户名而不是只存 user_id因为用户可能被删除删除后审计日志必须能还原出「谁在什么时间执行了什么操作」。IP 黑名单的维护接口只对系统管理员开放黑名单本身的增删操作也要写日志防止出现「把审计管理员自己的 IP 拉黑后又悄悄删除记录」的情况。这类审计设计不在显性权限模型里却在权限事故排查时决定了一个团队能否在一个小时内定位问题根源。本文还有配套的精品资源点击获取
返回列表