Oso授权框架实战:用Polar声明式策略替代if-else权限判断
做后端的朋友大概都经历过这种时刻:产品经理在需求评审上轻描淡写地说"这里加个权限控制,管理员能改,普通成员只能看",你点点头,心里清楚这句话背后是十几个 if-else、三张关联表、两处漏判的接口,以及三个月后没人敢动的那个checkPermission()函数。权限这件事,写起来不难,写对很难,写到能持续演进更难。Oso 授权框架解决的正是这个问题——它把散落在业务代码里的权限判断抽出来,用一种声明式的策略语言统一表达,让"谁能对什么做什么"变成一份可以读、可以测、可以版本化的文件。这篇内容面向已经踩过手写权限坑的后端开发者、正在做架构选型的技术负责人,以及想搞清楚"专业级权限系统"到底长什么样的同学。我会从选型逻辑讲到 Polar 语言的核心概念,再给出一套 5 分钟能跑起来、之后能稳步扩展的落地路径,中间穿插我自己踩过的坑和排查经验。
1. 先想清楚:你的应用到底缺哪一块授权能力
1.1 从散落的 if-else 说起,权限逻辑是怎么一步步烂掉的
任何一套权限系统的腐化都不是一夜之间发生的。最开始通常只有两种角色,代码里写个if user.is_admin就完事了。接着业务要求"项目负责人也能改",于是加一个判断;再后来要求"被邀请的协作者可以看但不能删",于是又加一层。半年之后,你会发现这段判断逻辑同时出现在控制器、服务层、序列化器和前端接口里,每一处都略有不同。真正的麻烦在于:没人能一句话说清当前的权限规则到底是什么。产品经理问"运营角色能不能删除别人的评论",你得翻五个文件才能回答。
这种腐化的根本原因,是把"策略"和"机制"混在了一起。机制是"如何判断",比如查数据库、读缓存;策略是"判断什么",比如"只有资源所有者或管理员可以删除"。手写代码时两者纠缠,改策略就得动机制,动机制就可能引入回归。专业的授权系统做的一件事,就是把这层纠缠切开——策略用独立语言描述,机制由框架托管。
我在一个内容管理类项目里做过统计:权限相关的代码在两年里从 40 行涨到 900 多行,分布在 23 个文件,其中有 4 处逻辑互相矛盾。重构时最大的成本不是写新代码,而是确认旧代码的意图。如果一开始就把策略集中到一份文件里,这份文件本身就是最好的文档。
1.2 授权框架接管的是哪部分工作,边界要划清楚
很多人对授权框架有误解,以为它能顺便解决登录、会话、Token 签发这些问题。实际上它只负责一件事:给定一个主体(actor)、一个动作(action)和一个资源(resource),回答"允许还是拒绝"。认证是"你是谁",授权是"你能干什么",这条线一定要划清楚,否则选型时容易把需求搞混。
在这个边界内,框架接管的工作包括:策略的表达与解析、策略的求值、部分场景下的数据过滤(把"当前用户能看的记录列表"翻译成查询条件)、以及策略的加载与热更新。它不接管的工作包括:用户体系建模、角色数据从哪来、审计日志怎么记、前端按钮显不显示。这些依然要你自己做,但框架会让它们变得有据可依——角色数据从哪来?策略文件里明确定义了角色来源;审计怎么记?每次判定结果都是一个结构化输出。
划清边界的价值在于预期管理。我见过团队期望引入框架后权限代码归零,结果发现只是把 900 行 Python 换成了 300 行 Polar,加上一堆数据管道的适配代码。实话讲,框架带来的是可维护性提升,不是代码量的必然下降。但在需求频繁变更的场景里,改一份策略文件远比改二十个文件的收益大得多。
1.3 五分钟能落地到什么程度,给个诚实的预期
标题里的"5 分钟"不是营销话术,但得说清楚它指的是什么。5 分钟能做到的是:装好依赖、写出一份包含三四种角色和继承关系的策略文件、在应用里接上判定调用、跑通第一个 allow 和第一个 deny。这是一个能跑的最小闭环,足以让你判断这套东西值不值得继续投入。
5 分钟做不到的是:完整的资源层级建模、列表接口的数据过滤、策略单测体系、灰度发布机制。这些是工程化的工作,需要按天甚至按周来算。我在给团队做内部分享时反复强调这个区分,因为很多人对框架失望,是因为一开始就把它当成了银弹,期待装上就万事大吉,结果遇到第一个复杂场景就弃用。
合理的预期是:第一周跑通核心资源的权限判定,第二周补齐角色继承和资源层级,第三到四周做列表过滤和策略测试,之后进入长期维护。这个节奏下,权限相关的线上问题会显著减少,而策略文件会逐渐成为团队里最被信任的那份文档。
2. 选型对比:Oso 在方案谱系里的位置
2.1 四类常见方案的取舍,别急着上重型方案
授权方案大致分四类,各有各的舒适区。我把它们的关键维度整理成下面这张表,方便对照自己的场景。
| 方案类型 | 典型形态 | 上手成本 | 表达力 | 维护成本 | 适合场景 |
|---|---|---|---|---|---|
| 手写判断 | 业务代码里的 if-else | 极低 | 取决于人 | 随规模快速上升 | 单角色、需求稳定的小系统 |
| 数据库角色表 | user_role 关联表 + 中间件 | 低 | 中等,难表达条件 | 中等 | 角色固定、无资源层级 |
| 声明式策略引擎 | 独立策略文件 + 求值引擎 | 中等 | 高,支持关系与属性 | 低到中等 | 需求持续演进的中大型系统 |
| 外部托管授权服务 | 独立服务 + SDK 调用 | 中等 | 高 | 低 | 多服务、多语言的大型架构 |
前两类方案的问题不在于"不够专业",而在于表达力的天花板会随业务增长被迅速撞破。一旦出现"仅当资源处于草稿状态且操作人是作者本人时可以编辑"这种复合条件,角色表就开始变形,代码里开始出现特判。第四类方案适合已经拆成十几个微服务的架构,单库应用上它属于过度设计,还会引入额外的网络依赖和可用性风险。
第三类是大多数中型应用的甜点区,也正是 Oso 所处的区间:库形态嵌入你的进程,没有额外服务要部署,策略文件跟着代码一起走版本管理。这个形态的好处是失败模式简单——判定失败就是进程内的异常,不需要考虑远程调用的超时和降级。
2.2 为什么选择声明式策略语言而不是配置表
配置表(比如一串 JSON 规则)乍看更简单,写起来像填表。但配置表的致命伤是缺乏组合能力。真实权限几乎总是嵌套的:管理员继承编辑者的全部权限,编辑者继承读者的全部权限;项目所属的组织成员自动获得项目的只读权限。用配置表表达继承,你得把所有权限平铺展开,改一处继承关系就要重算一大片。
声明式策略语言的核心是"规则"而非"条目"。规则本身是逻辑命题,引擎负责求解。这意味着你可以写一条"如果用户是组织成员,则对组织下的所有项目具有只读权限",而不需要为每个项目写一条记录。我在一个多租户系统里做过对比:用配置表方案,权限数据表有 60 多万行,每次角色调整都要批量重算;换成规则表达后,角色数据只剩用户与角色的绑定关系,层级推导全部由引擎在判定时完成,数据量降了两个数量级。
另一个隐性好处是可测试性。规则是可以被单独断言的——给定一组事实,判断某条规则是否成立。配置表只能测"展开后的结果对不对",很难测"继承关系写对没有"。
2.3 Polar 语言的三块拼图:事实、规则、查询
Polar 是 Oso 使用的策略语言,语法接近逻辑编程,看起来陌生但概念极简,只有三块拼图。
事实(fact)是外部世界的已知信息,比如"用户 A 在仓库 R 上是 maintainer"。事实通常不写在策略文件里,而是由应用侧提供——从数据库查出来、从 Token 里解出来、或通过注册类让策略回调你的方法。
规则(rule)是推导逻辑,长这样:
allow(user: User, "read", repo: Repository) if has_role(user, "reader", repo);意思是"如果用户在仓库上有 reader 角色,那么允许他执行 read 动作"。注意规则不是赋值,而是"只要右边成立,左边就成立",这跟命令式思维差别很大,也是新手最容易卡住的地方。
查询(query)是应用发起的判定请求,通常是is_allowed(user, "read", repo)或authorize(...)。引擎拿这个查询去反向匹配所有能推导出它的规则,找到一条成立的就返回真。
这三块拼图的关系可以用一句话概括:查询是问题,规则是推理过程,事实是前提。理解了这个结构,后面写多复杂的策略都是在这个框架内组合。
2.4 库形态与托管形态的取舍
Oso 提供两种使用形态:嵌入进程的库,以及独立托管的授权服务。前者把策略加载到你的应用进程里,判定是纯本地计算;后者把策略集中到一处,各服务通过接口调用。
选哪个取决于你的架构阶段。单体应用或者服务数量少于五个的时候,库形态几乎没有悬念——零额外部署、零网络开销、失败模式简单。等到服务数量上去了,策略散落在每个服务里、更新要逐service 发布,托管形态的价值才显现出来。
我自己倾向于先用库形态把策略写对,再考虑是否集中。原因是策略设计阶段的迭代频率极高,每次改动都要走一遍服务发布流程会严重拖慢节奏。等策略稳定下来、服务也拆开了,再迁到集中形态,迁移成本主要是把策略文件搬过去加上一层数据同步,不算复杂。
3. 五分钟上手:从零写出第一条可跑的授权规则
3.1 环境准备与安装,按语言选包
Oso 支持主流后端语言,包名基本是oso。下面列出几个常见的安装方式,按你实际用的语言挑一条。
# Python pip install oso # Node.js npm install oso # Go go get github.com/osohq/go-oso # Ruby gem install oso安装完之后,先跑一个最小验证,确认依赖没装歪:
from oso import Oso oso = Oso() oso.load_str('allow(_actor, "ping", _resource);') print(oso.is_allowed("anyone", "ping", "anything"))输出True就说明链路通了。这一步看着废话,但实际能省不少时间——很多后续的"策略不生效"问题,根因是包版本和文档对不上。不同语言包的 API 名称存在差异,有的版本用is_allowed,有的用authorize(后者在拒绝时抛异常而非返回布尔),写代码前先看一眼当前安装版本的方法签名,比照着旧教程硬套要靠谱得多。
3.2 写出第一份策略文件:角色、权限与继承
新建auth.polar,从最常见的 RBAC 场景开始。假设我们有一个代码仓库系统,角色分 admin、maintainer、reader 三档。
actor User {} resource Repository { roles = ["admin", "maintainer", "reader"]; permissions = ["read", "write", "delete"]; # 权限到角色的映射 "read" if "reader"; "write" if "maintainer"; "delete" if "admin"; # 角色继承:高权限角色隐含低权限角色的能力 "reader" if "maintainer"; "maintainer" if "admin"; }这段声明的读法是:定义一个资源类型 Repository,它有三种角色和三种权限;拥有 reader 角色的人可以 read;maintainer 隐含 reader,admin 隐含 maintainer。继承用两条规则就表达完了,不需要任何额外配置。这是我特别喜欢的一点——权限模型的形状直接写在文件里,新人接手看一眼就懂。
注意actor User {}这行的作用是为用户类型建一个空声明,让后续规则能做类型检查。它本身不产生任何逻辑,但漏掉它会报类型错误,属于典型的"不写就报错、写了也不影响业务"的样板代码。
3.3 把角色数据接进策略:让引擎知道谁是管理员
策略里写了"有 admin 角色就能 delete",但引擎怎么知道某个用户是 admin?这就轮到事实登场了。最直接的做法,是通过注册类把应用侧的数据源暴露给策略调用。
from oso import Oso class User: def __init__(self, id, roles_by_repo): self.id = id self.roles_by_repo = roles_by_repo def role_on(self, repo_id): return self.roles_by_repo.get(repo_id) oso = Oso() oso.register_class(User) oso.load_files(["auth.polar"])策略侧对应写一条has_role规则,调用应用对象上的方法:
has_role(user: User, role: String, repo: Repository) if role = user.role_on(repo.id); allow(user: User, action: String, repo: Repository) if has_role(user, role, repo) and repo allows action if role;这里的关键设计是:角色数据留在数据库和你熟悉的 ORM 里,策略只负责推导。不需要把角色同步到策略引擎的存储中,也不需要维护一份镜像数据,避免了两边不一致的经典问题。代价是每次判定都会回调一次应用方法,所以真实项目里这个方法的实现必须带缓存——这一点后面性能那节会细讲。
判定调用就很直白了:
if oso.is_allowed(user, "delete", repo): do_delete() else: raise PermissionError("no permission")3.4 补上资源层级,让权限沿着结构传递
真实的系统里资源很少是孤立的。仓库属于组织,文档属于项目,评论属于文档。Oso 处理这种层级的方式是让规则沿关系链推导。
resource Organization { roles = ["owner", "member"]; permissions = ["read", "admin"]; "read" if "member"; "admin" if "owner"; } resource Repository { relations = { parent: Organization }; roles = ["admin", "maintainer", "reader"]; permissions = ["read", "write", "delete"]; "read" if "reader"; "write" if "maintainer"; "delete" if "admin"; # 组织成员自动获得仓库只读权限 "reader" if "member" on "parent"; # 组织 owner 拥有仓库完整管理权 "admin" if "owner" on "parent"; }relations声明了资源之间的父子关系,on "parent"表示"去父资源上查这个角色"。这样一条规则就覆盖了"组织成员能读组织下所有仓库"的需求,新增仓库时不需要做任何权限数据初始化——这是层级模型相对于扁平角色表最大的优势。
需要提醒的是,层级不是越深越好。我见过五层嵌套的关系链,每次判定要跳四次,调试时看调用栈能看晕。控制在三层以内,超出就考虑用更扁平的关系表达,比如把跨层的权限直接挂在中间节点上。
4. 把权限做扎实:从"能跑"到"能上生产"
4.1 角色继承的两种写法,选对少踩坑
角色继承可以写在资源声明里,也可以抽成独立的规则。前者直观,后者灵活。
写在资源里的方式就是 3.2 节那种:
"reader" if "maintainer"; "maintainer" if "admin";这种写法的好处是一目了然,继承关系就在角色定义旁边。缺点是继承链固定,如果不同组织想要不同的继承规则(比如有的团队希望 maintainer 不能读私有仓库),就得拆多份策略文件。
抽成独立规则的方式:
role_implies("admin", "maintainer"); role_implies("maintainer", "reader"); has_role(user: User, role: String, repo: Repository) if direct_role(user, r, repo) and role_implies(r, role);这种写法的好处是继承关系变成了数据,可以从数据库加载、可以按租户定制。代价是多了一层间接,调试时要多查一步。我的经验是:继承关系固定就用第一种,需要按租户差异化就用第二种。中途从第一种改到第二种是常事,别为此纠结,先把能跑的版本上线。
4.2 列表接口才是真正的难点:authorize 与数据过滤
单个资源的判定很容易,难的是列表页。用户打开"我的项目"页面,你要返回他有权查看的所有项目。天真的做法是查出全量再逐个判定,一百条数据还好,一万条就是灾难。
Oso 的思路是把策略"翻译"成查询条件。库形态下,它支持对列表做过滤,输出一个可应用到集合上的筛选条件。核心差异在于:authorize 是单点判定,filter 是批量推导。前者回答"这条能不能看",后者回答"这个集合里哪些能看"。
我在一个日活几万的系统里做过实测对比。全量查询加逐条判定,一千条数据的耗时在 800 毫秒左右,而且随着数据增长线性恶化;改用过滤推导后,耗时降到 40 毫秒上下,且不随总量增长。差别就来自有没有把判定下推到查询层。
不过这里有个现实约束:过滤推导的表达能力强于 SQL,但不是所有关系都能下推。复杂的跨表条件、依赖外部接口的属性判断,往往还是得回退到"先粗筛再精判"。我的做法是分两步:先用数据库能表达的条件(比如所属组织、创建人)做粗筛,把候选集压到几十条以内,再用策略做精判。这个组合方案在绝大多数场景下够用,也容易理解和维护。
4.3 属性条件:让权限判断带上数据状态
纯 RBAC 表达不了"草稿状态的文档只有作者能改"这类需求,需要引入属性。Polar 里属性就是普通的方法调用或字段访问。
allow(user: User, "edit", doc: Document) if has_role(user, "editor", doc.project) and doc.status != "archived";这条规则的意思很明确:具备 editor 角色且文档未归档,才能编辑。把数据状态写进策略的好处是业务规则集中可见,坏处是策略开始依赖外部数据结构,改表结构可能影响策略。
我给自己定的边界是:稳定的业务状态字段可以进策略,变动频繁的字段不要进。比如"归档""已发布"这种状态机里的稳定终态,放进去很合适;"最近编辑时间""评论数"这种随时在变的值,放进去会让策略变得难以测试,也很难缓存。
4.4 一份自查清单,上线前逐条过
策略写完不等于能上线。我整理了一份上线前的自查清单,基本覆盖了实践中出问题最多的地方。
| 检查项 | 常见问题 | 验证方式 |
|---|---|---|
| 默认拒绝 | 规则写漏导致未匹配时行为不明 | 构造一个无任何角色的用户,确认全部拒绝 |
| 角色继承闭环 | 循环继承导致栈溢出或超时 | 打印继承关系图,人工核对 |
| 资源层级深度 | 超过三层导致判定变慢 | 统计判定耗时分布,看 P99 |
| 事实来源一致性 | 应用侧数据和策略侧判断不一致 | 抽取线上样本做双向比对 |
| 拒绝路径的日志 | 被拒时无记录,排查困难 | 人为触发一次拒绝,看日志有没有 actor 和 action |
| 策略加载失败的处理 | 加载异常被吞掉,策略为空却继续跑 | 故意写错策略文件,确认服务启动失败 |
最后一条特别重要。策略加载失败必须是致命错误,绝不能让服务带着空策略继续运行——空策略在默认拒绝的语义下意味着所有人都没权限,线上表现就是大面积 403,比直接启动失败更难排查。
5. 工程化落地:让权限系统活得过下一个版本
5.1 策略也要写单测,而且比业务代码更该写
策略文件的本质是逻辑代码,理应享受同等待遇。Oso 的策略测试写法很轻,直接在策略文件里写断言即可:
# 断言:admin 能删除 ?= allow(admin_user, "delete", repo_1); # 断言:reader 不能写 test "reader cannot write" { not allow(reader_user, "write", repo_1); }这些断言随策略文件一起走版本管理,改动策略时跑一遍,能挡住绝大部分低级错误。我给团队定的规矩是:每新增一条 allow 规则,至少配一个正例和一个反例。反例尤其重要——大部分权限事故不是因为权限给少了,而是因为给多了。
写测试时有个技巧:把测试用例组织成"角色 × 动作 × 资源状态"的三维矩阵,用参数化方式批量生成断言。一个三角色三动作的资源,配置文件里写九条断言就够,比逐个手写省事,也不会漏格子。
5.2 策略版本与灰度,别让一次改动全量生效
策略文件的改动影响面比普通代码更大,因为它同时影响所有接口。我的做法是在策略里留一个版本开关,新规则先对内部账号或小比例流量生效。
具体实现上,可以在判定入口处做一层包装:根据用户属性决定用哪份策略文件。Oso 支持加载多份策略,把新旧两份都加载进去,在规则里加一个开关条件即可。这样切换和回滚都是改一个字段的事,不需要重新发布。
灰度期间重点盯两个指标:403 率和关键接口的成功率。403 率上涨说明新策略比旧的更严,可能是漏了某条继承;成功率下降说明有请求走到了未覆盖的分支。这两个指标异常时立刻回滚,成本极低。
5.3 性能优化的三个抓手
授权判定的性能问题通常集中在三处,按收益排序。
第一是事实获取的缓存。判定时回调应用方法查角色,如果每次都打数据库,QPS 一上来就顶不住。加一层进程内缓存,TTL 设短一点(比如 30 秒),角色变更的延迟用户基本感知不到。我在一个读多写少的系统里做过这个优化,判定耗时从 12 毫秒降到 0.3 毫秒。
第二是判定结果的缓存。同样的 actor、action、resource 三元组,在短时间内结果通常不变。用一个带 TTL 的 LRU 缓存包住is_allowed,命中率往往很高,因为同一用户连续操作同一类资源的情况非常普遍。注意资源变更时要主动失效对应条目,否则会出现"改了权限但没生效"的诡异现象。
第三是批量判定的下推。前面提过的列表过滤,能下推到数据库就下推,实在不行再回退到粗筛加精判。这一条收益最大但改造量也最大,建议等其他两处优化做完、确实还扛不住再动。
5.4 团队协作:策略文件该归谁维护
一个容易被忽略但实际影响很大的问题:策略文件谁来改。全让后端改,产品经理永远搞不清规则细节;让产品直接改,语法错误和逻辑漏洞防不住。
我的经验是分层:策略的"形状"由后端定——有哪些资源类型、哪些动作、角色怎么继承,这部分是架构决策,需要技术判断。策略的"参数"由业务方定——某个角色到底能不能删除、某个状态能不能编辑,这部分是业务规则,业务方最清楚真实需求。
落地方式是把参数抽成独立的配置片段,策略文件引用它。业务方改配置,后端审核合并。这样既保住了策略的严谨性,又避免了每次改个开关都要排队等开发。
6. 常见问题与排查实录
6.1 高频问题速查表
下面这张表是我和团队实际遇到过的问题汇总,按出现频率排序,遇到症状可以直接对号入座。
| 症状 | 可能原因 | 排查动作 |
|---|---|---|
| 所有判定都返回拒绝 | 策略文件没加载成功,或加载后语法错误被吞 | 打印已加载的策略内容,确认非空 |
| 某角色判定意外通过 | 继承链写反了方向,高权限误继承了低权限的判定 | 单独断言该角色的每条权限 |
| 判定报类型错误 | actor 或 resource 未注册,引擎无法识别类型 | 检查 register_class 是否覆盖所有自定义类型 |
| 调用时报方法不存在 | 策略里调用的方法名和应用侧不一致,或参数个数不匹配 | 对照策略文件和类定义逐个核对 |
| 列表接口部分数据缺失 | 过滤条件把合法数据筛掉了 | 用全量加精判的结果做对照,找出差异集 |
| 角色改了但权限没变 | 缓存未失效,或角色数据走了另一条读取路径 | 清缓存重试,检查是否存在双写 |
| 策略改动后服务启动失败 | 语法错误 | 本地先跑一遍策略测试,再提交 |
6.2 三个我印象最深的坑
第一个坑是"空策略"。早期版本里,我们把策略加载包在了 try-catch 里,加载失败只打了个 warn 日志,服务继续启动。结果一次部署时策略文件路径写错,服务正常起来了,但所有接口全是 403。监控上看到的是 403 率 100%,排查花了二十多分钟才定位到路径。教训很直白:策略加载失败必须中断启动,宁可起不来也不要带着空策略对外提供服务。
第二个坑是继承方向写反。我们当时写的是"maintainer" if "admin",本意是"admin 隐含 maintainer",但方向搞反了,实际表达成了"maintainer 隐含 admin"。测试没覆盖到,因为测试账号都是 admin,反向判定也是通过。上线后普通成员突然能删仓库了。这个坑让我养成了一个习惯:每条继承规则都要用低权限角色做一次否定断言,只测高权限角色的正向路径根本发现不了方向错误。
第三个坑是过滤条件的不等价。列表接口做过滤推导时,我们写的条件和单点判定的逻辑不完全等价,导致列表里少了十几条本该出现的数据。用户反馈"我的项目不见了",但单点访问又是正常的。排查方式很粗暴但有效:写一个脚本,对同一批数据分别跑"全量加精判"和"过滤推导"两条路径,比对结果集差异,差异立刻暴露了问题所在。这个对拍脚本后来成了我们的常备工具,改任何过滤逻辑前都先跑一遍。
6.3 策略重构的时机判断
策略文件也会腐化,只是慢一些。我观察到几个信号,出现两个以上就该考虑重构了:规则条数超过一百五十条且开始出现重复片段;同一个概念在不同规则里有三种叫法;新人需要半小时以上才能说清某个角色的权限范围;测试用例里有大量"为了绕开某条规则"的特例。
重构的切入点通常是抽公共子规则。把反复出现的"判断用户是否属于某组织"这类逻辑抽成一个独立规则,调用处只写结论。我在一次重构里把 240 行策略压到 130 行,主要收益不是行数减少,而是每条规则变成了独立可理解的一句话,评审时能一眼看出意图。
7. 我个人在几个项目里沉淀下来的做法
写到这里,前面几节的技术细节基本讲完了,分享几个不太好归类但确实有用的经验。
策略文件的第一段永远写注释说明角色模型。不是写"这个文件定义了权限",而是画一个文字版的关系图:谁继承谁、哪些角色跨资源传递、默认行为是拒绝还是允许。这段注释的价值在半年后体现——那时候改策略的人可能已经不是你,一份清晰的模型说明能省掉大量考古时间。
给判定函数加一个调试开关。我们的判定入口支持传入一个 debug 标识,开启后返回完整的推导链路:哪条规则命中、依赖了哪些事实、经过了哪几层继承。这个功能开发时花了半天,之后每次排查线上问题都能省下几十分钟。权限问题最难的地方就是"为什么这个请求被拒了",有了链路输出,答案基本自明。
不要在策略里做数据查询。我见过有人在策略里直接调 ORM 查数据库,写起来确实顺手,但这会让策略变成不可测的代码——单测时需要造全套数据。正确的做法是应用侧把需要的数据准备好,通过参数或对象方法传进去,策略只做纯逻辑推导。这条边界守住了,策略测试才能真正做到又快又稳。
角色变更要能追溯。授权系统本身不记审计,但角色数据的每次变更都应该留痕:谁改的、改前改后是什么、什么时候生效。这个需求往往在出事故后才被提出来,但事后补日志是补不回历史数据的。上线前就把这张表建好,成本几乎为零。
最后是心态上的建议:不要追求一步到位的完美模型。权限模型是跟着业务长出来的,一开始就设计一套能覆盖未来三年需求的复杂模型,大概率是过度设计,而且会因为太复杂而没人敢改。从一个资源类型、三个角色、两条继承规则开始,需求来了再加,每次加完跑一遍测试。这套东西的扩展成本很低,真正贵的是那些一开始就写复杂、后来没人敢动、最后推倒重来的案例——我见过两次,都不太愉快。