授权一致性、缓存与撤销:把旧权限窗口压进可验证预算
一名外包人员被移出项目组后,IAM 已显示账号禁用,关系授权库里的 membership 也删除成功,但他建立的 WebSocket 仍能持续接收内部事件。API 网关清掉了自己的缓存,应用 SDK 还有五分钟 allow cache,消息 consumer 又只在订阅建立时检查一次权限。复盘报告写着“撤销接口 200,缓存 TTL 五分钟”,却没有测量从权威撤销提交到所有执行点稳定拒绝的时间。
另一场事故来自缓存键。两个租户拥有相同的 user:42 和 document:7,团队把 subject + resource + action 作为 key,却漏掉 tenant、模型 revision 和会话 epoch。A 租户的 allow 被 B 租户复用;修复时又一刀切关闭所有缓存,PDP 和 datastore 在峰值流量下排队超时。业务为了保可用把超时映射为 allow,最终把性能故障升级成越权事故。一致性治理不是在“强一致”和“高性能”之间选口号,而是明确每类动作允许多旧、哪一层能缓存、撤销怎样传播、错误时谁负责拒绝。
一次授权决定经过哪些状态
授权链至少包含主体、凭证、动作、资源、上下文、策略或关系数据、PDP 决定、PEP 执行和审计证据。认证系统撤销 token 不等于关系授权已撤销;PDP 返回 deny 不等于所有入口已经阻断;数据库写入成功也不等于缓存、长连接和异步 consumer 已经收敛。
把状态拆成四层更容易定位:
权威事实层保存身份状态、关系、属性、策略与业务资源版本。决策层在确定的 policy/model/schema revision 和数据快照上计算 allow、deny 或 indeterminate。缓存与分发层复制 bundle、模型、关系查询结果和最终决定。
执行层位于网关、应用、RPC、consumer、批处理和长连接,真正阻止副作用。
一致性问题通常发生在层间:新策略已发布但某个 sidecar 仍运行旧 revision;关系删除已提交但默认查询读了旧 snapshot;PDP 已拒绝但 PEP 复用了本地 allow;新连接被拒绝而旧连接没有终止。任何验收若只看其中一层,都会高估撤销速度。
先为动作定义新鲜度等级
并非所有动作都需要同一代价。密钥读取、导出、管理、资金操作和权限变更通常要求最严格的新鲜度;普通写次之;低敏列表和公共元数据可以接受短暂陈旧。每个 action class 应记录:
可接受的最大旧权限窗口,即撤销暴露目标 REO。允许的查询一致性模式和缓存层。PDP 超时、不可达、revision 未知时的执行结果。
是否需要终止长连接、令牌或后台任务。通过哪些指标和实验证明收敛。
不要写死一组脱离业务的万能秒数。REO 来自数据敏感度、法规、攻击成本、PDP SLO、网络拓扑和容量实测;演示环境可以设置较小值验证链路,但生产阈值必须由风险负责人、平台和业务共同批准。
撤销时间可以拆成:
T_revoke = T_detect + T_publish + T_apply + T_cache + T_inflight
assert p99(T_revoke) <= REO(action_class)T_detect 是权威事件被发现的时间,T_publish 是策略或关系变更提交时间,T_apply 是所有目标 PDP/副本可见时间,T_cache 是决策与结果缓存收敛时间,T_inflight 是已放行请求和长连接停止时间。计时从权威撤销提交成功开始,到所有目标 PEP 对同一会话连续稳定 deny 或连接终止为止,不能只命中一个新副本就停表。
缓存键首先是安全边界
一个可审查的缓存键至少包含:
tenant | principal type/id | action | resource type/id/version |
normalized policy-relevant context | authn assurance/session epoch |
policy/model/schema revision | relationship/entity freshness bound下面的 TypeScript 片段把组成过程显式化,便于测试是否遗漏字段:
import { createHash } from "node:crypto";
type CacheInput = {
tenant: string;
principal: { type: string; id: string; sessionEpoch: number };
action: string;
resource: { type: string; id: string; version: string };
context: Record<string, string | number | boolean>;
authnAssurance: string;
policyRevision: string;
freshness: string;
};
export function authzCacheKey(input: CacheInput): string {
const normalized = {
...input,
context: Object.fromEntries(
Object.entries(input.context).sort(([a], [b]) => a.localeCompare(b)),
),
};
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
}正向测试应证明字段顺序变化不会改变 key,而 tenant、action、资源版本、session epoch、policy revision、Caveat/context 任一相关值变化都会产生新 key。反向测试故意从输入中删掉 tenant,构造两个同 ID 租户,预期测试检测到碰撞并失败。上下文若包含无法稳定规范化的集合、时间或高基数风险信号,宁可禁用最终决定缓存,也不要用不完整摘要。
缓存值只保存 decision、稳定 reason code、revision、freshness 和 expiry,不保存 bearer token、cookie、完整 ZedToken、原始关系图或敏感上下文。日志中的主体/资源使用受控标识或不可逆摘要;token 只记录摘要和作用域,以便关联而不泄漏可重放材料。
allow 与 deny 的陈旧代价相反
allow cache 延迟撤销,deny cache 延迟新授权。两者必须使用不同 TTL、命中指标和业务目标:
高风险写、导出和管理动作默认不缓存最终 allow;即使缓存,TTL 也不得超过对应 REO。deny cache 按“授予可见目标”设置,产品若承诺授权后立即可用,就不能把长负缓存当性能优化。policy/model/schema 更新通过 revision 进入 key 实现逻辑失效,不依赖全量扫描删除旧 key。
relationship 或属性撤销使用有序、幂等、可重放的失效事件,并以一致性查询或短 TTL 兜底。进程内、SDK、sidecar、网关、反向代理、CDN、搜索结果和长连接都进入缓存清单。
清除 PDP cache 不是撤销完成。若 PEP 仍保留 allow,或网关缓存了上一次外部授权结果,旧决定仍能执行。团队需要一张“谁持有授权相关状态”的运行清单,记录 owner、key 结构、TTL、失效事件、最大年龄、指标和紧急清理入口。
不同工具的新鲜度语义不能混用
OPA bundle 是 eventually consistent 分发。Bundles 与 Status Service 的运行链要求把“发布成功”和“实例激活”分开:新 bundle 验证成功后原子激活,失败时继续使用旧 bundle;控制面通过 status 收集每个 agent 的 active revision。高风险撤销只有在所有目标实例达到目标 revision 后才完成。persist: true 能在重启和控制面不可达时恢复旧 bundle,也会延续旧授权,因此要设置可接受的最大 bundle age,超龄后收紧高风险动作。
OpenFGA 的 Query Consistency 提供 MINIMIZE_LATENCY 与 HIGHER_CONSISTENCY:前者可以从 cache 返回,后者跳过 OpenFGA cache 直接查询 datastore。后者没有写入 token,也不是 Zanzibar 的因果一致性;撤销后高风险 Check/List 使用它并计时。cache controller 可以改善近期 write/delete 的失效,但不能替代端到端 REO。
SpiceDB 的读己之写说明要求优先保存写入或删除返回的 ZedToken,后续查询用 at_least_as_fresh 要求不早于该 revision。默认 minimize_latency 可以使用近期缓存,at_exact_snapshot 只适合 GC window 内的短分页,fully_consistent 成本高,而且在 CockroachDB 上仍不能替代写 token 的读己之写。ZedToken 不透明、不可排序、不可跨 datastore 或恢复世代复用。
Cedar 是嵌入式授权语言和引擎,没有内建分布式新鲜度。宿主必须把 policy、schema、entities 组成不可变快照,以 digest 进入缓存键,再用原子指针切换。不能先暴露新 policy、稍后加载新 entities;旧快照读者要有最大生命周期并在超时后回收。
这些机制可以组合,但组合决定必须显式。例如 ReBAC Check 与 OPA 风险策略都参与时,先定义输入、revision、超时和结果代数:常见安全组合是二者都明确 allow 才执行,任一 deny 则拒绝,任一 indeterminate 对高风险动作失败关闭。不要把两个 allow 用模糊 OR 拼在一起,也不要让一侧错误被另一侧 permit 覆盖。
可复制实验:测出 SpiceDB 的陈旧窗口
在隔离环境启动固定版本 SpiceDB 和 zed CLI,创建最小 Schema:
definition user {}
definition document {
relation viewer: user
permission view = viewer
}写入 Schema,创建关系并捕获 JSON:
zed schema write schema.zed
GRANT_JSON="$(zed relationship create document:reo viewer user:alice --json)"
GRANT_TOKEN="$(printf '%s' "$GRANT_JSON" | jq -r '.writtenAt.token // .written_at.token')"
zed permission check document:reo view user:alice \
--consistency-at-least "$GRANT_TOKEN"预期为 HAS_PERMISSION。删除关系后,循环比较默认模式与 token 约束模式:
REVOKE_START="$(date +%s%3N)"
REVOKE_JSON="$(zed relationship delete document:reo viewer user:alice --json)"
REVOKE_TOKEN="$(printf '%s' "$REVOKE_JSON" | jq -r '.deletedAt.token // .deleted_at.token')"
for i in $(seq 1 20); do
printf 'sample=%s mode=min-latency ' "$i"
zed permission check document:reo view user:alice \
--consistency-min-latency || true
printf 'sample=%s mode=at-least ' "$i"
zed permission check document:reo view user:alice \
--consistency-at-least "$REVOKE_TOKEN" \
--error-on-no-permission || true
doneat_least_as_fresh 路径预期稳定 NO_PERMISSION;默认模式允许在缓存配置下观察到短暂旧 allow。实验记录镜像版本、Schema 摘要、token 摘要、每次结果、退出码和单调时钟。若 CLI JSON 字段与示例不同,先打印响应并按当前 --help 修正提取,空 token 必须让实验失败。
这组命令只证明授权服务的读取新鲜度,还没有证明应用撤销。完整实验要通过真实 PEP 请求下载、修改、批量、consumer 和长连接入口;从撤销提交到每个入口连续拒绝分别计时,最终取最慢路径作为 T_revoke。
反向实验:故意制造缓存键碰撞
把前面的 authzCacheKey 保存为 cache-key.ts,再写一个测试:
import assert from "node:assert/strict";
import { authzCacheKey } from "./cache-key.js";
const base = {
tenant: "tenant-a",
principal: { type: "user", id: "42", sessionEpoch: 7 },
action: "document.read",
resource: { type: "document", id: "7", version: "19" },
context: { deviceTrust: "managed", region: "cn" },
authnAssurance: "mfa",
policyRevision: "model-31",
freshness: "rev-88",
};
assert.notEqual(
authzCacheKey(base),
authzCacheKey({ ...base, tenant: "tenant-b" }),
);
assert.notEqual(
authzCacheKey(base),
authzCacheKey({ ...base, principal: { ...base.principal, sessionEpoch: 8 } }),
);
assert.notEqual(
authzCacheKey(base),
authzCacheKey({ ...base, policyRevision: "model-32" }),
);
assert.equal(
authzCacheKey(base),
authzCacheKey({ ...base, context: { region: "cn", deviceTrust: "managed" } }),
);用项目既有 TypeScript 运行器执行。随后做一个故障注入分支,故意从 key 构造中删除 tenant,第一条断言必须失败。这比代码评审中“看起来包含了用户和资源”更可靠,也能在后续重构时防止边界字段被优化掉。
撤销事件必须有顺序和状态
异步事件可能乱序、重复或延迟,不能只按 wall clock 覆盖。事件至少包含稳定 event ID、tenant、关系或主体 key、单调业务版本、操作类型、发生/接收时间和幂等结果:
{
"event_id": "evt-opaque",
"tenant": "tenant-a",
"entity_key": "document:7#viewer@user:42",
"operation": "delete",
"business_version": 42,
"status": "pending"
}消费者只接受比当前版本新的事件;重复 delete 幂等成功,延迟到达的旧 grant 不得覆盖新 revoke。授权写失败时状态保持 PENDING/FAILED,高风险 PEP 收紧并告警,不能因为业务数据库已提交就标记“撤销完成”。事件和 relationship 变更通过 outbox/意图日志协调,重放使用同一个 operation ID。
反向实验按顺序发送 grant v41 -> revoke v42 -> 延迟 grant v41 -> 重复 revoke v42,最终必须稳定 deny,旧事件计入 stale_event_rejected_total,重复事件计入幂等命中。只要最终又变成 allow,说明消费者用到达顺序或时间戳代替了业务版本。
PEP 的失败关闭要按动作分级
高风险动作遇到 PDP timeout、5xx、解析失败、未知 obligation、缺 revision、Caveat conditional 或关系库不可达时,默认 deny/error,且不能产生业务副作用。低敏只读若经过风险批准,可以使用短期、同 revision、年龄可验证的本地缓存;动作白名单、最大年龄和告警必须配置化,不能由调用方临时决定。
一个稳健的执行分支类似:
const decision = await authorizer.check(request, { signal: deadline.signal });
if (decision.kind !== "allow") {
audit.record({ result: decision.kind, reason: decision.reason });
throw new AuthorizationDeniedError();
}
await command.execute();不要把网络异常 catch 后返回 true,也不要“先执行、授权异步补查”。有限重试只用于幂等 Check,并受总 deadline 约束;relationship 写入用意图日志和幂等 operation,不能在请求线程盲重试。日志系统不可达时,决定路径可使用有界缓冲;缓冲满后高风险动作是否阻断应由合规等级预先定义并演练。
break-glass 不是 PDP 故障时的默认 allow。它使用独立强身份、窄动作、短时授权、双人批准、全量审计和自动回收,不经过普通用户 allow cache。任何自动把超时升级为 break-glass 的实现都应视为严重缺陷。
长连接和在途请求决定最后一段窗口
WebSocket、SSE、gRPC stream、消息订阅和批处理不能只在建立时授权。选择一种与风险匹配的方式:对每个敏感消息或命令重新 Check;定期续租并绑定 session epoch/revision;撤销事件主动终止连接;或把短生命周期 capability 限定到具体动作和资源。仅拒绝新连接不算旧会话撤销。
实验步骤是建立连接并确认能收到测试事件,提交撤销,继续发送事件直到连接被主动关闭或下一次敏感操作被拒绝。计时直到所有副本和连接稳定停止,并检查撤销之后没有消息、文件或写入副作用。若连接只靠自然超时结束,超时时间必须进入 T_inflight 和 REO,而不能从撤销报表中排除。
并发请求也存在检查后使用竞态。对不可逆高风险操作,可在业务事务提交前再次检查,或使用资源版本与最小 freshness token 绑定检查结果;资源版本变化后旧 allow cache 自动失效。授权决定不能无限期跨请求复用。
可观测性要证明正确,不只证明在线
分别观测 PDP 可用性、正确决定、p95/p99 延迟、revision 收敛、撤销 p99、PEP 覆盖率和日志投递完整率。HTTP 200 但使用旧模型、错误 allow 或未被 PEP 执行,都不算授权可用。
每次决定至少记录:关联 ID、PEP ID、tenant 摘要、action、resource type、decision/reason、policy/model/schema revision、freshness mode、token 摘要、cache hit 层级、延迟和 error class。禁止记录 bearer token、cookie、完整 Caveat context、原始关系图和可逆主体标识。敏感输入在离开 PDP 信任域前就最小化,不能寄希望于下游 SIEM 再脱敏。
核心告警包括:
deny_to_allow_unapproved_total > 0,表示影子或发布产生未批准权限扩大。revision_convergence_ratio 未在发布窗口收敛。revocation_latency 的 p99 超过对应 action REO。
pep_bypass_total > 0 或入口覆盖率低于 100%。conditional_or_indeterminate_total 突增。cache key 缺 revision、跨租户命中或旧 session epoch 命中。
失效事件 lag、乱序拒绝和重放失败持续增长。
阈值由基线与 SLO 决定,不能复制演示数字。撤销验收保存权威提交时间、目标 revision/token、所有 PEP 连续 deny 样本、长连接终止时间和失败列表;单张 403 截图不是证据包。
HA 与分区演练要观察决定语义
集中式 OpenFGA/SpiceDB 至少跨故障域多副本,datastore 有明确主从、备份和恢复策略;sidecar/SDK 模式则要盘点每个实例 revision。负载均衡必须支持相应 gRPC/HTTP2 行为,客户端设置短 timeout、有限重试和整体 deadline。
按顺序注入故障:杀单个 PDP 副本、隔离 PDP 与 datastore、阻断策略分发、延迟撤销事件、让日志后端不可达。每次都检查高风险动作没有越权,错误类型稳定,恢复后缓存没有被错误 allow 污染,revision 收敛重新达标。只测“服务还能返回”无法证明安全。
Datastore 切只读副本前要证明该副本满足目标 consistency;撤销写不能在主库失败时伪成功。OPA 控制面断开时可以继续使用已持久化 bundle,但超出最大年龄后高风险动作要拒绝或摘流。Cedar 宿主在更新中断时只能继续使用完整旧快照,不能暴露 policy/entity 混合版本。
发布、迁移与回滚都要携带新鲜度
策略、模型或 Schema 发布先生成不可变 revision,运行单元测试、黄金语料、跨租户反例、条件缺失和撤销实验。影子双读比较同一输入时,两侧必须记录可比较的新鲜度;若一侧 revision 未知,差异没有解释力。差异至少分为 allow -> deny、deny -> allow、新错误和列表集合变化,未批准的权限扩大必须为零。
双写迁移使用状态机:旧端权威 -> 回填 -> 双写 -> 影子双读 -> 按 tenant/action 灰度 -> 新端权威 -> 停旧写。每次授权变更由同一 operation ID 驱动两端,失败可重放;冲突依据业务版本和权威日志,不用两个系统的 wall clock 决胜。切换前必须证明新端撤销 p99、PEP 超时行为、列表集合和长连接终止都达标。
回滚到旧端前确认旧端已经追平灰度期间的新写。若旧端陈旧,先冻结高风险权限变更或重放日志,不能立即把流量切回已知旧权限。OpenFGA 回滚固定旧 model ID,OPA/Cedar 回指旧签名 bundle,SpiceDB 按 Schema 和 datastore migration 兼容窗口处理;禁止直接降数据库 Schema。
清理和退出仍然要证明撤销
退出包保存 policy/schema/model 源文件及不可变 revision、关系或实体导出、Caveat/condition 数据、PEP 动作映射、缓存键、黄金语料、意图日志、最后同步位置和审计保留策略。真实 token、Cookie、共享 key 与直接身份标识不进入包;ZedToken 只作为原系统新鲜度证据,不能当成新系统版本。
安全顺序是冻结模型功能、全量导出、追平变更日志、导入新端、重放 Check/List、影子双读、灰度执行、停止旧写、保留只读核对窗口,最后撤销客户端凭据、网络入口和 datastore 账号并按策略销毁数据。清理后还要扫描进程内缓存、网关、队列、重试死信、备份和长连接。
授权一致性的最终验收不是“缓存已经清了”,而是对每类动作都能给出:哪个权威变更触发撤销,哪个 revision 或 token 约束读取,哪些 PEP 已经执行,最慢路径用了多久,任何失败怎样保持拒绝,以及系统迁移后旧权限为何不会再次出现。
