Cedar 嵌入式应用授权
一个文件服务把“项目所有者可以读取文档”写成 Cedar permit,上线后大多数请求正常,只有继承自部门组的成员持续被拒绝。团队先后修改了三次策略,最后才发现应用为降低请求体积,只装配了用户和文档两个实体,没有带上 User -> Group 的祖先关系。策略没有失效,缺失的是求值需要的实体闭包;由于日志只记录 Deny,默认拒绝、实体缺失和显式禁止在事故现场看起来完全一样。
另一次发布增加了“未完成 MFA 不得读取机密文档”的 forbid,同时一条旧 permit 读取了不存在的可选属性并发生求值错误。调用方只检查最终布尔值,看到部分请求仍为 Allow 就认定策略集没有错误。Cedar 实际会跳过出错策略并把错误放进 diagnostics;只要另一条 permit 命中且没有 forbid 命中,原生决定仍可能是允许。事故根因不是 Cedar 绕过禁止,而是宿主把“允许且带求值错误”误当成干净的允许。
先把 Cedar 放在正确位置
Cedar 是策略语言、语言规范、Schema 和可嵌入授权引擎。Rust 应用通过 cedar-policy crate 在进程内构造请求并求值,cedar-policy-cli 提供格式化、验证和授权等开发入口;两者的源码、发布与安装入口都在 Cedar 官方仓库。它不是守护进程,不监听授权端口,不验证密码或令牌,也不保存策略、业务实体和审计日志。策略从哪里发布、实体从哪里读取、多实例怎样同步、错误如何收紧、最终资源操作怎样拒绝,都由宿主应用负责。
一次调用的核心输入可记为 PARC:principal、action、resource、context。前三项是 Cedar entity UID,context 是只属于本次请求的 record。引擎还需要 PolicySet 与 Entities。Schema 用来验证策略、约束 action 适用的主体/资源类型,并协助按类型解析请求和实体;Schema 不作为 Authorizer::is_authorized() 的运行输入,也不会在每次求值时自动替应用完成全部验证。
这张链路图里有三个不能合并的时刻。策略发布前用 Schema 验证 PolicySet;请求进入时,应用按 Schema 和业务契约构造 request/entities;求值后,应用同时读取 decision 与 diagnostics,再由执行点保护真实操作。只调用 authorizer 而跳过前后两端,会得到一个快速但难以治理的布尔函数。
锁定 SDK 与 CLI,而不是依赖 latest
Rust 项目从 cargo add cedar-policy 进入,但可复现构建应把实际解析出的 crate patch 固定在 Cargo.lock,发布流水线使用 cargo build --locked。如果团队要求精确依赖声明,可在 Cargo.toml 中锁定审核过的版本;升级机器人提交的锁文件差异必须与策略回放结果一起评审。实验性 feature 可能有更强的 patch 兼容风险,不应在没有独立 corpus 和回退路径时进入关键授权路径。
cargo add cedar-policy
cargo install cedar-policy-cli --version <locked-version>
cedar --version
cedar --help<locked-version> 应替换为团队审核并与 SDK 兼容的精确版本。cedar --version 的结果进入构建证据,不要在脚本中静默安装浮动版本。源码仓库里的 cargo run --bin cedar 是 workspace 开发入口,已安装 CLI 的命令是 cedar,两者不能混写成同一种部署方式。Cedar 本身无需 Docker 或 Compose;为了一个进程内库包装常驻容器,只会额外引入网络、身份、超时和运维责任。
项目可以按下面的结构把语言资产与业务装配器分开:
authorization/
├── schema.cedarschema
├── policies/
│ ├── document-read.cedar
│ └── confidential-guardrail.cedar
├── fixtures/
│ ├── entities.json
│ └── requests/
├── src/
│ ├── authorization.rs
│ └── entity_encoder.rs
└── Cargo.lock策略与 Schema 属于可评审资产,fixture 保存脱敏后的稳定语义样本,entity_encoder 把业务对象转换为 Cedar entities,authorization 同时处理决定和 diagnostics。不要让每个路由临时拼 JSON;一旦字段名、层级或缺失值处理不同,同一业务动作就会出现多个授权模型。
用 Schema 建立模型契约
下面用文档读取场景建立最小模型。用户可以属于组,文档拥有 owner、tenant 与 classification,Document::read 只能作用于 User 与 Document,context 要求调用方提供 mfa。示例使用 Cedar schema format;它与 Cedar 的 JSON 表示可以转换,但 Cedar Schema 的 JSON 形式不是通用 JSON Schema,不能直接拿普通 JSON Schema 工具推断全部授权语义。
namespace Docs {
entity Group;
entity User in [Group] {
tenant: String,
suspended?: Bool,
};
entity Document {
owner: User,
tenant: String,
classification: String,
};
action "read" appliesTo {
principal: [User],
resource: [Document],
context: {
mfa: Bool,
}
};
}属性默认 required,suspended? 才是 optional。策略读取 optional 属性之前必须先用 has 判断,否则缺失属性会产生求值错误。User in [Group] 约束允许的父实体类型,运行实体中的 parents 才提供具体层级。Schema 声明“允许怎样建模”,不会自动查询用户属于哪个组。
action appliesTo 是早期发现错请求的重要边界。若调用方把 Service 当 principal,或者把 Folder 当成 read 的 resource,schema-aware request parsing 应在 authorizer 之前拒绝。action 也可以通过 memberOf 组成动作组;不能直接出现在请求中的分组 action 可以使用空的 principal/resource 类型约束。团队应把 action ID 当稳定 API,不要把 HTTP method 或页面按钮文案直接当 action。
先验证策略集:
cedar validate --schema authorization/schema.cedarschema --policies authorization/policiesCLI 参数可能随锁定版本调整,首次接入时以 cedar validate --help 核对文件与目录选项。关键不是记住一行命令,而是把 validate 放在提交和发布门禁中:策略无法解析、引用未知类型、属性类型错误或 action 不适用时,候选版本不得进入应用。授权调用成功不能反向证明这一步已经执行。
实体、请求与 context 要分清生命周期
实体 JSON 顶层是数组,每项包含 uid、parents、attrs,目标版本支持时还可能使用 tags。稳定 ID 必须唯一、不可复用;显示名称、邮箱和可变路径不适合作为 UID。删除后重建同名资源若复用旧 ID,旧缓存和审计记录可能把新对象继承为旧对象。
[
{
"uid": { "type": "Docs::Group", "id": "project-maintainers" },
"parents": [],
"attrs": {}
},
{
"uid": { "type": "Docs::User", "id": "user-alice" },
"parents": [{ "type": "Docs::Group", "id": "project-maintainers" }],
"attrs": {
"tenant": "tenant-a"
}
},
{
"uid": { "type": "Docs::Document", "id": "document-a" },
"parents": [],
"attrs": {
"owner": { "__entity": { "type": "Docs::User", "id": "user-alice" } },
"tenant": "tenant-a",
"classification": "internal"
}
}
]parents 表示 Cedar 的实体层级,不是 UI 面包屑。principal in Docs::Group::"project-maintainers" 的成立依赖完整可达关系;只传 User 而不传目标 Group,或者漏掉中间祖先,会改变求值结果。实体切片应由 action 对应的装配器产生,并通过 fixture 测试闭包,不能由接口开发者凭感觉删字段。
context 只放本次请求的环境状态,例如经过认证组件确认的 MFA、来源网络分类或设备姿态。长期成员关系、资源 owner 和租户归属放到 entities,便于版本化和撤销。来自 JWT 的 claim 在进入 Cedar 前必须验证签发者、受众、有效期和类型;Cedar 不负责认证,未经可信化的 mfa: true 只是攻击者可控输入。
一个请求文件可表达为:
{
"principal": { "type": "Docs::User", "id": "user-alice" },
"action": { "type": "Docs::Action", "id": "read" },
"resource": { "type": "Docs::Document", "id": "document-a" },
"context": { "mfa": true }
}使用 CLI 时把 request、entities、policies 和 schema 都作为固定 fixture 管理,并先查看锁定版本的子命令帮助。CLI 适合验证模型、构造回归和排查,不应被包装成每个业务请求都启动一次的新进程;生产请求由 SDK 在已加载的不可变快照上求值。
permit、forbid 与默认拒绝不是顺序规则
先允许同租户 owner 或维护组读取文档:
permit (
principal is Docs::User,
action == Docs::Action::"read",
resource is Docs::Document
)
when {
principal.tenant == resource.tenant &&
(resource.owner == principal ||
principal in Docs::Group::"project-maintainers")
};再加两条 guardrail:暂停用户不得访问;机密文档必须带 MFA。
forbid (
principal is Docs::User,
action,
resource
)
when {
principal has suspended && principal.suspended
};
forbid (
principal,
action == Docs::Action::"read",
resource is Docs::Document
)
when {
resource.classification == "confidential" && !context.mfa
};Cedar 的授权组合语义固定。任意满足的 forbid 都令最终结果为 Deny;没有 forbid 命中时,只要有一个 permit 满足才是 Allow;没有 permit 命中就是默认 Deny。策略文件顺序和“最后一条”没有优先级意义,新增 permit 不能绕过已命中的 forbid。需要例外时应重新建模 guardrail 的条件,而不是靠把例外 permit 放到文件末尾。
diagnostics 解释决定来源。Allow 时 determining policy IDs 指向满足的 permit;forbid 导致拒绝时指向满足的 forbid;默认拒绝时 determining 集合为空。审计若只记录 Deny,就无法区分“明确触发安全护栏”和“没有任何授权依据”,排障与告警会被迫依赖猜测。
skip-on-error 是必须显式处理的分支
单条策略求值错误会被跳过,错误进入 diagnostics,然后其余策略继续组合。它既不是自动命中 forbid,也不是自动让整次请求失败。下面这条错误示例直接读取 optional suspended,当实体缺少该属性时会产生 evaluation error:
permit (
principal is Docs::User,
action == Docs::Action::"read",
resource is Docs::Document
)
when {
!principal.suspended && principal.tenant == resource.tenant
};若另一条 owner permit 同时满足且没有 forbid 命中,原生决定可能仍是 Allow,并同时携带错误。这是 Cedar 有意定义的 skip-on-error,不是偶发实现细节。普通低风险读取可以选择保留原生语义并告警;高保证应用通常采用严格包装:任何 diagnostics error 都拒绝或返回内部授权错误,绝不继续执行敏感资源操作。
use cedar_policy::{Authorizer, Decision, Entities, PolicySet, Request};
pub enum GateDecision {
Allow,
Deny,
EvaluationFailure,
}
pub fn authorize_strict(
authorizer: &Authorizer,
request: &Request,
policies: &PolicySet,
entities: &Entities,
) -> GateDecision {
let response = authorizer.is_authorized(request, policies, entities);
if response.diagnostics().errors().next().is_some() {
return GateDecision::EvaluationFailure;
}
match response.decision() {
Decision::Allow => GateDecision::Allow,
Decision::Deny => GateDecision::Deny,
}
}代码骨架强调的是调用顺序:先拿完整 response,再检查 errors,最后解释 decision。具体迭代器与构造 API 以锁定 crate 文档为准,并应由编译测试保护。不要把 error 文本直接返回客户端,也不要把完整 entities 写入日志;对外只给稳定错误码,对内记录脱敏 request 摘要、策略版本、错误类别和 correlation ID。
一组正反实验把语义钉住
实验使用固定 SDK/CLI patch、固定 Schema、策略与 entities fixture。以下是可复现设计及预期语义,不是声称已经运行得到的 stdout;接入项目时应让流水线真正执行,并保存退出码与结构化结果。
第一轮验证 owner 正向路径。Alice、read、document-a、mfa: true 与完整实体集合进入 authorizer。预期决定为 Allow,determining policies 包含 owner permit,没有 evaluation error;执行点随后读取真实 fixture 文件或数据库记录,并记录操作成功。只看到 Allow 而未执行资源读取,不算闭环。
第二轮只把 principal 改成同租户访客 Bob。预期为默认 Deny,determining policies 为空且没有 evaluation error。这个结果证明“没有授权依据”,与命中 forbid 的拒绝不同。清理时还原 request fixture,不需要改策略。
第三轮把 document-a 的 classification 改为 confidential,并把 context.mfa 改为 false,让 owner permit 与 MFA forbid 同时满足。预期最终 Deny,determining policies 指向 forbid。交换两个策略文件的加载顺序,结果应保持不变;若变化,说明测试的不是 Cedar 固定组合语义,而是宿主在装载前后修改了策略集。
第四轮加入前面的错误 permit,同时保留可命中的 owner permit,并让 Alice 实体缺少 suspended。原生 authorizer 的预期语义是 Allow 加 diagnostics error;严格包装的预期结果是 EvaluationFailure,执行点不得读取文档。随后把错误条件改成 principal has suspended 后再读取属性,验证 diagnostics 清空。这个反例必须长期留在回归语料中,避免未来重构只检查 decision。
第五轮删除 Alice 的 Group parent,再把文档 owner 改成另一个用户,使授权只可能来自组关系。预期从 Allow 变为默认 Deny;若提供 Schema 不允许的 parent type,schema-aware entity parsing 应在求值前失败。它证明实体层级是授权数据,不是展示元数据。
第六轮把 action 的 principal 改成 Schema 未允许的类型,分别走 schema-aware 请求解析与未绑定 Schema 的构造路径。前者应稳定拒绝错误输入;后者不能被描述为已经完成相同验证。项目应选择单一受保护入口,避免部分路由绕过 Schema 解析。
每轮都记录不变量:决定、determining policy IDs、错误类别、策略 revision、实体数据 revision 与执行结果。性能数据另行测量,不把本机一次耗时混入语义断言。实验结束只清理 fixture 生成物和临时策略快照,保留最小 corpus 作为升级门禁。
接进真实项目时让对象只有一个 owner
应用层先把认证身份映射为稳定 principal,再根据 action 和 resource 加载最小实体闭包,构造 context,调用授权包装器,最后在同一业务入口执行资源操作。HTTP handler 不应直接接触 Cedar JSON;它调用类型化的 can_read_document(actor, document_id, auth_context),由授权模块完成 ID 规范化、实体装配和错误分类。
列表接口不能对数据库返回的每一行盲目调用 Cedar。小列表可以在有上限的候选集上批量装配 entities 并逐项求值;大列表需要把权限关系转成查询索引、预计算可见集合,或选择原生支持列表查询的授权系统。先分页再过滤会产生空页和遗漏,取消分页上限则会把一次请求放大成内存与 CPU 风险。
策略快照由应用级 provider 持有,而不是每次请求读取文件。候选版本完成解析、Schema validation 和 corpus replay 后,生成不可变 PolicySet + revision;实例以原子引用切换,进行中的请求继续使用旧快照,新请求使用新快照。发布失败保留旧版本,不把半组策略暴露给 authorizer。Schema、实体编码器和策略发生不兼容变化时,应作为同一发布事务灰度。
排障先区分三种拒绝和一种危险允许
默认拒绝表现为 Deny、没有 determining policy、没有 evaluation error。先检查 action ID、实体 UID、租户与 permit 条件,再确认是否漏装实体祖先。不要为了“先恢复”添加宽泛 permit,它可能把请求装配错误掩盖成长期授权。
guardrail 拒绝表现为 Deny 且 determining policies 指向 forbid。核对 context 和实体属性是否来自可信来源,再检查 guardrail 的条件是否按预期命中。修改 permit 不会绕过 forbid;真正的修复是纠正输入或调整 forbid 的业务条件,并通过正反 corpus 评审。
求值错误导致的拒绝在严格包装下表现为内部授权错误,diagnostics 有 error。常见原因包括缺少 optional 属性却未用 has、类型不匹配、实体闭包不足或策略与编码器版本错位。先按错误 policy ID 定位,再比较 Schema、策略与实体 revision;不要把它降级成普通无权限,否则数据质量故障会长期隐藏。
允许且带错误是最危险的观测分支。它说明至少一条 permit 满足,同时另有策略求值失败。立即确认执行点是否采用严格包装、错误策略是否本应承担 guardrail,以及错误是否集中在某个实例或数据版本。监控不能只统计 Allow/Deny,还要单独统计 allow_with_evaluation_error。
策略在本地验证通过、线上却失败时,优先比较真实请求的 entity 类型和 context shape,而不是重新验证同一份文件。实例间答案不同则先比较 applied revision 与 entity data revision。只有某类层级成员失败时,检查父实体闭包和 UID 命名;只有大对象失败时,检查实体切片规模、解析限制与目标运行时,而不是直接提高所有输入上限。
性能优化从减少重复工作开始
Cedar 的优势是进程内求值,但总延迟还包括权限数据读取、实体编码、JSON 解析、策略装载、日志和业务事务。基准应分别测量冷启动解析、稳定快照上的 authorizer 求值、实体装配与端到端执行,避免把数据库查询耗时误归因于策略语言。测试数据规模至少覆盖策略数、实体数、层级深度、属性大小和并发请求,而不是只用一个 owner permit。
策略与 Schema 在发布时解析,稳定请求复用不可变 PolicySet;实体若由 Rust 对象直接构造,可减少重复 JSON 解析,但仍要经过统一编码契约。实体缓存必须绑定业务数据 revision,尤其要保证撤销不会继续命中旧允许。缓存完整 response 比只缓存 bool 更利于保留 reason 与 error,但其中的敏感 ID 和诊断信息需要受控存储。
容量门槛用趋势和不变量定义。随着固定并发下策略或实体规模增长,观察 p50/p95/p99、CPU、分配量与错误率;连续切换策略快照后,旧快照引用应回到稳定基线;撤销成员关系后,所有实例的旧允许应在既定传播预算内消失。具体毫秒和对象数由目标机器、锁定版本与 SLO 测量,不能写成脱离环境的万能阈值。
不要把整个组织目录塞进每次请求,也不要通过减少必要祖先换取漂亮延迟。正确优化顺序是:消除重复解析,固定策略快照,按 action 生成可证明完整的实体切片,批量读取权限事实,最后才考虑受版本约束的缓存。性能改动必须同时回放反例,否则“更快”可能只是少算了授权事实。
权限数据比策略更容易陈旧
Cedar 不拥有业务实体。用户组、资源 owner、租户、分类和暂停状态通常来自数据库、目录或事件投影。为每类事实指定权威来源与 revision,实体编码器只消费已验证内部模型。若通过 outbox 或 CDC 建投影,事件幂等键、删除语义、重放顺序和对账都要定义;消息被消费只说明投影尝试更新,不说明所有应用已经使用新数据。
授权新增与撤销的风险不对称。新增延迟多表现为合法用户暂时被拒,撤销延迟则让旧权限继续生效。高风险 action 应读取满足新鲜度要求的数据,或在撤销事件到达时主动清除相关实体/决定缓存。日志保存数据 revision 或 age,才能回答某次允许是否使用了撤销前快照。
entity attrs 与 context 可能包含部门、文档分类、IP、设备姿态等敏感信息。普通应用日志只保存脱敏 UID、必要的分类结果和 revision,不转储完整 entities;策略本身也可能暴露组织结构与高权限条件,应按代码和安全配置的同等级别授权。Cedar annotations 不参与求值,可以承载受控元数据,但不能拿来实现授权条件。
策略发布是一笔兼容事务
候选策略先解析,再以目标 Schema 进行严格验证,随后回放允许、默认拒绝、forbid、错误和实体层级 corpus。发布比较的不只是 Allow/Deny,还包括 determining policy 与 diagnostics diff。新版本若把干净 Allow 变成带错误 Allow,即使允许率不变也必须阻断。
Schema 变更会影响 policy、request builder 与 entity encoder。新增 required 属性要求所有生产者先具备该字段;删除或改类型则要求策略先停止读取。兼容发布可分阶段进行:先让编码器双写新 optional 字段,再发布兼容新旧形态的策略,确认所有实例越过目标 revision,最后收紧 Schema。不要先发布严格 Schema,再等待旧实例慢慢补字段。
多实例分发使用带摘要的不可变包,实例只在整包验证成功后更新 applied revision。灰度实例先承接影子请求或低风险流量,比较决定和错误分布;回滚是重新指向上一份完整快照,而不是在线编辑某个 policy 文件。实例启动时若拿不到候选版本,应按配置继续使用已验证旧快照或 fail closed,不能拼出部分新旧策略。
审计事件至少包含 correlation ID、脱敏 PARC、policy revision、entity data revision、decision、determining policy IDs、evaluation error 分类、latency 与执行结果。策略作者、发布者和业务数据 owner 分开授权;紧急变更需要短时审批与自动到期,事后仍回到正常仓库版本。
升级和退出都靠 corpus 保住语义
升级 cedar-policy 或 CLI 时先锁候选 patch 与 feature flags,在隔离构建中执行 parse、validate、全部 corpus 与 diagnostics diff,再做性能基线和目标平台验证。Rust SDK、CLI、Wasm 或其他绑定是不同交付物,不能因为语言规范相同就假设 API、错误形态和资源限制完全一致。目标运行时若涉及 Wasm、移动平台或特殊栈实现,还要对大输入与深层实体关系做单独边界测试。
迁移其他授权实现到 Cedar 时,先对齐组合语义。Casbin 的 effect/matcher 不必然等于 forbid-overrides-permit,Polar 的否定约束也不是 Cedar forbid。旧系统继续执行,新 Cedar 路径只记录 shadow decision、reason 和 errors;差异按请求建模、权限数据、组合语义和错误处理分型,不能只计算总允许率。
退出 Cedar 时导出 policy、Schema、entity encoding contract、action 目录、测试 corpus、策略版本历史和权限数据映射。先让替代实现通过同一语义 corpus,再灰度切换执行点;确认所有实例不再加载 Cedar 快照后,删除依赖、清理缓存和诊断数据。Cedar 没有替团队保存这些资产,因此从第一天就把它们作为可迁移契约管理,退出才不会退化成重新猜权限规则。
做到这里,Cedar 才真正发挥进程内授权的价值:决定足够快,语义足够明确,错误不会被布尔值吞掉,策略和权限数据都能按版本发布,最终执行点也能证明真实资源操作遵守了决定。
