测试策略
面向在生产环境修改文档的策略作者和评审者。每个文档变更都经过控制面:控制面检查并编译变更,可以在影子窗口中用线上流量试运行,在无法证明变更安全时要求第二个人审批,然后才发布。本页介绍这一流程及其周边工具。字段本身见策略语言。
控制面
控制面随 cosec-control 模块提供(它需要 cosec-cocache,因为文档保存在 Redis 中)。它在 {cosec.control.http.base-path}/v1(默认 /cosec/control/v1)下提供 HTTP API;其设置为 cosec.control.*(配置),文档如何存储以及如何首次发布见存储。
控制请求首先是一个普通请求:宿主管道对它认证,并且必须允许它,因此文档必须允许运维人员访问该 API 路径。之后每一步都以该主体的身份再次授权,所在应用为 cosec.control.app-id(默认 cosec-control),动作为针对所涉及文档(doc/platform、doc/app/<appId>、doc/tenant/<tenantId>)或条目的 cosec:<verb>。在任何策略之前,先执行一条硬性规则:租户的主体只能操作自己租户的文档;平台文档和应用文档属于 (platform) 租户的主体。
| 步骤 | 需要 |
|---|---|
| 读取文档、历史、差异 | cosec:read |
提议、apply、rebase | 对写入或删除的每个条目需要 cosec:write,对写入的每个绑定的作用域需要 cosec:grant;apply 和回滚还需要 cosec:read |
| 开启影子窗口、发布 | cosec:publish |
| 跳过影子窗口 | cosec:skip-shadow |
| 审批、驳回 | cosec:approve |
simulate、explain、analyze | 在决定该请求的租户文档上分别需要 cosec:simulate、cosec:explain(没有主体的请求是 (0) 的文档);在所查询租户的文档上需要 cosec:analyze |
cosec:grant 在每条绑定的作用域 scope/<tenant>/<app>/<space> 上检查:绑定任意 app 或任意空间时那一段为 *,默认空间为 (default),所以限制在默认空间中授出的策略写 scope/acme/order/(default)。以 ( 开头的空间 id 归 CoSec 所有,(default) 因而不会是你的空间。
可以把三个内置策略绑定给运维人员:cosec:platform-admin(对所有文档的所有动作)、cosec:tenant-admin(对自己租户文档的 read、write、grant、publish、simulate、explain、analyze、shadow-read 和 skip-shadow)和 cosec:tenant-viewer(同上,但没有 write、grant、publish 和 skip-shadow)。
| 方法和路径 | 作用 |
|---|---|
GET /documents/{key} | 当前修订。{key} 为 platform、app:<appId> 或 tenant:<tenantId>。 |
GET /documents/{key}/revisions?before=&limit= | 修订历史,最新的在前(limit 为 1 到 200,默认 20)。 |
GET /documents/{key}/revisions/{revision} | 某个保留的修订;已不再保留时返回 410。 |
GET /documents/{key}/diff?from=&to= | 两个修订之间(展开后的文档)新增、删除和修改的策略与绑定,按原样的 roles,以及 sections:其余部分(目录、限制、属性、边界或限流规则)是否不同,按其内容比较。 |
GET /documents/{key}/revisions/{revision}/propagation | 最近上报的实例中有多少已安装该修订。 |
POST /documents/{key}:apply、POST /documents/{key}:rollback | 从整份文档提议变更,或提议回到旧修订。 |
POST /changes、GET /changes/{id} | 提议变更;读取变更。 |
POST /changes/{id}:rebase、:shadow、:skip-shadow、:approve、:reject、:abandon、:publish | 推进变更。 |
POST /break-glass、POST /break-glass/{change}:approve、DELETE /break-glass/{bindingId}?reason= | 申请 break-glass、审批申请、撤销绑定(安全模型)。撤销只在该绑定不再生效之后才返回 200,平台文档中没有该 id 的绑定时返回 404,其他情况(包括该 id 是普通绑定)返回 409(抢占被占用的暂存槽)。 |
POST /simulate、POST /explain、POST /analyze | 不做任何修改地决策或分析。 |
错误为 application/problem+json。400、409、410、412、422 或 428 在 detail 中解释你自己的请求;401、403、404、500 或 503 不带详情,因此拒绝永远不会透露某个文档或变更是否存在。400 的 detail 是控制面自己的某项检查,或者点名解析不了的字段([mode] is not one of MERGE, REPLACE.),从不带异常消息。路径、查询或请求体中任何位置的字符串带有落单 UTF-16 代理项(如 JSON 转义 "\uD800")的请求,在路由读取它之前就是 400(IDN-10):Redis key 会把它写成 ?,指向另一个 id。存储的文档或记录解码失败(例如滚动升级期间较新版本写入的)是 500。背后没有主体的请求(包括管道匿名放行的请求),在读取请求体之前就是 401。只服务路由的确切路径:末尾带 / 或含空段的路径是 404。记录解码失败的变更(包括属于不能作为应用 id 的应用的变更),对每个调用方都是不带详情的 503(SCP-10)。原因由控制面所在的主机以 INFO 记入日志,带有问题响应中的 requestId(故障排查)。
校验策略
每个提议在存为已检查之前,都会针对其文档的当前修订进行检查。检查结果列在变更的 findings 中,每项包含 check、候选中的 JSON path、message、对应的 HTTP status 和 severity:
| 检查 | 状态码 | 发现 |
|---|---|---|
V1 | 400 | 候选无法读回为文档。 |
V2 | 400、422、503 | 文档超过 8 MB;id 使用了保留前缀(cosec:、g:、r:);租户策略复用了平台策略的 id 但不是它的完全相同的副本(完全相同的副本是分区流程的第一步);平台策略使用了某个租户文档已经定义或绑定的 id,或在某个租户文档持有它的副本时被修改,发现中列出这些文档(这类租户的文档读不到时为 503);候选中任何位置的绑定所在的应用不能作为应用 id(空白、*、含 :、控制字符或落单的代理项、超过 128 个字符;位于 $.bindings[i].app 的 400:仍含有这种绑定的文档必须移除它,只移除它的变更是合法的)。 |
COMPILE | 422 | 某个策略、条件、模式、绑定或规则无法用平台的限制、平台声明的属性以及控制面主机的属性提供者声明的属性编译。 |
REFERENCES | 422 | 绑定指向的策略无处可解析;平台变更删除了其他文档引用的策略(自己定义了同 id 策略的租户不算);租户的 boundary 不是平台边界。 |
OPERATION | 422 | 操作删除了不存在的内容,或设置了该文档没有的字段。 |
IF_MATCH | 412 | 条目已不再是操作的 ifMatch 所说的样子。 |
V3(警告) | 平台的 limits 或 attributes 有变化:其他文档会在每个实例上重新编译,不再能编译的文档失败即拒绝。 |
有任何错误的变更停留在 DRAFT,不能暂存或发布;请提议一个新变更来修正。干净的变更为 CHECKED。变更在发布之前不会影响执行,而发布时会针对当时的文档重新编译候选。
模拟请求
simulate 按执行时的方式,在当前文档上对一个描述出来的请求做决策,还可以同时在某个变更的候选上决策:
{
"request": {
"tenant": "acme",
"principal": "bob",
"roles": ["clerk@team-a"],
"app": "order",
"space": "team-a",
"action": "order:ship",
"resource": "order/42",
"time": "2026-11-01T09:30:00Z"
},
"candidate": "<change id>"
}| 字段 | 含义 |
|---|---|
tenant | 必填:主体的租户,即请求在其中决策的租户。匿名请求为 (0)。 |
principal | 主体 id;匿名请求时省略,执法在租户 (0) 上决策它。 |
roles、policies | 成员关系,按令牌声明的写法:role 或 role@space,以及附加策略的 id。 |
app、space | 声明的作用域;空间按数据面的方式与角色核对。 |
action | 必填,service:verb(未映射的 HTTP 请求为 http:GET)。 |
resource | 必填:type/id,或以 / 开头的 http 资源路径。http 路径与请求路径经同一个规范化:执法拒绝的路径(点号段、格式错误的转义、超过 2,048 个字符或每个 / 段 256 个)是 400,路径参数(;v=2)被去掉。类型化 id 受同样的长度限制。 |
remoteIp、time | 客户端地址,以及 ISO 时刻(默认:当前)。 |
principalBindings、breakGlass | 凭证能用到什么,默认都为 true(CoSec 自己的令牌)。模拟 API key 时把 breakGlass 设为 false;带角色上限的 key 还要把 principalBindings 设为 false:此时附加的策略和主体绑定不授予任何东西,它们的 DENY 照样生效。只接受布尔值。 |
响应包含 enforced、要求时的 candidate,以及 differs。每个决策为 Permit、Deny、Indeterminate(带 cause)或 Rejected(作用域被拒绝),并给出做出决策的语句(by)和完整的候选追踪。来自你无权读取的文档的语句会被省略,并计入 redacted。differs 比较两个决策的内容,失败按其类型和消息比较,所以两侧相同的编译失败不算不同。
模拟读取执行时读取的文档:平台文档、租户文档,以及在请求在其应用中有角色、或租户文档把该应用的某个权限策略绑定给 Anyone 或某个主体时的应用文档(策略语言)。对租户文档的变更,由候选决定这一点:添加了这种绑定的变更会读取应用文档,移除了最后一个这种绑定的变更则不再读取。无法编译的租户文档按未解析处理,因此与数据面一样,它的绑定不会让请求读取应用文档;对请求没有读取的文档给出 candidate 时为 400。
模拟在请求作用域实际解析到的租户上授权并决策,也就是决定它的那份文档的租户:主体的租户,匿名请求则与数据面一样是 (0)。 匿名请求指定其他任何 tenant 都是 400,这在检查 (0) 上的 cosec:simulate 之后;因此 acme 的管理员无法用匿名请求 读取 (0) 文档上的决策。
以不同的值两次发送它所读文档(条件、边界或限流 key 中)读取的某个头的请求是 400,与执法在决策前拒绝它一样(PIP-12); 相同的重复算一个值。回答不点名是哪个头。执法会拒绝的 resource 也是 400,analyze 的资源也一样处理。
模拟没有令牌属性,也不获取外部属性:读取 ext.* 的语句会发现它不可用并按其 onMissing 处理,因此在这里通常为 Indeterminate。
控制面对每份存储的文档按其内容只解码、编译一次:在同样的头上重复 simulate、explain 或 analyze 不会再次编译,头 移动之后,在下次读到时编译。candidate 每次调用编译一次,explain 在保留修订上的文档也是。保留的内容按估计的堆限定在 64 MB:一份文档解码后计其存储大小的 4 倍,每个编译形态(按编译时的平台文档,至多两个)再计 8 倍,所以存储为 1 MB 的租户文档计 12 MB。估计超过其一半的文档(存储超过约 2.7 MB)从不保留:每次读它的调用都解码、编译一次,与候选一样, 因此它永远不会把其他文档挤出去。按租户限制控制 API,以限定这种文档的开销。要限制一个调用者的调用开销,请按租户限制控制 API (限流)。
用 explain 调试决策
explain 接受同样的 request(与 simulate 一样授权和检查),以及可选的 at:某个决策所用的文档修订,取自其审计事件的记录({"platform": 12, "app": 3, "tenant": 4})。只要这些修订仍被保留,它就在这些修订上求值,并返回决策及其追踪,让你看到过去的请求为什么被这样决策。是否读取了应用文档,按执行时的方式、用所记录修订的租户文档来决定;它回答的修订就是它读取的修订:没有读取应用文档时没有 app。其字段以及如何找到该请求,见代码中的检查。
变更
变更是对一个文档的一组条目操作,基于你看到的修订(base):
{
"document": "tenant:acme",
"base": 4,
"note": "Let auditors read orders",
"operations": [
{
"op": "putBinding",
"binding": {
"id": "auditor-reads-orders",
"subject": { "role": "auditor" },
"app": "order",
"space": "",
"policy": "perm:order/read"
}
}
]
}| 操作 | 字段 | 作用 |
|---|---|---|
putPolicy | policy,可选 ifMatch | 新增策略,或替换同 id 的策略。 |
removePolicy | id,可选 ifMatch | 删除策略。 |
putBinding | binding,可选 ifMatch | 新增绑定,或替换同 id 的绑定。 |
removeBinding | id,可选 ifMatch | 删除绑定。 |
putRole | role,可选 ifMatch | 新增角色,或整体替换同 id 的角色。适用于应用和租户文档。 |
removeRole | id,可选 ifMatch | 删除角色。 |
putRateLimit | rateLimit,可选 ifMatch | 添加限流规则,或在原位置替换同 id 的规则。 |
removeRateLimit | id,可选 ifMatch | 删除限流规则。 |
putCatalog | catalog | 设置应用文档的目录;没有 catalog 时删除目录。 |
setLimits | 包含全部六个限制的 limits,每个都是与文档中相同的正整数 | 设置平台的限制;没有 limits 时恢复默认值。其他类型的值返回 400。 |
setAttributes | attributes,与文档中相同的数组 | 设置平台声明的属性;没有 attributes 时删除该字段。不是属性数组时返回 400。 |
ifMatch 是条目按文档写法的 SHA-256:只有条目仍是这个样子时操作才生效。不想手写操作时,POST /documents/{key}:apply 接受整份文档({"document", "mode", "note"}),并提议把当前修订变成该文档所需的操作:MERGE 只新增和修改,REPLACE 还会删除文档中没有的内容。设置租户的 boundary 或平台的默认边界请使用 apply。租户的边界属于平台:租户主体的 REPLACE 或回滚如果会删除或改变平台设定的边界,返回 403。限流规则变更如何授权和审批见限流。
base 已不是当前修订时返回 409。onMissing: "nomatch" 的 DENY 需要在 confirmations 中提供确认({"policy", "statement", "digest", "kind": "deny-nomatch"});没有确认的变更无法发布。
一个文档至多有一个暂存的变更(SHADOWING、AWAITING_APPROVAL 或 READY);在它发布、放弃、驳回或过期之前,暂存另一个变更返回 409。变更在占用暂存槽之前就已进入暂存状态,所以仍在写候选的调用也占着暂存槽;写不成或被取消的调用会归还暂存槽,变更回到 CHECKED。未完成的变更在提议后 cosec.control.changes.expire-after(默认 7 天)过期:此后第一次被读取时即为 EXPIRED,而控制面的维护任务每隔 cosec.control.shadow.check-interval(默认 1 分钟)读取一次所有未完成的变更。作者可以对 CONFLICTED、DRAFT 或 CHECKED 的变更执行 :rebase,把它放到当前修订上,这会重新运行所有检查,并丢弃之前的审批和影子证据。作者或持有 cosec:publish 的人可以 :abandon 它;设置租户边界的变更只有平台主体能驳回或放弃,撤销仍在生效的 break-glass 绑定的变更只有其作者能放弃(BRK-10)。每次放弃都记一条 ABANDONED 变更事件,备注写明变更的作者;放弃 break-glass 变更需要一个 REQUIRED 变更监听器,由它先记下(没有时返回 503)。
在影子模式中试运行变更
POST /changes/{id}:shadow 暂存一个 CHECKED 的变更并开启影子窗口。数据面实例用候选替换当前文档,对一部分线上请求再求值一次,并上报每个请求的结果;候选永远不会影响真实决策。候选只在读取了它所属文档的请求上比较(租户的候选在该租户的请求上,应用的候选在读取了该应用文档的请求上,平台的候选在所有请求上),并且只由已安装候选的基础修订的实例比较。租户的候选在请求于它发布后会读取的文档上比较:把某个应用的权限策略绑定给 Anyone 或某个主体的候选,会在请求原本没有读取应用文档时带入该应用已安装的文档;移除了最后一个这种绑定的候选则不带入。
| 结果 | 含义 |
|---|---|
SAME | 相同的决策,由相同的语句做出。 |
BY_CHANGED | 相同的决策,由另一条语句做出。 |
WIDENED | 执行时没有允许,候选允许。 |
NARROWED | 执行时允许,候选不允许。 |
BECAME_INDETERMINATE | 执行时不是 Indeterminate,候选是,原因不是属性不可用。 |
CANDIDATE_INDETERMINATE | 候选读取了请求没有获取的外部属性(影子求值不获取任何属性)。 |
CANDIDATE_ERROR | 某个实例无法编译或求值候选,或无法读取候选让请求读取的应用文档。 |
BASE_MISMATCH | 实例安装的是该文档的另一个修订:不比较,不算样本。 |
窗口在以下情况关闭:
- 任何实例上报
CANDIDATE_ERROR时立即关闭,状态为阻塞:该变更永远不能发布;请放弃它; - 经过
cosec.control.shadow.min-duration(15 分钟),且实例上报的非BASE_MISMATCH样本达到cosec.control.shadow.min-samples(1,000)时; - 到达
cosec.control.shadow.max-duration(24 小时)而样本不足时,状态为证据不足。
控制面每隔 cosec.control.shadow.check-interval(默认 1 分钟)检查一次所有未关闭的窗口,同一时间只由一个控制面实例执行。窗口关闭后,变更变为 READY,需要审批时变为 AWAITING_APPROVAL。如果期间文档修订发生变化,变更变为 CONFLICTED。数据面在启用 cosec-cocache 和 cosec.authorization.shadow.*(默认开启,采样 10% 的请求)时参与。
属性提供者及其声明的类型,在每个数据面实例和控制面主机上必须相同。控制面按自己的提供者检查候选;缺少其中某个提供者的实例无法编译读取其属性的候选,会报告 CANDIDATE_ERROR,窗口随即以阻塞状态关闭,该变更永远不会发布。跳过窗口的变更也跳过了这项检查:无法编译已发布文档的实例会对该文档失败即拒绝。
窗口比较的是决策,所以只涉及限流规则的变更在窗口中显示不出差异。
窗口的候选暂存期间,实例每隔 cosec.authorization.shadow.report-interval(10 秒)写一次它对该窗口的报告。候选撤回之后(窗口关闭,或变更被放弃、驳回、发布或抢占),实例再写一次报告,然后忘掉这个窗口;报告在这最后一次写入 8 天后过期。变更只撤回自己的候选:期间另一个变更已占用暂存槽并暂存了自己的候选时,那个候选保留(CTL-26)。
POST /changes/{id}:skip-shadow 带上 reason,可以不经窗口暂存一个 CHECKED 的变更;它需要 cosec:skip-shadow,修订会记录窗口被跳过。cosec:tenant-admin 只在自己租户的文档上持有它(CTL-27)。跳过窗口不改变审批:分析无法证明操作者已持有的授出仍要等待审批。只创建或吊销 break-glass 绑定的变更没有窗口。
抢占被占用的暂存槽
一个文档的暂存槽同一时间只由一个变更持有:它在影子窗口中、等待审批或已就绪时都占着槽;同一文档的其他变更要等待(409)。紧急情况不能排队:有两类变更可以抢占槽。
- 撤销 break-glass,只要该绑定尚未到期,总是可以。撤销仍在生效的绑定的变更持槽时,只有另一个这样的撤销能抢走它的暂存槽,也只有其作者能放弃它(
BRK-10),所以被撤销的主体无法用自己的变更拖住它。撤销已过期的绑定并不紧急,因为该绑定已不授予任何权限:这样的撤销像普通变更一样等待暂存槽、持有暂存槽,撤销仍在生效的绑定的变更或只收窄的变更能抢走它,其他变更仍要等待。发布中途停住的撤销(其 REQUIRED 监听器的存储不可用)以READY留在槽中:再撤销一次该绑定会把它发布出去,其REVOKED事件写明是你、出于什么原因;看不到它的撤销人用自己的撤销变更抢走它的槽;POST /changes/{id}:publish也能清掉它。两个撤销同时进行时可能互相抢占:以CONFLICTED结束的撤销(被抢占,或遇到已变化的头),或在提议、暂存之前头就变化了的撤销,只要其绑定仍在生效,就在新的头上再撤销一次,至多再试三次。DELETE /break-glass/{bindingId}只在该绑定不再生效之后才返回200,无论是谁删除的;平台文档中没有该 id 的绑定时(输错了 id,或已被删除)返回404;该 id 的绑定不是 break-glass 绑定时(无论是否到期)返回409(普通绑定请用变更删除);其他情况返回409,detail写明你的变更所处的状态。返回404或遇到普通绑定时都不提议任何变更。没有生效的撤销永远不会看起来像已生效;反过来的情况可能出现一次:另一个调用恰在你的撤销提议时删除了同一个绑定,你的调用返回409,重试则返回404(BRK-11)。撤销只有经一个记录它的 REQUIRED 变更监听器才能发布;该监听器的存储不可用时,改为吊销该主体的令牌(TokenRevoker.revokeSubject),这不需要监听器。 - 只收窄的变更,由在该文档上持有
cosec:skip-shadow的人暂存或跳过影子。每个操作都必须属于下列之一:删除一条绑定,被绑定的策略只含 ALLOW;把一条只含 ALLOW 的可附加策略改为不可附加,语句不变;新增一条只含 DENY 的策略,或新增一条指向这种策略的绑定。删除 DENY、把含 DENY 的策略改为不可附加、替换任何已有条目,或对租户与平台都定义的策略 id 所做的操作,都会放宽或可能放宽,与其他变更一样等待。
拉黑一个主体、设备或地址不是文档变更:黑名单在请求时读取(安全模型),不需要等待暂存槽。
原先持有槽的变更在槽转手之前就被标为 CONFLICTED,无论它此时处于哪个状态(其间可能已被审批或窗口已关闭),影子候选被撤回,并以 FAILED 记录,备注为 preempted by <change>;变基后可以再试。持有者因存储故障无法被标记时,它保留暂存槽,想抢占的变更被拒绝(503);放弃了的抢占把持有者的失败记为 conflicted by <change>, which did not take the slot。修订链上已能看到其修订的 READY 持有者(写入成功但答复丢失的发布),由来取这个暂存槽的变更结算为 PUBLISHED,并以它的发布人身份记录,之后暂存槽空出:普通的暂存也能取得它。
发布只在其变更仍持有暂存槽时继续:在写 PUBLISHING 记录之前检查一次,写头之前再检查一次;槽已不在时,记一条失败,写明 another change took the staging slot before the head write;那次检查之后头变化了时(取走槽的变更先发布了,或 bootstrap、迁移、旧实例写了头),写明 the document moved before the head write。最后一次读取与写头之间还隔着一次存储往返,再加上发布实例在这两步之间的任何停顿(例如 GC),被抢占的变更在这段时间里仍会发布;取走其暂存槽的变更随后在已变化的基准上冲突,它的失败记录写明先到的是哪个修订、由谁发布。写头时遇到的若是本变更自己的修订(同一变更的另一次发布刚刚写下),则不记失败,按变更当前的样子作答,由写下这个修订的那次发布来结算它。
审批
任何人都不能授予超出自己权限的东西。变更被检查时,控制面以作者身份分析它授予了什么,凡是无法证明包含在作者自身权限之内的,都成为一项升级:
| 原因 | 含义 |
|---|---|
NOT_COVERED | 没有作者的任何一条 ALLOW 被证明包含该变更授予的内容。 |
DENY_OVERLAP | 约束作者的某条 DENY 可能与之重叠。 |
CROSS_TENANT | 作者不在该文档的租户中。 |
UNRESOLVED | 新增或修改的绑定指向一条没有任何文档定义的策略,它授予什么无从判断。 |
ANONYMOUS | 授给默认租户 (0) 中的 Anyone:未认证的调用方都在这个租户中,授出也会作用于它们。 |
UNREADABLE | 授出触及的某个 app 文档读不到,作者在那里持有什么无从得知;或 app 列表不可信,而授出触及所有 app(存储)。 |
NOT_ANALYZED | 非信任根所做的平台或应用文档变更,或租户边界变更。 |
TENANT_BINDING | 与应用文档的 NOT_ANALYZED 一起出现:变更新增或放宽了某条权限策略的 ALLOW,租户文档中每条绑定该策略的绑定各是一项,只写出绑定 id 和它所在的租户文档,审批人由此看到变更影响到谁。仅供参考:审批针对的是 NOT_ANALYZED,只有这些条目变化时,已审批的变更不会退回 AWAITING_APPROVAL。 |
BREAK_GLASS | 新增或修改的 break-glass 绑定:总是需要第二个人。 |
作者的权限是本次请求所带凭证的权限。带角色上限的凭证(带 roles 的 API key)不计入点名作者或作者附加的 ALLOW,只计入上限内的角色和 Anyone 绑定,与数据面一致;点名作者或作者附加的 DENY 仍然限制它可以授予的范围。break-glass 绑定从不计入任何人可以授予的范围。
分析读取作者权限的方式与执行时相同:
- 任意类型的 token 属性。
subject.attributes.*上的条件把布尔值和数字当作它们的文本(true、3),数据面与分析都是如此。token 中写着"suspended": true的作者并不持有带条件not(eq(subject.attributes.suspended, "true"))的 ALLOW,因此不能去掉条件把它授出。 - 会到期的绑定。 作者经带
expiresAt的绑定持有的权限只持有到那时,所以它只包含自身绑定不晚于它到期的授出。没有expiresAt的授出、到期更晚的授出、把策略设为可附加,都是升级;最接近的 ALLOW 以短缺EXPIRY报告。 - 租户与平台都定义的 id。 执行时,两条策略内容不同,这样的绑定或附加才因二义而被拒绝。分析不比较内容,把每个这样的 id 都当作二义,这只会减少权利:作者经它什么也不持有;它绑定到作者的地方,按拒绝一切的 DENY 计。
- 每一份 app 文档。 app 文档绑定角色,所以无论变更是否点名该 app,它都可能在该 app 中拒绝作者某些东西。分析读取 app 列表列出的每一份 app 文档;读不到的那份,使触及它所在 app 的每个授出成为
UNREADABLE。app 列表完整之前(升级后,在cosec-migrate repair-references运行之前),覆盖所有 app 的每个授出(app 为*的绑定或附加)都是UNREADABLE,经条件收窄到变更没有点名的 app 的授出也是:分析从绑定的作用域取 app,不从条件中取。
删除条目和收窄 ALLOW 不授予任何东西,只有一个例外:删除租户自己的一条策略,而平台也定义了同一个 id 时,租户中指向这个 id 的绑定,以及平台的策略可附加时对它的附加,都改为指向平台的策略。这按把平台的策略授给它们来分析。对文档持有 cosec:approve 的平台主体是信任根:其自己的变更不做分析,除非开启了 cosec.control.approval.four-eyes,这时它们会像其他人的变更一样被分析。平台授予了其租户文档上 cosec:approve 的租户主体,审批别人的变更,它自己的变更要经分析。开启 four-eyes 时,信任根的变更需要审批的部分必须由另一位平台主体审批。租户的边界属于平台:只有平台主体能设置它(否则 403),也只有平台主体能审批、驳回或放弃设置边界的变更。
API key 无论其主体持有什么,都不是信任根,也从不审批。CoSec 按校验来源识别自己的 API key,任何名为 api-key 的凭证(包括网关转发的身份所带的)也算作 API key。你自己的认证器认可的凭证,在 CoSec 看来是人的凭证:不要给面向工作负载的认证器授予 cosec:approve 或 cosec:platform-admin。
分析看不到的东西:
- 受保护的条目。 租户文档目前还不能把条目标为
protected,所以租户管理员可以先删除平台放进该租户文档的 DENY,再授出它拒绝的东西。对租户管理员的限制请放在平台或 app 的文档中。 - 针对个人的限制。 绑定到
Anyone、带subject.id条件的 DENY 限制的是该主体能做什么,而不是它能授出什么:分析把Anyone的 DENY 读作对每个被授予者同样生效。请把这条 DENY 绑定到该主体。 - 信任根。 不开启 four-eyes 时,它的变更完全不经分析;这时 break-glass 的两个人和时间上限也都约束不了它(安全模型)。
有升级的变更在窗口关闭或被跳过后停留在 AWAITING_APPROVAL。另一位持有 cosec:approve 的人通过 POST /changes/{id}:approve 审批,并带上他们评审过的 candidate 摘要(来自 GET /changes/{id});摘要不同返回 409,作者不能审批自己的变更。审批是人的行为:以 API key 认证的主体不能审批(403),凭证不带 id 的主体也不能(403),因为发布时不会计入它的审批。审批记录审批人的令牌(jti 与 iat);变更发布之前,如果这个令牌,或审批人截至当时签发的全部令牌被吊销,审批就不再算数。没有记下凭证 id 的审批也不算数:升级到本版本之前做的、旧版本实例接受的,或用不带 id 的凭证给出的审批;请重新审批。凭证不说明签发时间时(你的认证器没有设置 issuedAt),该主体的令牌只要有一次吊销,其审批就作废。审批人的访问令牌过期之后,TokenRevoker.revoke 不再接受它,只有 revokeSubject 能收回其审批。吊销按数据面管道的方式读取,所以在审批有效期内,这里同样把降级读当作"未吊销"(认证);读取以其他方式失败时返回 503,变更保留其审批,等存储恢复后再发布。吊销保留 cosec.jwt.token-validity.refresh 这么久,它不能短于 cosec.control.approval.expire-after,否则 starter 不会启动。成员关系来自成员关系提供者的审批人,发布时判定两次:一次按审批时持有且现在仍持有的角色,一次按提供者现在答复的全部内容;两次都必须放行,所以绑定在其后新获得角色上的 DENY 会让审批作废。:reject 带上 reason 结束该变更。审批在 cosec.control.approval.expire-after(72 小时)内有效,且只对该候选有效。GET /changes/{id} 显示谁审批、何时、为何审批,不显示审批人的凭证、成员关系、属性,以及其凭证能触及什么;这些只保存在存储中的变更记录里,任何变更事件都不带它们。break-glass 的审批先由 REQUIRED 变更监听器记录,再存入变更:之后存入失败时,记录中会有一条变更上没有的审批,由审批人重新审批。
发布与回滚
POST /changes/{id}:publish 发布一个 READY 的变更。如果它的窗口出现了 WIDENED、NARROWED 或 BECAME_INDETERMINATE 的请求,或以证据不足关闭,发布者必须确认这些数字,否则返回 428:
{ "ack": { "window": "<window id>", "counts": { "SAME": 4180, "NARROWED": 3 } } }窗口 id 和计数在变更的 shadow 中;为零的计数可以省略。随后发布会以发布者身份在当前文档上重新检查一切:发布者对每个条目的权限、授权分析,以及审批。审批必须覆盖此时发现的升级、未过期、针对此候选、既不来自作者也不来自发布者,且审批人仍持有 cosec:approve。审批人按其审批时所用的凭证重新检查:用带上限的 key 给出的审批,在发布时不计入审批人自己绑定中的 ALLOW;不是用 CoSec 自己的令牌给出的审批,不计入其 break-glass 绑定。审批不再成立时,变更回到 AWAITING_APPROVAL,不发布任何内容。文档已变化,或其间另一个变更取走了该文档的暂存槽时,变更为 CONFLICTED;候选无法针对当前文档编译时,变更为带有检查结果的 DRAFT。修订已写入但答复丢失的发布,重试时为 PUBLISHED:控制面在修订链上找到这个变更自己的修订(即使其上已有后来的修订),补做写入之后的步骤,并以修订记录的发布人身份记录事件。写入头之后无法完成后续步骤的发布(存储失败或超时)返回 503:修订已经生效,再发布一次这个变更即可结算,不会增加修订。
写入时只比较该文档自己的头。检查读过的其他文档(例如同一时刻落地的平台变更)可能在发布的最后一次检查与写入之间变化;这时控制面重新分析这次变更,记 HEADS_MOVED,分析发现了无人审批的升级项时记 REVIEW_NEEDED。变更保持已发布:为什么这是安全的、如何复核这类事件,见一次发布比较什么。
要撤销某个修订,用 {"revision": 11, "note": "..."} 调用 POST /documents/{key}:rollback,它会提议一个 ROLLBACK 变更,把文档恢复为该修订时的样子。它和其他变更一样经过检查、窗口、审批和发布。保留哪些修订、保留多久,见存储。
访问审查
POST /analyze 列出在某租户中谁可能被允许或拒绝对某资源执行某动作:
{ "query": { "tenant": "acme", "app": "order", "space": "team-a", "action": "order:read", "resource": "order/42" } }响应列出 allows 和 denies:平台文档、租户文档以及(给出 app 时)应用文档中,能在所查询租户生效、作用域符合查询、且其策略有语句匹配该动作和资源的每个绑定,包括其文档、主体、应用、空间、策略和语句序号。conditional 标记带有条件的语句或策略,条件不会被求值。省略 app 和 space 时匹配任意应用或空间的绑定。你无权读取的文档计入 hidden,指向这些文档中策略的绑定计入 opaque。作用域为所查询应用的租户绑定,在你可以读取该应用文档时,会解析到该应用的权限策略,与执行时一样。附加策略不会列出:它们取决于每个主体的成员关系。绑定是否在该租户生效,按引擎查找它的方式判断:主体绑定只对该租户的主体生效,指定了租户的角色绑定只在该租户生效,未指定租户的角色绑定和 Anyone 在每个租户生效。所以平台中给 principal:globex/bob 的绑定不会列在 acme 下。
CI 中的策略
用 JSON Schema 校验文档文件,然后用 cosec-runtime 中控制面和每个实例所用的读取器和编译器编译它们:
import me.ahoo.cosec.model.decision.DocumentKind
import me.ahoo.cosec.runtime.document.PolicyDocumentCodec
import me.ahoo.cosec.runtime.document.PolicyDocumentCompiler
import java.io.File
val platform = PolicyDocumentCodec.decode(DocumentKind.PLATFORM, "", File("platform.json").readBytes())
val acme = PolicyDocumentCodec.decode(DocumentKind.TENANT, "acme", File("tenant-acme.json").readBytes())
// The platform's limits and declared attributes, as the control plane checks a candidate.
val compiler = PolicyDocumentCompiler.forPlatform(platform)
compiler.validate(platform)
compiler.validate(acme)
compiler.checkReferences(acme, platform)decode 抛出 SnapshotFormatException,validate 抛出 DocumentCompileException,其中包含每个错误及其 JSON 路径。
测试决策
cosec-testkit 在单元测试中断言文档的决策,使用的就是每个实例运行的引擎:
dependencies {
testImplementation("me.ahoo.cosec:cosec-testkit")
}按 cosec-migrate convert 写出的布局存放文档,每个场景一个 JSON 文件:
src/test/resources/policies/
documents/
platform.json
apps/order.json
tenants/acme.json id 经过 URL 编码:acme-west 为 tenants/acme%2Dwest.json
scenarios/
a-reader-reads-an-order.json
nobody-deletes-an-order.jsonimport me.ahoo.cosec.testkit.PolicyTestKit
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.TestFactory
import java.nio.file.Path
class PolicyTest {
private val kit = PolicyTestKit.load(Path.of("src/test/resources/policies/documents"))
@TestFactory
fun policies(): List<DynamicTest> = kit.tests(Path.of("src/test/resources/policies/scenarios"))
}tenants/acme.json 把角色 reader 绑定到 order:read 上的 ALLOW,并把每个主体绑定到 order:delete 上名为 orders are never deleted 的 DENY 时,下面两个场景通过:
{
"description": "A reader of acme reads an order",
"credential": { "tenant": "acme", "id": "alice", "roles": ["reader"] },
"request": { "action": "order:read", "resource": { "type": "order", "id": "1" } },
"expect": { "decision": "Permit" }
}{
"description": "The deny wins over the reader's role",
"credential": { "tenant": "acme", "id": "alice", "roles": ["reader"] },
"request": { "action": "order:delete", "resource": { "type": "order", "id": "1" } },
"expect": {
"decision": "Deny",
"by": {
"policy": "no-delete",
"statement": 0,
"name": "orders are never deleted",
"via": { "binding": "g:no-delete", "document": "tenant" }
}
}
}文档无法编译时 load 失败;没有 platform.json 时平台文档为空。tests 为目录下每个 .json 文件生成一个以其路径命名的测试,一个都没有时失败。check(file) 检查单个场景,evaluate(json) 只返回决策而不检查;PolicyTestKit.of(platform, apps, tenants) 接受 JSON 文本形式的文档。
| 场景字段 | 含义 |
|---|---|
description | 自由文本。 |
credential | 主体:tenant(默认 acme)、id(默认 alice)、roles(role 或 role@space)、policies(附加策略)、attributes;匿名调用方用 "anonymous": true。 |
membershipProvider | MembershipProvider 返回的成员关系,按管道的方式与凭证中的合并:按空间分组的 roles({"": ["reader"]})和 attachments;或 {"fails": "message"}。 |
request | action(默认 order:read)、resource(type、id、attributes;默认 order/1)、space、hostApp、time(ISO 时刻)、ip、device、blocked、headers、attributes(request.attributes.*)。 |
expect | decision:Permit、Deny、Indeterminate 或 Rejected,以及 by(作出决策的语句;隐式拒绝为 null)、cause 或 reason。 |
http.origin.host 和 http.referer.host 读取 headers 的方式与实例一致:取 Origin 或 Referer URL 的主机并折叠为 ASCII 小写,https://EXAMPLE.com 即 example.com。cosec-testkit 会带上其 API 用到的依赖(引擎、JUnit 的 DynamicTest,以及 evaluate(JsonNode) 中 Jackson 的 JsonNode):应用只需声明上面那一条依赖。
只比较 expect 写出的字段:{"decision": "Permit"} 不论哪条语句允许都通过,写上 by 则固定为某条语句,并整体比较。拼错的字段是错误,不会被忽略。
测试工具只评估决策。文档的编译方式与实例相同:使用平台文档的 limits 和它声明的 ext.* 属性。只由宿主的 AttributeProvider 声明的属性在测试工具中无法编译,而且不运行任何提供者,所以读取 ext.* 的语句会发现它不可用并按其 onMissing 处理。限流规则不运行;请求直接给出动作和资源,不经过路由映射。依赖外部属性值的决策可用 simulate 试验,它按控制面主机的提供者编译。
用真实管道测试代码
PolicyTestKit 断言文档的决策,PipelineFixture 则运行决策前后的其余部分:用你的文档,构建基于每个实例运行的编译器、快照源和引擎的真实 Pipeline。当被测代码读取已放行请求的 SecurityContext,或处理函数的结果取决于 CoSec 回答的状态码和 problem 时使用它。它是 @ExperimentalCoSecApi,与测试工具的其余部分相同。
@file:OptIn(ExperimentalCoSecApi::class)
import kotlinx.coroutines.runBlocking
import me.ahoo.cosec.annotations.ExperimentalCoSecApi
import me.ahoo.cosec.runtime.AuthorizationChecker
import me.ahoo.cosec.testkit.PipelineFixture
import org.junit.jupiter.api.Test
import java.nio.file.Path
class OrderPermissionsTest {
private val reader = PipelineFixture.Principal("alice", tenant = "acme", roles = setOf("reader"))
@Test
fun `a reader may read`() = runBlocking {
PipelineFixture.load(Path.of("src/test/resources/policies/documents")).use { fixture ->
val request = fixture.request("GET", "/orders/1", reader)
check(fixture.evaluate(request).status == 200)
// 处理函数为已放行的请求读取到的上下文;进一步的检查经过同一个管道。
val context = fixture.securityContext(request)
check(AuthorizationChecker(fixture.pipeline).check(context, "http:DELETE", "/orders/1").not())
}
}
}PipelineFixture.of(platform, apps, tenants) 接受 JSON 文本形式的文档。文档无法编译时,构建夹具就会失败。Principal 说明请求是谁,如同令牌一样:tenant、roles(role 或 role@space)、policies、app 和 attributes;不提供时请求是匿名的。它只替换凭证检查:状态码、SecurityContext 和决策都来自管道。用完关闭夹具,通常一个测试类一个。
下一步: 把测试过的策略发布到存储中,见存储。