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

120 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-0027Artifact 读取授权、存在性屏蔽与本地 Range 契约
- 状态:Proposed
- 日期:2026-07-19
- 关联:QL-RFC-0001、ADR-0024、ADR-0026
> 2026-08-12Local/Cluster 生产读取接线由 ADR-0377 接受,Local retention 二次检查与 durable 410
> 由 ADR-0378 接受;Cluster retention 删除实现仍属于 D-291,本 ADR 的其余跨 adapter 扩展继续保持 Proposed。
## 上下文
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,并返回:
- `start``endExclusive``totalBytes`
- 尚有快照内容时返回 `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=runtime``executor_type=local_process`、canonical `local-*` 绑定;
4. 调用 `artifact.read` authorizer,输入 subject、Project、Run 和 Artifact identity,不包含文件路径或内容;
5. 只有 `allow` 才能访问日志或 truncation fact`deny``require_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 `maximumBytes``observedAtMs`,但不能返回 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 保持不可达。