Casbin:从进程内判定到多实例策略一致性
一个多租户项目平台把角色、资源和动作写进 Casbin,上线后却出现了最危险的一类授权事故:同名 maintainer 在租户 A 与租户 B 都存在,应用调用 Enforce(user, object, action) 时没有传租户,策略也没有 domain。测试数据里资源名恰好全局唯一,所以回归一直正常;真实数据出现重复资源标识后,租户 B 的成员命中了租户 A 的角色关系。Casbin 没有“串租户”,是团队设计的 request、policy 与 matcher 从未表达租户边界。
另一次事故发生在撤销权限之后。管理接口调用 RemovePolicy() 返回成功,处理该请求的实例立即拒绝,其他实例却继续允许了十几分钟。所有实例共用同一数据库,团队因此认定策略天然一致;实际上每个 Enforcer 都在进程内持有策略快照,数据库更新不会自动刷新其他实例。Watcher 的消息虽然到达了 broker,但回调加载失败且没有指标,最终形成了“写库成功、通知成功、策略未应用”的三段式假成功。
先认清 Enforcer 持有什么
Casbin 是一组多语言嵌入式授权库。应用创建 Enforcer,装入 Model 与 Policy,然后在真实资源操作之前调用授权方法。它不验证登录令牌,不替应用读取业务对象,也不会因为前端按钮隐藏就阻止接口调用。最终执行点仍在服务端:只有授权调用得到允许且没有错误,代码才能继续查询、修改或删除资源。
Model 决定“请求长什么样、策略行长什么样、角色关系怎样展开、命中结果怎样组合”;Policy 提供具体事实;Enforcer 把二者编译为进程内求值对象。Adapter 可把策略放入数据库,Watcher 或 Dispatcher 可传播变化,但这些插件不会自动把多个进程变成强一致授权数据库。
这条链中至少存在三个不同状态:持久化策略、某个实例已经加载的策略、某次请求实际使用的策略。数据库行数相同只证明 durable store 的状态;消息被消费只证明通知经过;只有实例的 applied revision 与授权结果都收敛,才能证明新策略已经生效。
用 Go v3 跑通第一个多租户模型
Go 主线的安装入口是 github.com/casbin/casbin/v3。这里的 v3 是 Go module 的 major line,不能外推为 jCasbin、node-Casbin、PyCasbin、Casbin.NET 或 casbin-rs 的共同版本。其他语言有独立包、表达式引擎、同步/异步 API、插件与发布节奏;跨语言共享策略之前必须用同一 corpus 比较行为。
先建立隔离目录并记录解析到的精确依赖版本:
New-Item -ItemType Directory -Force casbin-lab | Out-Null
Set-Location casbin-lab
go mod init example.com/casbin-lab
go get github.com/casbin/casbin/v3
go list -m all | Select-String casbingo.mod 与 go.sum 应进入项目,CI 使用锁定后的依赖图构建。升级时更新的是审核过的 patch,不是在生产构建中每次拉取浮动版本。从 Go v2 迁移到 v3 时,顶层包和所有 subpackage import 都要从 /v2 改为 /v3。
创建 model.conf:
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act, eft
[role_definition]
g = _, _, _
[policy_effect]
e = !some(where (p.eft == deny)) && some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub, r.dom) &&
r.dom == p.dom &&
r.obj == p.obj &&
r.act == p.act五个 section 不是固定模板。Model 语法中的 r 定义调用参数及顺序;p 定义每条策略记录;g 的第三个参数把角色关系放入 domain;e 采用 deny-override;m 决定一次请求与哪些策略记录匹配。删掉 r.dom == p.dom 或把 g 改回两个参数,系统的租户语义就已经改变,即使代码仍能编译。
创建 policy.csv:
p, maintainer, tenant-a, project:alpha, read, allow
p, maintainer, tenant-a, project:alpha, delete, deny
p, maintainer, tenant-b, project:alpha, read, allow
g, alice, maintainer, tenant-a
g, bob, maintainer, tenant-b再创建完整的 main.go:
package main
import (
"fmt"
"log"
"github.com/casbin/casbin/v3"
)
type check struct {
name string
sub string
dom string
obj string
act string
want bool
}
func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
log.Fatalf("create enforcer: %v", err)
}
cases := []check{
{"alice reads tenant-a", "alice", "tenant-a", "project:alpha", "read", true},
{"alice cannot cross tenant", "alice", "tenant-b", "project:alpha", "read", false},
{"bob reads tenant-b", "bob", "tenant-b", "project:alpha", "read", true},
{"explicit delete deny", "alice", "tenant-a", "project:alpha", "delete", false},
{"unknown action defaults deny", "alice", "tenant-a", "project:alpha", "export", false},
}
failed := false
for _, tc := range cases {
got, err := e.Enforce(tc.sub, tc.dom, tc.obj, tc.act)
if err != nil {
log.Printf("ERROR name=%q err=%v", tc.name, err)
failed = true
continue
}
fmt.Printf("%-32s got=%-5t want=%-5t\n", tc.name, got, tc.want)
failed = failed || got != tc.want
}
if failed {
log.Fatal("authorization corpus failed")
}
}执行:
gofmt -w main.go
go run .预期五行的 got 与 want 完全相同,进程以成功状态退出。这个实验同时给出正例、跨租户反例、显式拒绝和默认拒绝。它没有证明数据库持久化、多实例传播或生产性能,只证明锁定的 Go 实现对这组文件产生了预期决定。
Model 是授权语义,不是配置皮肤
Casbin 并没有一套对所有 model 固定的 allow/deny 组合语义。上面的 e 明确表示:只要命中 deny 就拒绝,并且至少要命中一个 allow 才允许。把它改成下面的 allow-override:
[policy_effect]
e = some(where (p.eft == allow))然后向 policy.csv 追加一条与 read allow 冲突的 deny:
p, maintainer, tenant-a, project:alpha, read, deny重新运行时,allow-override 仍会允许 Alice 读取;换回 deny-override 才会拒绝。同一批 policy rows 在两个 Model 下得到不同结果,这是 Casbin 的设计能力,不是 bug。迁移 Cedar 的 forbid、旧 ACL 的 priority 或其他系统的 deny 规则时,必须先对齐 effect 语义,不能只转换策略行。
Model 的参数也是 API 契约。Enforce() 并不永远是三个字符串;参数数量、顺序和类型由 [request_definition] 决定。建议在业务层封装类型化入口,避免 handler 自由拼装参数:
type Authorizer struct {
enforcer *casbin.Enforcer
}
func (a *Authorizer) CanReadProject(userID, tenantID, projectID string) (bool, error) {
return a.enforcer.Enforce(userID, tenantID, "project:"+projectID, "read")
}稳定 ID 来自认证与业务数据库,不从客户端提交的 display name、租户名或路径直接生成。project: 这样的类型前缀可以降低不同资源类型 ID 冲突,但不能替代数据库查询中的租户条件。授权允许后,真正读取资源时仍应带 tenant_id;否则授权检查保护的是一个对象,SQL 操作的可能是另一个对象。
Adapter 解决持久化,不解决传播
文件策略适合教学、单实例工具和不可变制品发布。需要管理接口动态增删策略时,应从 Casbin Adapter 目录 选择目标语言、数据库和维护状态都明确的实现,并把 core 与 adapter 版本分别锁定。基础 Adapter 的关键职责是加载与全量保存;增量添加、删除、过滤加载、事务和上下文取消都可能是可选能力。
数据库 adapter 常见表形态是 ptype, v0 ... v5,并为组合列建立唯一索引。六个值列是常见插件契约,不是 Casbin 抽象模型的理论上限。上线前按下面顺序验证:
用迁移工具创建表和唯一索引,应用运行账号只取得所需的读写权限,不授予建库或任意 DDL。用独立连接插入种子策略,启动 Enforcer 并执行 corpus,记录 model hash、policy row count 和 snapshot revision。调用 AddPolicy() 后,不只检查当前 Enforcer;还要从独立数据库连接查询新增 row。
新建第二个 Enforcer,重新 LoadPolicy(),确认它也得到允许。内存立即允许而新实例拒绝,说明持久化链没有闭环。调用删除后重复数据库查询和新实例加载,撤销路径比新增路径更需要严格验证。
AutoSave 只表示 management API 会尝试调用 adapter 的增量接口。Adapter 不支持对应方法、数据库事务失败或唯一索引冲突时,内存状态与持久化状态可能分离。业务代码必须处理返回值与错误,不能把“当前请求看到新权限”当成保存成功。
SavePolicy() 是全量重写,策略量增大后会带来锁、写放大和并发覆盖风险。只通过 LoadFilteredPolicy() 加载某个租户子集时,内存根本不是全量真相;官方语义会阻止 filtered state 下执行 SavePolicy(),避免用一个租户的子集覆盖整个策略库。分片设计应让 filter、domain 列位置和租户索引进入自动测试。
多实例要证明 revision 收敛
仅共享 adapter 数据库,其他 Enforcer 不会自动刷新。Watcher 通常传播“策略有变化”的通知,接收方回调再执行 LoadPolicy();它不保存策略,也不天然保证消息必达、有序或回调成功。通知事件至少携带事件 ID 与目标 revision,实例暴露 loaded_policy_revision,只有回调成功并更新 revision 才确认消费。
可以按下面的反向实验暴露陈旧授权:
启动实例 A、B,从同一快照加载策略,确认两者 revision 与 corpus 一致。阻断 B 的 watcher 消息或让回调返回错误。在 A 撤销 Alice 的 read,确认 durable store 已删除对应 row。
A 重新求值应拒绝,B 仍可能允许;保存两个实例的 revision、decision 与 callback error。恢复消息通道后不要立刻宣布恢复,观察 B 是否自动对账。若没有补发或周期 reconciliation,它可能永久陈旧。触发全量 reload,让 B 的 revision 与 A 收敛并再次拒绝,才算撤销完成。
Watcher面向变化通知,WatcherEx 面向更细粒度的变化信息,但接口存在不等于已有适用实现;需要增量同步时应核对目标语言和插件,Casbin v3 文档建议评估 Dispatcher。Dispatcher 可传播运行期间的增量变化,却不会修复加入之前已经存在的初始分歧。因此启动协议仍要先从 authoritative store 加载同一 snapshot,完成 hash/revision 门禁,再加入分发组;重连后做对账,而不是假设后续增量会治愈历史差异。
高可用设计不能只画 broker。Adapter 数据库、消息组件和应用实例是三个故障域:数据库不可用时新实例可能无法加载;broker 不可用时旧实例继续用旧快照;实例重启时又可能拿到更新快照。明确每条路径是 fail closed、使用已验证旧快照,还是进入受限 break-glass,并对高风险动作采用更短的撤销传播预算。
何时把 Casbin 包成服务
多语言团队有时希望把授权集中到一个 gRPC 服务。Casbin Server 是独立项目,不是 Go Casbin v3 默认附带的守护进程。它把 Enforcer 与 Adapter 放进服务端,配置涉及 driver、connection、enforcer、dbSpecified,配置文件位置可由 CONNECTION_CONFIG_PATH 指定。数据库 adapter 需要编译进服务二进制;镜像能启动不等于目标驱动已启用。
服务化改变了契约:本地函数调用变成网络 RPC,新增客户端身份、mTLS、deadline、重试、限流、HA 和序列化边界。Casbin Server 的网络 ABAC 映射采用扁平对象约束,不能把本地 Go 对象匹配能力原样外推到远端。正式切换前应把同一 request corpus 同时送给本地 Enforcer 和 gRPC 路径,比较 allow/deny/error;再注入数据库断连、超时、重复请求和畸形对象,确认服务不会静默允许。
如果目标只是让多个实例共享策略,先补齐 adapter、revision、watcher/dispatcher 和对账,通常比自建授权服务更直接。只有跨语言统一控制、集中审计或独立扩缩容的收益足以覆盖网络关键路径成本时,服务化才成立。服务与嵌入库长期双跑会产生双 PDP 语义,迁移期应让新路径先 shadow,不要把两个结果串行 AND/OR 后永久保留。
性能问题往往藏在 matcher 顺序里
Casbin 的端到端延迟包括 matcher 求值、角色图查询、自定义函数、策略加载与业务装配。把高成本 g() 或正则函数放在最前面,再做廉价的 domain/object/action 过滤,可能让大量无关 policy 先进入角色计算。优化前保存真实 policy shape,分别测冷加载、稳定 Enforcer 的 p50/p95/p99、并发下分配量和 reload 抖动;调整 matcher 顺序后必须重放同一正反 corpus。
列表接口尤其容易形成 N+1:先查一页资源,再对每行 Enforce(),既放大 CPU,也可能出现“先分页后过滤”的空页与遗漏。资源量小且有硬上限时可以 BatchEnforce;资源量大时,应把可查询的租户、owner、角色关系投影成数据库过滤条件,或选择具备原生反向查询的数据模型。不能为了减少授权调用而把未授权资源先返回客户端。
策略规模增加时,观察加载时间、内存、role graph 深度、单次 management API 写放大和全量 reload 峰值。容量阈值由目标硬件、锁定版本和 SLO 测得,使用趋势而不是万能数字:固定负载下 policy 翻倍后长尾是否近似线性、连续 reload 后旧快照内存是否回到基线、撤销后所有实例是否在传播预算内停止允许。
排障要先判断错在哪一层
全量拒绝通常先看 request 参数数量与顺序、model/policy 列数、domain token 和 role relation。跨租户命中则比较 request domain、policy domain、g 的 domain 与业务 SQL 的 tenant predicate,不能只盯最终 bool。
单实例正确、其他实例错误时,先比较 model hash、policy revision、adapter endpoint 与已加载 row count。数据库已经更新但实例 revision 未动,是传播或 reload 问题;通知已到而 revision 未动,是 callback 问题;revision 相同但结果不同,则检查不同 core 版本、自定义函数、role manager 或并发装载。
管理接口成功但重启后丢失,重点检查 AutoSave、adapter 增量能力和返回错误。重复策略或结果漂移,检查唯一索引、并发全量保存与迁移。延迟突增则把 matcher 各阶段、角色图深度和 policy 候选数量拆开,不要直接提高超时掩盖算法放大。
Enforce() 的错误与拒绝必须分开。拒绝是策略决定,可以返回稳定的无权限错误;求值错误、模型装载失败或自定义函数异常是授权系统故障,通常应 fail closed 并触发告警。日志保留 correlation ID、脱敏的 sub/dom/obj/act、model/policy revision、decision、error category、latency 与真实执行结果,不记录完整令牌、连接串或敏感对象。
权限、凭证与团队职责
Casbin 本地文件、数据库行和 matcher 自定义函数都能改变权限,应像应用代码一样评审。数据库 DSN、watcher broker credential、Casbin Server 的 gRPC client credential 进入 Secret 管理系统,运行时只获取目标环境的最小权限。配置里使用 $ENV_VAR 只避免明文落盘,不会自动防止进程环境、错误日志或诊断 dump 泄漏。
策略作者负责业务语义,平台团队负责 adapter、传播和可观测,应用 owner 负责身份映射、业务对象装配与 PEP。发布者与审批者分离,高风险变更需要双人复核;紧急授权设置自动到期,并通过正常仓库版本回收。前端 Casbin 只能控制展示,不得成为唯一执行点。
上线证据至少包括:model hash、policy revision、每个实例 applied revision、adapter 写入结果、传播延迟、授权决定、错误分类和资源操作结果。权限新增延迟会让合法请求暂时失败,撤销延迟会继续放行旧权限,两者风险不对称;告警与 SLO 应单独统计。
升级、回滚与退出要保住语义
Go v3 升级先在隔离构建中固定候选 core、adapter、watcher/dispatcher 组合,执行 model parse、全量 corpus、多实例传播、性能和故障注入。其他语言不能因为 Go 的结果正常就跳过测试。自定义函数、正则、ABAC 对象和 batch API 都要进入跨实现契约集。
Adapter 替换先导出全量 snapshot、row count、唯一键与 hash,新旧 adapter 双读比较,再影子写入并用新 Enforcer 重载。Filtered in-memory state 不能作为迁移源。回滚是让实例重新加载上一份完整 model + policy snapshot,并核对 revision,而不是在线删几条“可疑”记录。
从本地 Enforcer 迁往 Casbin Server,或从服务退回嵌入库,都要比较序列化、ABAC、错误、deadline 与延迟。切换期间旧路径继续执行,新路径只记录 shadow decision;差异归零后再切 PEP,保留可快速恢复的旧客户端和只读审计窗口。
退出 Casbin 时导出 Model、Policy、角色/domain 约定、adapter schema、custom functions、test corpus、revision 历史和审计映射。替代实现必须通过同一 allow、deny、跨租户、错误与撤销实验;确认所有应用不再加载旧策略后,再撤销数据库账号、broker credential、RPC 证书并清理策略副本。能把这些资产带走,才说明授权能力属于团队,而不是被某个 Enforcer 实例临时托管。
实验目录可以直接删除:
Set-Location ..
Remove-Item -LiteralPath .\casbin-lab -Recurse -Force生产清理不能照搬这条命令。先确认替代 PEP 已执行、旧实例流量为零、审计保留满足要求,再删除策略存储和凭证;顺序反过来会把“退出工具”变成“绕过授权”。
