From 884912d1c86ae1513b713e6804c1493ac2378149 Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 29 Aug 2026 03:51:35 +0800 Subject: [PATCH] feat(ql3): add request-scoped console task creation --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 6 +- ...uest-scoped-local-console-task-mutation.md | 75 +++ docs/adr/README.md | 3 + docs/operations/ql3-alpha-candidate.md | 5 +- docs/operations/ql3-local-web-console.md | 22 +- docs/ql3-package-boundaries.json | 9 +- .../adopted-profile/localAdoptedProfile.ts | 5 + .../ql3-local-api/assets/console/console.css | 175 ++++++- .../ql3-local-api/assets/console/console.js | 215 +++++++- .../ql3-local-api/assets/console/index.html | 94 +++- packages/ql3-local-api/package.json | 1 + .../src/admission/localApiAdmission.ts | 30 ++ .../localApiProductSurface.ts | 61 ++- .../authentication/credentialAuthenticator.ts | 24 +- .../src/authentication/localPresenceProof.ts | 400 +++++++++++++++ .../ql3-local-api/src/task/taskPutRoute.ts | 462 ++++++++++++++++++ .../src/transport/httpSurface.ts | 28 ++ .../ql3-local-api/test/admission.test.cjs | 40 ++ .../ql3-local-api/test/consoleAssets.test.cjs | 9 + .../ql3-local-api/test/httpSurface.test.cjs | 40 +- .../test/localPresenceProof.test.cjs | 136 ++++++ .../test/sqliteHttp.integration.test.cjs | 66 ++- .../ql3-local-api/test/taskPutRoute.test.cjs | 292 +++++++++++ .../src/application-runtime/activation.ts | 2 + .../src/application-runtime/contract.ts | 5 +- .../src/profile/localProfile.ts | 3 + .../src/runtime/runtimeDatabase.ts | 31 ++ .../taskDefinitionAdministration.ts | 2 +- .../test/taskDefinitionRequestFence.test.cjs | 283 +++++++++++ pnpm-lock.yaml | 3 + scripts/ql3-cluster-dependency-audit.cjs | 10 + test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 32 files changed, 2485 insertions(+), 56 deletions(-) create mode 100644 docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md create mode 100644 packages/ql3-local-api/src/authentication/localPresenceProof.ts create mode 100644 packages/ql3-local-api/src/task/taskPutRoute.ts create mode 100644 packages/ql3-local-api/test/localPresenceProof.test.cjs create mode 100644 packages/ql3-local-api/test/taskPutRoute.test.cjs create mode 100644 packages/ql3-local-sqlite/test/taskDefinitionRequestFence.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e5a9c326..6c926e7f 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -6,10 +6,12 @@ - 目标版本:QingLong 3.x - 作者:QingLong Maintainers - 创建日期:2026-07-17 -- 最后更新:2026-08-28 +- 最后更新:2026-08-29 - 讨论范围:架构与演进路线,不包含最终 UI 视觉方案 -最新增量证据(2026-08-28): +最新增量证据(2026-08-29): + +- D-421/ADR-0516(实现与本地验收已完成,阶段提交/远端 milestone 待闭合):Local Web Task mutation 不再受限于短生命周期 CLI 的进程级 active credential。`PUT /api/v3/projects/:projectId/tasks/:taskId` 把 Bearer 限定为 session credential,第一次 exact request 只在 deployment root 内 `0700` 目录发布 `0600`、当前 UID、两分钟、一次性的私有 proof file;HTTP 只返回 authorization ID、canonical request digest、expiry 与 basename,不返回 proof 或宿主绝对路径。proof 绑定 Task 全内容、credential ID/version 与 User subject;Edge/Standalone pending 上限为 8/32,按请求惰性清理,不新增 timer/watcher/daemon。验证后 principal 提升为短期 `local_console`,既有 Task administration service 重跑 Policy;SQLite runtime 为每个请求建立独立 credential-fenced repository,并在 Task append 事务内复验 credential/Identity/pepper、actor、Project/RoleBinding fence,原子提交 allowed audit 与 mutation。并发测试证明两个 User repository 不共享 ambient authority,撤销 A 不影响 B,RoleBinding 漂移仍原子拒绝;真实 loopback HTTP→proof file→SQLite create 与 Chromium 编辑器/proof ticket 已通过。Console 当前完成 command Task 创建;HTTP 与 CLI 支持完整 create/update,Web update 等待强认证 authoring read/lease,避免用不含 spec 的 bounded read 覆盖未知字段。三资产合计 62,632 bytes;Local API 56/56、18-package clean build/test 3,030 total/3,008 pass/22 条件跳过/0 fail,package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 Edge 为 2,736,982 bytes/329 files/83 modules,opt-in Edge/Standalone Console 为 4,040,893/4,041,037 bytes、472 files/12 packages/94 modules,均保有门禁余量;默认 headless 与 Cluster 路径不变。完整 backend 仍由原生 CI 闭合,新的可下载双架构产物必须在本阶段提交后由显式 Console milestone run 重新生成,不能沿用 D-420 的旧 archive 冒充。 - D-420/ADR-0515(已实现,首份真实双架构 Console v5 Trial Kit 已交付):阶段可用的首个自动化从“原生 CI 能通过 API 看到 bounded log marker”推进为“部署者能在 Console 直接观察实际输出”。Local Run HTTP 详情只追加严格验证的 latest Attempt 低敏摘要(ID、序号、状态、时间和 `logAvailable`),不返回 executor handle、Artifact ID、路径、Worker、PID 或错误明细;共享 HIGH 风险 `executeBoundedRunReadProjection` 保持不变,避免 Local UI 字段漂移到内建 Run read/compare Tool。Console 使用既有 `artifact.read` Policy/Audit/credential re-confirm 链,每次固定读取首个 32 KiB base64 窗口,分别显示 available/pending/retired/not-found/unavailable、range 与 truncation,不新增轮询、WebSocket、timer、缓存或整文件下载。三资产增至 48,318 bytes;`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门,默认 headless Edge 保持 2,669,390 bytes/325 files/58 modules,Cluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0;package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。提交 `57953ec8` 的远端 QingLong 3.0 CI 为 41 success / 3 expected artifact-finalizer skip / 0 fail,独立 Kubernetes deployment live contract 为 1/1 success。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTP,Web Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。 diff --git a/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md b/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md new file mode 100644 index 00000000..f7139028 --- /dev/null +++ b/docs/adr/ADR-0516-request-scoped-local-console-task-mutation.md @@ -0,0 +1,75 @@ +# ADR-0516:request-scoped Local Console Task mutation + +- 状态:Accepted +- 日期:2026-08-29 +- 对应 RFC 切片:D-421 +- 关联:ADR-0256、ADR-0377、ADR-0512、ADR-0513、ADR-0514、ADR-0515 + +## 背景 + +Local Console 已能读取 Task、显式启动 Run、查看 Event/Step/有界日志并请求取消,但部署者仍必须离开 Console,使用短生命周期 `ql3-task` command file 才能创建或修订 Task。该 CLI 的安全性依赖“每个进程只激活一个 credential fence”;把同一个可变 active fence 搬进常驻、多请求 HTTP 会让并发用户互相覆盖凭据,并在 credential 撤销或 RoleBinding 漂移时产生错误授权。 + +单因子 Bearer 只能证明浏览器持有 API credential,不能替代现有 Task 管理要求的 strong User。本切片必须在不扩大默认 headless 路由设备常驻成本、不把 proof secret 返回 HTTP、不削弱 SQLite 原子审计、也不把本机 POSIX authority 误用到 Cluster 的前提下,形成部署者可实际操作的 Web 创建链路。 + +## 决策 + +### 1. PUT 使用 request-scoped credential fence + +Local API 新增固定路由: + +```text +PUT /api/v3/projects/:projectId/tasks/:taskId +``` + +请求体使用既有 immutable TaskDefinition command:`expectedRevision=null` 创建,`expectedRevision=current` 修订;mutation ID、occurredAt、name/kind/spec/labels/enabled 都参与规范化。Bearer 只建立 `single_factor` session,并交付该次认证解析出的 exact credential fence;服务不设置进程级 active credential。 + +SQLite runtime 以 `taskDefinitionAdministrationForCredential(fence)` 为每个请求创建独立 repository。factory 建立时先复验 credential/Identity/pepper;写事务内再次复验同一 exact fence、actor subject、Project version 与 latest RoleBinding version/state。allowed audit、Task head/revision、mutation replay 与适用的 local execution revision仍在一个事务中提交。两个同时存活的 repository 不共享可变 credential 状态。 + +### 2. 本机存在证明是第二权威,不经 HTTP 交付 secret + +第一次 exact PUT 不带 proof 时,服务在 `/console-presence/` 发布一次性 challenge file:目录 `0700`,文件 `0600`,当前 POSIX UID owner,exclusive/no-follow、原子 rename、文件和目录 `fsync`。响应只返回 authorization ID、请求摘要、过期时间和 basename;32-byte 随机 proof 只存在私有文件,不进入 HTTP response、URL、Cookie、Web Storage、日志或 challenge audit。 + +proof 精确绑定: + +- canonical Task command SHA-256; +- credential ID/version; +- User subject; +- 两分钟有效期与单次消费。 + +Edge 最多保留 8 个待确认 challenge,Standalone 最多 32 个;过期文件按请求惰性清理,不新增 timer、watcher、daemon 或后台 I/O。错误 proof、不同 Task 内容、不同 credential/subject 和过期 proof 都不能消费原 challenge。用户提交 proof 后,服务先重新确认 Bearer credential authority,再消费 proof,并把 principal 提升为短期 `local_console` assurance;既有 Task administration service 随后重新执行 Project Policy。 + +### 3. Challenge audit 与最终 mutation audit 分层 + +生成 challenge 时记录 `approval_required/local_presence_required`,但不记录 proof、命令内容或路径。错误 proof 记录无 subject 的 `authentication_rejected/local_presence_rejected`,符合既有 audit outcome identity contract。最终 `allowed` audit 不由 HTTP 先写,而是与 Task mutation 一同进入 SQLite 事务;Policy、credential、RoleBinding 或 mutation fence 漂移都不会留下“允许但未写入”的孤立记录。 + +### 4. Console 先提供可完成的 command Task 创建旅程 + +离线 Console 新增“创建任务”编辑器:Task ID、名称、说明、argv file、逐行 args 和 enabled。第一次保存后展示只包含 `console-presence/`、两分钟时效和 password proof 输入的本机证明票据;页面内存只保留同一 immutable request,验证成功后刷新并选中新 Task。页面继续禁止 CDN、前端框架、inline script、`innerHTML`、Cookie 与 Web Storage。 + +HTTP contract 已同时支持 create/update。当前 bounded Task read 有意不返回完整 spec/config,因此 Console 不伪造不完整 update:Web 修订编辑器要等后续受强认证的 authoring lease/read contract,或由调用方提供完整 exact definition。既有 `ql3-task` CLI 继续作为完整 create/update/enable/disable 入口。 + +### 5. 部署档位保持分层 + +- 默认 `edge`/`standalone` headless 不加载 Local API、Console 或 challenge manager,资源零增量; +- opt-in `edge-application-api` 使用 8 个 pending 上限,适合低内存路由/NAS; +- opt-in `standalone-application-api` 使用 32 个 pending 上限; +- Cluster 不复用 POSIX proof、SQLite fence 或 Local credential。集群 Task mutation 后续必须使用 Cluster Control 的 TLS/RBAC/多副本 authority。 + +## 不采用的方案 + +- 不允许 Bearer 直接 `task.put`:它会把 strong User 降级为单因子远程 secret possession。 +- 不复用 CLI 的进程级 `activateUserCredentialFence`:常驻并发 HTTP 会发生 ambient authority 串线。 +- 不把 proof 放进 challenge response、Console HTML 或 quickstart stdout:这会让第二权威退化为同一网络通道内的 bearer。 +- 不为低配设备增加 WebSocket、轮询 challenge、timer 或长期 session store:显式读取私有文件已经形成可审计的本机动作。 +- 不让 Console 用 bounded read 投影拼装 update:投影刻意不含 spec/config,猜测会覆盖调用方未知字段。 +- 不把 Local proof 抽象为 Cluster 通用插件:POSIX owner/mode 不能证明 Kubernetes/多节点身份与审批。 + +## 结果与验证边界 + +定向验证覆盖:proof 文件权限与无身份泄漏、exact request/credential/subject 绑定、一次性消费、Edge 容量与过期惰性清理;Task route 的 challenge→confirm→strong Policy→事务 mutation、内容漂移、非 User、过期和失败关闭;两个同时存活的 credential repository、单方 credential revoke、另一方继续写入与 RoleBinding 漂移原子拒绝;真实 loopback HTTP→私有 proof file→SQLite Task/audit 创建;真实 Chromium 的 Task 编辑器与 proof ticket 可访问性/布局。 + +本地 18-package clean build/test 已通过:`3,030 total / 3,008 pass / 22 conditional、platform 或 external-service skip / 0 fail`;Local API 完整 loopback/SQLite/Console 回归为 `56/56`,新增双 credential request-fence 为 `1/1`,package boundary 契约为 `10/10`。package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 `compatible=true`。离线 Console 三资产合计 62,632 bytes;默认 Edge 为 2,736,982 bytes/329 files/3 packages/83 loaded modules,仍低于 4 MiB/512-file/20 MiB RSS-delta 门;opt-in `edge-application-api|standalone-application-api` 为 4,040,893/4,041,037 bytes、472 files、12 packages、94 loaded modules,仍低于 6 MiB/640-file/28 MiB RSS-delta 门。真实 Chromium 的创建编辑器与 proof ticket 已完成桌面布局、键盘焦点与可访问树检查。 + +本机 2.x backend 兼容门首次运行得到 `1,348 total / 1,293 pass / 53 fail / 2 skip`,其中 52 个文件在加载 Sequelize 前统一因锁定的 `@whyour/sqlite3` 原生绑定缺失而失败,另一个 loopback 用例受当前 sandbox 拒绝;依赖重建先因 GitHub 预编译包下载超时、再因本机 C++ SDK 缺少 `` 失败,未伪装成源码回归。唯一实际源码契约漂移是 Local API 文件计数 18/17→20/19,修正后 package-boundary `10/10` 通过。完整 backend 与原生 Linux loopback 必须由阶段提交的远端 CI 闭合。 + +该 ADR 证明源码切片的安全与产品旅程,不自动声明已有新的双架构 Trial Kit:新的可下载阶段产物仍必须由同源显式 milestone run 重新生成并进入 Local milestone index。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 894e400b..3c6aaa68 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -517,6 +517,9 @@ | [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) | | [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Trial Kit 交付已由 ADR-0513 闭合) | | [ADR-0513](./ADR-0513-selectable-local-console-trial-kit.md) | 可选择的 Local Console Trial Kit | Accepted(首份实际双架构产物待维护者授权) | +| [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) | +| [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) | +| [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index f43ad47a..16d29a8a 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -15,7 +15,7 @@ 当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。 -## 当前阶段实物(2026-08-28) +## 当前阶段实物(2026-08-29) 在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已把 headless 用户旅程与 opt-in Console 合并为一条可选择的交付链: @@ -25,6 +25,9 @@ | Local headless Trial Kit v5 | 默认低配变体;同一源码支持 POSIX shell + Docker 一条命令完成 fresh setup、首 Owner与 Application active/stop;无 listener、示例 Task 或 Console 增量 | 尚未为当前提交单独触发 headless 双架构 milestone;不能把 Console archive 改名复用 | | D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 | | D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 | +| D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task | 当前源码尚未重新生成双架构 Trial Kit;Web update 等待 authoring read/lease,Cluster 不复用 Local proof | + +D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,但不能据此把 run `33173769047` 的旧 archive 改名为新产物。只有 D-421 阶段提交通过远端 CI,并由同源显式 Local Console milestone run 重新生成 amd64/arm64 Trial Kit 与 milestone index 后,下载者才能把该 Web 创建能力视为新的阶段实物;在此之前,旧 Console v5 仍是最新可下载实物,本工作树/提交只是下一候选源码。 D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index 583024f2..29fa7435 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -1,6 +1,6 @@ # QingLong 3.0 Local Web Console -Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建 command Task、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 ## 选择部署档位 @@ -11,14 +11,14 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查 | 普通单节点服务器 | 选择 `standalone-application-api` | | Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | -D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console,并明确展示 pending、retired、missing 与 truncation 状态。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。 +D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421 增加 request-scoped strong-auth Task PUT 与 Console command Task 创建器;它不复用 CLI 的进程级 active credential,也不让单因子 Bearer 直接写 Task。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。 ## 前置条件 - 已完成 Local fresh setup,并有受支持的 Application config; - Owner pepper keyring 与 SQLite active pepper 一致; - 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential; -- credential 对目标 Project 至少有读取 Task/Run 的权限;启动和取消分别还需要 `run.start` 与 `run.stop`; +- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、启动和取消分别还需要 `task.create`、`run.start` 与 `run.stop`; - config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。 ## 启动 @@ -54,19 +54,21 @@ ssh -L 5701:127.0.0.1:5701 router.example ## 使用 1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。 -2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。 -3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 -4. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 -5. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 -6. 完成后选择“断开并清除凭据”,再关闭页面。 +2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。 +3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的 `proof` 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact Task 一次。不要通过聊天、日志或 URL 转发 proof。 +4. 创建成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。 +5. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 +6. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 +7. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 +8. 完成后选择“断开并清除凭据”,再关闭页面。 Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 ## 当前阶段可用边界 -当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/log/cancel。页面不负责: +当前可操作闭环是 command Task create/list/read/start 与 Run list/read/events/steps/log/cancel。HTTP `PUT` 也支持提供完整 exact definition 的 update;页面暂不负责: -- 创建、编辑、启停 Task; +- 编辑/启停现有 Task(bounded read 不返回完整 spec,不能据此安全覆盖;继续使用 `ql3-task`,后续由 authoring lease/read 切片补齐); - Identity、Policy、Secret、Plugin Package 或 AI 配置管理; - 日志整文件下载、终端、文件管理或 2.x 数据迁移; - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index c3e39d84..395e5973 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -118,13 +118,18 @@ }, "criteria": ["authority", "shared_leaf"], "profiles": ["local-owner", "edge-adopted", "standalone-adopted"], - "consumers": ["@qinglong/local-application", "@qinglong/local-owner-cli"], + "consumers": [ + "@qinglong/local-api", + "@qinglong/local-application", + "@qinglong/local-owner-cli" + ], "authorities": [ "short-lived SQLite administration", + "request-scoped Local Console Task mutation", "legacy adoption fence", "reviewed adopted Profile activation" ], - "rationale": "短生命周期写 authority 与 adopted Profile activation 共享完全相同的部署闭包;后者通过 adopted-profile 子路径与惰性 runtime import 隔离,不再用三文件微型 workspace package 表达。" + "rationale": "短生命周期写 authority、request-scoped Local Console Task mutation 与 adopted Profile activation 共享完全相同的部署闭包;Local API 仅允许调用 task-definition-administration 精确子路径,adopted Profile 通过 adopted-profile 子路径与惰性 runtime import 隔离,不再用三文件微型 workspace package 表达。" }, { "path": "packages/ql3-local-api", diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts index cd15e77d..38250562 100644 --- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts +++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts @@ -17,6 +17,8 @@ type LocalSqliteRunCancellationRepository = ReadyLocalStorage['runCancellationRepository']; type LocalSqliteTaskStartRepository = ReadyLocalStorage['taskStartRepository']; type TaskDefinitionRepository = ReadyLocalStorage['taskDefinitions']; +type TaskDefinitionAdministrationForCredential = + ReadyLocalStorage['taskDefinitionAdministrationForCredential']; type LocalScheduleStore = ReadyLocalStorage['schedules']; type LocalDispatchStore = ReadyLocalStorage['dispatch']; type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets']; @@ -87,6 +89,7 @@ export type LocalAdoptedProfileBootstrapResult = readonly runCancellationRepository: LocalSqliteRunCancellationRepository; readonly taskStartRepository: LocalSqliteTaskStartRepository; readonly taskDefinitions: TaskDefinitionRepository; + readonly taskDefinitionAdministrationForCredential: TaskDefinitionAdministrationForCredential; readonly schedules: LocalScheduleStore; readonly dispatch: LocalDispatchStore; readonly executionControl: ReadyLocalStorage['executionControl']; @@ -235,6 +238,8 @@ export async function bootstrapLocalAdoptedProfileStorage( runCancellationRepository: readyStorage.runCancellationRepository, taskStartRepository: readyStorage.taskStartRepository, taskDefinitions: readyStorage.taskDefinitions, + taskDefinitionAdministrationForCredential: + readyStorage.taskDefinitionAdministrationForCredential, schedules: readyStorage.schedules, dispatch: readyStorage.dispatch, executionControl: readyStorage.executionControl, diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css index c87f6ebd..34335ea9 100644 --- a/packages/ql3-local-api/assets/console/console.css +++ b/packages/ql3-local-api/assets/console/console.css @@ -35,7 +35,8 @@ body { } button, -input { +input, +textarea { font: inherit; } @@ -45,6 +46,7 @@ button { button:focus-visible, input:focus-visible, +textarea:focus-visible, [tabindex]:focus-visible { outline: 3px solid var(--amber); outline-offset: 3px; @@ -321,6 +323,14 @@ input:focus-visible, line-height: 1.6; } +.masthead-actions { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .refresh-button { flex: 0 0 auto; color: var(--deep); @@ -700,6 +710,155 @@ input:focus-visible, box-shadow: var(--shadow); } +.task-editor-dialog, +.presence-dialog { + padding: 0; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 0; + background: var(--paper); + box-shadow: var(--shadow); +} + +.task-editor-dialog { + width: min(720px, calc(100vw - 32px)); +} + +.presence-dialog { + width: min(520px, calc(100vw - 32px)); +} + +.task-editor-dialog::backdrop, +.presence-dialog::backdrop { + background: rgba(11, 31, 36, 0.76); +} + +.task-editor-dialog form, +.presence-dialog form { + padding: clamp(22px, 4vw, 34px); +} + +.dialog-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; +} + +.task-editor-dialog h2, +.presence-dialog h2 { + margin: 0; + font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif; + font-size: 34px; + line-height: 1; +} + +.editor-intro, +.presence-dialog form > p:not(.eyebrow), +.editor-note, +.presence-expiry { + color: var(--muted); + line-height: 1.6; +} + +.editor-grid { + margin-top: 24px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.editor-grid label, +.presence-input { + display: grid; + gap: 7px; +} + +.editor-grid label > span, +.presence-input > span { + color: var(--muted); + font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.editor-grid input:not([type='checkbox']), +.editor-grid textarea, +.presence-input input { + width: 100%; + padding: 11px 12px; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 0; + background: var(--white); + font: 500 13px/1.45 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.editor-grid textarea { + resize: vertical; +} + +.editor-wide { + grid-column: 1 / -1; +} + +.editor-check { + grid-column: 1 / -1; + grid-template-columns: auto 1fr; + align-items: center; + justify-content: start; +} + +.editor-check input { + width: 18px; + height: 18px; + accent-color: var(--signal); +} + +.editor-note { + margin: 18px 0 0; + padding-left: 12px; + border-left: 3px solid var(--amber); + font-size: 11px; +} + +.proof-ticket { + position: relative; + margin: 22px 0; + padding: 18px 16px 18px 22px; + display: block; + color: var(--white); + border-left: 6px solid var(--signal); + background: var(--deep); + font: 650 12px/1.5 ui-monospace, 'SFMono-Regular', Consolas, monospace; + overflow-wrap: anywhere; +} + +.proof-ticket::after { + content: 'HOST FILE'; + position: absolute; + top: 8px; + right: 10px; + color: rgba(255, 255, 255, 0.35); + font-size: 8px; + letter-spacing: 0.12em; +} + +.presence-expiry { + margin-bottom: 0; + font-size: 11px; +} + +.presence-error { + margin: 14px 0 0; + padding: 10px 12px; + color: var(--danger); + border-left: 3px solid var(--danger); + background: rgba(186, 74, 67, 0.08); + font-size: 12px; + line-height: 1.5; +} + .confirmation-dialog::backdrop { background: rgba(11, 31, 36, 0.7); } @@ -813,6 +972,11 @@ input:focus-visible, flex-direction: column; } + .masthead-actions { + width: 100%; + justify-content: flex-start; + } + .masthead h2 { font-size: 40px; } @@ -836,6 +1000,15 @@ input:focus-visible, .record-side { justify-items: start; } + + .editor-grid { + grid-template-columns: 1fr; + } + + .editor-wide, + .editor-check { + grid-column: auto; + } } @media (prefers-reduced-motion: reduce) { diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index 34a8f76d..07ff02c9 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -2,8 +2,10 @@ 'use strict'; const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TOKEN_PATTERN = /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; + const PRESENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/; const LOG_READ_BYTES = 32 * 1024; const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); const STATUS_LABELS = Object.freeze({ @@ -30,6 +32,16 @@ run_step_list_unavailable: 'Workflow Step 暂时不可用。', task_start_fence_rejected: '任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。', + local_presence_rejected: + '本机证明不匹配或已过期。请核对文件;过期后关闭窗口并重新保存。', + local_presence_unavailable: + '暂时无法生成本机证明。请检查部署数据目录权限。', + strong_authentication_required: + '当前凭据不能执行管理操作;请使用本机 User API Credential。', + task_definition_fence_rejected: + 'Task 或授权在确认期间发生变化。请刷新后重新创建。', + invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。', + task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。', run_cancellation_fence_rejected: '运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。', request_unavailable: '本次请求没有完成,请确认服务仍在运行。', @@ -49,10 +61,29 @@ title: document.getElementById('section-title'), description: document.getElementById('section-description'), refresh: document.getElementById('refresh-button'), + createTask: document.getElementById('create-task-button'), dialog: document.getElementById('confirmation-dialog'), dialogTitle: document.getElementById('confirmation-title'), dialogCopy: document.getElementById('confirmation-copy'), dialogAccept: document.getElementById('confirmation-accept'), + taskEditor: document.getElementById('task-editor-dialog'), + taskEditorForm: document.getElementById('task-editor-form'), + taskEditorClose: document.getElementById('task-editor-close'), + taskEditorSave: document.getElementById('task-editor-save'), + taskId: document.getElementById('task-id-input'), + taskName: document.getElementById('task-name-input'), + taskDescription: document.getElementById('task-description-input'), + taskCommand: document.getElementById('task-command-input'), + taskArgs: document.getElementById('task-args-input'), + taskEnabled: document.getElementById('task-enabled-input'), + presenceDialog: document.getElementById('presence-dialog'), + presenceForm: document.getElementById('presence-form'), + presenceFile: document.getElementById('presence-file'), + presenceProof: document.getElementById('presence-proof-input'), + presenceExpiry: document.getElementById('presence-expiry'), + presenceError: document.getElementById('presence-error'), + presenceCancel: document.getElementById('presence-cancel'), + presenceSubmit: document.getElementById('presence-submit'), toast: document.getElementById('toast'), }); @@ -62,6 +93,7 @@ view: 'tasks', selectedId: null, pendingAction: null, + pendingTaskMutation: null, toastTimer: null, }; @@ -169,6 +201,9 @@ body = JSON.stringify(options.body); headers['content-type'] = 'application/json'; } + if (options.presence !== undefined) { + headers['x-qinglong-local-presence'] = options.presence; + } let response; try { response = await fetch(path, { @@ -193,7 +228,7 @@ response.headers.get('x-request-id'), ); } - if (!response.ok) { + if (!response.ok && response.status !== options.acceptStatus) { throw new ConsoleRequestError( typeof value.code === 'string' ? value.code : 'request_unavailable', response.status, @@ -310,6 +345,148 @@ nodes.dialog.showModal(); } + function openTaskEditor() { + nodes.taskEditorForm.reset(); + nodes.taskCommand.value = '/bin/echo'; + nodes.taskEnabled.checked = true; + nodes.taskEditor.returnValue = ''; + nodes.taskEditor.showModal(); + nodes.taskId.focus(); + } + + function taskDraft() { + const taskId = nodes.taskId.value.trim(); + const name = nodes.taskName.value.trim(); + const description = nodes.taskDescription.value.trim(); + const file = nodes.taskCommand.value.trim(); + const args = nodes.taskArgs.value + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((value) => value.length > 0); + if (!TASK_PATTERN.test(taskId)) { + throw new TypeError('Task ID 格式无效。'); + } + if (!name || !file || args.length > 128) { + throw new TypeError('名称、命令或参数数量无效。'); + } + return Object.freeze({ + taskId, + body: Object.freeze({ + expectedRevision: null, + mutationId: newMutationId(), + name, + ...(description ? { description } : {}), + kind: 'command', + spec: Object.freeze({ + schema: 'qinglong/command@v1', + config: Object.freeze({ + command: Object.freeze({ kind: 'argv', file, args }), + }), + }), + labels: Object.freeze({ 'qinglong.source': 'local-console' }), + enabled: nodes.taskEnabled.checked, + occurredAtMs: Date.now(), + }), + }); + } + + function showPresenceChallenge(mutation, challenge) { + if ( + challenge?.code !== 'local_presence_required' || + typeof challenge.proofFileName !== 'string' || + !/^[0-9a-f-]{36}\.json$/u.test(challenge.proofFileName) || + !Number.isSafeInteger(challenge.expiresAtMs) + ) { + throw new ConsoleRequestError('response_unavailable', 503, null); + } + state.pendingTaskMutation = Object.freeze({ mutation, challenge }); + nodes.presenceFile.textContent = `console-presence/${challenge.proofFileName}`; + nodes.presenceExpiry.textContent = `证明将在 ${formatTime( + challenge.expiresAtMs, + )} 失效;内容改变后必须重新生成。`; + nodes.presenceProof.value = ''; + nodes.presenceError.textContent = ''; + nodes.presenceError.hidden = true; + nodes.taskEditor.close(); + nodes.presenceDialog.returnValue = ''; + nodes.presenceDialog.showModal(); + nodes.presenceProof.focus(); + } + + async function saveTaskDraft() { + let mutation; + try { + mutation = taskDraft(); + } catch (error) { + showToast( + error instanceof Error ? error.message : 'Task 定义无效。', + 'error', + ); + return; + } + nodes.taskEditorSave.disabled = true; + try { + const value = await api( + `/api/v3/projects/${state.project}/tasks/${mutation.taskId}`, + { + method: 'PUT', + body: mutation.body, + acceptStatus: 428, + }, + ); + if (value.code === 'local_presence_required') { + showPresenceChallenge(mutation, value); + return; + } + throw new ConsoleRequestError('response_unavailable', 503, null); + } catch (error) { + showToast(describeError(error), 'error'); + } finally { + nodes.taskEditorSave.disabled = false; + } + } + + async function completeTaskMutation() { + const pending = state.pendingTaskMutation; + const proof = nodes.presenceProof.value.trim(); + if (!pending || !PRESENCE_PATTERN.test(proof)) { + nodes.presenceError.textContent = + 'proof 格式无效。请完整复制私有文件中的 proof 字段。'; + nodes.presenceError.hidden = false; + nodes.presenceProof.focus(); + return; + } + nodes.presenceSubmit.disabled = true; + nodes.presenceError.hidden = true; + try { + const value = await api( + `/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`, + { + method: 'PUT', + body: pending.mutation.body, + presence: proof, + }, + ); + state.pendingTaskMutation = null; + nodes.presenceProof.value = ''; + nodes.presenceDialog.close(); + showToast( + value.status === 'existing' + ? '已找到同一 Task 请求。' + : 'Task 已创建。', + ); + state.selectedId = pending.mutation.taskId; + await refresh(); + await selectTask(pending.mutation.taskId); + } catch (error) { + nodes.presenceError.textContent = describeError(error); + nodes.presenceError.hidden = false; + nodes.presenceProof.select(); + } finally { + nodes.presenceSubmit.disabled = false; + } + } + async function renderTasks() { const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`); const tasks = Array.isArray(value.tasks) ? value.tasks : []; @@ -595,7 +772,11 @@ unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。', }; section.append( - element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable), + element( + 'p', + 'run-log-placeholder', + labels[logView.status] || labels.unavailable, + ), ); return section; } @@ -702,11 +883,13 @@ else button.removeAttribute('aria-current'); } if (state.view === 'tasks') { + nodes.createTask.hidden = false; nodes.kicker.textContent = 'Project task authority'; nodes.title.textContent = '任务调度台'; nodes.description.textContent = - '查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。'; + '创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。'; } else { + nodes.createTask.hidden = true; nodes.kicker.textContent = 'Durable run evidence'; nodes.title.textContent = '运行事实账本'; nodes.description.textContent = @@ -750,6 +933,9 @@ state.token = null; state.selectedId = null; state.pendingAction = null; + state.pendingTaskMutation = null; + if (nodes.taskEditor.open) nodes.taskEditor.close(); + if (nodes.presenceDialog.open) nodes.presenceDialog.close(); nodes.token.value = ''; nodes.token.disabled = false; nodes.project.disabled = false; @@ -757,6 +943,7 @@ nodes.disconnect.hidden = true; nodes.nav.hidden = true; nodes.refresh.hidden = true; + nodes.createTask.hidden = true; setConnection('idle', '等待凭据'); nodes.kicker.textContent = 'Connection gate'; nodes.title.textContent = '先建立一条本机连接'; @@ -793,6 +980,23 @@ nodes.disconnect.addEventListener('click', disconnect); nodes.refresh.addEventListener('click', refresh); + nodes.createTask.addEventListener('click', openTaskEditor); + nodes.taskEditorClose.addEventListener('click', () => + nodes.taskEditor.close(), + ); + nodes.taskEditorForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveTaskDraft(); + }); + nodes.presenceCancel.addEventListener('click', () => { + state.pendingTaskMutation = null; + nodes.presenceProof.value = ''; + nodes.presenceDialog.close(); + }); + nodes.presenceForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await completeTaskMutation(); + }); for (const button of nodes.nav.querySelectorAll('button[data-view]')) { button.addEventListener('click', () => { @@ -820,7 +1024,10 @@ event.ctrlKey || event.altKey || event.target instanceof HTMLInputElement || - nodes.dialog.open + event.target instanceof HTMLTextAreaElement || + nodes.dialog.open || + nodes.taskEditor.open || + nodes.presenceDialog.open ) { return; } diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html index af027a9e..f2748209 100644 --- a/packages/ql3-local-api/assets/console/index.html +++ b/packages/ql3-local-api/assets/console/index.html @@ -87,9 +87,14 @@ 输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。

- +
+ + +
@@ -125,6 +130,89 @@ + +
+
+
+

Task authoring

+

创建命令任务

+
+ +
+

+ 定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。 +

+
+ + + + + + +
+

+ Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。 +

+
+ +
+
+
+ + +
+

Local presence · 02:00

+

从部署设备取得证明

+

+ 使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。 +

+ + +

+ +
+ + +
+
+
+ diff --git a/packages/ql3-local-api/package.json b/packages/ql3-local-api/package.json index 2b8d4e4b..fd9c134f 100644 --- a/packages/ql3-local-api/package.json +++ b/packages/ql3-local-api/package.json @@ -40,6 +40,7 @@ "test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs" }, "dependencies": { + "@qinglong/local-admin": "workspace:*", "@qinglong/local-application": "workspace:*", "@qinglong/local-command-file": "workspace:*", "@qinglong/local-owner-console": "workspace:*", diff --git a/packages/ql3-local-api/src/admission/localApiAdmission.ts b/packages/ql3-local-api/src/admission/localApiAdmission.ts index 6af7ed09..b4585ac6 100644 --- a/packages/ql3-local-api/src/admission/localApiAdmission.ts +++ b/packages/ql3-local-api/src/admission/localApiAdmission.ts @@ -26,6 +26,7 @@ import type { LocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRou import type { LocalApiTaskListRoute } from '../task/taskListRoute'; import type { LocalApiTaskReadRoute } from '../task/taskReadRoute'; import type { LocalApiTaskStartRoute } from '../task/taskStartRoute'; +import type { LocalApiTaskPutRoute } from '../task/taskPutRoute'; import type { LocalApiResponse } from '../transport/contract'; export type LocalApiAdmissionOperation = @@ -78,12 +79,18 @@ export type LocalApiAdmissionOperation = operationId: 'task.start'; projectId: string; taskId: string; + }> + | Readonly<{ + operationId: 'task.put'; + projectId: string; + taskId: string; }>; export interface LocalApiAdmissionRequest { readonly requestId: string; readonly operation: LocalApiAdmissionOperation; readonly authorization: string | null; + readonly localPresence: string | null; readonly signal: AbortSignal; } @@ -112,6 +119,7 @@ export interface LocalApiAdmissionOptions { readonly taskListRoute: LocalApiTaskListRoute; readonly taskReadRoute: LocalApiTaskReadRoute; readonly taskStartRoute: LocalApiTaskStartRoute; + readonly taskPutRoute: LocalApiTaskPutRoute; readonly now?: () => number; readonly randomUuid?: () => string; } @@ -190,6 +198,7 @@ export function createLocalApiAdmission( typeof options.taskListRoute?.handle !== 'function' || typeof options.taskReadRoute?.handle !== 'function' || typeof options.taskStartRoute?.handle !== 'function' || + typeof options.taskPutRoute?.handle !== 'function' || (options.now !== undefined && typeof options.now !== 'function') || (options.randomUuid !== undefined && typeof options.randomUuid !== 'function') @@ -239,6 +248,25 @@ export function createLocalApiAdmission( } if (request.signal.aborted) return response(503, 'request_unavailable'); + if (request.operation.operationId === 'task.put') { + const taskPutOperation = request.operation; + return Object.freeze({ + bodyMode: 'json' as const, + maximumBodyBytes: 72 * 1_024, + async handle(body: unknown | null) { + return options.taskPutRoute.handle({ + requestId: request.requestId, + projectId: taskPutOperation.projectId, + taskId: taskPutOperation.taskId, + body, + presence: request.localPresence, + authenticated, + signal: request.signal, + }); + }, + }); + } + let decision: Readonly; try { decision = normalizeSecurityPolicyDecision( @@ -386,6 +414,8 @@ export function createLocalApiAdmission( principal: authenticated.principal, policyFence: decision.fence, }); + case 'task.put': + return response(503, 'request_unavailable'); } }, }); diff --git a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts index ba8ba41e..602a5060 100644 --- a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts +++ b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts @@ -9,6 +9,7 @@ import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; import { createLocalApiAdmission } from '../admission/localApiAdmission'; import { createLocalApiCredentialAuthenticator } from '../authentication/credentialAuthenticator'; +import { createLocalPresenceProofManager } from '../authentication/localPresenceProof'; import type { LocalApiProcessConfig } from '../production-process/config'; import { createLocalApiRunListRoute } from '../run/runListRoute'; import { createLocalApiRunReadRoute } from '../run/runReadRoute'; @@ -19,6 +20,7 @@ import { createLocalApiRunAttemptLogReadRoute } from '../run/runAttemptLogReadRo import { createLocalApiTaskListRoute } from '../task/taskListRoute'; import { createLocalApiTaskReadRoute } from '../task/taskReadRoute'; import { createLocalApiTaskStartRoute } from '../task/taskStartRoute'; +import { createLocalApiTaskPutRoute } from '../task/taskPutRoute'; import { startLocalApiHttpSurface } from '../transport/httpSurface'; export interface LocalApiProductSurfaceEvent { @@ -98,6 +100,14 @@ export function createLocalApiProductSurface( provider, options.now === undefined ? {} : { now: options.now }, ); + const presenceProof = createLocalPresenceProofManager({ + deploymentRoot: config.deploymentRoot, + profile: authority.profile, + ...(options.now === undefined ? {} : { now: options.now }), + ...(options.randomUuid === undefined + ? {} + : { randomUuid: options.randomUuid }), + }); const policy = new ProjectPolicyEngine(authority.projectPolicy); const runReadRoute = createLocalApiRunReadRoute(authority.runs); const runListRoute = createLocalApiRunListRoute(authority.runs); @@ -123,6 +133,25 @@ export function createLocalApiProductSurface( authority.taskStart, options.randomUuid ?? randomUUID, ); + const taskPutRoute = createLocalApiTaskPutRoute({ + projectPolicy: authority.projectPolicy, + taskDefinitions: authority.taskDefinitions, + taskDefinitionAdministrationForCredential: (fence) => { + if (fence.subjectType !== 'user') { + throw new TypeError('Task mutation requires a User credential'); + } + return authority.taskDefinitionAdministrationForCredential({ + ...fence, + subjectType: 'user', + }); + }, + securityAudit: authority.securityAudit, + presenceProof, + ...(options.now === undefined ? {} : { now: options.now }), + ...(options.randomUuid === undefined + ? {} + : { randomUuid: options.randomUuid }), + }); const admission = createLocalApiAdmission({ authenticator, policy, @@ -136,20 +165,27 @@ export function createLocalApiProductSurface( taskListRoute, taskReadRoute, taskStartRoute, + taskPutRoute, ...(options.now === undefined ? {} : { now: options.now }), ...(options.randomUuid === undefined ? {} : { randomUuid: options.randomUuid }), }); - const active = await startLocalApiHttpSurface({ - profile: authority.profile, - host: config.listener.host, - port: config.listener.port, - admission, - ...(options.randomUuid === undefined - ? {} - : { randomUuid: options.randomUuid }), - }); + let active; + try { + active = await startLocalApiHttpSurface({ + profile: authority.profile, + host: config.listener.host, + port: config.listener.port, + admission, + ...(options.randomUuid === undefined + ? {} + : { randomUuid: options.randomUuid }), + }); + } catch (error) { + presenceProof.close(); + throw error; + } await bestEffortEmit( options.emit, surfaceEvent(config, 'listening', { level: 'info' }), @@ -163,7 +199,12 @@ export function createLocalApiProductSurface( options.emit, surfaceEvent(config, 'draining', { level: 'info' }), ); - const stopResult = await active.stopAndDrain(); + let stopResult = await active.stopAndDrain(); + try { + presenceProof.close(); + } catch { + stopResult = 'timed_out'; + } await bestEffortEmit( options.emit, surfaceEvent(config, 'stopped', { diff --git a/packages/ql3-local-api/src/authentication/credentialAuthenticator.ts b/packages/ql3-local-api/src/authentication/credentialAuthenticator.ts index 5be982f9..311f4531 100644 --- a/packages/ql3-local-api/src/authentication/credentialAuthenticator.ts +++ b/packages/ql3-local-api/src/authentication/credentialAuthenticator.ts @@ -19,6 +19,7 @@ const AUTHORIZATION_PATTERN = export interface AuthenticatedLocalApiRequest { readonly principal: Readonly; + readonly credentialFence: Readonly; confirm(): Promise; } @@ -32,7 +33,7 @@ export interface LocalApiCredentialAuthenticatorOptions { readonly now?: () => number; } -interface CredentialFence { +export interface LocalApiCredentialFence { readonly credentialId: string; readonly credentialVersion: number; readonly pepperKeyId: string; @@ -91,7 +92,7 @@ async function loadFence( provider: LocalOwnerPepperKeyringFileProvider, credentialId: string, credentialVersion: number, -): Promise> { +): Promise> { try { const candidate = await authority.apiCredentials.resolve(credentialId); if (!candidate) throw new Error('credential is unavailable'); @@ -103,11 +104,7 @@ async function loadFence( credential.state !== 'active' || credential.subjectStatus !== 'active' || !validKey(key) || - !validMaterial( - material, - credential.pepperKeyId, - key.materialDigest, - ) + !validMaterial(material, credential.pepperKeyId, key.materialDigest) ) { throw new Error('credential fence is unavailable'); } @@ -129,7 +126,10 @@ async function loadFence( } } -function sameFence(left: CredentialFence, right: CredentialFence): boolean { +function sameFence( + left: LocalApiCredentialFence, + right: LocalApiCredentialFence, +): boolean { return ( left.credentialId === right.credentialId && left.credentialVersion === right.credentialVersion && @@ -201,6 +201,7 @@ export function createLocalApiCredentialAuthenticator( } return Object.freeze({ principal: authentication.principal, + credentialFence: fence, async confirm() { try { const currentAuthentication = @@ -227,7 +228,8 @@ export function createLocalApiCredentialAuthenticator( } } catch (error) { if ( - error instanceof LocalApiCredentialAuthenticationUnavailableError + error instanceof + LocalApiCredentialAuthenticationUnavailableError ) { throw error; } @@ -238,9 +240,7 @@ export function createLocalApiCredentialAuthenticator( }, }); } catch (error) { - if ( - error instanceof LocalApiCredentialAuthenticationUnavailableError - ) { + if (error instanceof LocalApiCredentialAuthenticationUnavailableError) { throw error; } if (error instanceof LocalIdentityAuthenticationUnavailableError) { diff --git a/packages/ql3-local-api/src/authentication/localPresenceProof.ts b/packages/ql3-local-api/src/authentication/localPresenceProof.ts new file mode 100644 index 00000000..74afd998 --- /dev/null +++ b/packages/ql3-local-api/src/authentication/localPresenceProof.ts @@ -0,0 +1,400 @@ +import { + createHash, + randomBytes, + randomUUID, + timingSafeEqual, +} from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { LocalApplicationProfile } from '@qinglong/local-application'; + +const PRESENCE_DIRECTORY = 'console-presence'; +const AUTHORIZATION_TTL_MS = 120_000; +const AUTHORIZATION_PATTERN = + /^ql3p_([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})_([A-Za-z0-9_-]{43})$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +interface PendingLocalPresenceAuthorization { + readonly authorizationId: string; + readonly fileName: string; + readonly requestDigest: string; + readonly credentialDigest: string; + readonly proofDigest: Buffer; + readonly expiresAtMs: number; +} + +export interface LocalPresenceBinding { + readonly requestDigest: string; + readonly credentialId: string; + readonly credentialVersion: number; + readonly subjectType: 'user'; + readonly subjectId: string; +} + +export interface LocalPresenceChallenge { + readonly authorizationId: string; + readonly requestDigest: string; + readonly expiresAtMs: number; + readonly proofFileName: string; +} + +export interface ConsumedLocalPresenceProof { + readonly authorizationId: string; + readonly authenticatedAtMs: number; + readonly expiresAtMs: number; +} + +export interface LocalPresenceProofManager { + issue(binding: Readonly): LocalPresenceChallenge; + consume( + presentation: string | null, + binding: Readonly, + ): ConsumedLocalPresenceProof | null; + close(): void; +} + +export interface LocalPresenceProofManagerOptions { + readonly deploymentRoot: string; + readonly profile: LocalApplicationProfile; + readonly now?: () => number; + readonly randomUuid?: () => string; + readonly randomSecret?: () => Buffer; +} + +export class LocalPresenceProofConfigurationError extends TypeError { + readonly code = 'QL3_LOCAL_PRESENCE_CONFIG_INVALID'; + + constructor(message: string, options?: ErrorOptions) { + super(`Local presence proof configuration is invalid: ${message}`, options); + this.name = 'LocalPresenceProofConfigurationError'; + } +} + +export class LocalPresenceProofUnavailableError extends Error { + readonly code = 'QL3_LOCAL_PRESENCE_UNAVAILABLE'; + + constructor(message: string, options?: ErrorOptions) { + super(`Local presence proof is unavailable: ${message}`, options); + this.name = 'LocalPresenceProofUnavailableError'; + } +} + +function currentUid(): number { + if (typeof process.getuid !== 'function') { + throw new LocalPresenceProofConfigurationError( + 'POSIX user identity is unavailable', + ); + } + const uid = process.getuid(); + if (!Number.isSafeInteger(uid) || uid < 0) { + throw new LocalPresenceProofConfigurationError('POSIX user is invalid'); + } + return uid; +} + +function privateDirectory(directoryPath: string, uid: number): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directoryPath); + } catch (error) { + throw new LocalPresenceProofConfigurationError( + 'private directory is unavailable', + { cause: error }, + ); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + (stat.mode & 0o777) !== 0o700 + ) { + throw new LocalPresenceProofConfigurationError( + 'private directory ownership or mode is invalid', + ); + } +} + +function clock(now: () => number): number { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new LocalPresenceProofUnavailableError('clock is invalid'); + } + return value; +} + +function credentialDigest(binding: Readonly): string { + if ( + !binding || + typeof binding !== 'object' || + Array.isArray(binding) || + Object.keys(binding).sort().join('\0') !== + [ + 'credentialId', + 'credentialVersion', + 'requestDigest', + 'subjectId', + 'subjectType', + ] + .sort() + .join('\0') || + !SHA256_PATTERN.test(binding.requestDigest) || + typeof binding.credentialId !== 'string' || + binding.credentialId.length < 1 || + binding.credentialId.length > 64 || + !Number.isSafeInteger(binding.credentialVersion) || + binding.credentialVersion < 1 || + binding.subjectType !== 'user' || + typeof binding.subjectId !== 'string' || + binding.subjectId.length < 1 || + binding.subjectId.length > 128 + ) { + throw new LocalPresenceProofUnavailableError('binding is invalid'); + } + return createHash('sha256') + .update('qinglong3.local-presence-credential.v1\0', 'utf8') + .update(binding.credentialId, 'utf8') + .update('\0', 'utf8') + .update(String(binding.credentialVersion), 'utf8') + .update('\0', 'utf8') + .update(binding.subjectType, 'utf8') + .update('\0', 'utf8') + .update(binding.subjectId, 'utf8') + .digest('hex'); +} + +function removeFile(directory: string, fileName: string): void { + try { + fs.unlinkSync(path.join(directory, fileName)); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw new LocalPresenceProofUnavailableError( + 'proof file cannot be removed', + { cause: error }, + ); + } + } +} + +function writeProofFile( + directory: string, + authorization: Omit, + presentation: string, +): void { + const filePath = path.join(directory, authorization.fileName); + const payload = Buffer.from( + `${JSON.stringify({ + schemaVersion: 1, + kind: 'qinglong3-local-presence-proof', + authorizationId: authorization.authorizationId, + requestDigest: authorization.requestDigest, + expiresAtMs: authorization.expiresAtMs, + proof: presentation, + })}\n`, + 'utf8', + ); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_WRONLY | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + fs.writeFileSync(descriptor, payload); + fs.fsyncSync(descriptor); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1) { + throw new Error('proof file identity is invalid'); + } + } catch (error) { + try { + fs.unlinkSync(filePath); + } catch { + // Preserve the original publication failure. + } + throw new LocalPresenceProofUnavailableError( + 'proof file cannot be published', + { cause: error }, + ); + } finally { + payload.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +export function createLocalPresenceProofManager( + options: Readonly, +): Readonly { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + Object.keys(options).some( + (key) => + key !== 'deploymentRoot' && + key !== 'profile' && + key !== 'now' && + key !== 'randomUuid' && + key !== 'randomSecret', + ) || + typeof options.deploymentRoot !== 'string' || + !path.isAbsolute(options.deploymentRoot) || + path.normalize(options.deploymentRoot) !== options.deploymentRoot || + path.parse(options.deploymentRoot).root === options.deploymentRoot || + (options.profile !== 'edge' && options.profile !== 'standalone') || + (options.now !== undefined && typeof options.now !== 'function') || + (options.randomUuid !== undefined && + typeof options.randomUuid !== 'function') || + (options.randomSecret !== undefined && + typeof options.randomSecret !== 'function') + ) { + throw new LocalPresenceProofConfigurationError('options are invalid'); + } + const uid = currentUid(); + privateDirectory(options.deploymentRoot, uid); + const directory = path.join(options.deploymentRoot, PRESENCE_DIRECTORY); + try { + fs.mkdirSync(directory, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') { + throw new LocalPresenceProofConfigurationError( + 'private directory cannot be created', + { cause: error }, + ); + } + } + privateDirectory(directory, uid); + const now = options.now ?? Date.now; + const uuid = options.randomUuid ?? randomUUID; + const secret = options.randomSecret ?? (() => randomBytes(32)); + const maximumPending = options.profile === 'edge' ? 8 : 32; + const pending = new Map(); + let closed = false; + + const sweep = (nowMs: number) => { + for (const [authorizationId, authorization] of pending) { + if (authorization.expiresAtMs > nowMs) continue; + removeFile(directory, authorization.fileName); + authorization.proofDigest.fill(0); + pending.delete(authorizationId); + } + }; + + return Object.freeze({ + issue(binding: Readonly) { + if (closed) { + throw new LocalPresenceProofUnavailableError('manager is closed'); + } + const nowMs = clock(now); + sweep(nowMs); + if (pending.size >= maximumPending) { + throw new LocalPresenceProofUnavailableError( + 'pending authorization capacity is exhausted', + ); + } + const boundCredentialDigest = credentialDigest(binding); + const authorizationId = uuid(); + if ( + !AUTHORIZATION_PATTERN.test(`ql3p_${authorizationId}_${'A'.repeat(43)}`) + ) { + throw new LocalPresenceProofUnavailableError( + 'authorization identity is invalid', + ); + } + const material = secret(); + if (!Buffer.isBuffer(material) || material.byteLength !== 32) { + throw new LocalPresenceProofUnavailableError( + 'proof entropy is unavailable', + ); + } + let presentation: string | undefined; + try { + presentation = `ql3p_${authorizationId}_${material.toString( + 'base64url', + )}`; + const authorization = Object.freeze({ + authorizationId, + fileName: `${authorizationId}.json`, + requestDigest: binding.requestDigest, + credentialDigest: boundCredentialDigest, + expiresAtMs: nowMs + AUTHORIZATION_TTL_MS, + }); + writeProofFile(directory, authorization, presentation); + pending.set( + authorizationId, + Object.freeze({ + ...authorization, + proofDigest: createHash('sha256') + .update('qinglong3.local-presence-proof.v1\0', 'utf8') + .update(presentation, 'utf8') + .digest(), + }), + ); + return Object.freeze({ + authorizationId, + requestDigest: binding.requestDigest, + expiresAtMs: authorization.expiresAtMs, + proofFileName: authorization.fileName, + }); + } finally { + material.fill(0); + presentation = undefined; + } + }, + + consume( + presentation: string | null, + binding: Readonly, + ) { + if (closed || typeof presentation !== 'string') return null; + const nowMs = clock(now); + sweep(nowMs); + const match = AUTHORIZATION_PATTERN.exec(presentation); + if (!match) return null; + const authorization = pending.get(match[1]!); + if (!authorization) return null; + const actualProofDigest = createHash('sha256') + .update('qinglong3.local-presence-proof.v1\0', 'utf8') + .update(presentation, 'utf8') + .digest(); + let valid = false; + try { + valid = + authorization.expiresAtMs > nowMs && + authorization.requestDigest === binding.requestDigest && + authorization.credentialDigest === credentialDigest(binding) && + timingSafeEqual(actualProofDigest, authorization.proofDigest); + } finally { + actualProofDigest.fill(0); + } + if (!valid) return null; + pending.delete(authorization.authorizationId); + removeFile(directory, authorization.fileName); + authorization.proofDigest.fill(0); + return Object.freeze({ + authorizationId: authorization.authorizationId, + authenticatedAtMs: nowMs, + expiresAtMs: authorization.expiresAtMs, + }); + }, + + close() { + if (closed) return; + closed = true; + let firstError: unknown; + for (const authorization of pending.values()) { + try { + removeFile(directory, authorization.fileName); + } catch (error) { + firstError ??= error; + } + authorization.proofDigest.fill(0); + } + pending.clear(); + if (firstError) throw firstError; + }, + }); +} diff --git a/packages/ql3-local-api/src/task/taskPutRoute.ts b/packages/ql3-local-api/src/task/taskPutRoute.ts new file mode 100644 index 00000000..4ee4ab24 --- /dev/null +++ b/packages/ql3-local-api/src/task/taskPutRoute.ts @@ -0,0 +1,462 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { + LocalTaskDefinitionAdministrationAuthenticationError, + LocalTaskDefinitionAdministrationAuthorizationError, + LocalTaskDefinitionAdministrationConfigurationError, + LocalTaskDefinitionAdministrationUnavailableError, + createLocalTaskDefinitionAdministrationService, +} from '@qinglong/local-admin/task-definition-administration'; +import { + ProjectPolicyEngine, + ProjectPolicyUnavailableError, + type ProjectPolicyRepository, +} from '@qinglong/runtime-core/project-policy'; +import { + normalizeSecurityPolicyDecision, + normalizeSecurityPrincipal, + type SecurityPolicyDecision, +} from '@qinglong/runtime-core/security'; +import { + normalizeSecurityAuditRecord, + type SecurityAuditOutcome, + type SecurityAuditSink, +} from '@qinglong/runtime-core/security-audit'; +import { + InvalidTaskDefinitionError, + TaskDefinitionConflictError, + TaskDefinitionUnavailableError, + normalizeAppendTaskDefinitionRevisionCommand, + type AppendTaskDefinitionRevisionCommand, + type TaskDefinitionRecord, + type TaskDefinitionSource, +} from '@qinglong/runtime-core/task-definition'; +import { + TaskDefinitionAdministrationAuthorizationFenceConflictError, + TaskDefinitionAdministrationMutationConflictError, + type TaskDefinitionAdministrationRepository, +} from '@qinglong/runtime-core/task-definition-administration'; + +import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator'; +import { + LocalPresenceProofUnavailableError, + type LocalPresenceBinding, + type LocalPresenceProofManager, +} from '../authentication/localPresenceProof'; +import type { LocalApiResponse } from '../transport/contract'; + +const BODY_KEYS = Object.freeze([ + 'enabled', + 'expectedRevision', + 'kind', + 'labels', + 'mutationId', + 'name', + 'occurredAtMs', + 'spec', +]); +const OPTIONAL_BODY_KEYS = Object.freeze(['description']); + +export interface LocalApiTaskPutRequest { + readonly requestId: string; + readonly projectId: string; + readonly taskId: string; + readonly body: unknown | null; + readonly presence: string | null; + readonly authenticated: Readonly; + readonly signal: AbortSignal; +} + +export interface LocalApiTaskPutRoute { + handle(request: Readonly): Promise; +} + +export interface LocalApiTaskPutRouteOptions { + readonly projectPolicy: ProjectPolicyRepository; + readonly taskDefinitions: TaskDefinitionSource; + readonly taskDefinitionAdministrationForCredential: ( + fence: Readonly, + ) => TaskDefinitionAdministrationRepository; + readonly securityAudit: SecurityAuditSink; + readonly presenceProof: LocalPresenceProofManager; + readonly now?: () => number; + readonly randomUuid?: () => string; +} + +function response( + statusCode: number, + body: Readonly>, +): LocalApiResponse { + return Object.freeze({ statusCode, body: Object.freeze(body) }); +} + +function canonicalJson(value: unknown): string { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`; + } + const record = value as Readonly>; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; +} + +function normalizeBody( + body: unknown | null, + projectId: string, + taskId: string, +): Readonly { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new InvalidTaskDefinitionError('HTTP body must be an object'); + } + const keys = Object.keys(body).sort(); + const allowed = new Set([...BODY_KEYS, ...OPTIONAL_BODY_KEYS]); + if ( + BODY_KEYS.some((key) => !keys.includes(key)) || + keys.some((key) => !allowed.has(key)) + ) { + throw new InvalidTaskDefinitionError('HTTP body has an invalid shape'); + } + return normalizeAppendTaskDefinitionRevisionCommand({ + projectId, + taskId, + ...(body as Omit< + AppendTaskDefinitionRevisionCommand, + 'projectId' | 'taskId' + >), + }); +} + +function requestDigest( + command: Readonly, +): string { + return createHash('sha256') + .update('qinglong3.local-api-task-put.v1\0', 'utf8') + .update(canonicalJson(command), 'utf8') + .digest('hex'); +} + +function presenceBinding( + command: Readonly, + authenticated: Readonly, +): Readonly { + if ( + authenticated.principal.subject.type !== 'user' || + authenticated.credentialFence.subjectType !== 'user' + ) { + throw new LocalPresenceProofUnavailableError( + 'strong User credential is required', + ); + } + return Object.freeze({ + requestDigest: requestDigest(command), + credentialId: authenticated.credentialFence.credentialId, + credentialVersion: authenticated.credentialFence.credentialVersion, + subjectType: 'user', + subjectId: authenticated.credentialFence.subjectId, + }); +} + +function operationId( + command: Readonly, +): 'task.create' | 'task.update' { + return command.expectedRevision === null ? 'task.create' : 'task.update'; +} + +function summary(value: Readonly) { + return Object.freeze({ + taskId: value.taskId, + revision: value.revision, + name: value.name, + kind: value.kind, + specSchema: value.spec.schema, + enabled: value.enabled, + contentDigest: value.contentDigest, + createdAtMs: value.createdAtMs, + updatedAtMs: value.updatedAtMs, + }); +} + +function timestamp(now: () => number): number { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new LocalPresenceProofUnavailableError('clock is invalid'); + } + return value; +} + +async function recordAudit( + audit: SecurityAuditSink, + values: { + readonly eventId: string; + readonly requestId: string; + readonly operationId: 'task.create' | 'task.update'; + readonly projectId: string; + readonly authenticated: Readonly | null; + readonly outcome: SecurityAuditOutcome; + readonly reasons: readonly string[]; + readonly fence: SecurityPolicyDecision['fence']; + readonly occurredAtMs: number; + }, +): Promise { + try { + await audit.record( + normalizeSecurityAuditRecord({ + eventId: values.eventId, + requestId: values.requestId, + operationId: values.operationId, + projectId: values.projectId, + subject: values.authenticated?.principal.subject ?? null, + authenticationId: + values.authenticated?.principal.authenticationId ?? null, + outcome: values.outcome, + reasons: values.reasons, + fence: values.fence, + occurredAtMs: values.occurredAtMs, + }), + ); + return true; + } catch { + return false; + } +} + +export function createLocalApiTaskPutRoute( + options: Readonly, +): Readonly { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + typeof options.projectPolicy?.resolve !== 'function' || + typeof options.taskDefinitions?.findCurrentTaskDefinition !== 'function' || + typeof options.taskDefinitions?.findTaskDefinitionRevision !== 'function' || + typeof options.taskDefinitions?.listTaskDefinitions !== 'function' || + typeof options.taskDefinitionAdministrationForCredential !== 'function' || + typeof options.securityAudit?.record !== 'function' || + typeof options.presenceProof?.issue !== 'function' || + typeof options.presenceProof?.consume !== 'function' || + (options.now !== undefined && typeof options.now !== 'function') || + (options.randomUuid !== undefined && + typeof options.randomUuid !== 'function') + ) { + throw new TypeError('Local API Task put route options are invalid'); + } + const now = options.now ?? Date.now; + const uuid = options.randomUuid ?? randomUUID; + const policy = new ProjectPolicyEngine(options.projectPolicy); + + return Object.freeze({ + async handle(request: Readonly) { + if (request.signal.aborted) { + return response(503, { code: 'request_unavailable' }); + } + let command: Readonly; + try { + command = normalizeBody( + request.body, + request.projectId, + request.taskId, + ); + } catch (error) { + return error instanceof InvalidTaskDefinitionError + ? response(400, { code: 'invalid_task_definition' }) + : response(503, { code: 'task_definition_unavailable' }); + } + const operation = operationId(command); + const occurredAtMs = timestamp(now); + let decision: Readonly; + try { + decision = normalizeSecurityPolicyDecision( + await policy.authorize( + request.authenticated.principal, + request.projectId, + operation, + ), + ); + } catch (error) { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: 'authorization_unavailable', + reasons: ['policy_unavailable'], + fence: null, + occurredAtMs, + }); + return response(503, { + code: + audited && error instanceof ProjectPolicyUnavailableError + ? 'authorization_unavailable' + : 'security_audit_unavailable', + }); + } + if (decision.effect !== 'allow') { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: + decision.effect === 'require_approval' + ? 'approval_required' + : 'denied', + reasons: decision.reasons, + fence: decision.fence, + occurredAtMs, + }); + if (!audited) { + return response(503, { code: 'security_audit_unavailable' }); + } + return response(403, { + code: + decision.effect === 'require_approval' + ? 'approval_required' + : 'forbidden', + }); + } + let binding: Readonly; + try { + binding = presenceBinding(command, request.authenticated); + } catch { + return response(401, { code: 'strong_authentication_required' }); + } + if (!request.presence) { + let challenge; + try { + challenge = options.presenceProof.issue(binding); + } catch { + return response(503, { code: 'local_presence_unavailable' }); + } + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: 'approval_required', + reasons: ['local_presence_required'], + fence: decision.fence, + occurredAtMs, + }); + if (!audited) { + return response(503, { code: 'security_audit_unavailable' }); + } + return response(428, { + code: 'local_presence_required', + authorizationId: challenge.authorizationId, + requestDigest: challenge.requestDigest, + expiresAtMs: challenge.expiresAtMs, + proofFileName: challenge.proofFileName, + }); + } + let proof; + try { + await request.authenticated.confirm(); + proof = options.presenceProof.consume(request.presence, binding); + } catch { + return response(503, { code: 'authentication_unavailable' }); + } + if (!proof) { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: null, + outcome: 'authentication_rejected', + reasons: ['local_presence_rejected'], + fence: null, + occurredAtMs, + }); + return audited + ? response(401, { code: 'local_presence_rejected' }) + : response(503, { code: 'security_audit_unavailable' }); + } + if (request.signal.aborted) { + return response(503, { code: 'request_unavailable' }); + } + let strongPrincipal; + try { + strongPrincipal = normalizeSecurityPrincipal( + { + subject: request.authenticated.principal.subject, + authenticationId: `local_presence:${proof.authorizationId}`, + authenticatedAtMs: proof.authenticatedAtMs, + expiresAtMs: Math.min( + proof.expiresAtMs, + request.authenticated.principal.expiresAtMs, + ), + assurance: 'local_console', + }, + proof.authenticatedAtMs, + ); + } catch { + return response(503, { code: 'authentication_unavailable' }); + } + try { + const service = createLocalTaskDefinitionAdministrationService( + options.projectPolicy, + options.taskDefinitionAdministrationForCredential( + request.authenticated.credentialFence, + ), + options.taskDefinitions, + options.securityAudit, + { now }, + ); + const result = await service.put({ + ...command, + requestId: request.requestId, + principal: strongPrincipal, + }); + return response(result.status === 'created' ? 201 : 200, { + status: result.status, + task: summary(result.definition), + }); + } catch (error) { + if ( + error instanceof TaskDefinitionConflictError || + error instanceof TaskDefinitionAdministrationMutationConflictError || + error instanceof + TaskDefinitionAdministrationAuthorizationFenceConflictError + ) { + return response(409, { code: 'task_definition_fence_rejected' }); + } + if ( + error instanceof LocalTaskDefinitionAdministrationAuthenticationError + ) { + return response(401, { code: 'strong_authentication_required' }); + } + if ( + error instanceof LocalTaskDefinitionAdministrationAuthorizationError + ) { + return response(403, { code: 'forbidden' }); + } + if ( + error instanceof InvalidTaskDefinitionError || + error instanceof LocalTaskDefinitionAdministrationConfigurationError + ) { + return response(400, { code: 'invalid_task_definition' }); + } + if ( + error instanceof TaskDefinitionUnavailableError || + error instanceof LocalTaskDefinitionAdministrationUnavailableError + ) { + return response(503, { code: 'task_definition_unavailable' }); + } + return response(503, { code: 'task_definition_unavailable' }); + } + }, + }); +} diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index 73edb480..30555d3b 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -88,6 +88,15 @@ function authorization(request: IncomingMessage): string | null { return values.length === 1 ? values[0]! : null; } +function localPresence(request: IncomingMessage): string | null { + const values = rawHeaderValues(request, 'x-qinglong-local-presence'); + if (values.length === 0) return null; + if (values.length !== 1 || values[0]!.length > 160) { + throw new TypeError('invalid_local_presence'); + } + return values[0]!; +} + function hasRequestBody(request: IncomingMessage): boolean { const transferEncoding = rawHeaderValues(request, 'transfer-encoding'); const contentLength = rawHeaderValues(request, 'content-length'); @@ -453,6 +462,16 @@ function route( }) : null; } + if (request.method === 'PUT') { + const taskPutMatch = TASK_READ_ROUTE_PATTERN.exec(path); + return taskPutMatch && rawQuery === undefined + ? Object.freeze({ + operationId: 'task.put', + projectId: taskPutMatch[1]!, + taskId: taskPutMatch[2]!, + }) + : null; + } if (request.method !== 'GET') return null; const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path); if (runAttemptLogReadMatch) { @@ -693,10 +712,19 @@ export async function startLocalApiHttpSurface( response.once('close', () => { if (!response.writableFinished) abort.abort(); }); + let presentedLocalPresence: string | null; + try { + presentedLocalPresence = localPresence(request); + } catch { + send(response, requestId, errorResponse(400, 'invalid_local_presence')); + request.resume(); + return; + } const admissionRequest: LocalApiAdmissionRequest = Object.freeze({ requestId, operation: resolvedRoute, authorization: authorization(request), + localPresence: presentedLocalPresence, signal: abort.signal, }); let operation: Promise; diff --git a/packages/ql3-local-api/test/admission.test.cjs b/packages/ql3-local-api/test/admission.test.cjs index f49f038d..ba729ca3 100644 --- a/packages/ql3-local-api/test/admission.test.cjs +++ b/packages/ql3-local-api/test/admission.test.cjs @@ -22,6 +22,7 @@ function request(overrides = {}) { runId: 'run_123', }), authorization: 'Bearer opaque', + localPresence: null, signal: new AbortController().signal, ...overrides, }); @@ -35,6 +36,17 @@ function fixture(overrides = {}) { events.push('authenticate'); return Object.freeze({ principal: PRINCIPAL, + credentialFence: Object.freeze({ + credentialId: 'credential-local', + credentialVersion: 1, + pepperKeyId: 'owner-v1', + materialDigest: 'a'.repeat(64), + subjectType: 'user', + subjectId: 'usr_local', + secretDigest: 'b'.repeat(64), + notBeforeAtMs: 1, + expiresAtMs: 20_000, + }), async confirm() { events.push('confirm'); }, @@ -127,6 +139,12 @@ function fixture(overrides = {}) { return { statusCode: 202, body: { status: 'accepted' } }; }, }, + taskPutRoute: { + async handle(value) { + events.push(`task-put:${value.projectId}:${value.taskId}`); + return { statusCode: 201, body: { status: 'created' } }; + }, + }, now: () => 10_000, randomUuid: () => '019f70c0-0000-4000-8000-000000000002', ...overrides, @@ -382,6 +400,28 @@ test('authorizes and audits run.start before exposing the Task body handler', as assert.equal(events.at(-1), 'task-start:prj_default:task-a'); }); +test('defers Task put Policy, audit and strong confirmation to the request-bound route', async () => { + const { admission, events } = fixture(); + const prepared = await admission.prepare( + request({ + operation: Object.freeze({ + operationId: 'task.put', + projectId: 'prj_default', + taskId: 'task-a', + }), + localPresence: 'ql3p_proof', + }), + ); + assert.equal(prepared.bodyMode, 'json'); + assert.equal(prepared.maximumBodyBytes, 72 * 1024); + assert.deepEqual(events, ['authenticate']); + assert.deepEqual(await prepared.handle({ name: 'Task' }), { + statusCode: 201, + body: { status: 'created' }, + }); + assert.deepEqual(events, ['authenticate', 'task-put:prj_default:task-a']); +}); + test('audits authentication rejection before returning a challenge', async () => { const events = []; const { admission } = fixture({ diff --git a/packages/ql3-local-api/test/consoleAssets.test.cjs b/packages/ql3-local-api/test/consoleAssets.test.cjs index d7dd7926..67bc9aa5 100644 --- a/packages/ql3-local-api/test/consoleAssets.test.cjs +++ b/packages/ql3-local-api/test/consoleAssets.test.cjs @@ -76,6 +76,15 @@ test('loads one bounded offline Console asset closure', () => { assert.match(text, /const LOG_READ_BYTES = 32 \* 1024/u); assert.match(text, /new TextDecoder\('utf-8'\)/u); assert.match(text, /日志已按保留策略清理/u); + assert.match(text, /method: 'PUT'/u); + assert.match(text, /x-qinglong-local-presence/u); + assert.match(text, /local_presence_required/u); + assert.match(text, /state\.pendingTaskMutation/u); + } + if (requestPath === '/') { + assert.match(text, /id="task-editor-dialog"/u); + assert.match(text, /id="presence-dialog"/u); + assert.match(text, /保存并生成本机证明/u); } } assert.ok(totalBytes <= 192 * 1024); diff --git a/packages/ql3-local-api/test/httpSurface.test.cjs b/packages/ql3-local-api/test/httpSurface.test.cjs index 0719a193..1f40e80d 100644 --- a/packages/ql3-local-api/test/httpSurface.test.cjs +++ b/packages/ql3-local-api/test/httpSurface.test.cjs @@ -52,12 +52,17 @@ function request(port, path, options = {}) { function preparedAdmission(handler) { return { async prepare(value) { - const json = ['run.cancel', 'task.start'].includes( + const json = ['run.cancel', 'task.start', 'task.put'].includes( value.operation.operationId, ); return { bodyMode: json ? 'json' : 'none', - maximumBodyBytes: json ? 512 : 0, + maximumBodyBytes: + value.operation.operationId === 'task.put' + ? 72 * 1024 + : json + ? 512 + : 0, handle(body) { return handler(value, body); }, @@ -77,7 +82,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently observed.push(value); if ( value.operation.operationId === 'run.cancel' || - value.operation.operationId === 'task.start' + value.operation.operationId === 'task.start' || + value.operation.operationId === 'task.put' ) { return { statusCode: 202, body: { accepted: body } }; } @@ -283,12 +289,36 @@ test('serves only the fixed canonical loopback Run route and drains idempotently taskId: 'task_1', }); + const taskPutBody = JSON.stringify({ name: 'Task one' }); + const taskPut = await request( + port, + '/api/v3/projects/prj_default/tasks/task_1', + { + method: 'PUT', + headers: { + authorization: 'Bearer opaque', + 'x-qinglong-local-presence': 'ql3p_request_bound_proof', + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(taskPutBody)), + }, + body: taskPutBody, + }, + ); + assert.equal(taskPut.statusCode, 202); + assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody)); + assert.deepEqual(observed[8].operation, { + operationId: 'task.put', + projectId: 'prj_default', + taskId: 'task_1', + }); + assert.equal(observed[8].localPresence, 'ql3p_request_bound_proof'); + const log = await request( port, '/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32', ); assert.deepEqual(log.body, { range: { offset: 4, length: 32 } }); - assert.deepEqual(observed[8].operation, { + assert.deepEqual(observed[9].operation, { operationId: 'run.log.read', projectId: 'prj_default', runId: 'run_123', @@ -374,7 +404,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently assert.equal(invalid.statusCode, 400); assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' }); } - assert.equal(observed.length, 10); + assert.equal(observed.length, 11); assert.deepEqual( await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]), ['stopped', 'stopped'], diff --git a/packages/ql3-local-api/test/localPresenceProof.test.cjs b/packages/ql3-local-api/test/localPresenceProof.test.cjs new file mode 100644 index 00000000..1985239c --- /dev/null +++ b/packages/ql3-local-api/test/localPresenceProof.test.cjs @@ -0,0 +1,136 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + LocalPresenceProofUnavailableError, + createLocalPresenceProofManager, +} = require('../dist/authentication/localPresenceProof.js'); + +function root(t) { + const value = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-presence-')); + fs.chmodSync(value, 0o700); + t.after(() => fs.rmSync(value, { recursive: true, force: true })); + return value; +} + +function binding(overrides = {}) { + return Object.freeze({ + requestDigest: 'a'.repeat(64), + credentialId: 'owner-console', + credentialVersion: 1, + subjectType: 'user', + subjectId: 'owner', + ...overrides, + }); +} + +function uuidFactory() { + let sequence = 0; + return () => { + sequence += 1; + return `019f9000-0000-4000-8000-${String(sequence).padStart(12, '0')}`; + }; +} + +test('publishes a private request-bound proof and consumes it exactly once', (t) => { + const deploymentRoot = root(t); + const manager = createLocalPresenceProofManager({ + deploymentRoot, + profile: 'edge', + now: () => 1_000, + randomUuid: uuidFactory(), + randomSecret: () => Buffer.alloc(32, 7), + }); + t.after(() => manager.close()); + + const challenge = manager.issue(binding()); + const directory = path.join(deploymentRoot, 'console-presence'); + const filePath = path.join(directory, challenge.proofFileName); + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(filePath).mode & 0o777, 0o600); + const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')); + assert.equal(payload.authorizationId, challenge.authorizationId); + assert.equal(payload.requestDigest, challenge.requestDigest); + assert.match(payload.proof, /^ql3p_/); + assert.equal(JSON.stringify(payload).includes('owner-console'), false); + assert.equal(JSON.stringify(payload).includes('owner'), false); + + const consumed = manager.consume(payload.proof, binding()); + assert.deepEqual(consumed, { + authorizationId: challenge.authorizationId, + authenticatedAtMs: 1_000, + expiresAtMs: 121_000, + }); + assert.equal(fs.existsSync(filePath), false); + assert.equal(manager.consume(payload.proof, binding()), null); +}); + +test('rejects wrong request, credential and proof without consuming the valid authorization', (t) => { + const deploymentRoot = root(t); + const manager = createLocalPresenceProofManager({ + deploymentRoot, + profile: 'standalone', + now: () => 2_000, + randomUuid: uuidFactory(), + randomSecret: () => Buffer.alloc(32, 9), + }); + t.after(() => manager.close()); + const challenge = manager.issue(binding()); + const filePath = path.join( + deploymentRoot, + 'console-presence', + challenge.proofFileName, + ); + const proof = JSON.parse(fs.readFileSync(filePath, 'utf8')).proof; + assert.equal( + manager.consume(proof, binding({ requestDigest: 'b'.repeat(64) })), + null, + ); + assert.equal(manager.consume(proof, binding({ credentialVersion: 2 })), null); + assert.equal(manager.consume(`${proof.slice(0, -1)}A`, binding()), null); + assert.equal(fs.existsSync(filePath), true); + assert.equal( + manager.consume(proof, binding()).authorizationId, + challenge.authorizationId, + ); +}); + +test('bounds pending Edge authorizations and lazily removes expired proof files', (t) => { + const deploymentRoot = root(t); + let now = 3_000; + const manager = createLocalPresenceProofManager({ + deploymentRoot, + profile: 'edge', + now: () => now, + randomUuid: uuidFactory(), + randomSecret: () => Buffer.alloc(32, 11), + }); + t.after(() => manager.close()); + for (let index = 0; index < 8; index += 1) { + manager.issue( + binding({ requestDigest: index.toString(16).padStart(64, '0') }), + ); + } + assert.throws( + () => manager.issue(binding({ requestDigest: 'f'.repeat(64) })), + LocalPresenceProofUnavailableError, + ); + assert.equal( + fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length, + 8, + ); + now += 120_000; + manager.issue(binding({ requestDigest: 'f'.repeat(64) })); + assert.equal( + fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length, + 1, + ); + manager.close(); + assert.equal( + fs.readdirSync(path.join(deploymentRoot, 'console-presence')).length, + 0, + ); +}); diff --git a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs index 316c21df..bf350c45 100644 --- a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs +++ b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs @@ -41,7 +41,7 @@ const { LocalRunAttemptLogRangeReader, } = require('../../ql3-local-execution/dist/artifact-read/localRunAttemptLogRangeReader.js'); -const NOW = 1_800_000_000_000; +const NOW = Date.now(); const PEPPER_KEY_ID = 'local-api-pepper-v1'; const CREDENTIAL_ID = 'local-api-owner'; const RUN_ID = 'run_local_api_1'; @@ -437,6 +437,8 @@ test('serves an authenticated Run through one real SQLite authority and durable }, ), taskDefinitions: runtime.taskDefinitions, + taskDefinitionAdministrationForCredential: + runtime.taskDefinitionAdministrationForCredential, apiCredentials: runtime.apiCredentials, ownerPepper: runtime.ownerPepper, projectPolicy: runtime.projectPolicy, @@ -526,6 +528,64 @@ test('serves an authenticated Run through one real SQLite authority and durable { statusCode: 404, body: { code: 'task_not_found' } }, ); + const taskCreateBody = JSON.stringify({ + expectedRevision: null, + mutationId: '019f7300-0000-4000-8000-000000000701', + name: 'Console-created Task', + description: 'Created through request-scoped local presence', + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { + kind: 'argv', + file: '/bin/echo', + args: ['console-created'], + }, + }, + }, + labels: { source: 'local-console' }, + enabled: true, + occurredAtMs: NOW, + }); + const taskCreatePath = '/api/v3/projects/default/tasks/task-console-created'; + const taskCreateOptions = { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(taskCreateBody)), + }, + body: taskCreateBody, + }; + const challenge = await request( + port, + `Bearer ${TOKEN}`, + taskCreatePath, + taskCreateOptions, + ); + assert.equal(challenge.statusCode, 428); + assert.equal(challenge.body.code, 'local_presence_required'); + assert.match(challenge.body.requestDigest, /^[0-9a-f]{64}$/); + const proofDocument = JSON.parse( + fs.readFileSync( + path.join(root, 'console-presence', challenge.body.proofFileName), + 'utf8', + ), + ); + const taskCreated = await request(port, `Bearer ${TOKEN}`, taskCreatePath, { + ...taskCreateOptions, + headers: { + ...taskCreateOptions.headers, + 'x-qinglong-local-presence': proofDocument.proof, + }, + }); + assert.equal(taskCreated.statusCode, 201); + assert.equal(taskCreated.body.status, 'created'); + assert.equal(taskCreated.body.task.taskId, 'task-console-created'); + assert.equal(taskCreated.body.task.revision, 1); + assert.equal(JSON.stringify(taskCreated).includes('console-created'), true); + assert.equal(JSON.stringify(taskCreated).includes('/bin/echo'), false); + const taskStartBody = JSON.stringify({ schema: 'qinglong/task-start@v1', mutationId: '019f7300-0000-7000-8000-000000000800', @@ -685,7 +745,7 @@ test('serves an authenticated Run through one real SQLite authority and durable `SELECT operation_id, outcome FROM "QingLong3SecurityAuditEvents" WHERE operation_id IN ( 'run.get', 'run.list', 'run.events.list', 'run.steps.list', - 'run.cancel', 'task.get', 'task.list' + 'run.cancel', 'task.create', 'task.get', 'task.list' , 'task.start', 'run.log.read' ) ORDER BY operation_id, outcome`, @@ -701,6 +761,8 @@ test('serves an authenticated Run through one real SQLite authority and durable 'run.list:allowed', 'run.log.read:allowed', 'run.steps.list:allowed', + 'task.create:allowed', + 'task.create:approval_required', 'task.get:allowed', 'task.get:allowed', 'task.list:allowed', diff --git a/packages/ql3-local-api/test/taskPutRoute.test.cjs b/packages/ql3-local-api/test/taskPutRoute.test.cjs new file mode 100644 index 00000000..7893f038 --- /dev/null +++ b/packages/ql3-local-api/test/taskPutRoute.test.cjs @@ -0,0 +1,292 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + createTaskDefinitionRecord, +} = require('@qinglong/runtime-core/task-definition'); +const { + createLocalPresenceProofManager, +} = require('../dist/authentication/localPresenceProof.js'); +const { createLocalApiTaskPutRoute } = require('../dist/task/taskPutRoute.js'); + +const PRINCIPAL = Object.freeze({ + subject: Object.freeze({ type: 'user', id: 'owner' }), + authenticationId: 'local_credential:owner-console:1', + authenticatedAtMs: 9_000, + expiresAtMs: 60_000, + assurance: 'single_factor', +}); + +const FENCE = Object.freeze({ + credentialId: 'owner-console', + credentialVersion: 1, + pepperKeyId: 'owner-v1', + materialDigest: 'a'.repeat(64), + subjectType: 'user', + subjectId: 'owner', + secretDigest: 'b'.repeat(64), + notBeforeAtMs: 1, + expiresAtMs: 60_000, +}); + +function taskBody(overrides = {}) { + return Object.freeze({ + expectedRevision: null, + mutationId: '019f9000-0000-4000-8000-000000000101', + name: 'Presence-bound Task', + description: 'Created from the Local Console mutation route', + kind: 'command', + spec: Object.freeze({ + schema: 'qinglong/command@v1', + config: Object.freeze({ + command: Object.freeze({ + kind: 'argv', + file: '/bin/echo', + args: Object.freeze(['hello']), + }), + }), + }), + labels: Object.freeze({ 'qinglong.test': 'presence' }), + enabled: true, + occurredAtMs: 10_000, + ...overrides, + }); +} + +function uuidFactory() { + let sequence = 200; + return () => { + sequence += 1; + return `019f9000-0000-4000-8000-${String(sequence).padStart(12, '0')}`; + }; +} + +function fixture(t, overrides = {}) { + const deploymentRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-task-put-'), + ); + fs.chmodSync(deploymentRoot, 0o700); + t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true })); + let now = 10_000; + const calls = []; + const presenceProof = createLocalPresenceProofManager({ + deploymentRoot, + profile: 'edge', + now: () => now, + randomUuid: uuidFactory(), + randomSecret: () => Buffer.alloc(32, 13), + }); + t.after(() => presenceProof.close()); + const projectPolicy = { + async resolve(projectId, subject) { + calls.push(['policy', projectId, subject]); + return { + project: { + id: projectId, + name: 'Default', + slug: 'default', + status: 'active', + version: 3, + createdAtMs: 1, + updatedAtMs: 2, + }, + binding: { + projectId, + subject, + version: 5, + state: 'active', + role: 'owner', + mutationId: 'owner-binding', + changedBy: { type: 'user', id: 'bootstrap-owner' }, + createdAtMs: 2, + }, + }; + }, + async append() { + throw new Error('not used'); + }, + }; + const taskDefinitions = { + async findCurrentTaskDefinition() { + return null; + }, + async findTaskDefinitionRevision() { + return null; + }, + async listTaskDefinitions() { + return { definitions: [], truncated: false }; + }, + }; + const route = createLocalApiTaskPutRoute({ + projectPolicy, + taskDefinitions, + taskDefinitionAdministrationForCredential(fence) { + calls.push(['credential-fence', fence]); + return { + async appendAuthorizedTaskDefinitionRevision(mutation) { + calls.push(['mutation', mutation]); + return { + status: + mutation.command.expectedRevision === null + ? 'created' + : 'updated', + definition: createTaskDefinitionRecord(mutation.command, now), + }; + }, + }; + }, + securityAudit: { + async record(record) { + calls.push(['audit', record]); + }, + }, + presenceProof, + now: () => now, + randomUuid: uuidFactory(), + ...overrides, + }); + const authenticated = Object.freeze({ + principal: PRINCIPAL, + credentialFence: FENCE, + async confirm() { + calls.push(['confirm']); + }, + }); + return { + route, + calls, + deploymentRoot, + authenticated, + setNow(value) { + now = value; + }, + }; +} + +function request(state, body, overrides = {}) { + return Object.freeze({ + requestId: 'local:019f9000-0000-4000-8000-000000000301', + projectId: 'default', + taskId: 'task-console', + body, + presence: null, + authenticated: state.authenticated, + signal: new AbortController().signal, + ...overrides, + }); +} + +function readProof(state, response) { + const value = JSON.parse( + fs.readFileSync( + path.join( + state.deploymentRoot, + 'console-presence', + response.body.proofFileName, + ), + 'utf8', + ), + ); + return value.proof; +} + +test('requires local presence, re-confirms the credential and commits Policy/audit/mutation through a request fence', async (t) => { + const state = fixture(t); + const body = taskBody(); + const challenge = await state.route.handle(request(state, body)); + assert.equal(challenge.statusCode, 428); + assert.equal(challenge.body.code, 'local_presence_required'); + assert.match(challenge.body.requestDigest, /^[a-f0-9]{64}$/); + assert.match(challenge.body.proofFileName, /^[0-9a-f-]+\.json$/); + + const proof = readProof(state, challenge); + const created = await state.route.handle( + request(state, body, { presence: proof }), + ); + assert.equal(created.statusCode, 201); + assert.equal(created.body.status, 'created'); + assert.equal(created.body.task.taskId, 'task-console'); + assert.equal(created.body.task.revision, 1); + assert.equal(state.calls.filter(([kind]) => kind === 'confirm').length, 1); + assert.equal( + state.calls.filter(([kind]) => kind === 'credential-fence').length, + 1, + ); + const mutation = state.calls.find(([kind]) => kind === 'mutation')[1]; + assert.equal(mutation.actor.type, 'user'); + assert.equal(mutation.actor.id, 'owner'); + assert.deepEqual(mutation.fence, { + projectVersion: 3, + bindingVersion: 5, + }); + assert.equal(mutation.audit.outcome, 'allowed'); + assert.equal( + mutation.audit.authenticationId.startsWith('local_presence:'), + true, + ); + assert.deepEqual( + state.calls + .filter(([kind]) => kind === 'audit') + .map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]), + [['task.create', 'approval_required', 'local_presence_required']], + ); +}); + +test('binds the proof to exact Task content and leaves it usable only for the original request', async (t) => { + const state = fixture(t); + const body = taskBody(); + const challenge = await state.route.handle(request(state, body)); + const proof = readProof(state, challenge); + const changed = await state.route.handle( + request(state, taskBody({ name: 'Changed after challenge' }), { + presence: proof, + }), + ); + assert.deepEqual(changed, { + statusCode: 401, + body: { code: 'local_presence_rejected' }, + }); + const created = await state.route.handle( + request(state, body, { presence: proof }), + ); + assert.equal(created.statusCode, 201); + assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 1); +}); + +test('fails closed for malformed bodies, non-User credentials and expired presence proofs', async (t) => { + const state = fixture(t); + assert.deepEqual( + await state.route.handle(request(state, { name: 'partial' })), + { statusCode: 400, body: { code: 'invalid_task_definition' } }, + ); + const serviceCredential = Object.freeze({ + ...state.authenticated, + principal: Object.freeze({ + ...PRINCIPAL, + subject: Object.freeze({ type: 'system', id: 'runtime' }), + assurance: 'service', + }), + credentialFence: Object.freeze({ + ...FENCE, + subjectType: 'system', + subjectId: 'runtime', + }), + }); + assert.deepEqual( + await state.route.handle( + request(state, taskBody(), { authenticated: serviceCredential }), + ), + { statusCode: 401, body: { code: 'strong_authentication_required' } }, + ); + const challenge = await state.route.handle(request(state, taskBody())); + const proof = readProof(state, challenge); + state.setNow(challenge.body.expiresAtMs); + assert.deepEqual( + await state.route.handle(request(state, taskBody(), { presence: proof })), + { statusCode: 401, body: { code: 'local_presence_rejected' } }, + ); + assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0); +}); diff --git a/packages/ql3-local-application/src/application-runtime/activation.ts b/packages/ql3-local-application/src/application-runtime/activation.ts index 6566d70c..bf45d93f 100644 --- a/packages/ql3-local-application/src/application-runtime/activation.ts +++ b/packages/ql3-local-application/src/application-runtime/activation.ts @@ -520,6 +520,8 @@ export async function bootstrapLocalApplication( taskStart, runAttemptLogRead, taskDefinitions: storage.taskDefinitions, + taskDefinitionAdministrationForCredential: + storage.taskDefinitionAdministrationForCredential, apiCredentials: storage.apiCredentials, ownerPepper: storage.ownerPepper, projectPolicy: storage.projectPolicy, diff --git a/packages/ql3-local-application/src/application-runtime/contract.ts b/packages/ql3-local-application/src/application-runtime/contract.ts index 694aa3d0..4e335a6c 100644 --- a/packages/ql3-local-application/src/application-runtime/contract.ts +++ b/packages/ql3-local-application/src/application-runtime/contract.ts @@ -64,8 +64,11 @@ export interface LocalApplicationProductSurfaceAuthority { >; readonly taskDefinitions: Pick< ReadyFreshStorage['taskDefinitions'], - 'findCurrentTaskDefinition' | 'listTaskDefinitions' + | 'findCurrentTaskDefinition' + | 'findTaskDefinitionRevision' + | 'listTaskDefinitions' >; + readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential']; readonly runAttemptLogRead: Readonly<{ read( request: Readonly, diff --git a/packages/ql3-local-sqlite/src/profile/localProfile.ts b/packages/ql3-local-sqlite/src/profile/localProfile.ts index 81a8fd45..cb2835fe 100644 --- a/packages/ql3-local-sqlite/src/profile/localProfile.ts +++ b/packages/ql3-local-sqlite/src/profile/localProfile.ts @@ -42,6 +42,7 @@ export type LocalProfileStorageBootstrapResult = readonly runCancellationRepository: LocalSqliteRuntimeDatabase['runCancellationRepository']; readonly taskStartRepository: LocalSqliteRuntimeDatabase['taskStartRepository']; readonly taskDefinitions: LocalSqliteRuntimeDatabase['taskDefinitions']; + readonly taskDefinitionAdministrationForCredential: LocalSqliteRuntimeDatabase['taskDefinitionAdministrationForCredential']; readonly schedules: LocalSqliteRuntimeDatabase['schedules']; readonly dispatch: LocalSqliteRuntimeDatabase['localDispatch']; readonly executionControl: LocalSqliteRuntimeDatabase['executionControl']; @@ -138,6 +139,8 @@ export async function bootstrapLocalProfileStorage( runCancellationRepository: database.runCancellationRepository, taskStartRepository: database.taskStartRepository, taskDefinitions: database.taskDefinitions, + taskDefinitionAdministrationForCredential: + database.taskDefinitionAdministrationForCredential, schedules: database.schedules, dispatch: database.localDispatch, executionControl: database.executionControl, diff --git a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts index b6f90892..99d809cf 100644 --- a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts +++ b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts @@ -36,6 +36,7 @@ import type { PluginPackageTaskPublicationRecoverySource } from '@qinglong/runti import type { StepRunRepository } from '@qinglong/runtime-core/step-run'; import type { RunCancellationRepository } from '@qinglong/runtime-core/run-cancellation'; import type { TaskStartRepository } from '@qinglong/runtime-core/task-start'; +import type { TaskDefinitionAdministrationRepository } from '@qinglong/runtime-core/task-definition-administration'; import type { ToolExecutionCompletionRepository } from '@qinglong/runtime-core/tool-execution-completion'; import type { ToolExecutionFailureCompletionRepository } from '@qinglong/runtime-core/tool-execution-failure-completion'; import type { ToolExecutionStartBarrierRepository } from '@qinglong/runtime-core/tool-execution-start-barrier'; @@ -50,6 +51,12 @@ import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRep import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository'; import { LocalSqliteOperationAuthority } from '../authority/operationAuthority'; import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository'; +import { LocalSqliteTaskDefinitionAdministrationRepository } from '../task-definition/taskDefinitionAdministration'; +import { + confirmLocalSqliteAuthenticatedUserCredentialFence, + LocalSqliteAuthenticatedManagementFenceError, + type LocalSqliteAuthenticatedUserCredentialFence, +} from '../administration/packageManagement'; import { TaskSpecSemanticRegistry, createBuiltInTaskSpecSemanticRegistry, @@ -96,6 +103,9 @@ export interface LocalSqliteRuntimeDatabase { readonly readiness: LocalSqliteReadinessEvidence; readonly runRepository: LocalSqliteRunRepository; readonly taskDefinitions: LocalSqliteTaskDefinitionRepository; + taskDefinitionAdministrationForCredential( + fence: Readonly, + ): TaskDefinitionAdministrationRepository; readonly triggers: LocalSqliteTriggerRepository; readonly schedules: LocalSqliteScheduleRepository; readonly localDispatch: LocalDispatchStore; @@ -247,6 +257,27 @@ export async function openLocalSqliteRuntimeDatabase( readiness, runRepository, taskDefinitions, + taskDefinitionAdministrationForCredential( + fence: Readonly, + ) { + confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence); + return new LocalSqliteTaskDefinitionAdministrationRepository( + authority, + taskDefinitions, + (actor) => { + if ( + actor.type !== fence.subjectType || + actor.id !== fence.subjectId + ) { + throw new LocalSqliteAuthenticatedManagementFenceError(); + } + confirmLocalSqliteAuthenticatedUserCredentialFence( + authority, + fence, + ); + }, + ); + }, triggers, schedules, localDispatch: runRuntimeCapabilities.dispatch, diff --git a/packages/ql3-local-sqlite/src/task-definition/taskDefinitionAdministration.ts b/packages/ql3-local-sqlite/src/task-definition/taskDefinitionAdministration.ts index b752102a..0a7fb7f9 100644 --- a/packages/ql3-local-sqlite/src/task-definition/taskDefinitionAdministration.ts +++ b/packages/ql3-local-sqlite/src/task-definition/taskDefinitionAdministration.ts @@ -97,7 +97,7 @@ function sameCredentialFence( ); } -class LocalSqliteTaskDefinitionAdministrationRepository +export class LocalSqliteTaskDefinitionAdministrationRepository implements TaskDefinitionAdministrationRepository { constructor( diff --git a/packages/ql3-local-sqlite/test/taskDefinitionRequestFence.test.cjs b/packages/ql3-local-sqlite/test/taskDefinitionRequestFence.test.cjs new file mode 100644 index 00000000..5802b3cc --- /dev/null +++ b/packages/ql3-local-sqlite/test/taskDefinitionRequestFence.test.cjs @@ -0,0 +1,283 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); +const { test } = require('node:test'); + +const { + TaskDefinitionAdministrationAuthorizationFenceConflictError, +} = require('@qinglong/runtime-core/task-definition-administration'); +const { + migrateLocalSqlitePath, + openLocalSqliteRuntimeDatabase, +} = require('../dist'); + +const MATERIAL_DIGEST = 'a'.repeat(64); +const PEPPER_KEY_ID = 'request-fence-pepper-v1'; + +function command(index, subjectId, policyFence) { + const suffix = String(index).padStart(12, '0'); + const eventId = `019fa000-0000-4000-8000-${suffix}`; + const occurredAtMs = Date.now(); + return Object.freeze({ + command: { + projectId: 'default', + taskId: `request-fence-task-${index}`, + expectedRevision: null, + mutationId: eventId, + name: `Request fence task ${index}`, + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { + kind: 'argv', + file: '/bin/echo', + args: [subjectId], + }, + }, + }, + labels: { subject: subjectId }, + enabled: true, + occurredAtMs, + }, + actor: { type: 'user', id: subjectId }, + fence: policyFence, + audit: { + eventId, + requestId: `request-fence:${index}`, + operationId: 'task.create', + projectId: 'default', + subject: { type: 'user', id: subjectId }, + authenticationId: `local_presence:request-fence-${index}`, + outcome: 'allowed', + reasons: ['role_grant', 'strong_authentication'], + fence: policyFence, + occurredAtMs, + }, + }); +} + +async function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-request-fence-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const databasePath = path.join(root, 'qinglong3.sqlite'); + await migrateLocalSqlitePath({ databasePath, profile: 'edge' }); + const now = Date.now(); + const notBeforeAtMs = now - 60_000; + const expiresAtMs = now + 10 * 60_000; + const database = new DatabaseSync(databasePath); + try { + database + .prepare( + `INSERT INTO "QingLong3LocalOwnerPepperKeys" ( + "pepper_key_id", "material_digest", "backup_digest", "state", + "version", "register_mutation_id", "activate_mutation_id", + "registered_at_ms", "activated_at_ms" + ) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`, + ) + .run( + PEPPER_KEY_ID, + MATERIAL_DIGEST, + 'b'.repeat(64), + '019fa000-0000-4000-8000-000000000001', + '019fa000-0000-4000-8000-000000000002', + now - 120_000, + now - 90_000, + ); + for (const [index, subjectId] of [ + 'request-user-a', + 'request-user-b', + ].entries()) { + const credentialId = `request-credential-${index + 1}`; + const secretDigest = String(index + 1).repeat(64); + database + .prepare( + `INSERT INTO "QingLong3IdentitySubjects" ( + "subject_type", "subject_id", "status", "version", + "created_at_ms", "updated_at_ms" + ) VALUES ('user', ?, 'active', 1, ?, ?)`, + ) + .run(subjectId, now - 60_000, now - 60_000); + database + .prepare( + `INSERT INTO "QingLong3ApiCredentials" ( + "credential_id", "version", "state", "subject_type", + "subject_id", "secret_digest", "created_at_ms", + "not_before_at_ms", "expires_at_ms" + ) VALUES (?, 1, 'active', 'user', ?, ?, ?, ?, ?)`, + ) + .run( + credentialId, + subjectId, + secretDigest, + now - 60_000, + notBeforeAtMs, + expiresAtMs, + ); + database + .prepare( + `INSERT INTO "QingLong3ApiCredentialPepperBindings" ( + "credential_id", "credential_version", "pepper_key_id" + ) VALUES (?, 1, ?)`, + ) + .run(credentialId, PEPPER_KEY_ID); + database + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", + "role", "mutation_id", "changed_by_type", "changed_by_id", + "created_at_ms" + ) VALUES ( + 'default', 'user', ?, 1, 'active', 'operator', ?, 'user', ?, ? + )`, + ) + .run( + subjectId, + `request-binding-${index + 1}`, + subjectId, + now - 30_000, + ); + } + } finally { + database.close(); + } + const runtime = await openLocalSqliteRuntimeDatabase({ + databasePath, + profile: 'edge', + }); + t.after(() => runtime.close()); + function credentialFence(index, subjectId) { + return Object.freeze({ + credentialId: `request-credential-${index}`, + credentialVersion: 1, + pepperKeyId: PEPPER_KEY_ID, + materialDigest: MATERIAL_DIGEST, + subjectType: 'user', + subjectId, + secretDigest: String(index).repeat(64), + notBeforeAtMs, + expiresAtMs, + }); + } + return { + runtime, + databasePath, + fenceA: credentialFence(1, 'request-user-a'), + fenceB: credentialFence(2, 'request-user-b'), + }; +} + +test('keeps simultaneous Task mutation credentials request-scoped and rechecks both fences in-transaction', async (t) => { + const value = await fixture(t); + const repositoryA = value.runtime.taskDefinitionAdministrationForCredential( + value.fenceA, + ); + const repositoryB = value.runtime.taskDefinitionAdministrationForCredential( + value.fenceB, + ); + const policyA = await value.runtime.projectPolicy.resolve('default', { + type: 'user', + id: 'request-user-a', + }); + const policyB = await value.runtime.projectPolicy.resolve('default', { + type: 'user', + id: 'request-user-b', + }); + const fenceA = { + projectVersion: policyA.project.version, + bindingVersion: policyA.binding.version, + }; + const fenceB = { + projectVersion: policyB.project.version, + bindingVersion: policyB.binding.version, + }; + + const [createdA, createdB] = await Promise.all([ + repositoryA.appendAuthorizedTaskDefinitionRevision( + command(101, 'request-user-a', fenceA), + ), + repositoryB.appendAuthorizedTaskDefinitionRevision( + command(102, 'request-user-b', fenceB), + ), + ]); + assert.equal(createdA.status, 'created'); + assert.equal(createdB.status, 'created'); + + const competing = new DatabaseSync(value.databasePath); + try { + competing + .prepare( + `UPDATE "QingLong3ApiCredentials" + SET "state" = 'revoked' + WHERE "credential_id" = ? AND "version" = 1`, + ) + .run(value.fenceA.credentialId); + } finally { + competing.close(); + } + await assert.rejects( + repositoryA.appendAuthorizedTaskDefinitionRevision( + command(103, 'request-user-a', fenceA), + ), + { code: 'LOCAL_SQLITE_AUTHENTICATED_MANAGEMENT_FENCE_REJECTED' }, + ); + assert.equal( + ( + await repositoryB.appendAuthorizedTaskDefinitionRevision( + command(104, 'request-user-b', fenceB), + ) + ).status, + 'created', + ); + + const roleDrift = new DatabaseSync(value.databasePath); + try { + roleDrift + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", + "role", "mutation_id", "changed_by_type", "changed_by_id", + "created_at_ms" + ) VALUES ( + 'default', 'user', 'request-user-b', 2, 'revoked', NULL, + 'request-binding-revoked', 'user', 'request-user-b', ? + )`, + ) + .run(Date.now()); + } finally { + roleDrift.close(); + } + await assert.rejects( + repositoryB.appendAuthorizedTaskDefinitionRevision( + command(105, 'request-user-b', fenceB), + ), + TaskDefinitionAdministrationAuthorizationFenceConflictError, + ); + + const readOnly = new DatabaseSync(value.databasePath, { readOnly: true }); + try { + assert.equal( + readOnly + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3TaskDefinitionRevisions"`, + ) + .get().count, + 3, + ); + assert.equal( + readOnly + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3SecurityAuditEvents" + WHERE "operation_id" = 'task.create' AND "outcome" = 'allowed'`, + ) + .get().count, + 3, + ); + } finally { + readOnly.close(); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c31f1860..4d360d8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,6 +492,9 @@ importers: packages/ql3-local-api: dependencies: + '@qinglong/local-admin': + specifier: workspace:* + version: link:../ql3-local-admin '@qinglong/local-application': specifier: workspace:* version: link:../ql3-local-application diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 55f863c4..cf0e73d0 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -71,6 +71,7 @@ const EXPECTED_PACKAGE_DEPENDENCIES = Object.freeze({ }), 'packages/ql3-local-api': Object.freeze({ dependencies: Object.freeze({ + '@qinglong/local-admin': 'workspace:*', '@qinglong/local-application': 'workspace:*', '@qinglong/local-command-file': 'workspace:*', '@qinglong/local-owner-console': 'workspace:*', @@ -272,6 +273,7 @@ const EXPECTED_PACKAGE_DEPENDENCIES = Object.freeze({ }), }); const EXPECTED_WORKSPACE_RESOLUTIONS = Object.freeze({ + 'packages/ql3-local-api:@qinglong/local-admin': 'link:../ql3-local-admin', 'packages/ql3-local-api:@qinglong/local-application': 'link:../ql3-local-application', 'packages/ql3-local-api:@qinglong/local-command-file': @@ -3158,6 +3160,14 @@ function auditSourceImports(root, packagePath, findings) { ) { continue; } + if ( + packagePath === 'packages/ql3-local-api' && + path.relative(packageDirectory, filePath) === + 'src/task/taskPutRoute.ts' && + specifier === '@qinglong/local-admin/task-definition-administration' + ) { + continue; + } if ( path.isAbsolute(specifier) || forbidden.some( diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index e02df730..30f4edb4 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localApi.rootSourceFileRoles, }, { - sourceFiles: 18, + sourceFiles: 20, rootSourceFiles: 1, rootSourceLines: 84, - nestedSourceFiles: 17, + nestedSourceFiles: 19, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );