Skip to content

测试策略 ​

面向在生产环境修改文档的策略作者和评审者。每个文档变更都经过控制面:控制面检查并编译变更,可以在影子窗口中用线上流量试运行,在无法证明变更安全时要求第二个人审批,然后才发布。本页介绍这一流程及其周边工具。字段本身见策略语言。

控制面 ​

控制面随 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:

检查状态码发现
V1400候选无法读回为文档。
V2400、422、503文档超过 8 MB;id 使用了保留前缀(cosec:、g:、r:);租户策略复用了平台策略的 id 但不是它的完全相同的副本(完全相同的副本是分区流程的第一步);平台策略使用了某个租户文档已经定义或绑定的 id,或在某个租户文档持有它的副本时被修改,发现中列出这些文档(这类租户的文档读不到时为 503);候选中任何位置的绑定所在的应用不能作为应用 id(空白、*、含 :、控制字符或落单的代理项、超过 128 个字符;位于 $.bindings[i].app 的 400:仍含有这种绑定的文档必须移除它,只移除它的变更是合法的)。
COMPILE422某个策略、条件、模式、绑定或规则无法用平台的限制、平台声明的属性以及控制面主机的属性提供者声明的属性编译。
REFERENCES422绑定指向的策略无处可解析;平台变更删除了其他文档引用的策略(自己定义了同 id 策略的租户不算);租户的 boundary 不是平台边界。
OPERATION422操作删除了不存在的内容,或设置了该文档没有的字段。
IF_MATCH412条目已不再是操作的 ifMatch 所说的样子。
V3(警告)平台的 limits 或 attributes 有变化:其他文档会在每个实例上重新编译,不再能编译的文档失败即拒绝。

有任何错误的变更停留在 DRAFT,不能暂存或发布;请提议一个新变更来修正。干净的变更为 CHECKED。变更在发布之前不会影响执行,而发布时会针对当时的文档重新编译候选。

模拟请求 ​

simulate 按执行时的方式,在当前文档上对一个描述出来的请求做决策,还可以同时在某个变更的候选上决策:

json
{
  "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):

json
{
  "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"
      }
    }
  ]
}
操作字段作用
putPolicypolicy,可选 ifMatch新增策略,或替换同 id 的策略。
removePolicyid,可选 ifMatch删除策略。
putBindingbinding,可选 ifMatch新增绑定,或替换同 id 的绑定。
removeBindingid,可选 ifMatch删除绑定。
putRolerole,可选 ifMatch新增角色,或整体替换同 id 的角色。适用于应用和租户文档。
removeRoleid,可选 ifMatch删除角色。
putRateLimitrateLimit,可选 ifMatch添加限流规则,或在原位置替换同 id 的规则。
removeRateLimitid,可选 ifMatch删除限流规则。
putCatalogcatalog设置应用文档的目录;没有 catalog 时删除目录。
setLimits包含全部六个限制的 limits,每个都是与文档中相同的正整数设置平台的限制;没有 limits 时恢复默认值。其他类型的值返回 400。
setAttributesattributes,与文档中相同的数组设置平台声明的属性;没有 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:

json
{ "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 列出在某租户中谁可能被允许或拒绝对某资源执行某动作:

json
{ "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 中控制面和每个实例所用的读取器和编译器编译它们:

kotlin
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 在单元测试中断言文档的决策,使用的就是每个实例运行的引擎:

kotlin
dependencies {
    testImplementation("me.ahoo.cosec:cosec-testkit")
}

按 cosec-migrate convert 写出的布局存放文档,每个场景一个 JSON 文件:

text
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.json
kotlin
import 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 时,下面两个场景通过:

json
{
  "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" }
}
json
{
  "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。
membershipProviderMembershipProvider 返回的成员关系,按管道的方式与凭证中的合并:按空间分组的 roles({"": ["reader"]})和 attachments;或 {"fails": "message"}。
requestaction(默认 order:read)、resource(type、id、attributes;默认 order/1)、space、hostApp、time(ISO 时刻)、ip、device、blocked、headers、attributes(request.attributes.*)。
expectdecision: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,与测试工具的其余部分相同。

kotlin
@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 和决策都来自管道。用完关闭夹具,通常一个测试类一个。

下一步: 把测试过的策略发布到存储中,见存储。

基于 Apache License 2.0 发布