ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从ACL到RBAC0:访问控制判定链路与越权防护实践

从ACL到RBAC0:访问控制判定链路与越权防护实践

简介:「访问控制理论与策略.zip」面向网络安全初学者与后端开发者,围绕主体、客体、操作三要素梳理访问控制基本模型,并以RBAC0为主线讲解用户、角色、权限的组织方式,以及固定角色、职责分离、委派管理等特性。包内共671个文件,约59.74MB,以68个Java源码、115个class文件、40个jsp页面及css、js前端资源为主,辅以jar依赖、xml配置与sql脚本,覆盖角色管理、用户管理、权限管理和访问控制决策四个模块的可运行示例,并附gif、png图文素材与docx、md说明,便于对照模型落地过程。已有190人学习下载,读者可获得RBAC0从理论到代码实现的完整参照,包括库表与持久层映射、权限判定流程及页面级权限控制思路,适合课程设计、毕业项目与后台权限模块的二次开发借鉴。

1. 权限判定没坏,坏的是边界

很多人第一次遇到越权漏洞,第一反应是“鉴权代码写错了”。可把日志拉出来一看,hasRole('admin')判得好好的,问题出在某个接口压根没调用它——访问控制、网络安全这两个词听起来宏大,落到代码里其实就一句话:每一次访问都必须过一个统一的关卡。RBAC0 之所以成为绕不开的基线,不是因为它的算法多精妙,而是它把“人”与“权限”的绑定关系从一张乘积表压成了一条加法链,让这套关卡在几千人的组织里还能被人看懂、被人审计、被人改对。

值得把它搞懂的理由很朴素:功能模块写错了,用户当场能看见;权限模块写错了,没人看得见,直到某天有人把 URL 里的订单号改了一位。下面从矩阵怎么存讲起,一路走到角色激活、判定链路,最后落到为什么绝大多数团队用错了 RBAC0。

2. 访问控制矩阵:ACL 与能力表是同一张表的两种切法

2.1 主体、客体、操作构成的三维结构

任何访问控制模型,剥掉包装之后都是一张矩阵:行是主体(用户、进程、服务账号),列是客体(文件、订单、接口),格子里填的是这个主体能对这个客体执行哪些操作。这张表在教科书里叫访问矩阵,概念诞生得很早,但它的形状决定了一切后续设计——因为真实系统里这张表极度稀疏。

一个注册用户十万、资源百万量级的系统,矩阵里 99.99% 的格子是空的。没人会真的去分配一块十万乘百万的二维数组,所以所有工程实现的第一件事就是压缩存储:要么按列压,要么按行压。这两种压法分别对应 ACL 和能力表,而它们之间的差异不是风格问题,是撤销成本问题。

2.2 按列存与按行存,决定了撤销的难度

按列压就是 ACL:每个客体挂一份“谁能对我做什么”的清单。文件系统的 mode 位、POSIX ACL、数据库的 GRANT 语句,本质都是这个结构。它的好处是客体自己知道自己的守卫是谁,读取某个资源时一次查找就能拿到全部授权信息,天然适合“资源是中心”的场景。

按行压就是能力表:每个主体持有若干张“我能对谁做什么”的凭证,凭证跟着主体走。分布式系统里那些短期令牌、签名 URL、一次性上传凭证,全是能力表的变体。它适合“主体是中心”的场景——客户端拿着凭证离线判断,不用每次都回源查询。

两者的关键差别在撤销。ACL 撤销是删客体清单里的一行,做个标记就行;能力表撤销是收回已经发出去的所有副本,只要有一份复制流到了别处,你就永远收不干净。这解释了为什么所有“把权限借给第三方”的设计都倾向于短时效凭证而不是永久授权:撤销不了,那就让它自己过期。这是一条血泪经验,不是理论洁癖。

2.3 稀疏 ACL 的落库实现

下面这段是 ACL 视角的最小可用实现,重点不在功能,在于它把“授权变更”和“缓存失效”绑在了一起:

class SparseACL: def __init__(self): self._table = {} # object_id -> {subject_id: set(perms)},只存非空格子 self._epoch = {} # object_id -> 版本号,撤销时递增,用于让判定缓存失效 def grant(self, subject, obj, perms): slot = self._table.setdefault(obj, {}) slot.setdefault(subject, set()).update(perms) self._bump(obj) def revoke(self, subject, obj, perms=None): slot = self._table.get(obj, {}) if subject not in slot: return if perms is None: # 全量回收:直接删格子,O(1) del slot[subject] else: slot[subject] -= set(perms) # 按权限位回收,O(k),k 为传入权限数 if not slot[subject]: del slot[subject] self._bump(obj) # 任何变更都必须动版本号 def check(self, subject, obj, perm): return perm in self._table.get(obj, {}).get(subject, set()) def _bump(self, obj): self._epoch[obj] = self._epoch.get(obj, 0) + 1

_table用二级字典而不是稠密矩阵,省掉的是空间,付出的是“按主体反查所有客体”这类查询要全表扫描的代价。_epoch是整个类的关键:它不参与鉴权,只服务于缓存。上层如果把check的结果缓存十分钟,就必须把epoch拼进缓存 key,否则管理员刚撤销的权限会继续生效十分钟。revoke的perms参数为空表示整格清空,这是最快的路径;传了权限集合则做差集,保留其他授权。生产环境里还要在grant里加一条:写库成功后再_bump,顺序反了会出现“缓存失效了但数据没落盘”的假撤销。

2.4 能力表用 TTL 和版本号替代撤销

既然能力撤不回来,那就只能让它自己死。常见的实现思路是给每张能力配一个过期时间和一个版本号:

import time class Capability: __slots__ = ("obj", "perms", "expire_at", "version") def __init__(self, obj, perms, ttl, version): self.obj = obj self.perms = set(perms) self.expire_at = time.time() + ttl self.version = version def check_cap(cap, obj, perm, current_version): if cap.expire_at < time.time(): return False # 到期即失效,这是最可靠的撤销 if cap.version != current_version: return False # 版本号递增 = 批量吊销 return cap.obj == obj and perm in cap.perms

ttl决定安全窗口,也决定刷新成本:设成 60 秒,泄露出去的能力最多活一分钟,但客户端每分钟都要回头换一次;设成 24 小时,换证压力没了,可一旦泄露就是整整一天的敞口。version通常挂在主体维度上,用户改密码、降职、被风控时把版本号加一,所有旧能力瞬间作废——这是换密码能踢掉其他设备登录的底层原因,不是什么魔法。

现实系统里这两套结构是混着用的:数据库对象权限走 ACL,跨服务调用走能力凭证,网关上的路由规则又是一份独立策略。理解它们是同一张矩阵的不同切法,才能判断某个新需求该往哪边塞。

3. RBAC0 的内核:角色不是权限组,是会话约束器

3.1 三个多对多关系把乘积压成加法

直接把权限授给用户,绑定数量最坏是用户数乘权限数。引入角色之后,变成“用户绑角色”加“角色绑权限”两条边,数量级从乘积掉到加法。一个两千人的系统,如果人均十个权限,直授要两万条绑定;拆成两百个角色,绑定数是两千加两千,而且新员工入职只需要挂三个角色,不用逐条勾权限。

但这里有个常被忽略的点:RBAC0 的形式定义里有四个集合,用户、角色、权限之外还有会话。很多人实现时只做了前三个,把会话当成登录态随手带过,结果整套模型的最小权限只存在于配置里,运行时形同虚设。

3.2 会话存在的理由:权限要被“激活”才算数

一个人可能同时是部门主管、项目成员、审计联系人,这三重身份不是每时每刻都需要同时生效。会话就是“本次登录中,我从自己拥有的角色里挑哪几个激活”的机制。未激活的角色即使被授予了,判定时也不认——这样最小权限原则才落到运行时,而不是停留在权限配置页面的一句口号。

更需要会话的是职责分离。静态职责分离在授权阶段拦:同一个人不能同时被授予“发起付款”和“审核付款”两个角色。动态职责分离在会话阶段拦:两个角色可以都给,但同一个会话里只能激活一个。后者才是防止单人完成整条高风险流程的真正手段,因为它约束的是“同一时刻”,不是“同一身份”。

class RBAC0: def __init__(self): self.ua = {} # user -> set(role) self.pa = {} # role -> set(perm) self.sessions = {} # sid -> {"user": user, "active": set(role)} def assign_role(self, user, role): self.ua.setdefault(user, set()).add(role) def grant_perm(self, role, perm): self.pa.setdefault(role, set()).add(perm) def open_session(self, sid, user, active_roles=None): owned = self.ua.get(user, set()) # 关键:只能激活自己确实拥有的角色,取交集 active = owned if active_roles is None else (set(active_roles) & owned) self.sessions[sid] = {"user": user, "active": active} def check(self, sid, perm): s = self.sessions.get(sid) if s is None: return False for role in s["active"]: # 只看已激活角色,未激活视为没有 if perm in self.pa.get(role, set()): return True return False

open_session里那行交集运算是整段代码的安全核心。如果直接信任调用方传入的active_roles,攻击者只要构造一个“激活管理员角色”的请求就能提权,这类漏洞在真实系统里一点都不罕见。check遍历active而不是owned,是会话语义的体现,参数sid必须由服务端自己维护,不能让客户端传。active_roles=None表示激活全部角色,方便但不安全,生产环境通常要求显式选择,敏感操作还要二次激活。

3.3 RBAC1、RBAC2、RBAC3 是加法不是替代

RBAC1 加的是角色继承:高级角色自动拥有低级角色的全部权限。它把角色组织成一棵偏序结构,管理中不再需要给每个角色重复授权。实现上是递归展开父角色,但必须防环:

def expand(self, role, seen=None): seen = seen or set() if role in seen: return set() # 检测到环,直接截断,防止无限递归 seen.add(role) result = {role} for parent in self.parents.get(role, ()): result |= self.expand(parent, seen) return result

seen这个参数纯属防御性设计。角色继承配置成 A 继承 B、B 又继承 A,判定时就会栈溢出,这种事故我在不止一个系统里见过——配置界面上两个下拉框,谁也拦不住运维手滑。除了防环,还要设继承深度上限,否则一条超长继承链会让每次判定都变成一次图遍历。

RBAC2 加的是约束:互斥角色、基数约束(一个角色最多几个人)、先决条件角色(要有 A 才能授 B)。RBAC3 则是把 RBAC1 和 RBAC2 合并。这四个层级不是版本迭代关系,而是能力叠加,落地时按需取用,绝大多数业务系统停在 RBAC1 就够,真正需要 RBAC2 的往往是金融、医疗这类对职责分离有硬性要求的场景。

4. 判定链路:引用监视器为什么必须不可绕过

4.1 三个必要条件,最常破的是第一个

安全内核理论里对引用监视器提了三条要求:每一次访问都要经过它、它自身不能被主体篡改、它小到可以被验证。这三条里,第二条靠架构隔离,第三条靠克制(别把业务逻辑塞进去),第一条靠纪律——而第一条恰恰是出事最多的地方。

表现形态高度一致:主链路规规矩矩走网关鉴权,然后某个内部服务加了个“临时调试接口”直连数据库;或者批量导出功能为了性能绕过了服务层,直接拼 SQL 查全表。功能上线时没人觉得有问题,因为“内网嘛”。访问控制从来不是被攻破的,是被绕过去的。

4.2 执行点与决策点分离,是为了让策略能独立演进

把“在哪拦”和“怎么判”拆开,是这套体系里最有价值的工程决策。判定逻辑集中到一个决策点,所有入口只负责把请求上下文送过去、拿回一个结论。这样做的直接收益是:策略变更不用改业务代码,审计日志天然集中,多个入口(网关、服务、前端 BFF)能保证同一套结论,不会出现“网页端拦住、App 端放过”的经典翻车。

代价是每次判定多一次调用。所以真实系统会在决策点前面加缓存,缓存 key 必须包含主体、资源、操作和策略版本四个维度,少一个就会出现“策略改了但缓存还热着”的问题。这也是为什么上一章的 ACL 实现里要单独维护epoch。

def decide(subject, resource, action, ctx): # 第一趟:显式拒绝优先,任何一条 deny 命中立即短路 for rule in policy.rules: if rule.effect == "deny" and rule.match(subject, resource, action, ctx): return "DENY" # 第二趟:再看允许 for rule in policy.rules: if rule.effect == "allow" and rule.match(subject, resource, action, ctx): return "ALLOW" # 没有任何规则命中:拒绝。默认拒绝,不是默认放行 return "DENY"

两趟扫描不是为了性能,是为了让结果与规则书写顺序无关。如果按顺序单趟匹配,一条写在前面的宽泛允许规则会把后面的精确拒绝盖掉,而这种错误在策略文件攒到几百条之后几乎不可能靠肉眼发现。rule.match内部通常还要处理资源层级——父目录的权限对子文件生效、组织的权限对下级部门生效,这层继承关系是策略引擎里最容易写错的部分。返回字符串而非布尔值,是为了让审计日志能记录“为什么拒”,排查时能省掉大量猜测。

4.3 执行点要贴着数据放,不要贴着路由放

def guarded_read(user, doc_id): # 执行点:所有读取路径的唯一入口,任何旁路都是漏洞 if decide(user, doc_id, "read", ctx=build_ctx(user)) != "ALLOW": raise PermissionError(doc_id) return storage.load(doc_id)

这段代码的位置比内容重要。放在路由层,新增一个出口就可能漏;放在数据访问层,绕过去的成本高得多。build_ctx负责收集环境属性——时间、来源网段、是否完成二次验证,这些信息不属于 RBAC0 的角色模型,但判定时经常要用。把它们塞进角色名里(比如造一个“财务-工作时间”角色)是典型的错误做法,角色会因此爆炸,而时间这种每天都在变的维度根本不该进角色。

5. 排查与避坑:五种把 RBAC0 用废的典型症状

5.1 角色爆炸:角色表里出现几千个“部门-区域-只读”

现象是角色管理页面翻不到底,命名开始带连字符,新人入职要勾十几个角色。

原因是把角色当成了权限组的同义词。角色应该描述职责(审核员、客服、财务),而不是描述属性(华东区、只读、临时)。部门和区域是会变的维度,一旦固化进角色名,每加一个区域就要复制一整套角色。

解决办法是把变化维度抽出来做作用域,角色只管职责,数据范围由作用域或属性条件决定;实在复杂的场景交给基于属性的判定,RBAC 保留职责这一层。

5.2 权限残留:转岗三个月了还能打开老部门的报表

现象是用户投诉“我明明换部门了,怎么还能看到之前的客户列表”。

原因是用户与角色的绑定只增不删。转岗流程走了 HR 系统,权限系统没人通知,历史角色一直挂着。

解决办法是给每条授权加来源标记和过期时间,转岗时按来源批量回收;再配一个定期的权限复核,把“长期未使用的授权”列出来让人确认。别指望靠人工记得删,这类清理一定会漏。

5.3 只做了静态职责分离,没做动态的

现象是两个互斥角色确实没给同一个人,但同一个高频操作流程里,一个人换个账号就能走完两步。

原因是约束只作用在授权阶段。互斥角色分给了两个人,可这两人共用一个服务账号,或者系统允许同一个人先后切换身份完成整个流程。

解决办法是引入会话级约束:两个角色可以都授予,但同一个会话里只能激活一个,切换身份要重开会话并留痕。约束的对象是“同一时刻的操作者”,不是“账号”。

5.4 撤销生效延迟:权限收回去了,用户还能继续访问半小时

现象是管理员在后台点了移除,用户刷新页面照样能进。

原因是判定结果被缓存了,而缓存 key 里没有策略版本。角色移除只改了数据库,缓存里那条“允许”还活着。

解决办法是把策略版本拼进缓存键,任何授权变更都递增版本;或者退一步,把 TTL 压到秒级并接受一次抖动。这类延迟在有合规要求的场景里是硬伤,不是体验问题。

5.5 用前端隐藏按钮代替服务端判定

现象是普通用户按 F12 把按钮的display:none去掉,接口照常返回数据。

原因很直白:只在渲染层做了角色判断,服务端接口裸奔。

解决办法是每个写入入口、每个敏感读取入口都必须有独立的执行点,前端判断只负责体验,不承担安全职责。顺带一提,导出、批量操作、内部调用接口是三个最容易被漏掉的入口,排查时优先看它们。

6. 设计取舍:策略是数据还是代码

走到最后,问题会收敛成一个选择题:访问策略应该写成代码里的if,还是外置成可配置的数据?

写成代码,改起来快,调试直观,小系统里几乎是最优解。代价是策略和业务耦合,每次调整权限都要发版,审计时只能翻代码,多个服务各写各的,判定结论无法保证一致。外置成数据,策略可以热更新、可以集中审计、可以被非开发人员维护,代价是多了一套策略语言、一个判定服务、一层缓存,以及随之而来的延迟和运维复杂度。

维度代码内嵌外置策略
变更成本需要发版配置即生效
判定一致性各服务各自实现集中保证
审计能力翻代码策略与决策日志可查
延迟纳秒级一次远程调用加缓存
适合规模单服务、角色少多服务、角色与约束复杂

我的判断标准是看“授权变更的频率”和“入口的数量”。一周改一次权限、只有一个 Web 入口,写代码里完全够用;一天改几次、入口有网关、App、内部服务三套,就该把判定外置,否则一致性根本守不住。中间地带可以先外置在最外层网关,内部服务之间用可信凭证传递已判定的结论,避免每次调用都回源判定。

还有一个绕不开的取舍是模型选择。RBAC 表达的是“谁是什么角色”,简洁、可解释、好审计,但对“同部门”“工作时间内”“资源属主本人”这类条件无能为力;基于属性的模型能表达这些,代价是策略复杂度和判定成本上升,且很难向业务方解释清楚为什么这次拒绝。常见的做法是两层:RBAC 管职责,属性条件管数据范围,别试图用一套模型解决所有问题。

最后是那句老话的重新表述:默认拒绝不是一句配置,是一个必须在架构层面保证的性质。只要还存在一条不经过判定点的路径,前面所有的模型设计都是装饰。从那以后我每次评审权限相关需求,都会强制问一句“这条路径的判定点在哪,能不能绕过去”,问不出答案就先不写代码。

希望帮到你。这份资料把 DAC、MAC、RBAC 各层级模型和策略表达放在一起做了系统梳理,适合拿来对照着自己系统里的判定链路逐条核一遍。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进