权限系统设计:从访问控制理论到工程落地

目录

用户可以打开订单页面,却不一定能查看另一个部门的订单;拥有退款审批角色,也不应批准自己提交的退款。权限系统真正要回答的,是这个主体此刻能否对这个对象执行这项操作,以及业务执行时是否仍满足这些条件。

本文以多租户订单审批为贯穿场景。先选择合适的权限模型,再把决策落实到接口、数据和状态变更中,最后处理权限撤销、缓存及跨服务调用。设计重点不是建多少张权限表,而是让授权边界可表达、可执行、可验证。

认证提供身份,授权决定行为

访问控制的基本输入包括主体、资源、操作及必要的环境。主体可以是用户或服务,资源可以是订单、字段或导出任务,操作可以是读取、审批或删除。

AAA 中的 Authentication 负责认证,Authorization 负责授权,Accounting 记录活动和资源使用,可用于审计。它们是相关职责,不必理解成每次请求严格串行执行的三个独立服务。

认证并不保证后续所有行为都允许。OAuth 2.0 是授权框架,OIDC 在其上提供认证;Access Token 不一定是 JWT,也不能把发给客户端的 ID Token 随意当作业务 API 的访问凭据。JWT 只是载体,其签名、发行者、受众、有效期及用途都必须按接口约定验证。

登录响应包含用于渲染菜单的权限信息,本身不意味着架构混乱。问题在于后端是否把这份客户端状态当作授权依据。隐藏按钮可以改善体验,真正的授权仍在可信的服务端执行。

模型选择取决于谁制定策略、策略依据什么

DAC、MAC、RBAC、ABAC 不构成一条后者必然替代前者的能力排行榜,也可以在一个系统中组合。

模型 关注点 适合表达的关系
DAC 资源所有者在系统规定范围内管理访问 文件或文档共享
MAC 系统强制实施用户不能自行修改的策略 标签、域与信息流限制
RBAC 通过角色分配操作权限 审核员、出纳、运维等职责
ABAC 根据主体、资源、操作和环境属性求值 同租户、所属部门、状态、时间与设备条件

读取文件后可以复制内容,是信息流控制问题;不能由此推导 DAC 中所有接收者都天然有再次授权的权限。MAC 也不只表示一个高低等级:Bell–LaPadula 是关注保密性的特定模型,SELinux 等强制控制还可以使用类型和域。

RBAC 能显著简化职责分配,ABAC 则能避免把每个部门、区域、时间组合都固化成一个角色。但角色关系本身也可以参与属性判断,不需要把“功能只能用 RBAC,数据只能用 ABAC”当作硬规则。

RBAC 的继承、会话与约束各有含义

常见 RBAC96 框架中,RBAC0 表示核心关系,RBAC1 加入角色层次,RBAC2 加入约束,RBAC3 组合两者。RBAC1 与 RBAC2 都基于 RBAC0,并非 0→1→2→3 的单向升级链。NIST 推动的标准模型整合了相关研究,不能把这些编号直接说成 2004 年标准的四个递进模块。NIST RBAC FAQ

核心关系包括用户、角色、权限、用户角色分配和角色权限分配。会话激活用户已获分配角色的一个允许子集;会话角色不能由客户端随意指定,更不能通过激活动作越过有效期、租户或互斥限制。

角色继承不等于行政级别继承

先给边一个明确含义,例如“退款主管继承退款审核员的权限”。同一含义贯穿数据库、界面和计算,不要混用父角色、子角色、高级和下级的方向。

加入继承边时应检查:

  1. 两个角色属于允许关联的作用域且都存在;
  2. 不允许自环,新边不会形成循环;
  3. 继承闭包不会违反已定义的职责分离约束;
  4. 并发变更也不能分别通过检查后共同构成环。

遍历时设置 visited 可以避免死循环,但只是运行保护,不能把非法环悄悄变成合法配置。单个 parent_id 只能表达单父结构;需要多继承时,应使用单独的角色继承关系。

职责分离不能只检查角色名称

静态职责分离在分配阶段约束角色组合,动态职责分离约束一次会话中的激活组合。两者都不自动解决“同一人先提交,再换一个会话批准同一退款”的问题。

后一个场景需要对象级业务条件:审批人与申请人不同,且审批对应当前退款版本。约束检查还要考虑继承得到的权限。角色人数上限等限制需要与实际分配原子执行,先 count 再 insert 不能保证并发下不超额。

一次审批怎样组合授权条件

对某个退款申请,允许审批可能同时要求:

  • 用户已认证且账户有效;
  • 用户和退款属于同一租户;
  • 当前激活职责包含退款审批能力;
  • 退款处于可审批状态,并位于用户允许管理的范围;
  • 审批人不是申请人,审批金额和版本符合本次请求;
  • 所需的二次验证或复核已经完成且尚未失效。

这些条件来自不同证据。租户成员关系来自服务端身份目录,订单归属来自业务数据,客户端提交的 tenant_id、owner_id 或“已审批”字段都不能自行变成可信事实。

XACML 将策略管理、决策、执行和属性提供分别称作 PAP、PDP、PEP、PIP。决策结果除 Permit、Deny 外,还包括 NotApplicable 和 Indeterminate,并可能附带执行义务;PEP 需要处理这些结果,不能把无法求值当作允许。XACML 3.0 规范

可以用 Rego、Cedar、Casbin 或业务代码表达策略,选择时关注模型适配、类型校验、策略版本、调试和发布方式。时间条件应明确时区并使用可信时间;网络条件应来自可信代理链,不能直接信任用户提供的 IP Header。

功能权限、对象权限和数据返回一起收口

菜单树可以用于呈现产品结构,但它不是完整的权限模型。访问菜单不自动获得其全部操作,拥有审批操作也不自动获得任意订单的审批权。

后端应默认拒绝未获明确授权的受保护操作,并维护清晰的公开接口列表。接口路由、HTTP 方法及匹配优先级需要确定,不能遍历无序 Map、找到第一个模式就决定权限。OWASP 的授权建议也强调默认拒绝和每次请求校验。OWASP Authorization Cheat Sheet

使用注解或 AOP 可以统一部分检查,但要验证代理绕过、自调用、异步任务及其他入口。批量接口应检查全部目标或按约定逐项返回,不能只检查列表中的第一条。

查询约束先于分页和聚合

订单列表应在查询阶段限定租户和可见范围,再进行分页、计数、排序和汇总。查询全部记录后在应用层删掉无权限行,会泄露数量、聚合或排序信息,也可能产生错误分页。

授权范围应区分 ALL、NONE、SELF、明确集合等状态。空集合与 null 不应同时承担“无限制”和“无人可见”的含义;未知范围必须拒绝或报错。

下面是服务端获得可信上下文后的参数化查询片段,参数标记采用常见的命名形式,不是所有数据库驱动的原生语法:

SELECT id, status, amount_minor
FROM orders
WHERE tenant_id = :tenant_id
  AND owner_id = :subject_id
ORDER BY id
LIMIT :page_size;

字段授权独立于行过滤。查询、导出、日志和缓存中都需避免把未获授权的字段带出去;页面上将手机号打星,不能补救 API 已返回完整号码。

SQL 改写可以减少重复代码,但要使用能理解语法的方案,并覆盖 JOIN、子查询、CTE、别名和写操作。不应对 SQL 字符串直接追加 WHERE,也不能遇到无法识别的语法就默认放行。

RLS 需要验证真实运行身份

PostgreSQL RLS 能在数据库侧限制行访问,但超管和 BYPASSRLS 角色会绕过,表所有者通常也绕过;FORCE ROW LEVEL SECURITY 可以约束表所有者,不能把它当作约束所有高权限角色的开关。读取和新行写入还分别涉及 USING 与 WITH CHECK。PostgreSQL 17 行安全文档

应用连接角色不应使用这些绕过能力。若策略读取应用写入的租户上下文,需要限制其设置来源,并处理连接池复用;不可信客户端能够任意 SET 一个自定义变量时,这个变量就不是可靠身份边界。

RLS、独立 Schema、独立数据库属于不同层面的隔离手段,不能排成一个无需条件的安全榜单。备份、运维身份、密钥、网络和跨租户管理路径同样决定实际隔离。

授权结果要与真正的状态变更对应

业务记录可能在“检查允许”之后改变。审批时应将仍然成立的对象条件放进事务或条件更新,避免审批旧版本。

以下是说明性的条件更新,执行前仍需验证身份、角色、租户成员关系与必要审批证据;它只展示对象和状态检查如何接近写入:

UPDATE refund_request
SET status = 'APPROVED',
    approved_by = :subject_id,
    version = version + 1
WHERE tenant_id = :tenant_id
  AND id = :refund_id
  AND status = 'PENDING'
  AND requester_id <> :subject_id
  AND amount_minor = :approved_amount_minor
  AND version = :expected_version;

只有影响一行才进入成功路径。返回零行可能表示不可见、状态变化或条件失效,应按接口策略返回相应结果,避免为了区分原因泄露其他租户对象是否存在。

这段 SQL 本身不能阻止权限目录在检查后撤销角色。高风险业务还要规定授权有效性的时间边界,例如在同一提交协调中验证授权版本,或使用短期、绑定对象版本且有明确撤销语义的授权凭据。普通读取与资金操作可以采用不同强度,不能暗示所有系统都实现跨存储瞬时撤销。

审批通过也不等于实际退款已完成。资金动作另有幂等操作号、执行状态和结果核对,重试不能重复退款。

组织关系用于授权,不能直接取权限并集

组织、用户组和职位可以减少管理成本,但企业未必是一棵单纯的树,兼职、矩阵汇报和临时项目都可能存在。组织继承角色也需要单独定义,不能默认员工继承所有祖先部门绑定的权限。

对多个来源的授权取并集,只能得到候选权限。禁用状态、显式拒绝、租户范围、有效期、会话和职责分离等约束,仍需按明确组合规则生效。

数据模型可以从以下关系开始,按实际需求扩展:

关系 主要字段或约束
主体 身份引用、状态;凭据管理可以由身份系统承担
租户成员 tenant_id、subject_id、状态与有效期
角色 作用域、稳定编码、状态
权限 资源类型与动作,或可验证的策略引用
主体角色 作用域、主体、角色、授权来源与有效期
角色权限 作用域、角色与权限
角色继承 继承方向明确的角色对及防环约束
授权变更 操作人、原因、版本及审计关联

唯一键和外键需要覆盖租户作用域,避免关联表把租户 A 的角色授给租户 B 的主体。角色删除、停用和迁移的语义也应定义,不能只依赖物理删行。

菜单与组织结构可以使用邻接表、物化路径或闭包表。插入节点、移动子树和查询的成本不同;不能把单节点插入的 O(depth) 当作所有树更新的复杂度,也不能从层级少直接推出唯一最佳存储方案。

缓存性能不能以撤销失效为代价

权限缓存应包含作用域、主体、激活角色、策略版本及实际参与判断的上下文。完整决策缓存还可能依赖对象版本、动作、时间和环境,不能只用 user_id 作键。

“没有权限”是有效结果,应与缓存未命中区分。权限集与 TTL 应一起发布;在旧集合上逐项 add 会留下已经撤销的条目。缓存构建过程中还要防止旧快照在失效通知之后重新写回。

一种做法是使用版本化快照:读取可信版本、构建完整结果,再核对版本仍有效后发布。角色、成员和组织变更需要覆盖实际受影响的主体,而不只是直接关联该角色的用户。

版本号也有前提:如果 JWT 和服务端缓存都是旧版本,两者相等并不能证明当前有效。必须定义最新版本从哪里获得、传播延迟多久,以及失联时怎样处理。短 Token 有效期只限制旧权限持续的最长窗口,黑名单和强制退出也只有被所有入口检查时才有效。

防缓存击穿使用租约锁时,每个持有者需要唯一标识,释放应原子比较持有者再删除;锁过期后,旧持有者不能删掉新锁。这个锁解决并发构建,不代替授权版本校验,也不证明缓存内容永远新鲜。

跨服务传播同时保留用户与调用方

网关可以校验凭据并执行粗粒度策略,业务服务负责对象、租户和状态相关的授权。内部接口也应验证调用方,不能因为来自内网就接受任意 X-User-Id。

若采用可信网关注入身份 Header,需要移除外部同名字段,验证来源并阻止绕过入口;更稳妥的服务边界可以使用适合目标受众的签名上下文或访问令牌。服务间调用还应保留代表哪个用户、由哪个服务发起、允许哪些动作,避免把某个高权限服务变成任意用户的代理。

mTLS 证明连接一方身份,并不自动授权某项业务操作。Kubernetes ServiceAccount 与 RBAC 主要控制 Kubernetes API;业务服务仍需自己的授权规则。

后台任务没有用户上下文时,应使用明确的服务授权,不能因用户信息缺失而自动退回权限更大的身份。异步任务保存必要的授权依据和对象版本,执行时根据业务约定重新核验。

用授权矩阵测试允许和拒绝

权限测试要覆盖实际业务入口,不能只对一个 matchesAny 函数做字符串测试。

测试变化 应确认的结果
同角色、不同租户 不能读取或修改另一租户对象
同租户、不同负责人 按对象范围决定,不能只看页面权限
申请人切换角色或会话 仍不能审批自己的退款
审批后金额或版本变化 旧审批依据不能授权新操作
权限撤销,Token 未过期 在承诺的撤销时限内停止旧授权
缓存重建与撤销同时发生 旧快照不能覆盖新状态
属性缺失、策略异常或超时 不得意外放行受保护操作
导出、批量、搜索和异步入口 与单对象 API 使用一致的边界
RLS 用真实应用角色运行 绕过能力、连接池上下文和写入策略均受检验

审计应关联主体、调用服务、租户、对象、动作、策略版本、决策与实际执行结果。独立存储有助于隔离,但 Elasticsearch 本身不保证记录不可篡改;还需访问控制、留存与完整性措施,并避免记录凭据和不必要的敏感字段。

权限设计最终要形成一条可追踪的链:可信身份提供主体,策略确定允许条件,业务执行维持对象约束,撤销机制限制旧授权寿命,审计与测试验证实际结果。模型可以逐步扩展,这条链上的缺口却不能靠更多角色和权限表弥补。

评论