mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): add request-scoped console task creation
This commit is contained in:
@@ -6,10 +6,12 @@
|
||||
- 目标版本:QingLong 3.x
|
||||
- 作者:QingLong Maintainers
|
||||
- 创建日期:2026-07-17
|
||||
- 最后更新:2026-08-28
|
||||
- 最后更新:2026-08-29
|
||||
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
||||
|
||||
最新增量证据(2026-08-28):
|
||||
最新增量证据(2026-08-29):
|
||||
|
||||
- D-421/ADR-0516(实现与本地验收已完成,阶段提交/远端 milestone 待闭合):Local Web Task mutation 不再受限于短生命周期 CLI 的进程级 active credential。`PUT /api/v3/projects/:projectId/tasks/:taskId` 把 Bearer 限定为 session credential,第一次 exact request 只在 deployment root 内 `0700` 目录发布 `0600`、当前 UID、两分钟、一次性的私有 proof file;HTTP 只返回 authorization ID、canonical request digest、expiry 与 basename,不返回 proof 或宿主绝对路径。proof 绑定 Task 全内容、credential ID/version 与 User subject;Edge/Standalone pending 上限为 8/32,按请求惰性清理,不新增 timer/watcher/daemon。验证后 principal 提升为短期 `local_console`,既有 Task administration service 重跑 Policy;SQLite runtime 为每个请求建立独立 credential-fenced repository,并在 Task append 事务内复验 credential/Identity/pepper、actor、Project/RoleBinding fence,原子提交 allowed audit 与 mutation。并发测试证明两个 User repository 不共享 ambient authority,撤销 A 不影响 B,RoleBinding 漂移仍原子拒绝;真实 loopback HTTP→proof file→SQLite create 与 Chromium 编辑器/proof ticket 已通过。Console 当前完成 command Task 创建;HTTP 与 CLI 支持完整 create/update,Web update 等待强认证 authoring read/lease,避免用不含 spec 的 bounded read 覆盖未知字段。三资产合计 62,632 bytes;Local API 56/56、18-package clean build/test 3,030 total/3,008 pass/22 条件跳过/0 fail,package/source、Local image、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 Edge 为 2,736,982 bytes/329 files/83 modules,opt-in Edge/Standalone Console 为 4,040,893/4,041,037 bytes、472 files/12 packages/94 modules,均保有门禁余量;默认 headless 与 Cluster 路径不变。完整 backend 仍由原生 CI 闭合,新的可下载双架构产物必须在本阶段提交后由显式 Console milestone run 重新生成,不能沿用 D-420 的旧 archive 冒充。
|
||||
|
||||
- D-420/ADR-0515(已实现,首份真实双架构 Console v5 Trial Kit 已交付):阶段可用的首个自动化从“原生 CI 能通过 API 看到 bounded log marker”推进为“部署者能在 Console 直接观察实际输出”。Local Run HTTP 详情只追加严格验证的 latest Attempt 低敏摘要(ID、序号、状态、时间和 `logAvailable`),不返回 executor handle、Artifact ID、路径、Worker、PID 或错误明细;共享 HIGH 风险 `executeBoundedRunReadProjection` 保持不变,避免 Local UI 字段漂移到内建 Run read/compare Tool。Console 使用既有 `artifact.read` Policy/Audit/credential re-confirm 链,每次固定读取首个 32 KiB base64 窗口,分别显示 available/pending/retired/not-found/unavailable、range 与 truncation,不新增轮询、WebSocket、timer、缓存或整文件下载。三资产增至 48,318 bytes;`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门,默认 headless Edge 保持 2,669,390 bytes/325 files/58 modules,Cluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0;package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。提交 `57953ec8` 的远端 QingLong 3.0 CI 为 41 success / 3 expected artifact-finalizer skip / 0 fail,独立 Kubernetes deployment live contract 为 1/1 success。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTP,Web Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR-0516:request-scoped Local Console Task mutation
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-29
|
||||
- 对应 RFC 切片:D-421
|
||||
- 关联:ADR-0256、ADR-0377、ADR-0512、ADR-0513、ADR-0514、ADR-0515
|
||||
|
||||
## 背景
|
||||
|
||||
Local Console 已能读取 Task、显式启动 Run、查看 Event/Step/有界日志并请求取消,但部署者仍必须离开 Console,使用短生命周期 `ql3-task` command file 才能创建或修订 Task。该 CLI 的安全性依赖“每个进程只激活一个 credential fence”;把同一个可变 active fence 搬进常驻、多请求 HTTP 会让并发用户互相覆盖凭据,并在 credential 撤销或 RoleBinding 漂移时产生错误授权。
|
||||
|
||||
单因子 Bearer 只能证明浏览器持有 API credential,不能替代现有 Task 管理要求的 strong User。本切片必须在不扩大默认 headless 路由设备常驻成本、不把 proof secret 返回 HTTP、不削弱 SQLite 原子审计、也不把本机 POSIX authority 误用到 Cluster 的前提下,形成部署者可实际操作的 Web 创建链路。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. PUT 使用 request-scoped credential fence
|
||||
|
||||
Local API 新增固定路由:
|
||||
|
||||
```text
|
||||
PUT /api/v3/projects/:projectId/tasks/:taskId
|
||||
```
|
||||
|
||||
请求体使用既有 immutable TaskDefinition command:`expectedRevision=null` 创建,`expectedRevision=current` 修订;mutation ID、occurredAt、name/kind/spec/labels/enabled 都参与规范化。Bearer 只建立 `single_factor` session,并交付该次认证解析出的 exact credential fence;服务不设置进程级 active credential。
|
||||
|
||||
SQLite runtime 以 `taskDefinitionAdministrationForCredential(fence)` 为每个请求创建独立 repository。factory 建立时先复验 credential/Identity/pepper;写事务内再次复验同一 exact fence、actor subject、Project version 与 latest RoleBinding version/state。allowed audit、Task head/revision、mutation replay 与适用的 local execution revision仍在一个事务中提交。两个同时存活的 repository 不共享可变 credential 状态。
|
||||
|
||||
### 2. 本机存在证明是第二权威,不经 HTTP 交付 secret
|
||||
|
||||
第一次 exact PUT 不带 proof 时,服务在 `<deploymentRoot>/console-presence/` 发布一次性 challenge file:目录 `0700`,文件 `0600`,当前 POSIX UID owner,exclusive/no-follow、原子 rename、文件和目录 `fsync`。响应只返回 authorization ID、请求摘要、过期时间和 basename;32-byte 随机 proof 只存在私有文件,不进入 HTTP response、URL、Cookie、Web Storage、日志或 challenge audit。
|
||||
|
||||
proof 精确绑定:
|
||||
|
||||
- canonical Task command SHA-256;
|
||||
- credential ID/version;
|
||||
- User subject;
|
||||
- 两分钟有效期与单次消费。
|
||||
|
||||
Edge 最多保留 8 个待确认 challenge,Standalone 最多 32 个;过期文件按请求惰性清理,不新增 timer、watcher、daemon 或后台 I/O。错误 proof、不同 Task 内容、不同 credential/subject 和过期 proof 都不能消费原 challenge。用户提交 proof 后,服务先重新确认 Bearer credential authority,再消费 proof,并把 principal 提升为短期 `local_console` assurance;既有 Task administration service 随后重新执行 Project Policy。
|
||||
|
||||
### 3. Challenge audit 与最终 mutation audit 分层
|
||||
|
||||
生成 challenge 时记录 `approval_required/local_presence_required`,但不记录 proof、命令内容或路径。错误 proof 记录无 subject 的 `authentication_rejected/local_presence_rejected`,符合既有 audit outcome identity contract。最终 `allowed` audit 不由 HTTP 先写,而是与 Task mutation 一同进入 SQLite 事务;Policy、credential、RoleBinding 或 mutation fence 漂移都不会留下“允许但未写入”的孤立记录。
|
||||
|
||||
### 4. Console 先提供可完成的 command Task 创建旅程
|
||||
|
||||
离线 Console 新增“创建任务”编辑器:Task ID、名称、说明、argv file、逐行 args 和 enabled。第一次保存后展示只包含 `console-presence/<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。
|
||||
@@ -517,6 +517,9 @@
|
||||
| [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) |
|
||||
| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Trial Kit 交付已由 ADR-0513 闭合) |
|
||||
| [ADR-0513](./ADR-0513-selectable-local-console-trial-kit.md) | 可选择的 Local Console Trial Kit | Accepted(首份实际双架构产物待维护者授权) |
|
||||
| [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) |
|
||||
| [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) |
|
||||
| [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
当维护者显式选择 `alpha_artifact_scope=all` 时,还会生成 `Alpha stage index`。它把同一次 run 的 Local/Cluster milestone 交叉绑定,并为 Edge、Standalone、Cluster 给出目标架构的最小 artifact 选择;这是阶段交付导航,不是正式 release catalog。只生成 Local 或 Cluster 时,各自 milestone 仍可独立成立,不制造一个不完整的总索引。
|
||||
|
||||
## 当前阶段实物(2026-08-28)
|
||||
## 当前阶段实物(2026-08-29)
|
||||
|
||||
在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已把 headless 用户旅程与 opt-in Console 合并为一条可选择的交付链:
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
| Local headless Trial Kit v5 | 默认低配变体;同一源码支持 POSIX shell + Docker 一条命令完成 fresh setup、首 Owner与 Application active/stop;无 listener、示例 Task 或 Console 增量 | 尚未为当前提交单独触发 headless 双架构 milestone;不能把 Console archive 改名复用 |
|
||||
| D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 |
|
||||
| D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 |
|
||||
| D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task | 当前源码尚未重新生成双架构 Trial Kit;Web update 等待 authoring read/lease,Cluster 不复用 Local proof |
|
||||
|
||||
D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,但不能据此把 run `33173769047` 的旧 archive 改名为新产物。只有 D-421 阶段提交通过远端 CI,并由同源显式 Local Console milestone run 重新生成 amd64/arm64 Trial Kit 与 milestone index 后,下载者才能把该 Web 创建能力视为新的阶段实物;在此之前,旧 Console v5 仍是最新可下载实物,本工作树/提交只是下一候选源码。
|
||||
|
||||
D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QingLong 3.0 Local Web Console
|
||||
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建 command Task、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
|
||||
## 选择部署档位
|
||||
|
||||
@@ -11,14 +11,14 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查
|
||||
| 普通单节点服务器 | 选择 `standalone-application-api` |
|
||||
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
|
||||
|
||||
D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console,并明确展示 pending、retired、missing 与 truncation 状态。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
|
||||
D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421 增加 request-scoped strong-auth Task PUT 与 Console command Task 创建器;它不复用 CLI 的进程级 active credential,也不让单因子 Bearer 直接写 Task。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已完成 Local fresh setup,并有受支持的 Application config;
|
||||
- Owner pepper keyring 与 SQLite active pepper 一致;
|
||||
- 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential;
|
||||
- credential 对目标 Project 至少有读取 Task/Run 的权限;启动和取消分别还需要 `run.start` 与 `run.stop`;
|
||||
- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、启动和取消分别还需要 `task.create`、`run.start` 与 `run.stop`;
|
||||
- config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。
|
||||
|
||||
## 启动
|
||||
@@ -54,19 +54,21 @@ ssh -L 5701:127.0.0.1:5701 router.example
|
||||
## 使用
|
||||
|
||||
1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。
|
||||
2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。
|
||||
3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||
4. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||
5. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
6. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。
|
||||
3. 在部署设备上以 QingLong 数据目录 owner 读取 `<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;
|
||||
- 编辑/启停现有 Task(bounded read 不返回完整 spec,不能据此安全覆盖;继续使用 `ql3-task`,后续由 authoring lease/read 切片补齐);
|
||||
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
|
||||
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
||||
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user