feat(ql3): add request-scoped console task creation

This commit is contained in:
whyour
2026-08-29 03:51:35 +08:00
parent 53b7072370
commit 884912d1c8
32 changed files with 2485 additions and 56 deletions
+4 -2
View File
@@ -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 fileHTTP 只返回 authorization ID、canonical request digest、expiry 与 basename,不返回 proof 或宿主绝对路径。proof 绑定 Task 全内容、credential ID/version 与 User subjectEdge/Standalone pending 上限为 8/32,按请求惰性清理,不新增 timer/watcher/daemon。验证后 principal 提升为短期 `local_console`,既有 Task administration service 重跑 PolicySQLite runtime 为每个请求建立独立 credential-fenced repository,并在 Task append 事务内复验 credential/Identity/pepper、actor、Project/RoleBinding fence,原子提交 allowed audit 与 mutation。并发测试证明两个 User repository 不共享 ambient authority,撤销 A 不影响 BRoleBinding 漂移仍原子拒绝;真实 loopback HTTP→proof file→SQLite create 与 Chromium 编辑器/proof ticket 已通过。Console 当前完成 command Task 创建;HTTP 与 CLI 支持完整 create/updateWeb update 等待强认证 authoring read/lease,避免用不含 spec 的 bounded read 覆盖未知字段。三资产合计 62,632 bytesLocal API 56/56、18-package clean build/test 3,030 total/3,008 pass/22 条件跳过/0 failpackage/source、Local image、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 Edge 为 2,736,982 bytes/329 files/83 modulesopt-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 modulesCluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0package/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 不适合常驻并发 HTTPWeb Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。
@@ -0,0 +1,75 @@
# ADR-0516request-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 时,服务在 `<deploymentRoot>/console-presence/` 发布一次性 challenge file:目录 `0700`,文件 `0600`,当前 POSIX UID ownerexclusive/no-follow、原子 rename、文件和目录 `fsync`。响应只返回 authorization ID、请求摘要、过期时间和 basename32-byte 随机 proof 只存在私有文件,不进入 HTTP response、URL、Cookie、Web Storage、日志或 challenge audit。
proof 精确绑定:
- canonical Task command SHA-256
- credential ID/version
- User subject
- 两分钟有效期与单次消费。
Edge 最多保留 8 个待确认 challengeStandalone 最多 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/<basename>`、两分钟时效和 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 缺少 `<functional>` 失败,未伪装成源码回归。唯一实际源码契约漂移是 Local API 文件计数 18/17→20/19,修正后 package-boundary `10/10` 通过。完整 backend 与原生 Linux loopback 必须由阶段提交的远端 CI 闭合。
该 ADR 证明源码切片的安全与产品旅程,不自动声明已有新的双架构 Trial Kit:新的可下载阶段产物仍必须由同源显式 milestone run 重新生成并进入 Local milestone index。
+3
View File
@@ -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 | AcceptedTrial 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) | 阶段可用的首个自动化旅程 | AcceptedConsole 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 |
## 规则
+4 -1
View File
@@ -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 KitWeb update 等待 authoring read/leaseCluster 不复用 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)。
+12 -10
View File
@@ -1,6 +1,6 @@
# QingLong 3.0 Local Web Console
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 TaskRun执行事件,并显式启动或取消一次运行。它由 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 KitD-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 KitD-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 读取 `<deploymentRoot>/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
- 编辑/启停现有 Taskbounded read 不返回完整 spec,不能据此安全覆盖;继续使用 `ql3-task`,后续由 authoring lease/read 切片补齐)
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
+7 -2
View File
@@ -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",
@@ -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,
@@ -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) {
@@ -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;
}
@@ -87,9 +87,14 @@
输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。
</p>
</div>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
<div class="masthead-actions">
<button class="action-button" id="create-task-button" type="button" hidden>
<span aria-hidden="true"></span> 创建任务
</button>
<button class="refresh-button" id="refresh-button" type="button" hidden>
<span aria-hidden="true"></span> 刷新
</button>
</div>
</header>
<div class="workspace-grid">
@@ -125,6 +130,89 @@
</form>
</dialog>
<dialog id="task-editor-dialog" class="task-editor-dialog">
<form id="task-editor-form" autocomplete="off">
<div class="dialog-heading">
<div>
<p class="eyebrow">Task authoring</p>
<h2>创建命令任务</h2>
</div>
<button class="quiet-button" id="task-editor-close" type="button">关闭</button>
</div>
<p class="editor-intro">
定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。
</p>
<div class="editor-grid">
<label>
<span>Task ID</span>
<input id="task-id-input" maxlength="128" spellcheck="false" required />
</label>
<label>
<span>名称</span>
<input id="task-name-input" maxlength="160" required />
</label>
<label class="editor-wide">
<span>说明(可选)</span>
<input id="task-description-input" maxlength="1000" />
</label>
<label class="editor-wide">
<span>可执行文件</span>
<input
id="task-command-input"
value="/bin/echo"
maxlength="4096"
spellcheck="false"
required
/>
</label>
<label class="editor-wide">
<span>参数 · 每行一个</span>
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
</label>
<label class="editor-check">
<input id="task-enabled-input" type="checkbox" checked />
<span>创建后允许运行</span>
</label>
</div>
<p class="editor-note">
Alpha 当前从 Console 创建 <code>qinglong/command@v1</code>;高级 Task schema 仍使用受信任管理入口。
</p>
<div class="dialog-actions">
<button class="primary-button" id="task-editor-save" type="submit">
保存并生成本机证明
</button>
</div>
</form>
</dialog>
<dialog id="presence-dialog" class="presence-dialog">
<form id="presence-form" autocomplete="off">
<p class="eyebrow">Local presence · 02:00</p>
<h2>从部署设备取得证明</h2>
<p>
使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。
</p>
<code class="proof-ticket" id="presence-file"></code>
<label class="presence-input">
<span>文件中的 proof</span>
<input
id="presence-proof-input"
type="password"
maxlength="128"
spellcheck="false"
autocomplete="off"
required
/>
</label>
<p class="presence-expiry" id="presence-expiry"></p>
<p class="presence-error" id="presence-error" role="alert" hidden></p>
<div class="dialog-actions">
<button class="quiet-button" id="presence-cancel" type="button">取消</button>
<button class="primary-button" id="presence-submit" type="submit">验证并创建</button>
</div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+1
View File
@@ -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:*",
@@ -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<SecurityPolicyDecision>;
try {
decision = normalizeSecurityPolicyDecision(
@@ -386,6 +414,8 @@ export function createLocalApiAdmission(
principal: authenticated.principal,
policyFence: decision.fence,
});
case 'task.put':
return response(503, 'request_unavailable');
}
},
});
@@ -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', {
@@ -19,6 +19,7 @@ const AUTHORIZATION_PATTERN =
export interface AuthenticatedLocalApiRequest {
readonly principal: Readonly<SecurityPrincipal>;
readonly credentialFence: Readonly<LocalApiCredentialFence>;
confirm(): Promise<void>;
}
@@ -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<Readonly<CredentialFence>> {
): Promise<Readonly<LocalApiCredentialFence>> {
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) {
@@ -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<LocalPresenceBinding>): LocalPresenceChallenge;
consume(
presentation: string | null,
binding: Readonly<LocalPresenceBinding>,
): 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<LocalPresenceBinding>): 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<PendingLocalPresenceAuthorization, 'proofDigest'>,
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<LocalPresenceProofManagerOptions>,
): Readonly<LocalPresenceProofManager> {
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<string, PendingLocalPresenceAuthorization>();
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<LocalPresenceBinding>) {
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<LocalPresenceBinding>,
) {
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;
},
});
}
@@ -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<AuthenticatedLocalApiRequest>;
readonly signal: AbortSignal;
}
export interface LocalApiTaskPutRoute {
handle(request: Readonly<LocalApiTaskPutRequest>): Promise<LocalApiResponse>;
}
export interface LocalApiTaskPutRouteOptions {
readonly projectPolicy: ProjectPolicyRepository;
readonly taskDefinitions: TaskDefinitionSource;
readonly taskDefinitionAdministrationForCredential: (
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
) => TaskDefinitionAdministrationRepository;
readonly securityAudit: SecurityAuditSink;
readonly presenceProof: LocalPresenceProofManager;
readonly now?: () => number;
readonly randomUuid?: () => string;
}
function response(
statusCode: number,
body: Readonly<Record<string, unknown>>,
): 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<Record<string, unknown>>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',')}}`;
}
function normalizeBody(
body: unknown | null,
projectId: string,
taskId: string,
): Readonly<AppendTaskDefinitionRevisionCommand> {
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<AppendTaskDefinitionRevisionCommand>,
): string {
return createHash('sha256')
.update('qinglong3.local-api-task-put.v1\0', 'utf8')
.update(canonicalJson(command), 'utf8')
.digest('hex');
}
function presenceBinding(
command: Readonly<AppendTaskDefinitionRevisionCommand>,
authenticated: Readonly<AuthenticatedLocalApiRequest>,
): Readonly<LocalPresenceBinding> {
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<AppendTaskDefinitionRevisionCommand>,
): 'task.create' | 'task.update' {
return command.expectedRevision === null ? 'task.create' : 'task.update';
}
function summary(value: Readonly<TaskDefinitionRecord>) {
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<AuthenticatedLocalApiRequest> | null;
readonly outcome: SecurityAuditOutcome;
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
},
): Promise<boolean> {
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<LocalApiTaskPutRouteOptions>,
): Readonly<LocalApiTaskPutRoute> {
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<LocalApiTaskPutRequest>) {
if (request.signal.aborted) {
return response(503, { code: 'request_unavailable' });
}
let command: Readonly<AppendTaskDefinitionRevisionCommand>;
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<SecurityPolicyDecision>;
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<LocalPresenceBinding>;
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' });
}
},
});
}
@@ -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<void>;
@@ -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({
@@ -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);
@@ -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'],
@@ -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,
);
});
@@ -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',
@@ -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);
});
@@ -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,
@@ -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<RunAttemptLogReadRequest>,
@@ -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,
@@ -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<LocalSqliteAuthenticatedUserCredentialFence>,
): TaskDefinitionAdministrationRepository;
readonly triggers: LocalSqliteTriggerRepository;
readonly schedules: LocalSqliteScheduleRepository;
readonly localDispatch: LocalDispatchStore;
@@ -247,6 +257,27 @@ export async function openLocalSqliteRuntimeDatabase(
readiness,
runRepository,
taskDefinitions,
taskDefinitionAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
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,
@@ -97,7 +97,7 @@ function sameCredentialFence(
);
}
class LocalSqliteTaskDefinitionAdministrationRepository
export class LocalSqliteTaskDefinitionAdministrationRepository
implements TaskDefinitionAdministrationRepository
{
constructor(
@@ -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();
}
});
+3
View File
@@ -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
+10
View File
@@ -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(
+2 -2
View File
@@ -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' },
},
);