策略测试、影子发布与决策可观测
某支付平台把一条“值班工程师可以重放失败事件”的策略从角色判断改成角色、租户和事件状态的组合判断。单元测试全部通过,灰度请求的总体一致率也超过 99%,团队便切换了全部流量。被平均数遮住的是几十条 deny -> allow:候选策略在缺少租户属性时走了宽松默认值,恰好让跨租户重放获得许可。测试证明了编写者想到的分支,却没有证明真实请求分布,更没有把“权限扩大”和普通差异分开。
另一套授权服务采用影子决策验证新模型,但影子调用和权威调用分别读取不同时间点的关系快照。管理员刚撤销成员关系,旧端仍命中缓存,新端已经读取最新数据,于是监控持续报告大量 allow -> deny。团队为追查差异,把完整 JWT、资源 ID 和请求上下文写入 decision log,结果授权发布尚未完成,敏感数据先进入了日志索引、重试队列和备份。问题不在影子流量本身,而在不可比较的新鲜度与失控的审计边界。
把策略发布当成授权行为变更
策略文件可以通过语法检查,不代表授权行为正确。发布真正改变的是一个函数:在给定主体、动作、资源、上下文、策略 revision 和数据快照时,PDP 返回 allow、deny 或不可判定,PEP 随后决定是否产生业务副作用。因此测试对象不能只是一段 Rego、CEL 或 Cedar 语句,还必须包括输入规范化、属性与关系数据、错误映射、缓存新鲜度以及执行点。
先在代码库固定最小目录。二进制由项目工具管理器或内部制品库安装,并固定可审计版本与摘要;不要让 CI 每次下载“最新版”。下面以 OPA 展示可直接运行的策略测试,其方法同样适用于 OpenFGA model test、SpiceDB schema validate 和 Cedar policy validation。
authz/
policy.rego
policy_test.rego
corpus/
allow-owner.json
deny-cross-tenant.json
deny-missing-tenant.json
revisions/
stable.rego
candidate.rego
reports/安装后先保存版本证据,再让空测试集成为失败,而不是“没有失败就算成功”。OPA 的测试、覆盖率与基准能力可核对 OPA Policy Testing;若项目还编译 Wasm,必须对 Wasm target 再跑同一组语义用例。
opa version
opa test --fail-on-empty --coverage --threshold 90 authz/
opa test --fail-on-empty --target wasm authz/覆盖率只是定位未执行分支的信号。即使达到阈值,也可能没有跨租户、撤销、未知 action、错误类型或 PEP 旁路用例,不能用一个百分比代替风险分析。
先固定可比较的决策合同
旧版本与候选版本必须接收同一份规范化请求。主体 ID 不从客户端参数读取,资源 tenant 由服务端 resolver 取得,action 来自稳定枚举,上下文字段必须携带来源和缺失语义。比较结果至少绑定 policy_revision、data_revision 或 freshness mode;任一侧无法证明数据新鲜度时,差异标为 INCOMPARABLE_FRESHNESS,不能混进一致率。
{
"case_id": "cross-tenant-reader",
"request": {
"tenant": "tenant-a",
"principal": { "type": "user", "id": "user-17", "roles": ["reader"] },
"action": "document.read",
"resource": { "type": "document", "id": "doc-9", "tenant": "tenant-b" },
"context": { "mfa": true }
},
"expected": { "decision": "deny", "reason": "TENANT_MISMATCH" }
}合同还要约束错误。未知 action、缺失关键属性、PIP 超时和畸形响应不能自动折叠为普通 false,否则一条“没有匹配禁止规则便允许”的组合策略可能把依赖错误变成静默放行。应用层只消费稳定的 Allowed、Denied、Indeterminate,并由 PEP 对高风险 action 采用失败关闭。
用单元测试锁住策略语义
先写最小策略和正反用例。下面的 Rego v1 策略只有同租户 reader 才允许读取,缺字段自然得不到 allow;测试同时覆盖成功、跨租户和缺少 tenant。保存为 authz/policy.rego 与 authz/policy_test.rego 后执行前面的 opa test。
package app.authz
import rego.v1
default allow := false
allow if {
input.action == "document.read"
input.tenant == input.resource.tenant
"reader" in input.principal.roles
}package app.authz_test
import rego.v1
import data.app.authz.allow
test_same_tenant_reader if {
allow with input as {
"tenant": "tenant-a",
"action": "document.read",
"principal": {"roles": ["reader"]},
"resource": {"tenant": "tenant-a"}
}
}
test_cross_tenant_is_denied if {
not allow with input as {
"tenant": "tenant-a",
"action": "document.read",
"principal": {"roles": ["reader"]},
"resource": {"tenant": "tenant-b"}
}
}
test_missing_resource_tenant_is_denied if {
not allow with input as {
"tenant": "tenant-a",
"action": "document.read",
"principal": {"roles": ["reader"]},
"resource": {}
}
}正向结果应是三个测试通过;反向实验可暂时把策略中的租户比较删除,跨租户用例应立即失败,并在输出中指向 test_cross_tenant_is_denied。这个故障证据比“接口返回非 200”更有价值,因为它直接证明错误分支是权限扩大。恢复策略后再次执行,退出码回到零,才算完成清理。
历史回放补上真实请求分布
fixture 由规则作者设计,历史语料则负责暴露作者没有想到的组合。语料从生产请求派生时,应在受控边界内完成字段白名单、HMAC 伪名化和低频组合保留;删除 token、cookie、完整 JWT、原始主体与资源 ID、资源内容和自由文本。冻结后的 corpus 带摘要、采样规则和数据 revision,不能随着每次测试静默变化。
回放程序不访问生产 PEP,也不产生消息、写库或导出等副作用。它调用纯决策入口,把稳定版和候选版的结果写成逐行 JSON。除了随机样本,还要定额保留高风险 action、跨租户拒绝、权限撤销、条件缺失、深层组关系、List 与 Check 组合以及曾经发生过的事故语料。
case_id,tenant_hash,action,resource_type,
old_decision,new_decision,old_revision,new_revision,
old_data_revision,new_data_revision,diff_class,
old_error_class,new_error_class,old_latency_bucket,new_latency_bucket回放容量按“请求数 × 两路求值成本 × 数据快照装载成本”估算。大关系图、列表查询和条件属性不能只用均匀随机请求压测;热门资源、大组嵌套和高扇出查询通常决定 p99。语料规模增长后按 action 与资源类型分片并行,但同一 case 的旧新求值必须使用可比较快照。
差分门禁按风险分类
总体一致率会把一条危险差异埋进成千上万条相同决定。发布报告至少把差异分成 allow -> deny、deny -> allow、same -> error、error -> allow、reason 变化与延迟退化。deny -> allow 是权限扩大,任何未解释记录都应阻断;allow -> deny 可能是有意收紧,也可能中断业务,必须关联批准的需求或缺陷证据。
same -> error 要求归零,因为候选版本已经无法对原本可判定请求给出结果;error -> allow 默认阻断,防止旧依赖故障被新默认值变成放行。reason code 是审计和客服排障合同,消费者未同步时也属于兼容性破坏。延迟差异按 action 分组观察 p95/p99,不能让候选 PDP 吃完整个业务 deadline。
可以把门禁写成机器可判定的摘要:
gates:
unexplained_deny_to_allow: 0
same_to_error: 0
error_to_allow: 0
approved_allow_to_deny_ratio: 1.0
comparable_freshness_ratio: 1.0
candidate_p99_ms_max: 20
shadow_drop_ratio_max: 0.001阈值由业务延迟预算和风险分级决定,示例数值不能直接搬到生产。真正不可妥协的是分类语义、不可比样本剔除、权限扩大逐条复核和审批证据可追踪。
影子决策不能影响权威路径
影子架构由 PEP 将规范化请求交给权威 PDP,并把一份有界、异步副本发送给候选 PDP。权威决定是唯一执行结果;候选只比较,不得写业务状态、通知用户、修改缓存权威或把 obligation 交给 PEP。影子队列满时丢弃影子副本并告警,绝不能反压业务授权路径。
request -> PEP -> authoritative PDP -> enforce -> business side effect
\
-> bounded async queue -> candidate PDP -> diff sink每条比较记录同时携带两路 revision、数据新鲜度、decision ID、采样原因和队列等待时间。若旧端读的是关系快照 R17,新端读的是 R18,比较器只能输出不可比,不能声称候选策略收紧。关系授权还要比较 ListObjects/ListUsers 的集合,固定分页 snapshot、去重与排序后再求差集,不能只比较单点 Check。
影子资源池与权威资源池隔离。限制并发、队列长度、单请求 deadline 和最大图遍历成本;记录采样率、丢弃率与不可比率。否则漂亮的一致率可能只是因为最慢、最复杂、最危险的请求都被丢掉了。
分阶段发布每步只改变一个变量
发布从离线验证开始:语法与 schema、语义 fixture、冻结语料回放、PEP 合同和性能基线全部通过后,生成不可变、带摘要或签名的策略制品。随后进入只观察的影子阶段,再按 tenant + action + resource type 切换极小执行单元,逐步扩大。不要同时升级策略引擎、改变数据模型、切换缓存和扩大流量,否则差异无法归因。
每个阶段至少检查四组指标:决策正确性、授权依赖可用性、执行结果和新鲜度。一个 PDP 返回 HTTP 200,但使用错误 revision 或 PEP 没有执行 deny,仍是失败。候选阶段出现未解释的权限扩大、错误率增加、撤销超过 REO、p99 越界或审计缺失时,路由立即回到上一批准 revision;新制品保留用于复盘,不要删除证据。
OPA bundle 可在验证成功后原子激活,失败时继续运行旧 bundle,具体状态与 revision 可结合 OPA Bundles 和 status API 设计摘流条件。OpenFGA 应显式固定 authorization model ID,并使用官方 Model Testing 验证模型;SpiceDB schema 变更则先用 zed validate 与 schema diff 检查兼容影响。
决策日志只记录排障所需证据
decision log 的最小集合是随机 decision ID、低基数租户代理值、主体与资源的带密钥不可逆伪名、action、resource type、allow/deny/error、稳定 reason code、policy/model revision、consistency mode、延迟桶、PEP ID 和 trace ID。日志应能回答“谁的哪类动作,由哪个 revision 在哪个执行点得到何种结果”,但不应重建完整请求。
禁止记录 bearer token、cookie、authorization header、完整 claims、邮箱手机号、原始 ID、资源内容、完整 input/context/entities、关系路径、Caveat 参数、数据库 URI 和策略服务凭证。OPA decision log 可能包含 input、result、headers 与 bundle revision,应在事件编码和上传前通过 data.system.log.mask 删除或替换敏感 JSON Pointer;细节可查 OPA Decision Logs。
package system.log
mask contains "/input/authorization" if true
mask contains "/input/context/token" if true
mask contains {"op": "upsert", "path": "/input/principal/id", "value": "REDACTED"} if {
input.input.principal.id
}反向实验在隔离环境注入唯一 canary 字符串,然后检查进程日志、上传 body、collector、重试队列和死信;预期所有位置都没有明文,同时保留字段已按合同出现。若发现泄漏,先停止日志出口和轮换受影响凭证,再清点索引、对象存储、队列和备份,不能只在查询界面增加掩码。
可观测性要连接决定与执行
授权仪表盘至少分开 allow、deny 与 error,并按 action、resource type、PEP、revision 和错误类观察;原始主体与资源 ID 不作为指标标签。核心信号包括 PDP 可用性、正确决策率、p95/p99、PEP 覆盖率、revision 收敛率、影子差异分类、不可比率、队列丢弃率、撤销 p99 和日志投递完整率。
PEP 还要记录 enforcement outcome。策略说 deny,但 handler 仍写库,说明故障在执行点;策略说 allow,业务因下游故障失败,也不能算授权错误。批量接口、后台 worker、消息消费者、预签名 URL 和长连接都进入 PEP 清单,并通过合成请求持续验证,避免新入口绕过授权网关。
告警不能只看总体拒绝率。deny -> allow 出现一条、同 revision 在不同副本产生不同决定、候选 same -> error、新 revision 长时间不收敛、影子丢弃率陡升、日志 canary 泄漏以及高风险撤销超时,都需要独立告警和 owner。
项目接入从 CI 延伸到运行时
CI 先固定工具和制品摘要,再运行格式化、语法/schema、单元测试、冻结语料回放、差分门禁与敏感字段扫描。通过后只发布不可变 candidate revision,不直接覆盖 stable。流水线服务账号只能读取测试语料、写候选制品和报告,不能修改生产路由或删除审计;生产发布由独立身份和审批控制。
运行时由 AuthorizationGateway 生成规范化请求与 decision ID。影子复制只接收白名单字段,使用独立短期凭证和网络策略;diff sink 只写伪名化结果。策略作者不能删除决策日志,日志管理员不能发布策略,高风险灰度与 break-glass 使用双人批准和自动过期。
成本模型要包含候选 PDP 计算、双倍数据读取、影子队列、差分存储、审计索引、语料维护、人工复核与演练。长期保留每条高粒度 decision log 往往比 PDP 更贵;按业务必要期保存原始日志,聚合指标去除可重识别维度,影子数据在发布完成后按策略清理。
从故障现象反推缺失证据
“单元测试通过但线上越权”先查语料是否包含真实入口、PEP 是否覆盖批量与后台路径、缺字段是否失败关闭,再查策略分支。不要先增加更多同类 allow 用例。“影子差异突然增加”先按 revision 与 data snapshot 分组,再看输入规范化与 PIP 来源;两路新鲜度不同,不是策略回归证据。
“候选延迟正常但业务超时”要把队列等待、网络、PIP/datastore、序列化、有限重试和 PEP 剩余预算都纳入 trace。影子调用应有更短 deadline,超时后记录丢弃,不与权威路径争抢连接池。“决策日志查不到事故”则核对 decision ID 是否贯穿 PDP、PEP 与业务副作用,日志投递失败是否进入有界缓冲,以及采样是否错误排除了 deny/error。
发布回滚后,确认所有 PEP 已回到批准 revision,候选队列不再接收请求,影子凭证和临时网络入口已撤销,差分与 canary 数据按保留策略清理。保留不可变制品、报告摘要、审批与故障证据,下一次发布才能从已知基线继续,而不是重新猜测。
每个高风险 action 都有 allow、deny、缺字段、跨租户、依赖错误和撤销用例,测试集为空会失败。冻结语料经过白名单与伪名化,旧新决策使用同一规范化输入和可比较数据快照。差异按权限扩大、权限收紧、错误变化、reason 与延迟分类,未解释的 deny -> allow 阻断发布。
影子候选永不执行决定,有独立有界资源池,并记录采样、丢弃与不可比率。灰度以可回滚的租户、动作和资源类型为单位,每步只改变一个主要变量。decision log 不含凭证、完整 claims、原始 ID 或业务内容,canary 扫描覆盖本地、上传、队列与死信。
PDP 决定、PEP 执行结果、业务副作用和 revision 可通过 decision ID 关联。回滚能恢复上一批准 revision,并完成候选凭证、临时入口与测试数据清理。
