diff --git a/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md b/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md new file mode 100644 index 00000000..6bf7fbd3 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-duty-etag-overwrite.md @@ -0,0 +1,48 @@ +--- +title: "文档职责伪造与保存时重取 ETag 可覆盖并发正文" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: critical +--- + +# 文档职责伪造与保存时重取 ETag 可覆盖并发正文 + +## 现象 + +静态审查与 E2E UI-13 路径暴露两类产品缺陷: + +1. **职责提升**:审核人 B 或项目外陌生人打开受控文档后,工作台把其当成编制人,出现正文编辑 / 送审动作。 +2. **并发覆盖**:两窗口同开同一修订时,后保存方在点击保存瞬间重新 GET 最新 ETag,If-Match 对准已更新账本,409 不会出现,先保存方的正文被静默覆盖。 + +## 根因分析 + +| 层 | 问题 | 风险 | +|---|---|---| +| API | `ListWorkflowDocumentsByProject` / `GET projects/{id}/documents` 的 summary 不投影 `document_records` 的 author/reviewer/approver id+name | 前端无权威职责,只能猜 | +| 前端 merge | `mergeWorkflowDocumentsFromServer` 用当前登录用户名伪造 `author`,审核/批准写死「李主管」「王总监」 | B 登录后 `author===自己`,权限矩阵显示编制人 | +| 角色解析 | `currentDocRole` 比 name,失败默认 `'author'` | 未知身份 fail-open,陌生人获得编辑/送审 | +| 保存 | `handleSaveBlockEdit` 在 PATCH 前 `getRevisionState` 取最新 ETag | 打开时基线被丢弃;后写覆盖先写 | +| E2E | UI-13b 在 A1 完整保存后才打开 A2 编辑器 | 不是真实竞争,无法钉住上述 ETag 缺陷 | + +安全含义:职责伪造是**越权写**(viewer/reviewer 可改受控正文并送审)。ETag 刷新是**完整性破坏**(并发编辑丢失且无冲突提示)。 + +## 严重度 + +`critical` + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-duty-etag-overwrite.md](../remediation/2026-09-13-document-revision-duty-etag-overwrite.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md](../ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md) +- 分支: `fix/document-revision-e2e-reject-routing` + +## 证据 + +- 代码(修复前):`internal/docgov/project_docs.go` summary 无职责列;`web/src/lib/deliverableSync.ts` `author: fallbackUser.name`;`web/src/App.tsx` 默认 `'author'` 与保存前 `getRevisionState` +- 单测钉住:B/陌生人不成 author;stale hydrate ETag 用于 PATCH +- 本轮不跑真实 E2E、不提交 evidence assets + +## 开放问题 + +- 占槽后真实 UI-13a/13b 重跑;CURRENT 仍 `in_progress`,不得标 `done` diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 9407afd7..ccf4419a 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **DOCREV-RT**: [E2E UI-05b 钉住 intake 并收紧历史比较/冲突](short-term/2026-09-13-document-revision-e2e-reject-routing.md)(`fix/document-revision-e2e-reject-routing`:不改产品逻辑;不重跑 E2E / 不操作容器) +1. **DOCREV-RT**: [E2E UI-05b + 职责/ETag 产品修复](short-term/2026-09-13-document-revision-e2e-reject-routing.md)(`fix/document-revision-e2e-reject-routing`:权威职责 + 打开时基线 ETag;不重跑 E2E / 不操作容器;仍 `in_progress`) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -60,3 +60,4 @@ | 行动表状态假阳性审计 | [../audit/2026-09-13-document-revision-quality-status-false-positive.md](../audit/2026-09-13-document-revision-quality-status-false-positive.md) | | 待确认报告选择失败审计 | [../audit/2026-09-13-document-revision-pending-report-selection.md](../audit/2026-09-13-document-revision-pending-report-selection.md) | | UI-05b 拒绝路由失败审计 | [../audit/2026-09-13-document-revision-e2e-reject-routing.md](../audit/2026-09-13-document-revision-e2e-reject-routing.md) | +| 职责伪造与 ETag 覆盖审计 | [../audit/2026-09-13-document-revision-duty-etag-overwrite.md](../audit/2026-09-13-document-revision-duty-etag-overwrite.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md b/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md index 40248226..54ebf4cf 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md @@ -13,16 +13,17 @@ mutex: document-revision-runtime-ui ## 目标 -第五轮 UI-05b 首错修复:拒绝用例 prompt 必须带「当前受控文档《用户初始需求收集表》」,并在本轮 `POST /tasks/runs` 上严格断言 `task_key==='intake'` 且 `doc_id===controlledDocId`。同步收紧 UI-10/12/13/14 真实用户路径。不改产品业务代码。 +第五轮 UI-05b 首错修复:拒绝用例 prompt 必须带「当前受控文档《用户初始需求收集表》」,并在本轮 `POST /tasks/runs` 上严格断言 `task_key==='intake'` 且 `doc_id===controlledDocId`。同步收紧 UI-10/12/13/14 真实用户路径。本轮追加产品修复:权威文档职责 + 打开时基线 ETag。 ## 范围 -- `web/e2e/document-revision-runtime.spec.ts` -- 文档:audit / CURRENT(mutex `document-revision-runtime-ui`) +- `internal/docgov/project_docs.go`(summary 职责投影) +- `web/src` 职责字段、`resolveCurrentDocRole`、hydrate/PATCH ETag +- `web/e2e/document-revision-runtime.spec.ts`(UI-13b 真实竞争) +- 文档:audit / remediation / CURRENT(mutex `document-revision-runtime-ui`) ## 非目标 -- 不改 `templateMatch` / Toolbar / decision / PATCH - 不降低已有业务断言或超时;不伪造、不直接打 API - 不触碰/提交 `.doc/test-reports/assets`;不操作容器;本轮不重跑 E2E - 不改 FEATURE-MATRIX @@ -33,7 +34,9 @@ mutex: document-revision-runtime-ui 2. [x] UI-05b prompt + tasks/runs 契约断言 + 真实打开拒绝 3. [x] UI-10 历史 dialog 内选 A/B 比较并关闭;UI-12 项目不可见;UI-13 拆成审核人只读 + 双 A 冲突;UI-14 同时锁 v/r 4. [x] 仅修 05a/05b/10 遗留弹窗对 UI-06 的确切遮挡 -5. [ ] 占槽后真实 UI 重跑(本轮不做) +5. [x] 产品修复:权威职责 + viewer fail-closed + hydrate 基线 ETag +6. [x] UI-13b:A1 保存前 A2 编辑器已打开并填入文本;两窗口同一 r/etag +7. [ ] 占槽后真实 UI 重跑(本轮不做) ## 验收标准 @@ -41,11 +44,15 @@ mutex: document-revision-runtime-ui - [x] UI-10 不点全局工具条「比较」 - [x] UI-12 不接受「项目可见但只查 marker」 - [x] UI-13a/13b 结果分别记录;B 不作正文编辑者 +- [x] B/陌生人不会变 author;A 仍 author(Go + 前端单测) +- [x] stale hydrate ETag 用于 PATCH(不能用最新 ETag 掩盖冲突) - [x] web tests + `tsc`/build + `git diff --check` -- [ ] 真实 UI-05b+ 后段 PASS 后才可将本任务标 `done` +- [ ] 真实 UI-05b+ 后段及 UI-13a/13b PASS 后才可将本任务标 `done` ## 相关链接 -- Audit: [../../audit/2026-09-13-document-revision-e2e-reject-routing.md](../../audit/2026-09-13-document-revision-e2e-reject-routing.md) +- Audit (05b 路由): [../../audit/2026-09-13-document-revision-e2e-reject-routing.md](../../audit/2026-09-13-document-revision-e2e-reject-routing.md) +- Audit (职责/ETag): [../../audit/2026-09-13-document-revision-duty-etag-overwrite.md](../../audit/2026-09-13-document-revision-duty-etag-overwrite.md) +- Remediation: [../../remediation/2026-09-13-document-revision-duty-etag-overwrite.md](../../remediation/2026-09-13-document-revision-duty-etag-overwrite.md) - Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui05b-reject-routing-failure](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui05b-reject-routing-failure) - 前序: [2026-09-13-document-revision-e2e-pending-flow.md](./2026-09-13-document-revision-e2e-pending-flow.md) diff --git a/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md b/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md new file mode 100644 index 00000000..8359c4e7 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-duty-etag-overwrite.md @@ -0,0 +1,44 @@ +--- +title: "权威文档职责 + 打开时基线 ETag" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# 权威文档职责 + 打开时基线 ETag + +## 背景 + +链到 [../audit/2026-09-13-document-revision-duty-etag-overwrite.md](../audit/2026-09-13-document-revision-duty-etag-overwrite.md)。 + +陌生人/审核人被抬成编制人可改受控正文;保存时重取最新 ETag 会掩盖 409 并覆盖并发编辑。 + +## 目标 + +1. 列表 summary 返回台账 author/reviewer/approver 的 id+name;前端保留权威字段,禁止用当前登录用户伪造 author。 +2. `currentDocRole` 按权威 user id 解析;name 仅兼容旧本地文档;未知身份 fail-closed 为 `viewer`;viewer 无编辑/审核/批准动作。 +3. 受控正文 hydrate 同时取 revision-state ETag 写入 AST/缓存;人工 PATCH 只用该基线;成功后用响应 `state.etag` 更新;冲突保留编辑器与文本。无账本 bootstrap/freeform 仍可用 `0:0`。 +4. UI-13b:A1 保存前两窗口已加载相同 r/etag,且 A2 编辑器已打开并填入文本。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 1 | Go summary 投影职责 + 单测 | B 不是 author;A 仍是 author | +| Wave 2 | 前端 merge / `UserRole=viewer` / `resolveCurrentDocRole` | B/陌生人不成 author;viewer 无动作 | +| Wave 3 | hydrate 基线 ETag + PATCH 不再 GET | stale ETag 单测;不能用最新 ETag 掩盖冲突 | +| Wave 4 | UI-13b 真实竞争脚本 | A2 先开编辑器再 A1/A2 保存 | +| Wave 5 | 占槽真实 UI(本轮不做) | UI-13a/13b 实战 PASS 后才可 done | + +## 验收标准 + +- [x] Go `ListWorkflowDocumentsIncludesAuthoritativeDuties` +- [x] 前端 merge + `resolveCurrentDocRole` 单测 +- [x] stale hydrate ETag 用于保存(纯函数 + hydrate 单测) +- [x] UI-13b 脚本不降级(本轮不跑真实 E2E) +- [ ] 真实 UI 重跑 PASS + +## 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-duty-etag-overwrite.md](../audit/2026-09-13-document-revision-duty-etag-overwrite.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md](../ops/short-term/2026-09-13-document-revision-e2e-reject-routing.md)