Files
qinglong/docs/adr/ADR-0027-artifact-read-authorization-and-local-range-contract.md
T

7.7 KiB
Raw Blame History

ADR-0027Artifact 读取授权、存在性屏蔽与本地 Range 契约

  • 状态:Proposed
  • 日期:2026-07-19
  • 关联:QL-RFC-0001、ADR-0024、ADR-0026

上下文

2.x 日志 API 由全局登录或 Open API scope 保护,读取端仍接受 path + file,无法表达 Project 所有权、artifact.read 策略和 opaque Artifact identity。3.0 已把 LocalProcess 日志绑定到 (projectId, runId, attemptId, logArtifactId),并用 retention tombstone 与 canonical truncation fact 保存清理和截断证据,但尚无稳定读取语义。

直接复用旧接口会产生四类问题:授权粒度不足;路径成为外部能力;文件缺失无法区分 retention 与异常丢失;文件大小等于 quota 时无法判断是否真正截断。对 edge,整文件读取还会产生与日志大小线性增长的内存峰值;对 cluster,本地路径又不是共享事实。

决策

1. API 只接受 identity 和有界 byte range

3.0 查询请求只包含 subject、Project ID、Run ID、opaque Artifact ID、offset 和 length,不接受目录、文件名、URI 或绝对/相对路径。首版本地单次读取长度上限固定为 256 KiB;offset 为非负安全整数,length 为正安全整数。

本地 reader 从 Artifact ID 派生私有 shard 和 ${artifactId}.log,验证 root/shard 为非 symlink 目录,并以 O_RDONLY|O_NOFOLLOW 打开普通文件。它在打开的 fd 上采样一次 size,最多分配请求上限的 Buffer,并返回:

  • startendExclusivetotalBytes
  • 尚有快照内容时返回 nextOffset
  • 读取期间新增的 append 不进入当前响应,下一次 range 才可见;
  • 文件在 stat 后异常缩短时 fail closed,不伪造完整 range。

这不是 HTTP Range 的最终 wire 格式;HTTP、MCP、CLI adapter 必须映射同一 application contract,不能各自直接读文件。

2. 数据库绑定和授权必须先于任何 Artifact 文件访问

固定顺序为:

  1. 纯内存规范化 subject、Project/Run/Artifact identity 和 range
  2. metadata repository 按 (projectId, runId, logArtifactId) 查询 Run、Attempt 和 tombstone
  3. 只接受 execution_owner=runtimeexecutor_type=local_process、canonical local-* 绑定;
  4. 调用 artifact.read authorizer,输入 subject、Project、Run 和 Artifact identity,不包含文件路径或内容;
  5. 只有 allow 才能访问日志或 truncation factdenyrequire_approval 都不会触发 FS probe。

不存在绑定时返回内部 not_found,策略拒绝返回内部 forbidden(effect)。面向非可信调用方的 adapter 默认必须把 forbidden 和 not-found 映射为同一种不可用响应,避免通过状态、响应体、长度或 timing 暴露跨 Project 存在性;真实拒绝原因留给低敏审计。当前尚未实现 HTTP adapter,因此不能把 2.x 全局登录视为 3.0 authorizer。

3. 读取状态稳定区分 available、retained 与 missing

授权后 application service 返回以下领域状态:

  • available:日志 fd 已安全打开并返回有界快照;
  • retained:存在与 Attempt/Artifact identity 一致的 immutable tombstone,不访问已清理文件;
  • missing:数据库仍有绑定、没有 tombstone,但本地文件不存在,表示需要运维诊断的未知丢失;
  • not_found:请求 Project 内没有可读的 runtime/local Artifact 绑定;
  • forbidden:内部策略结果,外部默认屏蔽。

为关闭 metadata read → retention unlink → file open 竞态,文件返回 ENOENT 后必须再次读取 metadata:若出现同 identity tombstone,收敛为 retained;若 identity 消失或漂移,按证据冲突 fail closed;仍无 tombstone才返回 missing。若 retention 在文件已打开后 unlinkPOSIX fd 快照仍可完成 available 响应。

4. truncated 是严格三态

truncation.truncated 只能是 true | false | unknown

  • canonical fact 的 quotaReached=true|false 分别映射 true/false
  • fact 缺失映射 unknown,禁止从 size、末尾文本或退出状态推断;
  • fact 的 Run/Attempt/Artifact identity 与数据库不一致时 fail closed
  • retained 始终返回 unknown,因为 ADR-0026 retention 会删除 fact,当前 tombstone 没有保存截断字段。

已知 fact 可以同时返回 bounded maximumBytesobservedAtMs,但不能返回 launcher capability、路径或用户输出。

5. edge、standalone 与 cluster 复用语义,不复用 adapter

  • edge/standaloneSQLite metadata + private local range reader + local truncation fact;单次内存上限 256 KiB,无 watcher、tail timer 或目录扫描;
  • cluster-controlPostgreSQL metadata + Project policy + object-store range GET/delete marker;控制面不得拼接 Worker 本地路径;
  • worker:本地 spool 读取属于 Worker capability,必须由 authenticated transport、Run lease/fencing 和上传 ACK 约束。

所有实现必须通过同一 contract suite,保持状态、授权顺序、存在性屏蔽、range 边界和三态截断一致。

6. 当前保持 production unreachable

本切片已实现领域类型、ArtifactReadAuthorizer、metadata/range ports、SQLite metadata adapter、私有文件 reader 和组合 service,但没有注册到 typedi、Express/Open API、MCP、UI 或启动 lifecycle。生产接入至少还需要:

  • Project/Actor 身份来源和真实 policy engine
  • forbidden/not-found wire masking、审计和 rate limit
  • content type、下载头、流式 tail/backpressure 与客户端取消契约;
  • cluster PostgreSQL/object-store adapter 和 Worker transport
  • 实机权限、unlink/append、ENOSPC 和多架构 contract 测试。

影响

正面影响:

  • 授权拒绝可结构化证明不会接触日志文件和 truncation fact
  • 调用方不再持有路径能力,单次读取内存有固定上限;
  • retention、异常缺失和截断三态有稳定、可测试解释;
  • edge 与 cluster 可以共享领域协议而替换存储 adapter。

代价与风险:

  • 每次首次读取至少需要一次 metadata 查询和一次 policy 决策;ENOENT 竞态需要第二次点查;
  • 当前 tombstone 不保存 truncationretained 历史只能返回 unknown
  • 外部 adapter 若错误区分 forbidden/not-found,仍可能形成存在性侧信道;
  • 256 KiB 是首版安全上限,后续调整必须结合 edge 内存并发预算,而不是只看单请求吞吐。

未选择的方案

  1. 复用 2.x path + file:路径是越权能力且没有 Project 绑定,拒绝。
  2. 先打开文件再授权:即使不返回内容也泄露存在性并触发敏感副作用,拒绝。
  3. 一次读取完整日志:内存随 Artifact 增长,不适合 edge,拒绝。
  4. 文件不存在统一当 404:无法解释已执行 retention,也无法暴露运维异常,拒绝。
  5. size 达到 quota 即 truncated=true:精确写满与超额不可区分,拒绝。
  6. cluster 通过共享挂载读取 Worker 路径:没有对象 ownership、ACK 和 fencing,拒绝。

验证要求

  • 非法 identity/range 在 metadata、policy 和 FS 副作用前拒绝;
  • deny/require-approval 时 file/fact 调用次数为零;
  • available range 的 byte、offset、snapshot size、nextOffset 和 256 KiB 上限可重复验证;
  • symlink file、symlink shard、非普通文件和异常短读 fail closed
  • retained 不访问 FSENOENT 后出现 tombstone 收敛为 retained
  • missing 与 retained 稳定区分,identity 漂移 fail closed
  • truncation true、false、unknown 和 fact identity 冲突均有测试;
  • SQLite adapter 排除跨 Project、legacy owner、非 LocalProcess 和非 canonical Artifact
  • Node 22/24 类型检查和全量测试通过;production import graph 保持不可达。