访问控制模型设计与越权排查:从矩阵到RBAC混合选型
简介这份《访问控制理论与策略》压缩包面向网络安全学习者、Java Web开发者以及需要搭建权限系统的工程人员围绕主体、客体与操作三要素和RBAC0模型展开可用于课程设计、项目实践与权限体系梳理帮助解决权限边界划分与访问决策落地问题。包内共671个文件以115个class、68个java源码、40个jsp页面、46个xml配置、63个jar依赖以及50个css、31个js等前端资源为主较完整地呈现从后端权限判断到页面交互的实现脉络压缩包约59.74MB。内容重点落在用户、角色、权限的关系设计以及角色管理、用户管理、权限维护和访问控制决策等源码模块读者可据此理解固定角色、职责分离、委派管理和非侵入式检查等特性并对照网络服务、企业信息系统与云计算平台等场景梳理权限分配流程。已有190人学习适合需要把RBAC0从概念落到代码结构中的读者参考。1. 权限判断不该写在业务里为什么访问控制要先拆出决策层很多人第一次做访问控制是在业务代码里塞一个if (user.role admin)。这种写法在演示环境几乎不会出错可一旦进入网络安全审计流程往往第一批就被打回。原因不在于判断写得对不对而在于判断的依据从哪来、在哪一层判、判完之后能不能被绕过。这三点决定了一个看起来只有四张表的 RBAC0 模型为什么能支撑起大量真实生产系统它把“谁能做什么”从散落的分支语句里抽出来变成了可以被统一求值、统一审计、统一变更的数据。值得较真的是这层抽象背后的代价。访问控制不是加密它不提供任何数学上的不可破解性它提供的是一套可被验证的决策流程。理解这套流程为什么长成这样比背下 DAC、MAC、RBAC 的定义有用得多。2. 访问控制矩阵为什么所有模型最终都退回同一张稀疏表把任意一个访问控制系统的判定过程拆到最底层都会露出同一个结构一个主体、一个客体、一个操作然后回答允许还是拒绝。理论上的标准形态是访问控制矩阵行是主体列是客体格子里是操作集合。几乎所有教材都从这里开始但几乎没有人真的把它存成一张二维表原因很现实——矩阵太大了。一个中等规模系统用户几十万客体文件、订单、字段、接口上千万矩阵的元素数量是天文数字而其中非空的比例通常不到万分之一。存储一张几乎全空的表是纯粹的浪费。于是所有工程实现做的事情本质上是同一件压缩稀疏矩阵只不过压缩的方向不同。2.1 主体、客体、操作三要素如何覆盖一切鉴权场景主体不一定是人。进程、服务账号、一次定时任务、某个下游系统回调都可以是主体。客体也不一定是文件一行订单记录、一个 API 路径、一列手机号、一个按钮的点击行为都能当客体。操作则是动词的抽象读、写、执行、审批、导出、脱敏查看。把这三者抽象出来好处是判定逻辑可以完全与业务解耦。业务只需要在关键位置抛出“谁要对什么做什么”由决策层返回结果。这也是为什么成熟的权限系统会拆成 PEP策略执行点和 PDP策略决策点前者贴近业务负责拦截和收集上下文后者独立部署负责求值和返回。有一个容易被忽略的细节客体的粒度决定了整个系统的复杂度。如果客体是“订单”权限模型可能十张表就够如果客体是“订单里的某一列”那 RBAC 的粗粒度就不够用了必须引入属性或标签。选型上的很多争论其实是在争论客体粒度而不是模型本身。2.2 矩阵为什么必须被压缩从二维表到三层关系压缩方式主要有三种对应三种截然不同的查询性能特征。第一种是按列存也就是 ACL访问控制列表每个客体挂一个“谁能操作我”的列表。文件系统用的就是这个思路chmod的权限位本质上是一张极简 ACL。它的优势是判定快——拿到客体就能立刻回答“这个主体能不能访问”因为列表就在客体旁边。第二种是按行存也就是能力表capability每个主体持有一组“我能操作谁”的凭证。令牌、临时凭证、签名 URL 都属于这一类。它的优势是分发方便主体拿着凭证就能访问不需要每次都回源查询劣势是撤销困难凭证发出去之后想收回要么等它过期要么维护一张吊销名单。第三种是按关系存也就是把三元组拆成关系表。这是业务系统里最常见的做法因为权限往往需要按组织、按项目、按角色批量授予和批量撤销而关系模型天然适合做集合运算。代价是每次判定都要做多次连接所以必须在中间加缓存。提示ACL 和能力表不是二选一。真实的系统常常两层都用——外层用能力表做快速准入内层用 ACL 做最终校验任何一层判定不一致都视为拒绝。把三种压缩方式和查询模式对照一下选型的思路就清楚了存储形态判定入口典型查询撤销成本适用场景ACL按客体客体谁能访问这个资源低文件、文档、资源详情能力表按主体主体这个主体能访问哪些资源高临时授权、跨服务调用关系表三元组策略库两者皆可靠索引中业务系统、组织级授权2.3 引用监视器检查点必须卡在不可绕过的位置矩阵解决了“存什么”引用监视器解决了“在哪判”。这个概念来自安全内核的理论系统中必须存在一个中介所有访问请求都必须无条件经过它且它本身不能被绕过、不能被篡改、必须足够小以便被验证。现实里最常见的翻车就是把检查点放在了可以被绕开的位置。比如只在网关层校验内部服务之间直接调用就跳过了比如只在前端隐藏按钮接口照样能被直接请求。引用监视器的要求是绕过它就不能完成访问而不是“绕过它会不太方便”。一个最小骨架大致长这样def reference_monitor(subject, obj, action, policy_store, audit): # 1. 身份必须来自不可伪造的凭证不能来自请求体里的 user_id if not subject.is_authenticated(): audit.deny(subject, obj, action, reasonunauthenticated) return False # 2. 策略是数据从策略源求值不散落在业务分支里 decision policy_store.evaluate(subject.id, obj.id, action) # 3. 默认拒绝没有匹配到任何显式 allow就是拒绝 allowed decision.effect allow # 4. 先落审计再返回保证拒绝也有迹可循 audit.record(subject, obj, action, allowed, decision.matched_rule) return allowed这段代码里subject的身份校验必须独立于业务参数policy_store是可以热更新、可以版本化的策略源audit的写入顺序在返回之前——否则一次放行之后的崩溃会让审计缺失。decision.matched_rule这个字段很关键它让事后追查能直接定位到是哪条规则放行的而不是只知道“被允许了”。真正的难点在于让整个系统只有这一个入口。多语言、多服务、多团队协作时这件事靠的不是代码规范而是架构约束把资源访问收敛到少数几个 SDK 或边车让绕过检查的成本高于走检查的成本。3. DAC 与 MAC自主授权为什么会自己长权限强制标签又兜住了什么访问控制模型的两大源头是自主访问控制DAC和强制访问控制MAC。它们的差别不在于严格程度而在于“谁有权改规则”。DAC 把授权能力交给资源所有者灵活、贴近业务代价是权限会沿着人际关系扩散MAC 把规则交给系统主体和客体都带标签判定由标签比较得出所有者也无权改变。这个差别在理论上很清楚落到工程上却经常被混用。很多系统自称 RBAC实际上底层还是 DAC——因为角色可以被管理员随意分配分配的依据是人的判断而不是系统规则。这不算错但要知道自己失去了什么一旦授权链条足够长追溯“这个权限最初是谁给的”就会变成一件非常痛苦的事。3.1 DAC 的授权传递一个所有者的疏忽如何变成全网可达DAC 的核心操作是“授予”。所有者可以把访问权转授给别人别人还可以继续转授。这在协作场景里非常自然但它构建出的是一张有向图而图是有连通性的。设想一个内容协作系统某个项目负责人把自己的一份文档共享给了外部合作方合作方又把它共享给了自己的同事。这个链条本身没问题问题在于链条上的每个节点都可能成为新的扩散源。一旦某个中间账号被盗用攻击面不是“这一份文档”而是这个账号能触达的所有资源以及它还能继续转授出去的能力。常见缓解手段是限制转授深度或者引入“不可再授权”标志位。但更根本的做法是记录授权来源让每一次授权都带上祖先链。这样在撤销时可以沿着链一次性收回派生权限而不是指望人工逐个排查。3.2 BLP 的两条铁律no read up 与 no write down 分别在防什么强制访问控制的经典模型是 BLP它用两个性质保证机密性简单安全性主体不能读取比自己级别更高的客体和星号性质主体不能向比自己级别更低的客体写入。很多人只记住了“上读下写都不行”却没想清楚为什么。不允许向上读是为了防止低权限者直接获取高密级信息。不允许向下写是为了防止高权限者把高密级信息“降级”写进低密级容器里——如果不拦这一步前面那条规则就形同虚设因为信息可以从上层流下来。LEVELS {public: 0, internal: 1, secret: 2, top: 3} def blp_read(subject, obj): # 简单安全性许可级别低于客体级别时拒绝 if LEVELS[subject.clearance] LEVELS[obj.classification]: return False # 类别集合必须覆盖客体类别防止同级不同域的横向读取 return set(obj.categories).issubset(set(subject.categories)) def blp_write(subject, obj): # 星号性质许可级别高于客体级别时拒绝防止高密级信息向下流动 if LEVELS[subject.clearance] LEVELS[obj.classification]: return False return set(obj.categories).issubset(set(subject.categories))clearance是主体的许可级别classification是客体的密级categories是领域划分比如财务、人事。只比较级别是不够的还必须比较类别集合否则两个同级别但不同领域的主体就能互相读取。这套规则在实际系统里会撞上一个尴尬如果主体的级别高于它需要写入的客体的级别它写不进去。解决办法通常是引入“可信降级过程”——一个被显式授权、行为被审计的组件允许它在受控条件下完成降级写入。这个组件的存在本身就是系统最脆弱的一环所以它必须足够小、足够显眼。3.3 Biba 与 BLP 的对偶完整性和机密性为什么方向相反BLP 保机密性Biba 保完整性两者的规则几乎是镜像的Biba 要求主体不能读取完整性级别比自己低的客体不读脏数据也不能向完整性级别比自己高的客体写入不许污染干净数据。这个对偶关系初看很别扭想通之后会觉得非常合理。机密性的威胁是信息外流所以要阻止高到低的流动完整性的威胁是数据被污染所以要阻止低到高的流动。方向相反是因为保护的目标相反。工程上很少有系统纯用其中一种。更常见的做法是分区涉及敏感数据的模块按 BLP 思路打标签涉及核心配置和账务的模块按 Biba 思路打标签中间靠受控的转换层衔接。这也解释了为什么纯 MAC 系统落地的成本很高——它要求先对整个数据资产做完整的标签化而这件事在很多组织里比写权限代码难得多。3.4 隐蔽通道强制模型也堵不住的那条路强制访问控制能管住显式的读写但管不住信息以其他形式流动。典型的隐蔽通道有两类存储通道和时间通道。存储通道指的是利用系统资源的可见状态传递信息。比如一个高密级进程通过“某个文件是否存在”来表达一个比特低密级进程只要观察文件系统就能读出来全程没有发生任何一次被策略允许的读取。时间通道则更隐蔽高密级进程通过控制自己的 CPU 占用或响应延迟把信息编码进时间维度。要压制这类通道通常的做法是降低共享资源的可观测精度比如对资源使用情况做模糊化、对响应时间加随机抖动、限制低密级主体能看到的系统统计信息。这些手段会带来性能损失而且没法彻底根除只能把通道容量压到可接受的范围。这也是为什么高安全等级的系统对共享资源的管控会严到影响体验——它不是过度设计而是承认了通道无法被消灭。4. RBAC角色这层间接引用到底替谁挡掉了复杂度RBAC 的贡献不在于“把权限给角色”而在于把用户和权限之间的多对多关系压缩成了两个一对多关系。用户数乘以权限数是一个巨大的笛卡尔积而用户到角色、角色到权限这两条边的数量要小得多。授权管理的成本因此从“按人配”变成了“按岗位配”。RBAC0 是最小完备集它只包含用户、角色、权限、会话四类元素以及用户-角色、角色-权限两组分配关系。很多系统自称实现了 RBAC其实只做了用户-角色这一半权限仍然硬编码在代码里那就退化成了一种粗糙的 DAC。4.1 RBAC0 的最小完备集四张表与一次会话最小实现需要六张表三张实体表加三张关系表其中会话是两张。会话这个概念经常被忽略但它才是 RBAC0 区别于“角色字段”的关键。CREATE TABLE app_user ( id BIGINT PRIMARY KEY, name TEXT NOT NULL ); CREATE TABLE role ( id BIGINT PRIMARY KEY, name TEXT NOT NULL UNIQUE ); -- 权限用资源 动作描述避免一个权限对应一个硬编码常量 CREATE TABLE permission ( id BIGINT PRIMARY KEY, resource TEXT NOT NULL, action TEXT NOT NULL, UNIQUE (resource, action) ); CREATE TABLE user_role ( user_id BIGINT REFERENCES app_user(id), role_id BIGINT REFERENCES role(id), PRIMARY KEY (user_id, role_id) ); CREATE TABLE role_permission ( role_id BIGINT REFERENCES role(id), permission_id BIGINT REFERENCES permission(id), PRIMARY KEY (role_id, permission_id) ); -- 会话一次登录期间激活的角色子集属于运行时概念不是持久授权 CREATE TABLE session ( id TEXT PRIMARY KEY, user_id BIGINT REFERENCES app_user(id), started_at TIMESTAMP NOT NULL ); CREATE TABLE session_role ( session_id TEXT REFERENCES session(id), role_id BIGINT REFERENCES role(id), PRIMARY KEY (session_id, role_id) );permission表用resource action而不是枚举常量是为了让权限可以随资源配置自动生成而不是每加一个接口就改一次代码。session_role独立于user_role是因为用户拥有的角色和当前激活的角色不是一回事——一个用户可能同时拥有审批和复核两个角色但在处理同一笔业务时只能激活其中一个这就是后面要讲的动态职责分离的基础。这段建表里没有外键级联删除是有意的。权限数据的删除应该走显式流程并留痕级联删除会让一次误操作变成一次无法追溯的权限消失。4.2 角色继承的偏序RBAC1 为什么不是简单的树RBAC1 在 RBAC0 上加了角色层次。高层角色继承低层角色的权限这样“部门管理员”可以继承“普通成员”的全部权限再附加自己的管理权限。关键点在于这个层次结构是偏序不是树。一个角色可以同时继承两个父角色比如“区域财务主管”既继承“财务专员”又继承“区域管理员”。一旦允许多继承结构就从树变成了有向无环图而图就必然要处理环的问题。环一旦出现递归求值会直接爆栈或者超时。防环的手段有两层写入时校验把“A 继承 B”插入之前先检查 B 是否已经可达 A读取时兜底用UNION而不是UNION ALL做递归让重复节点被去重从而在环存在时也能收敛。角色爆炸是另一个高频问题。业务方常常要求“这个角色和那个角色只差一个权限”于是每来一个新需求就新建一个角色最后角色数量比用户还多维护成本彻底失控。缓解方式是给角色加上领域归属把角色限制在某个业务域内生效跨域的组合通过会话激活来完成而不是造一个全局巨无霸角色。4.3 SSD 与 DSD职责分离约束怎么在关系模型里落地职责分离的目的是防止单点拥有过大的权力。经典例子是付款和审批不能由同一个人完成否则内控就失效了。静态职责分离SSD约束的是用户-角色分配一个用户不能被同时授予互斥的两个角色这条规则在分配时检查一次校验就够。动态职责分离DSD约束的是会话用户可以同时拥有两个互斥角色但在同一个会话里只能激活其中一个需要切换时重新建立会话。DSD 的落地点就在session_role上。每次激活角色前都要检查当前会话已激活的角色集合与待激活角色是否构成互斥对。这个检查必须和激活动作在同一个事务里完成否则并发激活两个互斥角色就能绕过约束。约束本身的表达也需要落表而不是写死在代码里-- 互斥角色对两个角色不能同时出现在同一会话中 CREATE TABLE role_exclusion ( role_a BIGINT REFERENCES role(id), role_b BIGINT REFERENCES role(id), CHECK (role_a role_b), PRIMARY KEY (role_a, role_b) );CHECK (role_a role_b)保证同一对角色只存一行避免正反两行导致的校验遗漏——这是很容易漏掉的一个细节判断互斥时如果只查一个方向另一条记录就等于不存在。4.4 决策查询怎么写一条 SQL 讲清用户到权限的路径把角色继承、会话激活、权限展开合到一次查询里是理解 RBAC 求值过程最直接的方式。下面是带角色继承的版本WITH RECURSIVE effective_role AS ( -- 起点本次会话激活的角色 SELECT r.id FROM role r JOIN session_role sr ON sr.role_id r.id WHERE sr.session_id :session_id UNION -- 用 UNION 而非 UNION ALL去重的同时防止继承成环导致无限递归 -- 递归沿继承关系向上展开父角色 SELECT rh.parent_id FROM role_hierarchy rh JOIN effective_role er ON rh.child_id er.id ) SELECT DISTINCT p.resource, p.action FROM effective_role er JOIN role_permission rp ON rp.role_id er.id JOIN permission p ON p.id rp.permission_id;参数只有session_id一个因为用户身份已经从会话里推导出来不再从请求参数读取——这一点很关键多传一个user_id参数就等于多开了一个越权入口。role_hierarchy表需要两个字段parent_id和child_id表示子角色继承父角色的全部权限。递归的起点是会话激活的角色而不是用户拥有的全部角色这个区别决定了 DSD 是否真的生效。这条查询的代价是每次判定都要递归所以在真实系统里必须缓存。缓存的键要包含角色版本号否则角色变更后旧决策会在缓存里继续生效形成一个看不见的越权窗口。5. 权限系统排查五类越权问题为什么在测试环境永远看不到权限相关的缺陷有个共同特征它们在功能测试里几乎不出现因为测试用例总是用“正确的用户”访问“正确的资源”。真正的问题往往藏在状态变更、并发、缓存和边界条件里。下面这几类是我见过反复出现、且每次都要花不少时间定位的。5.1 越权只在缓存过期后出现现象修改了用户的角色界面上权限变了但用户仍然能访问刚被收回的资源等一段时间之后问题自己消失了。原因决策结果被缓存但缓存的键只包含用户和资源没有包含角色版本。角色变更后键不变旧决策继续命中直到 TTL 到期。这个窗口的长度等于缓存过期时间而它通常没人记得。解决把角色版本号纳入缓存键用户角色或角色权限发生变更时自增版本。缓存键的构造必须显式列出所有影响判定的维度。def cache_key(tenant, user_id, role_version, resource, action): # role_version 在角色分配或角色权限变更时自增缺失任何一维都会导致决策过期 return facl:v1:{tenant}:{user_id}:{role_version}:{resource}:{action}tenant不能省多租户场景下相同 user_id 在不同租户里可能指向不同主体v1是键格式版本用于结构变更时整体作废旧缓存。5.2 角色继承成环导致决策递归爆栈现象给某个角色配置了继承关系后接口开始超时日志里出现递归深度异常或数据库连接被占满。原因角色层次被写成了有环图比如 A 继承 B、B 继承 C、C 又继承 A。写入时没有做可达性校验读取时的递归查询又没有去重于是无限展开。解决读取侧必须用UNION去重兜底写入侧必须加可达性检查——在插入继承边之前先查一遍父角色是否已经能到达子角色能到达就拒绝。读取侧是兜底写入侧才是根治只做读取侧会留下一个隐形的性能陷阱。5.3 前端隐藏按钮被当成权限控制现象审计时被告知存在越权但复现时发现“正常用户界面上根本看不到那个按钮”。原因权限判断只发生在渲染层接口本身没有校验或者只校验了登录态没校验权限。攻击者直接请求接口就能绕过全部限制。解决前端隐藏只处理体验不承担安全职责。所有涉及资源变更的接口必须在服务端做一次完整的决策求值且这次求值不能依赖前端传来的角色字段。检查点要放在引用监视器的位置上也就是所有请求的必经路径而不是散落在各控制器里。5.4 会话里激活的角色未随登出清理现象用户切换账号或者重新登录后仍然保留着上一个账号的部分权限刷新页面后恢复正常。原因会话状态存在服务端缓存里登出时只清了客户端的 token没有清服务端的会话记录或者新旧会话 ID 复用了同一个键导致角色集合被合并。解决登出必须显式销毁服务端会话及其激活角色会话 ID 必须在每次登录时重新生成。判断权限时以服务端会话为准不信任任何客户端携带的角色声明。5.5 策略冲突时“默认拒绝”被覆盖现象一条显式拒绝规则明明已经配置实际访问却被放行了。原因多条策略同时命中同一个请求时合并顺序写错了。常见错误是把默认规则放在最后求值或者把“拒绝”当成异常处理结果异常被上层捕获后走了放行分支。解决明确合并语义并写死。推荐拒绝优先且默认拒绝作为兜底而非可覆盖的规则。评估顺序固定为显式拒绝 → 显式允许 → 默认拒绝。只要有一条拒绝命中就直接短路不再继续求值。def merge_decisions(decisions): # 拒绝优先任意一条 deny 命中即终止不给后续规则覆盖的机会 if any(d.effect deny for d in decisions): return deny if any(d.effect allow for d in decisions): return allow # 没有任何规则命中时的兜底必须是拒绝 return denydecisions是本次请求命中的全部策略求值结果顺序不影响最终结论这样就不会因为策略加载顺序不同而产生不一致的行为。6. 模型选型的取舍为什么大多数系统最终停在混合带纯模型在实践中很少见原因不是模型不好而是每种模型都假设了某种前提。DAC 假设资源所有者有能力做出正确判断MAC 假设整个组织已经完成了完整的标签化和等级划分RBAC 假设岗位职责足够稳定可以用角色概括ABAC 假设属性来源可靠、策略可评估。这些假设在真实组织里往往只成立一部分。所以实际系统的形态通常是分层混合用 RBAC 管粗粒度的功能权限因为岗位和职能相对稳定角色不会天天变用 ABAC 管数据级权限因为“只能看自己部门的订单”这类规则无法用有限角色穷举用 MAC 的标签思路处理少数高敏感数据域把它隔离出来不强求全系统统一。模型判定依据灵活度管理成本常见误用DAC所有者授权高随授权链增长失控转授无深度限制MAC主体与客体标签低前期标签化极重只比级别不比类别RBAC角色中角色易膨胀只做用户-角色权限硬编码ABAC属性与策略高属性质量难保证属性来源不可信选型时真正该问的问题不是“哪个模型更先进”而是“权限变更的频率和粒度是什么”。变更频率低、粒度粗RBAC 足够粒度细到行和列、且规则随业务数据变化就必须引入属性监管要求能证明信息不会向下流动才需要考虑标签体系。反过来如果只是内部工具上一整套 ABAC 策略引擎光是属性同步的链路就够维护很久。验证一个权限设计是否站得住有个很朴素的测试方法随机挑一个用户列出他能访问的全部资源然后逐条问“这条权限是谁在什么时候因为什么给的”。如果有任何一条答不上来说明授权链缺少可追溯性出问题时就没有后悔药可吃。这个检查不需要工具但极其有效。我自己的习惯是每当要新增一个角色或一条策略先强制走一遍这个追问它能不能被现有角色组合表达它和其他规则冲突时谁优先它的变更会不会影响正在进行的会话。从那以后我每次调整权限模型都会先把“撤销路径”和“冲突顺序”写在设计文档最前面而不是等出事之后再补。希望帮到你。这套资料把访问控制矩阵、DAC 与 MAC、BLP 与 Biba、RBAC0 到 RBAC3 的模型定义和策略样例按主题整理在一起想对着定义逐条核对实现细节的可以直接取用。本文还有配套的精品资源点击获取