策略引擎与授权数据库组合:让属性规则和关系图共同决策
一个下载服务先问关系授权库“用户是不是文档 viewer”,再问策略引擎“设备和网络是否可信”。为了提高可用性,开发者把两个结果写成 relationshipAllow || policyAllow。某次外包人员的 viewer 已被撤销,但设备策略仍返回 allow,下载接口继续放行。两个正确工具被一个错误布尔表达式组合后,形成了比任何单一系统都更宽的权限。
另一套系统采用 relationshipAllow && policyAllow,看起来足够保守,却仍在策略发布时发生大面积 403。关系查询固定的是新 model,策略副本仍在执行旧 bundle;审计日志只写最终 deny,没有记录两侧 revision、上下文来源和失败类型。团队无法判断这是主动收紧、属性缺失、策略未收敛还是授权数据库超时,只能整体回滚。组合授权的难点不在“调用两次”,而在两个状态系统如何形成一个可解释、可撤销、可回切的决定。
两类引擎解决的是不同维度
关系授权数据库(authorization database)擅长回答“主体通过哪些对象关系获得某项权限”。OpenFGA 和 SpiceDB 可以表达直接共享、组织成员、父子资源继承和 userset,并提供 Check 与反向列表。它们把关系当可查询数据,因此适合回答“Anne 能看哪些文档”和“谁能管理这个项目”。
策略引擎擅长根据结构化输入执行规则。OPA 可以把 Rego 策略和小规模数据分发到本地或共享 PDP;Cedar 可以嵌入应用进程,用 principal、action、resource、context 和实体求值。它们更适合设备可信度、数据分级、操作时间、风险等级、地区限制和强制禁止等条件。OPA 不是开箱即用的完整授权平台,Cedar 也不自带分发、关系数据库或 HA。
只有同时出现“关系图很重要”和“动态属性会改变同一关系的可用性”时,组合才值得承担两套系统的延迟、发布、日志和运维成本。固定角色加少量属性规则,通常先选一个嵌入式引擎;主要是对象共享和层级继承,优先单独使用关系授权库;把简单问题拆成两个 PDP,不会自动增加安全性。
用 PAP、PIP、PDP、PEP 固定责任
NIST SP 800-162 的功能划分能阻止组件边界漂移:PAP 管策略和模型发布,PIP 提供主体、资源、环境和关系信息,PDP 计算决定,PEP 在资源入口执行决定。组合系统可以有两个计算组件,但只能有一个清晰的最终决定合同和受控执行路径。
编排器不是第三套随意规则。它负责规范化输入、按确定顺序调用两类引擎、校验版本和新鲜度、合并错误,并输出 allow | deny | indeterminate。PEP 只认这个合同;任何直接访问后端、批量接口、异步消费者、WebSocket 或管理脚本都必须经过等价执行点。
请求和响应可以固定成:
type AuthorizationRequest = {
tenant: string;
principal: { type: string; id: string };
action: string;
resource: { type: string; id: string; classification: string };
context: { mfa: boolean; deviceTrust: "trusted" | "unknown" };
relationModelId: string;
policyRevision: string;
consistency: "latency" | "higher";
};
type AuthorizationDecision =
| {
kind: "allow";
reason: string;
relationModelId: string;
policyRevision: string;
decisionId: string;
}
| {
kind: "deny";
reason: string;
relationModelId: string;
policyRevision: string;
decisionId: string;
}
| {
kind: "indeterminate";
reason: string;
decisionId: string;
};indeterminate 不能折叠成 deny 后永久缓存,因为依赖故障与真实无权限的恢复动作不同;更不能折叠成 allow。下载、写入、管理、密钥读取等高风险 action 在 indeterminate 时失败关闭,低风险只读若要使用短期旧决定,必须限定同一主体、资源、action、两侧 revision、最大年龄和审批策略。
选择确定的组合顺序
最容易审计的默认链路是“关系资格,再做策略约束”:
从认证上下文取得稳定主体,从路由和业务库取得租户、资源与 action,禁止调用者自报这些字段。调用 OpenFGA/SpiceDB,验证主体是否通过 owner、editor、viewer 或组织关系获得候选资格。关系明确允许后,把关系决定、模型版本和可信属性交给 OPA/Cedar,执行 MFA、设备、分级和地区等约束。
只有关系允许、策略允许、版本满足发布合同且两侧无错误时,编排器才返回 allow。PEP 在执行业务副作用之前再次确认决定与当前请求关联,记录执行结果。
这一顺序能减少策略引擎调用,并让策略规则只负责收紧已有关系资格。不能把关系结果作为普通客户端字段:它必须由编排器生成、在进程内传递或签名,策略入口拒绝外部伪造的 relation_allowed:true。
也有先策略后关系的场景,例如在高峰期先用便宜的租户状态和动作禁用规则拒绝大量请求,再执行昂贵图查询。顺序可以优化成本,但语义不能变化:任何阶段的 deny 都终止,任一错误都进入 indeterminate,最终 allow 仍要求所有必要条件成立。两个 PDP 并行可以降低串行延迟,却会增加无谓调用和审计关联成本,适合两侧耗时接近且资源预算充足的路径。
不要让 OPA 通过 http.send 临时调用 OpenFGA 并把网络、重试、模型 ID 和新鲜度隐藏在 Rego 内。这样会让策略评审无法看见依赖,OPA bundle 和关系模型也难以独立回滚。更稳妥的边界是由宿主编排器调用授权数据库,再把经过验证的关系证据作为只读输入交给策略引擎。
启动两个独立的本地决策组件
本地实验可让 OpenFGA 保存关系,OPA 执行动态约束。镜像版本应来自团队锁定文件;下面用 <approved-opa-version> 明确要求替换,而 OpenFGA 使用 v1.18.1 示例基线。OPA 1.x 采用 Rego v1 语义,实际 tag 和摘要应从 OPA Releases 选择。
services:
openfga:
image: openfga/openfga:v1.18.1
command:
- run
- --datastore-engine=memory
- --playground-enabled=false
ports:
- "127.0.0.1:8080:8080"
opa:
image: openpolicyagent/opa:<approved-opa-version>-static
command:
- run
- --server
- --addr=0.0.0.0:8181
- /policy
volumes:
- ./policy:/policy:ro
ports:
- "127.0.0.1:8181:8181"把下面规则保存为 policy/authz.rego。策略把关系结果视作必要资格,并对机密文档要求 MFA 与可信设备:
package authz
import rego.v1
default decision := {
"allow": false,
"reason": "default_deny",
}
decision := {
"allow": true,
"reason": "relationship_and_context_allowed",
} if {
input.relation.allowed == true
input.relation.model_id == input.expected.relation_model_id
input.expected.policy_revision == "policy-r1"
input.resource.classification != "confidential"
}
decision := {
"allow": true,
"reason": "confidential_controls_satisfied",
} if {
input.relation.allowed == true
input.relation.model_id == input.expected.relation_model_id
input.expected.policy_revision == "policy-r1"
input.resource.classification == "confidential"
input.context.mfa == true
input.context.device_trust == "trusted"
}启动并确认两个进程可访问:
docker compose up -d
curl -fsS http://127.0.0.1:8080/healthz
curl -fsS http://127.0.0.1:8181/health预期两条健康请求成功。它只证明进程可达,不证明组合授权正确;还要在 OpenFGA 创建 store/model/tuple,并由编排器完成两阶段调用。实验结束执行 docker compose down -v,memory datastore 和本地策略容器随之清理。
正向实验要证明两类证据同时成立
在 OpenFGA 创建模型,使 user:anne 成为 document:q3 的 viewer,并保存不可变 model ID。先直接 Check,预期 allowed:true;随后把返回值和 model ID 放进 OPA 输入:
curl -fsS -X POST \
http://127.0.0.1:8181/v1/data/authz/decision \
-H 'content-type: application/json' \
-d '{
"input": {
"relation": {
"allowed": true,
"model_id": "<model-r1>"
},
"expected": {
"relation_model_id": "<model-r1>",
"policy_revision": "policy-r1"
},
"resource": {
"classification": "confidential"
},
"context": {
"mfa": true,
"device_trust": "trusted"
}
}
}'预期 result.allow 为 true,reason 为 confidential_controls_satisfied。编排器保存的证据至少包含 OpenFGA store/model、关系请求摘要、OPA policy revision、上下文来源版本、两侧延迟、最终决定和 decision ID。真实 token、完整主体属性和原始设备指纹不能写入日志。
接着用非机密资源验证低风险分支,预期 relation 允许且 classification 不是 confidential 时返回允许。再用同一请求重放两次,两个结果应完全一致;若两次命中不同 model 或 policy revision,发布状态尚未收敛,不能进入执行流量。
反向实验必须覆盖错误而不只是 deny
把输入逐项改坏,每次都观察最终业务副作用,而不是只看 PDP JSON:
关系撤销:删除 viewer tuple,并用 OpenFGA HIGHER_CONSISTENCY 复查。即使 MFA 和设备满足,最终也必须拒绝。动态条件失败:保留 viewer,但把 mfa 改为 false。OpenFGA 仍允许,OPA 应拒绝,文件读取不能发生。模型错配:OpenFGA 返回 <model-r2>,OPA 输入仍要求 <model-r1>。预期策略默认拒绝;编排器应把它分类为版本错配,而不是普通无权限。
策略依赖失败:停止 OPA 或让它返回畸形 JSON。预期最终为 indeterminate,高风险操作无副作用。关系依赖失败:停止 OpenFGA 或让 Check 超时。OPA 不应拿伪造的 allowed:true 继续求值;最终同样为 indeterminate。上下文缺失:删除 device_trust。规则不应因 JavaScript truthy 转换意外允许;输入 schema 或编排器先拒绝,最终没有副作用。
可把 PEP 合同写成自动化断言:
for each case in [deny, timeout, 500, malformed, missing_context, revision_mismatch]:
assert final_decision != allow
assert protected_side_effect_count == 0
assert decision_id is correlated with both PDP attempts“超时也返回 403”只证明用户界面看起来安全,仍需确认数据库写入、对象存储读取、消息发布和异步补偿都没有提前发生。PEP 必须位于副作用之前,后台消费者和批量 API 也要做等价合同测试。
PIP 决定属性是否可信和新鲜
策略引擎读取的 mfa、设备信任、数据分级和地区不是天然事实。每个字段都需要 owner、来源、类型、采集时间、最大年龄和缺失行为:MFA 来自经过验证的 token claim;资源分级来自业务库;设备信任来自设备服务;租户状态来自控制平面。浏览器请求体只能提供提示,不能成为权威来源。
关系 tuple 和动态属性的生命周期不同。组织成员适合持久写入 OpenFGA/SpiceDB;一次登录选择可使用 OpenFGA contextual tuple;快速变化的风险分数更适合当次 OPA input;长期业务属性应保存在权威业务库并由 PIP 读取。把所有属性复制进关系图会造成 tuple 爆炸,把所有关系临时塞进策略输入又会失去反向列表和集中撤权能力。
OpenFGA contextual tuple 只在一次请求内有效,并可能覆盖数据库中同 key 的 tuple;OPA input 也只属于一次决定。两者都必须标记来源,不能把“请求临时数据”误写为“已持久授权”。若 token 里的组关系持续到 token 过期,撤权目标必须把 token 最大年龄计入,而不是只测授权数据库删除时间。
一致性要跨两套状态系统计算
组合链的撤权暴露时间可以拆成:
T_revoke = T_detect + T_relation_write + T_relation_visible
+ T_policy_publish + T_policy_apply
+ T_pep_cache + T_inflightOpenFGA 的 HIGHER_CONSISTENCY 绕过服务端缓存,但不返回写 revision token;SpiceDB 可用写入返回的 ZedToken 配合 at_least_as_fresh;OPA bundle 是最终收敛分发,要通过每个实例的 status/revision 证明激活;Cedar 的新鲜度完全由宿主加载策略和实体快照的方式决定。两侧证据不可互换。
缓存键至少包含租户、主体、action、资源、关系 model ID、policy revision、上下文摘要和一致性模式。allow cache 会延迟撤权,deny cache 会延迟新授权;两者需要不同的可见目标。高风险动作不应使用无法证明 revision 的旧 allow。长连接在建立后撤权时,要主动断开或在每个敏感消息前重验,不能只保护握手。
当两套系统的新鲜度无法比较时,影子差异应标记为 incomparable,而不是计入一致率。比如权威请求使用 OpenFGA 新模型和新 tuple,候选请求却使用旧 OPA bundle,即便最终结果相同,也不能证明语义等价。
决策组合器要显式处理全部状态
一个保守状态表比散落的布尔运算更可靠:
| 关系结果 | 策略结果 | 最终状态 | 执行动作 |
|---|---|---|---|
| allow | allow | allow | 执行并记录两侧 revision |
| deny | 任意 | deny | 不再执行副作用 |
| allow | deny | deny | 记录策略收紧原因 |
| error/timeout | 任意 | indeterminate | 高风险失败关闭,触发依赖告警 |
| allow | error/timeout | indeterminate | 高风险失败关闭,禁止复用无版本旧 allow |
| 版本错配 | 任意 | indeterminate | 摘流或回到已知组合版本 |
若业务确实存在 break-glass,它不是把 error 改成 allow。break-glass 使用独立强身份、窄 action、短时授权、双人批准和全量审计,并且不走普通用户缓存。服务故障不能自动把所有调用者升级成紧急管理员。
组合器自身需要短整体 deadline。两侧串行调用时,relation_timeout + policy_timeout + retry_budget 必须小于 PEP 总预算;重试只用于幂等查询,且保留同一 model/policy revision。不要重试 tuple 写入而没有 operation ID,也不要在超时后让第一次请求继续执行并与重试形成双副作用。
项目接入从 action 映射和旁路清单开始
为每个受保护入口建立 endpoint/RPC/consumer -> action -> resource type -> 风险级别 -> 组合策略 映射。GET 不天然等于低风险,导出、搜索和列表可能泄露大量数据;后台任务和批量接口也不能因为“不是用户请求”而绕过关系检查。
编排器可以暴露窄接口:
interface CompositeAuthorization {
decide(input: {
principalFromIdentity: string;
tenantFromServer: string;
action: "document.read" | "document.export" | "document.admin";
resourceFromDatabase: {
id: string;
classification: "internal" | "confidential";
};
trustedContext: {
mfa: boolean;
deviceTrust: "trusted" | "unknown";
};
}): Promise<AuthorizationDecision>;
}controller 不接收 model ID、policy revision 或 relation 名;这些来自部署清单。业务动作变更必须同时更新映射、黄金语料和 PEP 合同测试。列表场景先让 OpenFGA ListObjects/SpiceDB LookupResources 生成候选,再由策略条件批量过滤;最终敏感操作仍做单项决定。只用逐条远程调用过滤十万候选会造成扇出和分页空洞,反过来只信候选索引又可能放过已撤权对象。
资源创建与关系写入之间没有天然事务。业务事务写资源和 outbox,消费者幂等写 tuple;在关系确认前资源保持 owner-only 或不可见。策略需要的资源分级与关系事件共享业务 version,编排器拒绝“关系属于 v42、资源属性属于 v40”这种不可比快照。删除时先阻断访问、撤关系、等待强新鲜度拒绝,再删除内容和缓存。
可观测性既要能解释又不能泄密
一条组合决定至少记录:decision ID、PEP ID、action、脱敏主体/资源、关系引擎、store、model/schema revision、一致性模式、策略引擎、policy revision、PIP 版本、两侧结果类别、最终结果、延迟和错误码。日志不应包含 bearer token、cookie、完整关系图、原始设备指纹、完整 contextual tuple 或未经白名单处理的 OPA input。
OPA 决策日志可能包含 input、result、请求头和非确定 builtin 缓存,必须在离开 PDP 信任域前用白名单或 data.system.log.mask 擦除;不能把脱敏责任推给下游 SIEM。OpenFGA/SpiceDB 的关系日志同样可能暴露组织结构和资源存在性。重试队列、debug dump、追踪 baggage 与死信也是敏感数据副本。
指标按组件和结果拆分:关系 Check/List 延迟与错误、策略求值延迟与错误、版本错配、indeterminate、最终 deny/allow、PEP 旁路探针、撤权可见时间、缓存命中、PIP 缺失和影子差异。告警优先关注 deny -> allow 差异、新错误、版本分裂和撤权目标违约,而不是单纯 allow 比例变化。
HA 的目标是无越权退化
本地 OPA sidecar 能避免网络跳转,但每个副本都要独立接收 bundle 并报告 revision;集中 OPA 便于治理,却增加网络依赖。OpenFGA/SpiceDB 通常是无状态多副本加持久 datastore,副本、数据库、缓存和 dispatch 都会影响尾延迟。组合拓扑应按故障域决定,而不是把两个服务简单放进同一个 Pod 就称为高可用。
逐项注入故障:终止一个策略副本、终止一个授权副本、隔离 datastore、延迟 bundle 服务、让 PIP 超时、制造 model/policy 版本分裂。预期没有任何请求从 deny/indeterminate 变为 allow;错误率和延迟在预算内;恢复后缓存没有重新放出已撤销权限。若多数副本结果不一致,不能投票决定授权,应按已批准 revision 摘除异常副本。
容量预算包含两次求值、PIP 读取和审计写入。记录关系图深度与分支、List 候选数、策略 input 大小、bundle 大小、PIP 延迟、两侧缓存命中和最终 p99。把候选关系结果送入 OPA 时,不要附上整个组织图;传递最小证据可以降低序列化成本和敏感数据暴露。
发布、灰度和回滚使用组合版本
关系 model M1 与策略 bundle P1 构成一个已批准组合版本 C1。发布 M2 或 P2 时,不要让应用自动拼成未经验证的 M2+P1。部署清单显式声明允许的组合,实例启动和健康探针验证两侧 revision;不在清单中的组合进入 indeterminate 并摘流。
发布流水线依次执行模型/策略单测、黄金请求回放、旧新差异、影子读和小流量灰度。差异按四类统计:allow -> deny、deny -> allow、新增 error、无法比较。所有权限扩大必须有批准变更;新错误必须为零;收紧项要能对应业务意图。平均 99.9% 一致不能抵消一条未解释的管理员越权。
双写迁移期间只有一个权威写入意图日志和一个执行 PDP。新授权库先消费事件并影子 Check/List,不执行决定;达到差异、新鲜度和容量门槛后,按 tenant + action + resource type 切换。回切前确认旧系统已追平新系统期间的写入,不能直接回到陈旧旧端。
回滚按三层处理:关系模型固定回 M1,策略 bundle 回指 P1,应用恢复旧组合器与 action 映射。新 tuple 若旧模型无法解释,只回滚二进制会产生错误,因此兼容期要双写可逆数据并保留旧关系。datastore migration 和策略制品格式也要有各自的备份与兼容演练。
退出前证明语义可移植
迁出包不能只有 Rego/Cedar 源码或关系 tuple。还要包含不可变 model/schema/policy revision、引擎版本和制品摘要,tenant/store 映射,PIP 来源和最大年龄,PEP action 映射,缓存与一致性策略,关系/条件全量导出,黄金语料及期望的 allow/deny/error/reason。
迁移顺序是冻结新功能、全量导出、追平变更日志、导入新环境、计数和摘要核对、黄金语料与随机抽样回放、影子双读、切执行、停旧写、保留只读核对窗口,最后撤销客户端凭据、网络入口、数据库账号、备份和日志出口。无法等价表达的条件必须显式保留旧 PEP 或重新建模,不能用近似 allow 追求迁移完成率。
上线评审应能现场回答:为什么需要两类引擎;最终 allow 的必要条件是什么;每个属性由谁提供;两侧 revision 怎样组成批准版本;OpenFGA/SpiceDB 的新鲜度证据和 OPA/Cedar 的发布证据怎样记录;任一超时是否无副作用;Check 与列表如何避免扇出;撤权暴露时间怎样测;日志如何在 PDP 内脱敏;关系、策略、PEP、PIP 和 datastore 各由谁维护。只有这些问题都有可执行答案,组合架构才是在收敛授权复杂度,而不是把复杂度藏进两个远程调用之间。
