mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): add secret-backed console automation
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# QingLong 3.0 Architecture RFC
|
||||
|
||||
- D-424/ADR-0519(已实现并通过本地门,当前为源码候选):Local Console 新增 current-only Secret metadata list,以及要求 User credential、`secret.manage`、exact plaintext digest owner-private proof 和事务内 credential/Policy/RoleBinding fence 的 Secret create/rotate。既有 Local administration service 继续负责 AES-256-GCM 与 immutable version;HTTP/Console/audit/Task 均不返回或持久化 plaintext、ciphertext、key ID。Task 编辑器把 `ENV=secret[@version]` 解析成 pinned `SecretRef`,省略版本时立即固定当前版本,执行时仍由既有 materializer 解密。Fresh/adopted Profile 共用现有 SQLite connection/close fence;无新 package、migration、connection、cache、daemon、timer 或 watcher。默认 headless 无 API/Console surface,Cluster 不复用 Local proof/SQLite custody。18-package clean build/test `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`,Local API `76/76`、Local SQLite `250/250`;package boundary、122-module Edge source import 和精确 Cluster dependency audit compatible。默认 Edge 2,760,847 bytes/332 files/59 modules,opt-in Edge/Standalone Console 4,210,024/4,210,168 bytes、482 files/111 modules,三资产 102,182 bytes,资源门均通过。D-424 必须等 exact commit 的远端普通主 CI、Kubernetes 与显式双架构 milestone 完成后才升级为阶段实物;在此之前,D-423 run `33245745837` 仍是最新可下载、已验真的 `3.0.0-alpha.2` 产物。
|
||||
|
||||
- D-423/ADR-0518(已实现并交付同源双架构 Console Alpha Candidate):Local Console 复用既有 immutable Trigger revision、固定 Task revision/content digest、semantic cron validation、durable schedule cursor 与原子 Run admission,新增有界 `GET /api/v3/projects/:projectId/triggers[/:triggerId]` 和强认证 `PUT /api/v3/projects/:projectId/triggers/:triggerId`。列表只返回低敏摘要,精确读取才返回完整 spec/Task digest;读取走 `task.read`,mutation 要求 User credential、`task.update`、两分钟一次性 owner-private exact-content proof、credential reconfirm,并由 request-scoped SQLite Trigger repository 在同一事务重验 Policy/RoleBinding/Task pin/credential fence、追加 immutable revision、初始化或更新 schedule 与 durable audit。Console 仅支持冻结的 `qinglong/cron@v1` expression/timezone/`skip|fire_once`,编辑、启停均追加 revision,不提供删除或通用 provider 编辑。Fresh/adopted Profile 共用现有 database close fence;不新增 package、migration、connection、daemon、watcher、每 Trigger timer 或第二 scheduler。默认 headless 不携带 API/Console 资产与 listener,Cluster 不复用 Local POSIX proof/SQLite authority。18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;package boundary、122-module Edge import 与精确 Cluster dependency audit compatible。默认 Edge 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 4,150,439/4,150,583 bytes、479 files/101 modules,三资产 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI run `33244982727` 为 41 success/3 expected skip/0 fail,Kubernetes run `33244982694` 成功;显式 Console milestone run `33245745837` 为 42 success/2 scope skip/0 fail,生成 187,914,706-byte amd64、185,146,322-byte arm64 Trial Kit 与 5,623-byte milestone,保留至 2026-09-28。下载索引通过 checksum 与离线 auditor,返回 `compatible=true`。因此 D-423 已是可下载、可验真、可 fresh 试运行并配置 cron 自动化的 `3.0.0-alpha.2` 阶段实物,但仍不是公开 release、2.x 生产升级、Cluster HA 交付或长期支持版本。
|
||||
|
||||
- RFC ID: QL-RFC-0001
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-0519:Local Console Secret-backed 自动化
|
||||
|
||||
- 状态:Accepted(源码候选;阶段产物待远端门闭合)
|
||||
- 日期:2026-08-29
|
||||
- 对应 RFC 切片:D-424
|
||||
- 关联:ADR-0069、ADR-0071、ADR-0512、ADR-0516、ADR-0517、ADR-0518
|
||||
|
||||
## 背景
|
||||
|
||||
D-423 已让部署者在 Local Console 创建 command Task 并配置 cron Trigger,但需要凭据的自动化仍只能借助受信 CLI。QingLong 3.0 已有 AES-256-GCM Secret custody、immutable version、pinned `SecretRef`、Task execution-time materialization 和 durable audit;缺口是一个不会暴露明文或复制密钥 authority 的有界产品入口。
|
||||
|
||||
该入口必须同时适配小路由设备和普通单节点:默认 headless 不承担 Console/API 成本,opt-in Console 不增加连接、迁移或后台生命周期。Cluster 必须继续使用自己的共享 Secret custody 和 HA authority,不能复用 Local SQLite/POSIX proof。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 列表只暴露当前版本元数据
|
||||
|
||||
新增固定路由:
|
||||
|
||||
```text
|
||||
GET /api/v3/projects/:projectId/secrets
|
||||
PUT /api/v3/projects/:projectId/secrets
|
||||
```
|
||||
|
||||
GET 使用按名称排序的稳定 keyset;Edge 默认 16 条、Standalone 默认 32 条、硬上限 64 条。每个名称只返回当前 version、创建时间和 canonical pinned `SecretRef`。响应不包含 plaintext、ciphertext、nonce、tag、key ID、mutation ID 或历史版本内容。
|
||||
|
||||
Fresh 与 adopted Profile 在既有数据库 close fence 上暴露只读 metadata source;不开第二个 SQLite connection,不新增 table、migration、cache、watcher 或 timer。损坏、关闭或越界查询均失败关闭。
|
||||
|
||||
### 2. Secret mutation 继续使用既有加密 authority
|
||||
|
||||
PUT body 精确包含 `name`、`plaintext`、`mutationId` 和 `expectedCurrentVersion`。请求只接受 User credential、`secret.manage` Policy 与绑定 exact plaintext digest、Project、credential ID/version、User subject 的两分钟一次性 owner-private proof。
|
||||
|
||||
proof 验证后再次确认 credential,并创建 request-scoped credential-fenced Secret repository。既有 Local administration service 在数据库事务外完成 AES-256-GCM 加密,在事务内重验 credential、Identity、RoleBinding、Policy 与 expected current version,再原子追加 immutable Secret version 和 durable audit。HTTP 只返回名称、当前版本、pinned reference 与幂等状态。
|
||||
|
||||
plaintext 仅在请求解析、proof 等待的页面内存与加密调用所需的短生命周期中存在;Console 在成功、取消、断开时清空输入和 pending body。它不进入 Cookie、Web Storage、URL、响应、audit 或日志。
|
||||
|
||||
### 3. Task 编辑器只保存 pinned SecretRef
|
||||
|
||||
Console 使用逐行 `ENV_NAME=secret-name[@version]` 绑定。省略 version 时只能从当前有界目录解析并立即固定为当前版本;显式历史版本不得大于目录中的当前版本。未知名称、未来版本、重复环境变量、`QL3_` 保留前缀和不安全格式均拒绝。
|
||||
|
||||
保存时保留完整 authoring snapshot 中未展示的 public environment 与其他 config/labels,只替换 Console 可识别的 Secret bindings。Task spec 只持久化 canonical `SecretRef`;执行时继续由既有 runtime materializer 解密,不把明文写回 Task、Run、Event 或 Console。
|
||||
|
||||
### 4. Profile 与部署边界不扩张
|
||||
|
||||
- 默认 Edge/Standalone headless 不开放 Secret HTTP/Console surface,不携带静态资产或 listener;常驻增量仅是复用现有 SQLite connection 的小型 metadata repository。
|
||||
- `edge-application-api` 与 `standalone-application-api` 才装配 Secret metadata、presence 与 administration capability;仍是同一 Application 进程和数据库连接。
|
||||
- `@qinglong/local-api` 仅允许 `src/secret/secretRoutes.ts` 导入 Local administration,Local Application contract 仅允许 runtime-core 的 metadata contract;Cluster dependency audit 对其他路径继续拒绝。
|
||||
- Cluster 不使用 Local proof、keyring 或 SQLite authority。后续 Cluster Console Secret 管理必须走共享 custody、TLS、RBAC/Approval、PostgreSQL transaction 与 HA fence。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 不让浏览器读取、回显或下载 Secret 明文;轮换也只接受新值,不提供“显示现值”。
|
||||
- 不把 ciphertext、key ID 或 mutation metadata 当作“低敏列表字段”;这些都留在 custody 边界内。
|
||||
- 不把 Secret value 写入 Task environment、authoring lease、presence challenge 或 audit detail。
|
||||
- 不用单因子 Bearer 直接创建或轮换 Secret;长期自动化凭据需要本机 presence 与 credential transaction fence。
|
||||
- 不新建只有一两个文件的微包;该能力留在现有 runtime-core contract、Local SQLite repository、Local administration 和 Local API capability 内。
|
||||
- 不为低配设备增加 Secret cache、后台清理器或独立进程。
|
||||
|
||||
## 结果与验证边界
|
||||
|
||||
定向测试覆盖 current-only metadata、稳定分页、关闭/越界失败、无 ciphertext/key 泄漏、exact presence、plaintext digest 漂移、宽化 body 拒绝、admission delegation、HTTP 路由与真实 SQLite/loopback 创建。集成旅程验证数据库只保存不含 plaintext 的密文,Task spec 保存精确 pinned `SecretRef`,并出现 `secret.create` 与 `secret.list` durable audit。
|
||||
|
||||
本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional、platform 或 external-service skip / 0 fail`,其中 Local SQLite `250/250`、Local API `76/76`、Local Admin `96/96`、Local Application `55 total / 51 pass / 4 platform skip / 0 fail`。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;122-module Edge source import 与精确 Cluster dependency audit compatible。
|
||||
|
||||
三项 Console 静态资产合计 102,182 bytes,低于 192 KiB 总闭包与 96 KiB 单文件门。默认 Edge 为 2,760,847 bytes/332 files/3 packages/59 loaded modules,RSS delta 11,026,432 bytes;opt-in Edge/Standalone Console 为 4,210,024/4,210,168 bytes、482 files/12 packages/111 loaded modules,RSS delta 20,447,232/18,399,232 bytes,均低于既有门。Edge executor benchmark 继续通过。
|
||||
|
||||
这些证据只把 D-424 提升为可提交的源码候选。必须等待 exact source commit 的普通主 CI、Kubernetes deployment 和显式 Local Console 双架构 milestone 全绿,并下载复核 milestone checksum/auditor 后,才能把 D-424 称为新的阶段可用实物。在此之前,D-423 run `33245745837` 仍是最新可下载、可验真的 Console Trial Kit。
|
||||
@@ -522,6 +522,7 @@
|
||||
| [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) |
|
||||
| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(D-422 双架构 milestone 已交付) |
|
||||
| [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | Accepted(D-423 双架构 milestone 已交付) |
|
||||
| [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | Accepted(D-424 源码候选;阶段产物待远端门闭合) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
| D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Web update 等待 authoring read/lease;Cluster 不复用 Local proof;仍不是生产或公开发布 |
|
||||
| D-422 Console Task 安全编辑切片 | 强认证完整定义读取、10 分钟一次性 authoring lease、第二份 exact save proof 与 revision/content/credential fence 已完成;Console 可无损编辑内建 argv command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Cluster 不复用 Local proof;尚无通用 workflow 编辑器、2.x 升级或生产远程管理;仍不是正式发布 |
|
||||
| D-423 Console cron Trigger 管理切片 | 已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authority;Console/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 与同源双架构 Console milestone 已通过 | Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑;仍不是正式发布或生产升级 |
|
||||
| D-424 Console Secret-backed 自动化切片 | 源码候选已完成 current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定;真实 SQLite/loopback、本地全量包、资源与架构门已通过 | exact commit 的远端主 CI、Kubernetes 与双架构 milestone 尚未闭合;当前可下载实物仍是 D-423 |
|
||||
|
||||
D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。
|
||||
|
||||
@@ -35,6 +36,8 @@ D-422 已从“源码候选”升级为阶段实物:本地真实 SQLite/loopba
|
||||
|
||||
D-423 已从“源码候选”升级为阶段实物:18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;18-package boundary、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 headless Edge 为 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/101 modules,三资产合计 84,401 bytes。提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的普通主 CI [run 33244982727](https://github.com/whyour/qinglong/actions/runs/33244982727) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33244982694](https://github.com/whyour/qinglong/actions/runs/33244982694) 成功;随后显式 Local Console milestone [run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 为 42 success/2 scope skip/0 fail。阶段产物绑定新的 exact commit/run/artifact digest,没有沿用或改名复用 D-422 archive。
|
||||
|
||||
D-424 当前严格标记为源码候选,而不是“开发完成即交付”:本地 18-package clean build/test 为 `3,052 total / 3,030 pass / 22 conditional skip / 0 fail`,Local SQLite `250/250`、Local API `76/76`,默认 Edge 与 opt-in Console 资源门、Edge benchmark、package/source boundary 和 Cluster dependency audit 均通过。它已经证明 Secret metadata 不泄漏 custody 字段、create/rotate 必须 exact local presence、SQLite 只保存密文、Task 只保存 pinned `SecretRef`。只有 exact source commit 的普通主 CI、Kubernetes deployment 与显式双架构 Console milestone 全绿,并下载复核 checksum/auditor 后,本段才会改为阶段实物;当前下载者应继续使用下面的 D-423 run `33245745837`。
|
||||
|
||||
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)。
|
||||
|
||||
当前最新可交付 Local Console v5 绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 与 [GitHub Actions run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837),保留至 2026-09-28:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QingLong 3.0 Local Web Console
|
||||
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、管理加密 Secret 绑定、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。
|
||||
|
||||
## 选择部署档位
|
||||
|
||||
@@ -11,7 +11,7 @@ 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。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。
|
||||
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/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑。D-423 继续开放既有 immutable Trigger/cron authority;绑定提交 `b970e2aede516c350b1cdb409e0d0d3038a5deee` 的最新双架构实物已由 [milestone run 33245745837](https://github.com/whyour/qinglong/actions/runs/33245745837) 生成并完成离线审计。D-424 的 Secret-backed 自动化已通过本地门,但在新的远端 CI 与双架构 milestone 闭合前仍是源码候选,不能借用 D-423 archive 宣称交付。
|
||||
|
||||
## 前置条件
|
||||
|
||||
@@ -57,25 +57,26 @@ ssh -L 5701:127.0.0.1:5701 router.example
|
||||
2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。
|
||||
3. 在部署设备上以 QingLong 数据目录 owner 读取 `<deploymentRoot>/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。
|
||||
4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。
|
||||
5. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。
|
||||
6. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。
|
||||
7. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。
|
||||
8. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||
9. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||
10. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
11. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
5. 需要敏感环境变量时进入“凭据”,创建或轮换 Secret。每次操作都要求一份绑定 exact plaintext digest 的新 proof;Console 永不显示旧值,提交后立即清空输入。Task 编辑器使用 `ENV_NAME=secret-name` 或 `ENV_NAME=secret-name@version`,省略版本时也会在保存前固定为当前版本。
|
||||
6. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。
|
||||
7. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。
|
||||
8. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。
|
||||
9. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||
10. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||
11. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||
12. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||
|
||||
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
|
||||
|
||||
## 当前阶段可用边界
|
||||
|
||||
D-423 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责:
|
||||
D-423 阶段实物的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。D-424 源码候选在此基础上增加 Secret current metadata、create/rotate 与 Task pinned binding;只有新 milestone 生成后该能力才进入下载产物。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责:
|
||||
|
||||
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
|
||||
- Identity、Policy、Secret 明文读取/删除/历史浏览、Plugin Package 或 AI 配置管理;
|
||||
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
|
||||
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
||||
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
|
||||
|
||||
三项静态资产总计 84,401 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,150,439 / 4,150,583 bytes、479 files、12 packages、101 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 17,088,512 / 17,055,744 bytes,低于 28 MiB。默认 headless Edge 为 2,754,742 bytes、331 files、3 packages、58 modules,RSS delta 11,108,352 bytes;它不携带 Console/API 资产、listener 或第二个 scheduler,但包含复用既有 SQLite Trigger mutation authority 的小幅装配代码增量。
|
||||
D-424 源码候选的三项静态资产总计 102,182 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,210,024 / 4,210,168 bytes、482 files、12 packages、111 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 20,447,232 / 18,399,232 bytes,低于 28 MiB。默认 headless Edge 为 2,760,847 bytes、332 files、3 packages、59 modules,RSS delta 11,026,432 bytes;它不携带 Console/API 资产、listener 或 Secret mutation surface,只增加复用现有 SQLite connection 的有界 metadata 装配。
|
||||
|
||||
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
|
||||
|
||||
@@ -25,8 +25,11 @@ type TriggerAdministrationForCredential =
|
||||
type LocalScheduleStore = ReadyLocalStorage['schedules'];
|
||||
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
|
||||
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
|
||||
type LocalSecretMetadataSource = ReadyLocalStorage['localSecretMetadata'];
|
||||
type LocalSecretAdministrationRepository =
|
||||
ReadyLocalStorage['localSecretAdministration'];
|
||||
type LocalSecretAdministrationForCredential =
|
||||
ReadyLocalStorage['localSecretAdministrationForCredential'];
|
||||
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
|
||||
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
|
||||
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
|
||||
@@ -102,7 +105,9 @@ export type LocalAdoptedProfileBootstrapResult =
|
||||
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
|
||||
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
|
||||
readonly localSecrets: LocalSecretEnvelopeRepository;
|
||||
readonly localSecretMetadata: LocalSecretMetadataSource;
|
||||
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
||||
readonly localSecretAdministrationForCredential: LocalSecretAdministrationForCredential;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
@@ -255,7 +260,10 @@ export async function bootstrapLocalAdoptedProfileStorage(
|
||||
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
|
||||
runLostRetry: readyStorage.runLostRetry,
|
||||
localSecrets: readyStorage.localSecrets,
|
||||
localSecretMetadata: readyStorage.localSecretMetadata,
|
||||
localSecretAdministration: readyStorage.localSecretAdministration,
|
||||
localSecretAdministrationForCredential:
|
||||
readyStorage.localSecretAdministrationForCredential,
|
||||
projectPolicy: readyStorage.projectPolicy,
|
||||
securityAudit: readyStorage.securityAudit,
|
||||
apiCredentials: readyStorage.apiCredentials,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
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 ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SECRET_REF_PREFIX = 'qlsecret:v1:';
|
||||
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/;
|
||||
const TOKEN_PATTERN =
|
||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||
@@ -57,6 +59,11 @@
|
||||
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
|
||||
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
|
||||
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
|
||||
secret_query_unavailable: 'Secret 元数据暂时不可读取。请检查数据库状态。',
|
||||
secret_fence_rejected:
|
||||
'Secret、凭据或授权在确认期间发生变化。请刷新后重新保存。',
|
||||
invalid_secret: 'Secret 名称、版本或明文无效。',
|
||||
secret_unavailable: 'Secret 暂时无法加密保存。请检查密钥与数据库状态。',
|
||||
run_cancellation_fence_rejected:
|
||||
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
||||
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
||||
@@ -78,6 +85,7 @@
|
||||
refresh: document.getElementById('refresh-button'),
|
||||
createTask: document.getElementById('create-task-button'),
|
||||
createTrigger: document.getElementById('create-trigger-button'),
|
||||
createSecret: document.getElementById('create-secret-button'),
|
||||
dialog: document.getElementById('confirmation-dialog'),
|
||||
dialogTitle: document.getElementById('confirmation-title'),
|
||||
dialogCopy: document.getElementById('confirmation-copy'),
|
||||
@@ -94,6 +102,7 @@
|
||||
taskDescription: document.getElementById('task-description-input'),
|
||||
taskCommand: document.getElementById('task-command-input'),
|
||||
taskArgs: document.getElementById('task-args-input'),
|
||||
taskSecretBindings: document.getElementById('task-secret-bindings-input'),
|
||||
taskEnabled: document.getElementById('task-enabled-input'),
|
||||
taskEnabledLabel: document.getElementById('task-enabled-label'),
|
||||
triggerEditor: document.getElementById('trigger-editor-dialog'),
|
||||
@@ -108,6 +117,15 @@
|
||||
triggerTimezone: document.getElementById('trigger-timezone-input'),
|
||||
triggerMisfire: document.getElementById('trigger-misfire-input'),
|
||||
triggerEnabled: document.getElementById('trigger-enabled-input'),
|
||||
secretEditor: document.getElementById('secret-editor-dialog'),
|
||||
secretEditorTitle: document.getElementById('secret-editor-title'),
|
||||
secretEditorIntro: document.getElementById('secret-editor-intro'),
|
||||
secretEditorNote: document.getElementById('secret-editor-note'),
|
||||
secretEditorForm: document.getElementById('secret-editor-form'),
|
||||
secretEditorClose: document.getElementById('secret-editor-close'),
|
||||
secretEditorSave: document.getElementById('secret-editor-save'),
|
||||
secretName: document.getElementById('secret-name-input'),
|
||||
secretValue: document.getElementById('secret-value-input'),
|
||||
presenceDialog: document.getElementById('presence-dialog'),
|
||||
presenceForm: document.getElementById('presence-form'),
|
||||
presenceCopy: document.getElementById('presence-copy'),
|
||||
@@ -129,6 +147,8 @@
|
||||
pendingPresence: null,
|
||||
authoringSnapshot: null,
|
||||
triggerSnapshot: null,
|
||||
secretSnapshot: null,
|
||||
secretCatalog: [],
|
||||
toastTimer: null,
|
||||
};
|
||||
|
||||
@@ -223,6 +243,138 @@
|
||||
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
|
||||
}
|
||||
|
||||
function encodeBase64UrlUtf8(value) {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/gu, '-')
|
||||
.replace(/\//gu, '_')
|
||||
.replace(/=+$/gu, '');
|
||||
}
|
||||
|
||||
function decodeBase64UrlUtf8(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/u.test(value)) {
|
||||
throw new TypeError('SecretRef 编码无效。');
|
||||
}
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4);
|
||||
const binary = window.atob(
|
||||
value.replace(/-/gu, '+').replace(/_/gu, '/') + padding,
|
||||
);
|
||||
const bytes = Uint8Array.from(binary, (character) =>
|
||||
character.charCodeAt(0),
|
||||
);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
function createSecretRef(projectId, name, version) {
|
||||
const payload = JSON.stringify({ projectId, name, version });
|
||||
const result = `${SECRET_REF_PREFIX}${encodeBase64UrlUtf8(payload)}`;
|
||||
if (result.length > 512) throw new TypeError('SecretRef 过长。');
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSecretRef(value) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length > 512 ||
|
||||
!value.startsWith(SECRET_REF_PREFIX)
|
||||
) {
|
||||
throw new TypeError('SecretRef 无效。');
|
||||
}
|
||||
const encoded = value.slice(SECRET_REF_PREFIX.length);
|
||||
const parsed = JSON.parse(decodeBase64UrlUtf8(encoded));
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.keys(parsed).sort().join(',') !== 'name,projectId,version' ||
|
||||
typeof parsed.projectId !== 'string' ||
|
||||
typeof parsed.name !== 'string' ||
|
||||
!Number.isSafeInteger(parsed.version) ||
|
||||
parsed.version < 1 ||
|
||||
createSecretRef(parsed.projectId, parsed.name, parsed.version) !== value
|
||||
) {
|
||||
throw new TypeError('SecretRef 无效。');
|
||||
}
|
||||
return Object.freeze(parsed);
|
||||
}
|
||||
|
||||
function isValidSecretName(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
new TextEncoder().encode(value).length <= 128 &&
|
||||
!/[\u0000-\u001f\u007f]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function secretBindingsFromTask(task) {
|
||||
const environment = task?.spec?.config?.environment;
|
||||
if (environment === undefined) return '';
|
||||
if (!Array.isArray(environment) || environment.length > 256) {
|
||||
throw new TypeError('Task 环境变量定义无效。');
|
||||
}
|
||||
return environment
|
||||
.filter((entry) => entry?.kind === 'secret')
|
||||
.map((entry) => {
|
||||
const reference = parseSecretRef(entry.secretRef);
|
||||
if (
|
||||
!ENVIRONMENT_NAME_PATTERN.test(entry.name) ||
|
||||
entry.name.startsWith('QL3_') ||
|
||||
reference.projectId !== state.project ||
|
||||
!TASK_PATTERN.test(reference.name)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'当前 Task 包含 Console 无法安全编辑的 Secret 绑定。',
|
||||
);
|
||||
}
|
||||
return `${entry.name}=${reference.name}@${reference.version}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function parseSecretBindings(value) {
|
||||
const entries = [];
|
||||
const names = new Set();
|
||||
const lines = value
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length > 256) throw new TypeError('Secret 绑定不能超过 256 条。');
|
||||
for (const line of lines) {
|
||||
const match =
|
||||
/^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:@([1-9][0-9]{0,9}))?$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (!match || match[1].startsWith('QL3_') || names.has(match[1])) {
|
||||
throw new TypeError(`Secret 绑定无效:${line}`);
|
||||
}
|
||||
const available = state.secretCatalog.find(
|
||||
(secret) => secret.name === match[2],
|
||||
);
|
||||
const version = match[3] ? Number(match[3]) : available?.currentVersion;
|
||||
if (
|
||||
!available ||
|
||||
!Number.isSafeInteger(version) ||
|
||||
version < 1 ||
|
||||
version > available.currentVersion
|
||||
) {
|
||||
throw new TypeError(`找不到 Secret 当前版本:${match[2]}`);
|
||||
}
|
||||
names.add(match[1]);
|
||||
entries.push(
|
||||
Object.freeze({
|
||||
name: match[1],
|
||||
kind: 'secret',
|
||||
secretRef: createSecretRef(state.project, match[2], version),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (!state.token) {
|
||||
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||
@@ -393,11 +545,17 @@
|
||||
nodes.taskEditorIntro.textContent = editing
|
||||
? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。`
|
||||
: '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。';
|
||||
const secretHint =
|
||||
state.secretCatalog.length === 0
|
||||
? '当前没有可绑定 Secret;可先到“凭据”创建。'
|
||||
: `当前可绑定:${state.secretCatalog
|
||||
.map((secret) => `${secret.name}@${secret.currentVersion}`)
|
||||
.join('、')}`;
|
||||
nodes.taskEditorNote.textContent = editing
|
||||
? `编辑租约将在 ${formatTime(
|
||||
snapshot.authoring.expiresAtMs,
|
||||
)} 失效;关闭后重新选择“编辑任务”可取得新快照。`
|
||||
: 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。';
|
||||
)} 失效。${secretHint}`
|
||||
: `Console 创建 qinglong/command@v1。${secretHint}`;
|
||||
nodes.taskEnabledLabel.textContent = editing
|
||||
? '保存后允许运行'
|
||||
: '创建后允许运行';
|
||||
@@ -411,9 +569,11 @@
|
||||
nodes.taskDescription.value = snapshot.task.description || '';
|
||||
nodes.taskCommand.value = command.file;
|
||||
nodes.taskArgs.value = command.args.join('\n');
|
||||
nodes.taskSecretBindings.value = secretBindingsFromTask(snapshot.task);
|
||||
nodes.taskEnabled.checked = snapshot.task.enabled;
|
||||
} else {
|
||||
nodes.taskCommand.value = '/bin/echo';
|
||||
nodes.taskSecretBindings.value = '';
|
||||
nodes.taskEnabled.checked = true;
|
||||
}
|
||||
nodes.taskEditor.returnValue = '';
|
||||
@@ -462,6 +622,9 @@
|
||||
.split(/\r?\n/u)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
const secretEnvironment = parseSecretBindings(
|
||||
nodes.taskSecretBindings.value,
|
||||
);
|
||||
if (!TASK_PATTERN.test(taskId)) {
|
||||
throw new TypeError('Task ID 格式无效。');
|
||||
}
|
||||
@@ -469,11 +632,24 @@
|
||||
throw new TypeError('名称、命令或参数数量无效。');
|
||||
}
|
||||
const snapshot = state.authoringSnapshot;
|
||||
const publicEnvironment = snapshot
|
||||
? (snapshot.task.spec.config.environment || []).filter(
|
||||
(entry) => entry?.kind === 'public',
|
||||
)
|
||||
: [];
|
||||
const environment = Object.freeze([
|
||||
...publicEnvironment,
|
||||
...secretEnvironment,
|
||||
]);
|
||||
if (environment.length > 256) {
|
||||
throw new TypeError('环境变量总数不能超过 256 条。');
|
||||
}
|
||||
const spec = snapshot
|
||||
? Object.freeze({
|
||||
...snapshot.task.spec,
|
||||
config: Object.freeze({
|
||||
...snapshot.task.spec.config,
|
||||
environment,
|
||||
command: Object.freeze({
|
||||
...snapshot.task.spec.config.command,
|
||||
kind: 'argv',
|
||||
@@ -486,6 +662,7 @@
|
||||
schema: 'qinglong/command@v1',
|
||||
config: Object.freeze({
|
||||
command: Object.freeze({ kind: 'argv', file, args }),
|
||||
environment,
|
||||
}),
|
||||
});
|
||||
return Object.freeze({
|
||||
@@ -622,6 +799,205 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecretMetadata(value) {
|
||||
const secrets = Array.isArray(value?.secrets) ? value.secrets : null;
|
||||
if (
|
||||
!secrets ||
|
||||
secrets.length > 64 ||
|
||||
secrets.some((secret) => {
|
||||
if (
|
||||
!secret ||
|
||||
!isValidSecretName(secret.name) ||
|
||||
!Number.isSafeInteger(secret.currentVersion) ||
|
||||
secret.currentVersion < 1 ||
|
||||
!Number.isSafeInteger(secret.createdAtMs) ||
|
||||
secret.createdAtMs < 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const reference = parseSecretRef(secret.secretRef);
|
||||
return (
|
||||
reference.projectId !== state.project ||
|
||||
reference.name !== secret.name ||
|
||||
reference.version !== secret.currentVersion
|
||||
);
|
||||
})
|
||||
) {
|
||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||
}
|
||||
return Object.freeze(secrets.map((secret) => Object.freeze(secret)));
|
||||
}
|
||||
|
||||
async function loadSecretCatalog() {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/secrets?limit=64`,
|
||||
);
|
||||
state.secretCatalog = normalizeSecretMetadata(value);
|
||||
return Object.freeze({
|
||||
secrets: state.secretCatalog,
|
||||
truncated: value.truncated === true,
|
||||
});
|
||||
}
|
||||
|
||||
function openSecretEditor(snapshot = null) {
|
||||
state.secretSnapshot = snapshot;
|
||||
nodes.secretEditorForm.reset();
|
||||
const rotating = snapshot !== null;
|
||||
nodes.secretEditorTitle.textContent = rotating
|
||||
? '轮换加密凭据'
|
||||
: '创建加密凭据';
|
||||
nodes.secretEditorIntro.textContent = rotating
|
||||
? `新值将写入 ${snapshot.name} 的 version ${
|
||||
snapshot.currentVersion + 1
|
||||
};已有 Task 仍固定使用旧版本。`
|
||||
: '明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。';
|
||||
nodes.secretEditorNote.textContent = rotating
|
||||
? '轮换不会悄悄改变现有自动化;请编辑 Task 明确切换到新版本。'
|
||||
: '保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。';
|
||||
nodes.secretName.readOnly = rotating;
|
||||
if (rotating) {
|
||||
nodes.secretName.setAttribute('aria-readonly', 'true');
|
||||
nodes.secretName.value = snapshot.name;
|
||||
} else {
|
||||
nodes.secretName.removeAttribute('aria-readonly');
|
||||
}
|
||||
nodes.secretValue.value = '';
|
||||
nodes.secretEditor.returnValue = '';
|
||||
nodes.secretEditor.showModal();
|
||||
(rotating ? nodes.secretValue : nodes.secretName).focus();
|
||||
}
|
||||
|
||||
function secretDraft() {
|
||||
const name = nodes.secretName.value.trim();
|
||||
const plaintext = nodes.secretValue.value;
|
||||
nodes.secretValue.value = '';
|
||||
if (!TASK_PATTERN.test(name)) {
|
||||
throw new TypeError('Secret 名称格式无效。');
|
||||
}
|
||||
if (!plaintext || new TextEncoder().encode(plaintext).length > 16 * 1024) {
|
||||
throw new TypeError('Secret 新值必须为 1–16384 bytes。');
|
||||
}
|
||||
return Object.freeze({
|
||||
name,
|
||||
plaintext,
|
||||
mutationId: newMutationId(),
|
||||
expectedCurrentVersion: state.secretSnapshot?.currentVersion || 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSecretDraft() {
|
||||
let body;
|
||||
try {
|
||||
body = secretDraft();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : 'Secret 定义无效。',
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
nodes.secretEditorSave.disabled = true;
|
||||
try {
|
||||
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
acceptStatus: 428,
|
||||
});
|
||||
if (value.code === 'local_presence_required') {
|
||||
showPresenceChallenge({ kind: 'secret-mutation', body }, value);
|
||||
return;
|
||||
}
|
||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||
} catch (error) {
|
||||
body = null;
|
||||
showToast(describeError(error), 'error');
|
||||
} finally {
|
||||
nodes.secretEditorSave.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderSecrets() {
|
||||
const value = await loadSecretCatalog();
|
||||
if (value.secrets.length === 0) {
|
||||
empty('还没有加密 Secret。创建后可在命令 Task 中绑定固定版本。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(
|
||||
listHeader('Encrypted Secret catalog', value.secrets.length),
|
||||
);
|
||||
const list = element('div', 'record-list');
|
||||
for (const secret of value.secrets) {
|
||||
const button = element('button', 'record');
|
||||
button.type = 'button';
|
||||
button.dataset.identity = secret.name;
|
||||
if (state.selectedId === secret.name) {
|
||||
button.setAttribute('aria-current', 'true');
|
||||
}
|
||||
const main = element('span');
|
||||
main.append(element('span', 'record-title', secret.name));
|
||||
main.append(
|
||||
recordMeta([`version ${secret.currentVersion}`, 'AES-256-GCM']),
|
||||
);
|
||||
const side = element('span', 'record-side');
|
||||
const status = element('span', 'status', '已加密');
|
||||
status.dataset.tone = 'active';
|
||||
side.append(status);
|
||||
side.append(
|
||||
element('span', 'record-time', formatTime(secret.createdAtMs)),
|
||||
);
|
||||
button.append(main, side);
|
||||
button.addEventListener('click', () => selectSecret(secret.name));
|
||||
list.append(button);
|
||||
}
|
||||
fragment.append(list);
|
||||
if (value.truncated) {
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'当前只展示前 64 条;使用 API 可继续读取下一页。',
|
||||
),
|
||||
);
|
||||
}
|
||||
replace(nodes.ledger, fragment);
|
||||
}
|
||||
|
||||
function selectSecret(name) {
|
||||
state.selectedId = name;
|
||||
for (const row of nodes.ledger.querySelectorAll('.record')) {
|
||||
if (row.dataset.identity === name)
|
||||
row.setAttribute('aria-current', 'true');
|
||||
else row.removeAttribute('aria-current');
|
||||
}
|
||||
const secret = state.secretCatalog.find((entry) => entry.name === name);
|
||||
if (!secret) {
|
||||
detailEmpty('Secret 元数据已变化,请刷新后重试。');
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.append(detailHeader('Encrypted Secret', secret.name, secret.name));
|
||||
const facts = element('div', 'facts');
|
||||
facts.append(
|
||||
fact('当前版本', secret.currentVersion),
|
||||
fact('存储', 'AES-256-GCM 密文'),
|
||||
fact('Pinned ref', shortDigest(secret.secretRef)),
|
||||
fact('版本时间', formatTime(secret.createdAtMs)),
|
||||
);
|
||||
fragment.append(facts);
|
||||
const actions = element('div', 'detail-actions');
|
||||
actions.append(actionButton('轮换新版本', () => openSecretEditor(secret)));
|
||||
fragment.append(actions);
|
||||
fragment.append(
|
||||
element(
|
||||
'p',
|
||||
'privacy-note',
|
||||
'Task 只绑定固定版本;轮换后必须显式编辑 Task 才会采用新值。明文永不从此接口返回。',
|
||||
),
|
||||
);
|
||||
replace(nodes.detail, fragment);
|
||||
}
|
||||
|
||||
function showPresenceChallenge(action, challenge) {
|
||||
if (
|
||||
challenge?.code !== 'local_presence_required' ||
|
||||
@@ -634,13 +1010,20 @@
|
||||
state.pendingPresence = Object.freeze({ ...action, challenge });
|
||||
const authoringRead = action.kind === 'authoring';
|
||||
const triggerMutation = action.kind === 'trigger-mutation';
|
||||
const secretMutation = action.kind === 'secret-mutation';
|
||||
nodes.presenceCopy.textContent = authoringRead
|
||||
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
|
||||
: triggerMutation
|
||||
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
|
||||
: secretMutation
|
||||
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明绑定这次 Secret 内容摘要、当前版本和 User Credential,且只能使用一次。'
|
||||
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
|
||||
nodes.presenceSubmit.textContent = authoringRead
|
||||
? '验证并加载定义'
|
||||
: secretMutation
|
||||
? action.body.expectedCurrentVersion === 0
|
||||
? '验证并加密创建'
|
||||
: '验证并轮换版本'
|
||||
: action.mutation.body.expectedRevision === null
|
||||
? '验证并创建'
|
||||
: '验证并更新';
|
||||
@@ -653,6 +1036,7 @@
|
||||
nodes.presenceError.hidden = true;
|
||||
nodes.taskEditor.close();
|
||||
nodes.triggerEditor.close();
|
||||
nodes.secretEditor.close();
|
||||
nodes.presenceDialog.returnValue = '';
|
||||
nodes.presenceDialog.showModal();
|
||||
nodes.presenceProof.focus();
|
||||
@@ -735,6 +1119,15 @@
|
||||
state.pendingPresence = null;
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
try {
|
||||
await loadSecretCatalog();
|
||||
} catch {
|
||||
state.secretCatalog = [];
|
||||
showToast(
|
||||
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
openTaskEditor(snapshot);
|
||||
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
||||
return;
|
||||
@@ -767,6 +1160,32 @@
|
||||
await selectTrigger(pending.mutation.triggerId);
|
||||
return;
|
||||
}
|
||||
if (pending.kind === 'secret-mutation') {
|
||||
const value = await api(`/api/v3/projects/${state.project}/secrets`, {
|
||||
method: 'PUT',
|
||||
body: pending.body,
|
||||
presence: proof,
|
||||
});
|
||||
const rotated = pending.body.expectedCurrentVersion > 0;
|
||||
state.pendingPresence = null;
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
showToast(
|
||||
value.status === 'existing'
|
||||
? '已找到同一 Secret 请求。'
|
||||
: rotated
|
||||
? `Secret 已轮换到 version ${value.secret.currentVersion}。`
|
||||
: 'Secret 已加密创建。',
|
||||
);
|
||||
state.view = 'secrets';
|
||||
state.selectedId = pending.body.name;
|
||||
updateNavigation();
|
||||
await refresh();
|
||||
selectSecret(pending.body.name);
|
||||
return;
|
||||
}
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
|
||||
{
|
||||
@@ -1326,6 +1745,7 @@
|
||||
if (state.view === 'tasks') {
|
||||
nodes.createTask.hidden = false;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Project task authority';
|
||||
nodes.title.textContent = '任务调度台';
|
||||
nodes.description.textContent =
|
||||
@@ -1333,13 +1753,23 @@
|
||||
} else if (state.view === 'triggers') {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = false;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Durable cron authority';
|
||||
nodes.title.textContent = '定时触发器';
|
||||
nodes.description.textContent =
|
||||
'配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。';
|
||||
} else if (state.view === 'secrets') {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = false;
|
||||
nodes.kicker.textContent = 'Encrypted local custody';
|
||||
nodes.title.textContent = 'Secret 凭据库';
|
||||
nodes.description.textContent =
|
||||
'只展示名称和当前版本;明文经本机证明后加密保存,Task 显式绑定固定版本。';
|
||||
} else {
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
nodes.kicker.textContent = 'Durable run evidence';
|
||||
nodes.title.textContent = '运行事实账本';
|
||||
nodes.description.textContent =
|
||||
@@ -1353,6 +1783,7 @@
|
||||
try {
|
||||
if (state.view === 'tasks') await renderTasks();
|
||||
else if (state.view === 'triggers') await renderTriggers();
|
||||
else if (state.view === 'secrets') await renderSecrets();
|
||||
else await renderRuns();
|
||||
setConnection('connected', `${state.project} · 已连接`);
|
||||
} catch (error) {
|
||||
@@ -1387,8 +1818,11 @@
|
||||
state.pendingPresence = null;
|
||||
state.authoringSnapshot = null;
|
||||
state.triggerSnapshot = null;
|
||||
state.secretSnapshot = null;
|
||||
state.secretCatalog = [];
|
||||
if (nodes.taskEditor.open) nodes.taskEditor.close();
|
||||
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
||||
if (nodes.secretEditor.open) nodes.secretEditor.close();
|
||||
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
|
||||
nodes.token.value = '';
|
||||
nodes.token.disabled = false;
|
||||
@@ -1399,6 +1833,7 @@
|
||||
nodes.refresh.hidden = true;
|
||||
nodes.createTask.hidden = true;
|
||||
nodes.createTrigger.hidden = true;
|
||||
nodes.createSecret.hidden = true;
|
||||
setConnection('idle', '等待凭据');
|
||||
nodes.kicker.textContent = 'Connection gate';
|
||||
nodes.title.textContent = '先建立一条本机连接';
|
||||
@@ -1435,8 +1870,16 @@
|
||||
|
||||
nodes.disconnect.addEventListener('click', disconnect);
|
||||
nodes.refresh.addEventListener('click', refresh);
|
||||
nodes.createTask.addEventListener('click', () => openTaskEditor());
|
||||
nodes.createTask.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadSecretCatalog();
|
||||
openTaskEditor();
|
||||
} catch (error) {
|
||||
showToast(describeError(error), 'error');
|
||||
}
|
||||
});
|
||||
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
|
||||
nodes.createSecret.addEventListener('click', () => openSecretEditor());
|
||||
nodes.taskEditorClose.addEventListener('click', () => {
|
||||
state.authoringSnapshot = null;
|
||||
nodes.taskEditor.close();
|
||||
@@ -1453,10 +1896,21 @@
|
||||
event.preventDefault();
|
||||
await saveTriggerDraft();
|
||||
});
|
||||
nodes.secretEditorClose.addEventListener('click', () => {
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.secretEditor.close();
|
||||
});
|
||||
nodes.secretEditorForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await saveSecretDraft();
|
||||
});
|
||||
nodes.presenceCancel.addEventListener('click', () => {
|
||||
state.pendingPresence = null;
|
||||
state.authoringSnapshot = null;
|
||||
state.triggerSnapshot = null;
|
||||
state.secretSnapshot = null;
|
||||
nodes.secretValue.value = '';
|
||||
nodes.presenceProof.value = '';
|
||||
nodes.presenceDialog.close();
|
||||
});
|
||||
@@ -1495,6 +1949,7 @@
|
||||
nodes.dialog.open ||
|
||||
nodes.taskEditor.open ||
|
||||
nodes.triggerEditor.open ||
|
||||
nodes.secretEditor.open ||
|
||||
nodes.presenceDialog.open
|
||||
) {
|
||||
return;
|
||||
@@ -1504,6 +1959,8 @@
|
||||
? 'tasks'
|
||||
: event.key.toLowerCase() === 's'
|
||||
? 'triggers'
|
||||
: event.key.toLowerCase() === 'k'
|
||||
? 'secrets'
|
||||
: event.key.toLowerCase() === 'r'
|
||||
? 'runs'
|
||||
: null;
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
<button type="button" data-view="triggers">
|
||||
<span>定时</span><kbd>S</kbd>
|
||||
</button>
|
||||
<button type="button" data-view="secrets">
|
||||
<span>凭据</span><kbd>K</kbd>
|
||||
</button>
|
||||
<button type="button" data-view="runs">
|
||||
<span>运行</span><kbd>R</kbd>
|
||||
</button>
|
||||
@@ -97,6 +100,9 @@
|
||||
<button class="action-button" id="create-trigger-button" type="button" hidden>
|
||||
<span aria-hidden="true">+</span> 创建定时
|
||||
</button>
|
||||
<button class="action-button" id="create-secret-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>
|
||||
@@ -175,6 +181,10 @@
|
||||
<span>参数 · 每行一个</span>
|
||||
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label class="editor-wide">
|
||||
<span>Secret 环境变量 · 每行 ENV=secret-name@version</span>
|
||||
<textarea id="task-secret-bindings-input" rows="4" maxlength="24576" spellcheck="false" placeholder="API_TOKEN=github-token"></textarea>
|
||||
</label>
|
||||
<label class="editor-check">
|
||||
<input id="task-enabled-input" type="checkbox" checked />
|
||||
<span id="task-enabled-label">创建后允许运行</span>
|
||||
@@ -243,6 +253,39 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="secret-editor-dialog" class="task-editor-dialog">
|
||||
<form id="secret-editor-form" autocomplete="off">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Encrypted Secret custody</p>
|
||||
<h2 id="secret-editor-title">创建加密凭据</h2>
|
||||
</div>
|
||||
<button class="quiet-button" id="secret-editor-close" type="button">关闭</button>
|
||||
</div>
|
||||
<p class="editor-intro" id="secret-editor-intro">
|
||||
明文只在当前页面内存和本次 loopback 请求中短暂存在;服务端只持久化 AES-256-GCM 密文。
|
||||
</p>
|
||||
<div class="editor-grid">
|
||||
<label class="editor-wide">
|
||||
<span>Secret 名称</span>
|
||||
<input id="secret-name-input" maxlength="128" spellcheck="false" autocomplete="off" required />
|
||||
</label>
|
||||
<label class="editor-wide">
|
||||
<span>新值</span>
|
||||
<input id="secret-value-input" type="password" maxlength="16384" spellcheck="false" autocomplete="new-password" required />
|
||||
</label>
|
||||
</div>
|
||||
<p class="editor-note" id="secret-editor-note">
|
||||
保存需要一次性本机证明;API、审计、Console 与日志都不会返回明文。
|
||||
</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="primary-button" id="secret-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>
|
||||
|
||||
@@ -33,6 +33,10 @@ import type {
|
||||
LocalApiTriggerReadRoute,
|
||||
} from '../trigger/triggerReadRoutes';
|
||||
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||
import type {
|
||||
LocalApiSecretListRoute,
|
||||
LocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
@@ -111,6 +115,16 @@ export type LocalApiAdmissionOperation =
|
||||
operationId: 'trigger.put';
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'secret.list';
|
||||
projectId: string;
|
||||
limit: number;
|
||||
after?: Readonly<{ readonly name: string }>;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'secret.put';
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
@@ -152,6 +166,8 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly triggerListRoute: LocalApiTriggerListRoute;
|
||||
readonly triggerReadRoute: LocalApiTriggerReadRoute;
|
||||
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
||||
readonly secretListRoute: LocalApiSecretListRoute;
|
||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
@@ -235,6 +251,8 @@ export function createLocalApiAdmission(
|
||||
typeof options.triggerListRoute?.handle !== 'function' ||
|
||||
typeof options.triggerReadRoute?.handle !== 'function' ||
|
||||
typeof options.triggerPutRoute?.handle !== 'function' ||
|
||||
typeof options.secretListRoute?.handle !== 'function' ||
|
||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
@@ -342,6 +360,24 @@ export function createLocalApiAdmission(
|
||||
});
|
||||
}
|
||||
|
||||
if (request.operation.operationId === 'secret.put') {
|
||||
const secretPutOperation = request.operation;
|
||||
return Object.freeze({
|
||||
bodyMode: 'json' as const,
|
||||
maximumBodyBytes: 20 * 1_024,
|
||||
async handle(body: unknown | null) {
|
||||
return options.secretPutRoute.handle({
|
||||
requestId: request.requestId,
|
||||
projectId: secretPutOperation.projectId,
|
||||
body,
|
||||
presence: request.localPresence,
|
||||
authenticated,
|
||||
signal: request.signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
@@ -359,6 +395,8 @@ export function createLocalApiAdmission(
|
||||
request.operation.operationId === 'trigger.list' ||
|
||||
request.operation.operationId === 'trigger.get'
|
||||
? 'task.read'
|
||||
: request.operation.operationId === 'secret.list'
|
||||
? 'secret.manage'
|
||||
: 'run.read',
|
||||
),
|
||||
);
|
||||
@@ -506,9 +544,19 @@ export function createLocalApiAdmission(
|
||||
projectId: request.operation.projectId,
|
||||
triggerId: request.operation.triggerId,
|
||||
});
|
||||
case 'secret.list':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.secretListRoute.handle({
|
||||
projectId: request.operation.projectId,
|
||||
limit: request.operation.limit,
|
||||
...(request.operation.after
|
||||
? { after: request.operation.after }
|
||||
: {}),
|
||||
});
|
||||
case 'task.put':
|
||||
case 'task.authoring':
|
||||
case 'trigger.put':
|
||||
case 'secret.put':
|
||||
return response(503, 'request_unavailable');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
createLocalApiTriggerReadRoute,
|
||||
} from '../trigger/triggerReadRoutes';
|
||||
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||
import {
|
||||
createLocalApiSecretListRoute,
|
||||
createLocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
@@ -195,6 +199,28 @@ export function createLocalApiProductSurface(
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const secretListRoute = createLocalApiSecretListRoute(
|
||||
authority.localSecretMetadata,
|
||||
);
|
||||
const secretPutRoute = createLocalApiSecretPutRoute({
|
||||
projectPolicy: authority.projectPolicy,
|
||||
secretAdministrationForCredential: (fence) => {
|
||||
if (fence.subjectType !== 'user') {
|
||||
throw new TypeError('Secret mutation requires a User credential');
|
||||
}
|
||||
return authority.localSecretAdministrationForCredential({
|
||||
...fence,
|
||||
subjectType: 'user',
|
||||
});
|
||||
},
|
||||
securityAudit: authority.securityAudit,
|
||||
secretKeys: authority.localSecretKeys,
|
||||
presenceProof,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
: { randomUuid: options.randomUuid }),
|
||||
});
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
@@ -213,6 +239,8 @@ export function createLocalApiProductSurface(
|
||||
triggerListRoute,
|
||||
triggerReadRoute,
|
||||
triggerPutRoute,
|
||||
secretListRoute,
|
||||
secretPutRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
LocalSecretAdministrationConfigurationError,
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
createLocalSecretAdministrationService,
|
||||
} from '@qinglong/local-admin/secret-administration';
|
||||
import {
|
||||
LocalSecretMetadataUnavailableError,
|
||||
LocalSecretMutationConflictError,
|
||||
LocalSecretVersionConflictError,
|
||||
assertLocalSecretExpectedVersion,
|
||||
assertLocalSecretMutationId,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretPlaintext,
|
||||
assertLocalSecretProjectId,
|
||||
assertLocalSecretVersion,
|
||||
createLocalSecretRef,
|
||||
type LocalSecretMetadataPage,
|
||||
type LocalSecretKeyProvider,
|
||||
type LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
type LocalSecretAdministrationRepository,
|
||||
} from '@qinglong/runtime-core/local-secret-administration';
|
||||
import {
|
||||
ProjectPolicyEngine,
|
||||
ProjectPolicyUnavailableError,
|
||||
type ProjectPolicyRepository,
|
||||
} from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPolicyDecision,
|
||||
type SecurityPolicyDecision,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditOutcome,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator';
|
||||
import {
|
||||
LocalPresenceProofUnavailableError,
|
||||
type LocalPresenceBinding,
|
||||
type LocalPresenceProofManager,
|
||||
} from '../authentication/localPresenceProof';
|
||||
import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
const BODY_KEYS = Object.freeze([
|
||||
'expectedCurrentVersion',
|
||||
'mutationId',
|
||||
'name',
|
||||
'plaintext',
|
||||
]);
|
||||
|
||||
export interface LocalApiSecretListRoute {
|
||||
handle(request: LocalApiSecretListRequest): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretListRequest {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly name: string }>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRequest {
|
||||
readonly requestId: string;
|
||||
readonly projectId: string;
|
||||
readonly body: unknown | null;
|
||||
readonly presence: string | null;
|
||||
readonly authenticated: Readonly<AuthenticatedLocalApiRequest>;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRoute {
|
||||
handle(
|
||||
request: Readonly<LocalApiSecretPutRequest>,
|
||||
): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
export interface LocalApiSecretPutRouteOptions {
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly secretAdministrationForCredential: (
|
||||
fence: Readonly<AuthenticatedLocalApiRequest['credentialFence']>,
|
||||
) => Promise<LocalSecretAdministrationRepository>;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
readonly secretKeys: LocalSecretKeyProvider;
|
||||
readonly presenceProof: LocalPresenceProofManager;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
type SecretPutCommand = Readonly<{
|
||||
name: string;
|
||||
plaintext: string;
|
||||
mutationId: string;
|
||||
expectedCurrentVersion: number;
|
||||
}>;
|
||||
|
||||
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): SecretPutCommand {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new TypeError('Secret body is invalid');
|
||||
}
|
||||
const keys = Object.keys(body).sort();
|
||||
if (
|
||||
BODY_KEYS.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !BODY_KEYS.includes(key))
|
||||
) {
|
||||
throw new TypeError('Secret body shape is invalid');
|
||||
}
|
||||
const candidate = body as Record<string, unknown>;
|
||||
assertLocalSecretName(candidate.name);
|
||||
assertLocalSecretPlaintext(candidate.plaintext);
|
||||
assertLocalSecretMutationId(candidate.mutationId);
|
||||
assertLocalSecretExpectedVersion(candidate.expectedCurrentVersion);
|
||||
if (
|
||||
typeof candidate.mutationId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(
|
||||
candidate.mutationId,
|
||||
)
|
||||
) {
|
||||
throw new TypeError('Secret mutation identity is invalid');
|
||||
}
|
||||
return Object.freeze(candidate as SecretPutCommand);
|
||||
}
|
||||
|
||||
function requestDigest(projectId: string, command: SecretPutCommand): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong3.local-api-secret-put.v1\0', 'utf8')
|
||||
.update(canonicalJson({ projectId, ...command }), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function presenceBinding(
|
||||
projectId: string,
|
||||
command: SecretPutCommand,
|
||||
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(projectId, command),
|
||||
credentialId: authenticated.credentialFence.credentialId,
|
||||
credentialVersion: authenticated.credentialFence.credentialVersion,
|
||||
subjectType: 'user',
|
||||
subjectId: authenticated.credentialFence.subjectId,
|
||||
});
|
||||
}
|
||||
|
||||
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: 'secret.create' | 'secret.rotate';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function isCredentialFenceConflict(error: unknown): boolean {
|
||||
return (
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMetadataPage(
|
||||
request: Readonly<LocalApiSecretListRequest>,
|
||||
value: unknown,
|
||||
): Readonly<LocalSecretMetadataPage> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).some(
|
||||
(key) => !['next', 'secrets', 'truncated'].includes(key),
|
||||
)
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const page = value as Readonly<Record<string, unknown>>;
|
||||
if (
|
||||
!Array.isArray(page.secrets) ||
|
||||
page.secrets.length > request.limit ||
|
||||
typeof page.truncated !== 'boolean'
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
assertLocalSecretProjectId(request.projectId);
|
||||
if (request.after) assertLocalSecretName(request.after.name);
|
||||
let previous = request.after?.name;
|
||||
const secrets = Object.freeze(
|
||||
page.secrets.map((candidate) => {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
Object.keys(candidate).sort().join(',') !==
|
||||
'createdAtMs,currentVersion,name,projectId'
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const secret = candidate as Readonly<Record<string, unknown>>;
|
||||
assertLocalSecretProjectId(secret.projectId);
|
||||
assertLocalSecretName(secret.name);
|
||||
assertLocalSecretVersion(secret.currentVersion);
|
||||
if (
|
||||
secret.projectId !== request.projectId ||
|
||||
!Number.isSafeInteger(secret.createdAtMs) ||
|
||||
(secret.createdAtMs as number) < 0 ||
|
||||
(previous !== undefined &&
|
||||
Buffer.compare(
|
||||
Buffer.from(secret.name as string, 'utf8'),
|
||||
Buffer.from(previous, 'utf8'),
|
||||
) <= 0)
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
previous = secret.name as string;
|
||||
return Object.freeze({
|
||||
projectId: secret.projectId as string,
|
||||
name: secret.name as string,
|
||||
currentVersion: secret.currentVersion as number,
|
||||
createdAtMs: secret.createdAtMs as number,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const next = page.next;
|
||||
if (
|
||||
page.truncated === true
|
||||
? !next ||
|
||||
typeof next !== 'object' ||
|
||||
Array.isArray(next) ||
|
||||
Object.keys(next).join('') !== 'name' ||
|
||||
(next as Readonly<Record<string, unknown>>).name !== previous
|
||||
: next !== undefined
|
||||
) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
secrets,
|
||||
truncated: page.truncated,
|
||||
...(page.truncated === true && previous !== undefined
|
||||
? { next: Object.freeze({ name: previous }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiSecretListRoute(
|
||||
source: LocalSecretMetadataSource,
|
||||
): Readonly<LocalApiSecretListRoute> {
|
||||
if (!source || typeof source.listLocalSecretMetadata !== 'function') {
|
||||
throw new TypeError('Local API Secret metadata source is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<LocalApiSecretListRequest>) {
|
||||
try {
|
||||
const page = normalizeMetadataPage(
|
||||
request,
|
||||
await source.listLocalSecretMetadata(request),
|
||||
);
|
||||
return response(200, {
|
||||
secrets: Object.freeze(
|
||||
page.secrets.map((secret) =>
|
||||
Object.freeze({
|
||||
name: secret.name,
|
||||
currentVersion: secret.currentVersion,
|
||||
secretRef: createLocalSecretRef({
|
||||
projectId: secret.projectId,
|
||||
name: secret.name,
|
||||
version: secret.currentVersion,
|
||||
}),
|
||||
createdAtMs: secret.createdAtMs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
truncated: page.truncated,
|
||||
...(page.next
|
||||
? {
|
||||
next: Object.freeze({
|
||||
after: Buffer.from(page.next.name, 'utf8').toString(
|
||||
'base64url',
|
||||
),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
return response(503, { code: 'secret_query_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalApiSecretPutRoute(
|
||||
options: Readonly<LocalApiSecretPutRouteOptions>,
|
||||
): Readonly<LocalApiSecretPutRoute> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
typeof options.projectPolicy?.resolve !== 'function' ||
|
||||
typeof options.secretAdministrationForCredential !== 'function' ||
|
||||
typeof options.securityAudit?.record !== 'function' ||
|
||||
typeof options.secretKeys?.active !== 'function' ||
|
||||
typeof options.secretKeys?.resolve !== '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 Secret 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<LocalApiSecretPutRequest>) {
|
||||
if (request.signal.aborted) {
|
||||
return response(503, { code: 'request_unavailable' });
|
||||
}
|
||||
let command: SecretPutCommand;
|
||||
try {
|
||||
command = normalizeBody(request.body);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_secret' });
|
||||
}
|
||||
const operationId =
|
||||
command.expectedCurrentVersion === 0
|
||||
? ('secret.create' as const)
|
||||
: ('secret.rotate' as const);
|
||||
let occurredAtMs: number;
|
||||
try {
|
||||
occurredAtMs = timestamp(now);
|
||||
} catch {
|
||||
return response(503, { code: 'local_presence_unavailable' });
|
||||
}
|
||||
let decision: Readonly<SecurityPolicyDecision>;
|
||||
try {
|
||||
decision = normalizeSecurityPolicyDecision(
|
||||
await policy.authorize(
|
||||
request.authenticated.principal,
|
||||
request.projectId,
|
||||
'secret.manage',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const audited = await recordAudit(options.securityAudit, {
|
||||
eventId: uuid(),
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
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,
|
||||
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(
|
||||
request.projectId,
|
||||
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,
|
||||
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,
|
||||
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' });
|
||||
}
|
||||
try {
|
||||
const strongPrincipal = strongLocalConsolePrincipal(
|
||||
request.authenticated,
|
||||
proof,
|
||||
);
|
||||
const mutations = await options.secretAdministrationForCredential(
|
||||
request.authenticated.credentialFence,
|
||||
);
|
||||
const service = createLocalSecretAdministrationService(
|
||||
options.projectPolicy,
|
||||
mutations,
|
||||
options.securityAudit,
|
||||
options.secretKeys,
|
||||
{ now },
|
||||
);
|
||||
const result = await service.put({
|
||||
projectId: request.projectId,
|
||||
name: command.name,
|
||||
plaintext: command.plaintext,
|
||||
mutationId: command.mutationId,
|
||||
requestId: request.requestId,
|
||||
expectedCurrentVersion: command.expectedCurrentVersion,
|
||||
principal: strongPrincipal,
|
||||
});
|
||||
return response(
|
||||
result.status === 'inserted' && command.expectedCurrentVersion === 0
|
||||
? 201
|
||||
: 200,
|
||||
{
|
||||
status: result.status,
|
||||
secret: Object.freeze({
|
||||
name: command.name,
|
||||
currentVersion: result.version,
|
||||
secretRef: result.secretRef,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof LocalSecretVersionConflictError ||
|
||||
error instanceof LocalSecretMutationConflictError ||
|
||||
error instanceof LocalSecretAuthorizationFenceConflictError ||
|
||||
isCredentialFenceConflict(error)
|
||||
) {
|
||||
return response(409, { code: 'secret_fence_rejected' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationAuthenticationError) {
|
||||
return response(401, { code: 'strong_authentication_required' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationAuthorizationError) {
|
||||
return response(403, { code: 'forbidden' });
|
||||
}
|
||||
if (error instanceof LocalSecretAdministrationConfigurationError) {
|
||||
return response(400, { code: 'invalid_secret' });
|
||||
}
|
||||
if (
|
||||
error instanceof LocalSecretAdministrationUnavailableError ||
|
||||
error instanceof LocalSecretMetadataUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'secret_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'secret_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
|
||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import { assertLocalSecretName } from '@qinglong/runtime-core/local-secret';
|
||||
import {
|
||||
loadLocalConsoleAssets,
|
||||
type LocalConsoleAsset,
|
||||
@@ -46,6 +47,8 @@ const TRIGGER_LIST_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers$/;
|
||||
const TRIGGER_READ_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/;
|
||||
const SECRET_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
||||
@@ -60,7 +63,8 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_run_step_list_query'
|
||||
| 'invalid_run_log_read_query'
|
||||
| 'invalid_task_list_query'
|
||||
| 'invalid_trigger_list_query';
|
||||
| 'invalid_trigger_list_query'
|
||||
| 'invalid_secret_list_query';
|
||||
}>;
|
||||
|
||||
export interface LocalApiHttpSurfaceOptions {
|
||||
@@ -445,6 +449,62 @@ function parseTriggerListQuery(
|
||||
});
|
||||
}
|
||||
|
||||
function parseSecretListQuery(
|
||||
rawQuery: string | undefined,
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<{
|
||||
limit: number;
|
||||
after?: Readonly<{ readonly name: string }>;
|
||||
}> {
|
||||
if (rawQuery === undefined) {
|
||||
return Object.freeze({ limit: profile === 'edge' ? 16 : 32 });
|
||||
}
|
||||
if (rawQuery.length === 0) throw new TypeError();
|
||||
const values = new Map<string, string>();
|
||||
for (const field of rawQuery.split('&')) {
|
||||
const separator = field.indexOf('=');
|
||||
if (
|
||||
separator < 1 ||
|
||||
separator !== field.lastIndexOf('=') ||
|
||||
separator === field.length - 1
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const name = field.slice(0, separator);
|
||||
const value = field.slice(separator + 1);
|
||||
if (values.has(name) || (name !== 'limit' && name !== 'after')) {
|
||||
throw new TypeError();
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
const rawLimit = values.get('limit');
|
||||
const limit =
|
||||
rawLimit === undefined ? (profile === 'edge' ? 16 : 32) : Number(rawLimit);
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > 64 ||
|
||||
(rawLimit !== undefined && String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const encoded = values.get('after');
|
||||
if (encoded === undefined) return Object.freeze({ limit });
|
||||
if (encoded.length > 256 || !/^[A-Za-z0-9_-]+$/u.test(encoded)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const bytes = Buffer.from(encoded, 'base64url');
|
||||
let name: string;
|
||||
try {
|
||||
if (bytes.toString('base64url') !== encoded) throw new TypeError();
|
||||
name = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
assertLocalSecretName(name);
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
return Object.freeze({ limit, after: Object.freeze({ name }) });
|
||||
}
|
||||
|
||||
function parseRunAttemptLogReadQuery(
|
||||
rawQuery: string | undefined,
|
||||
profile: LocalApplicationProfile,
|
||||
@@ -534,6 +594,13 @@ function route(
|
||||
: null;
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
const secretPutMatch = SECRET_ROUTE_PATTERN.exec(path);
|
||||
if (secretPutMatch && rawQuery === undefined) {
|
||||
return Object.freeze({
|
||||
operationId: 'secret.put',
|
||||
projectId: secretPutMatch[1]!,
|
||||
});
|
||||
}
|
||||
const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||
if (triggerPutMatch && rawQuery === undefined) {
|
||||
return Object.freeze({
|
||||
@@ -552,6 +619,18 @@ function route(
|
||||
: null;
|
||||
}
|
||||
if (request.method !== 'GET') return null;
|
||||
const secretListMatch = SECRET_ROUTE_PATTERN.exec(path);
|
||||
if (secretListMatch) {
|
||||
try {
|
||||
return Object.freeze({
|
||||
operationId: 'secret.list',
|
||||
projectId: secretListMatch[1]!,
|
||||
...parseSecretListQuery(rawQuery, profile),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ errorCode: 'invalid_secret_list_query' });
|
||||
}
|
||||
}
|
||||
const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||
if (triggerReadMatch) {
|
||||
return rawQuery === undefined
|
||||
|
||||
@@ -86,6 +86,51 @@ test('defers Trigger put Policy, presence and mutation to the route', async () =
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses secret.manage for bounded Secret metadata and never widens the route input', async () => {
|
||||
const { admission, events } = fixture();
|
||||
assert.deepEqual(
|
||||
await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 16,
|
||||
after: { name: 'alpha' },
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ statusCode: 200, body: { secrets: [], truncated: false } },
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:secret.manage:prj_default',
|
||||
'audit:allowed:secret.list',
|
||||
'confirm',
|
||||
'secrets:prj_default:16',
|
||||
]);
|
||||
});
|
||||
|
||||
test('defers Secret put Policy, presence and plaintext body to the fenced route', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
request({
|
||||
localPresence: 'ql3p_bound',
|
||||
operation: {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(prepared.bodyMode, 'json');
|
||||
assert.equal(prepared.maximumBodyBytes, 20 * 1024);
|
||||
assert.deepEqual(await prepared.handle({ plaintext: 'ephemeral' }), {
|
||||
statusCode: 201,
|
||||
body: { status: 'inserted' },
|
||||
});
|
||||
assert.deepEqual(events, ['authenticate', 'secret-put:prj_default']);
|
||||
});
|
||||
|
||||
function request(overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
|
||||
@@ -246,6 +291,18 @@ function fixture(overrides = {}) {
|
||||
return { statusCode: 201, body: { status: 'created' } };
|
||||
},
|
||||
},
|
||||
secretListRoute: {
|
||||
async handle(value) {
|
||||
events.push(`secrets:${value.projectId}:${value.limit}`);
|
||||
return { statusCode: 200, body: { secrets: [], truncated: false } };
|
||||
},
|
||||
},
|
||||
secretPutRoute: {
|
||||
async handle(value) {
|
||||
events.push(`secret-put:${value.projectId}`);
|
||||
return { statusCode: 201, body: { status: 'inserted' } };
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
|
||||
@@ -90,6 +90,15 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
|
||||
assert.match(text, /state\.view === 'triggers'/u);
|
||||
assert.match(text, /trigger_fence_rejected/u);
|
||||
assert.match(text, /state\.view === 'secrets'/u);
|
||||
assert.match(text, /secret-mutation/u);
|
||||
assert.match(text, /createSecretRef/u);
|
||||
assert.match(text, /kind: 'secret'/u);
|
||||
assert.match(text, /secret_query_unavailable/u);
|
||||
assert.equal(
|
||||
/localStorage.*plaintext|sessionStorage.*plaintext/u.test(text),
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (requestPath === '/') {
|
||||
assert.match(text, /id="task-editor-dialog"/u);
|
||||
@@ -99,6 +108,10 @@ test('loads one bounded offline Console asset closure', () => {
|
||||
assert.match(text, /id="presence-copy"/u);
|
||||
assert.match(text, /id="trigger-editor-dialog"/u);
|
||||
assert.match(text, /data-view="triggers"/u);
|
||||
assert.match(text, /data-view="secrets"/u);
|
||||
assert.match(text, /id="secret-editor-dialog"/u);
|
||||
assert.match(text, /id="task-secret-bindings-input"/u);
|
||||
assert.match(text, /AES-256-GCM/u);
|
||||
}
|
||||
}
|
||||
assert.ok(totalBytes <= 192 * 1024);
|
||||
|
||||
@@ -52,14 +52,19 @@ function request(port, path, options = {}) {
|
||||
function preparedAdmission(handler) {
|
||||
return {
|
||||
async prepare(value) {
|
||||
const json = ['run.cancel', 'task.start', 'task.put'].includes(
|
||||
value.operation.operationId,
|
||||
);
|
||||
const json = [
|
||||
'run.cancel',
|
||||
'task.start',
|
||||
'task.put',
|
||||
'secret.put',
|
||||
].includes(value.operation.operationId);
|
||||
return {
|
||||
bodyMode: json ? 'json' : 'none',
|
||||
maximumBodyBytes:
|
||||
value.operation.operationId === 'task.put'
|
||||
? 72 * 1024
|
||||
: value.operation.operationId === 'secret.put'
|
||||
? 20 * 1024
|
||||
: json
|
||||
? 512
|
||||
: 0,
|
||||
@@ -83,7 +88,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
if (
|
||||
value.operation.operationId === 'run.cancel' ||
|
||||
value.operation.operationId === 'task.start' ||
|
||||
value.operation.operationId === 'task.put'
|
||||
value.operation.operationId === 'task.put' ||
|
||||
value.operation.operationId === 'secret.put'
|
||||
) {
|
||||
return { statusCode: 202, body: { accepted: body } };
|
||||
}
|
||||
@@ -140,6 +146,12 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
body: { task: { taskId: value.operation.taskId } },
|
||||
};
|
||||
}
|
||||
if (value.operation.operationId === 'secret.list') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { secrets: [], truncated: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
||||
@@ -356,6 +368,41 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
});
|
||||
assert.equal(observed[11].localPresence, 'ql3p_authoring_read_proof');
|
||||
|
||||
const secrets = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||
);
|
||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||
assert.deepEqual(observed[12].operation, {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
after: { name: 'alpha' },
|
||||
});
|
||||
|
||||
const secretPutBody = JSON.stringify({ name: 'github-token' });
|
||||
const secretPut = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
authorization: 'Bearer opaque',
|
||||
'x-qinglong-local-presence': 'ql3p_secret_bound_proof',
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(secretPutBody)),
|
||||
},
|
||||
body: secretPutBody,
|
||||
},
|
||||
);
|
||||
assert.equal(secretPut.statusCode, 202);
|
||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
});
|
||||
assert.equal(observed[13].localPresence, 'ql3p_secret_bound_proof');
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
||||
@@ -402,6 +449,17 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_task_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/secrets?',
|
||||
'/api/v3/projects/prj_default/secrets?limit=08',
|
||||
'/api/v3/projects/prj_default/secrets?limit=65',
|
||||
'/api/v3/projects/prj_default/secrets?after=Y',
|
||||
'/api/v3/projects/prj_default/secrets?unknown=value',
|
||||
]) {
|
||||
const invalid = await request(port, invalidQuery);
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_secret_list_query' });
|
||||
}
|
||||
for (const invalidQuery of [
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?',
|
||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=07',
|
||||
@@ -425,7 +483,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, 12);
|
||||
assert.equal(observed.length, 14);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
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 {
|
||||
createLocalPresenceProofManager,
|
||||
} = require('../dist/authentication/localPresenceProof.js');
|
||||
const {
|
||||
createLocalApiSecretListRoute,
|
||||
createLocalApiSecretPutRoute,
|
||||
} = require('../dist/secret/secretRoutes.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 uuidFactory() {
|
||||
let sequence = 400;
|
||||
return () => {
|
||||
sequence += 1;
|
||||
return `019f9200-0000-4000-8000-${String(sequence).padStart(12, '0')}`;
|
||||
};
|
||||
}
|
||||
|
||||
function body(overrides = {}) {
|
||||
return Object.freeze({
|
||||
name: 'github-token',
|
||||
plaintext: 'never-return-this-value',
|
||||
mutationId: '019f9200-0000-4000-8000-000000000101',
|
||||
expectedCurrentVersion: 0,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-secret-put-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const calls = [];
|
||||
const presenceProof = createLocalPresenceProofManager({
|
||||
deploymentRoot,
|
||||
profile: 'edge',
|
||||
now: () => 10_000,
|
||||
randomUuid: uuidFactory(),
|
||||
randomSecret: () => Buffer.alloc(32, 17),
|
||||
});
|
||||
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 route = createLocalApiSecretPutRoute({
|
||||
projectPolicy,
|
||||
async secretAdministrationForCredential(fence) {
|
||||
calls.push(['credential-fence', fence]);
|
||||
return {
|
||||
async resolveLocalSecretAdministrationMutation() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedLocalSecretEnvelope(command) {
|
||||
calls.push(['mutation', command]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
envelope: command.envelope,
|
||||
audit: command.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
},
|
||||
securityAudit: {
|
||||
async record(record) {
|
||||
calls.push(['audit', record]);
|
||||
},
|
||||
},
|
||||
secretKeys: {
|
||||
async active() {
|
||||
calls.push(['active-key']);
|
||||
return { keyId: 'active-key', key: Buffer.alloc(32, 23) };
|
||||
},
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
presenceProof,
|
||||
now: () => 10_000,
|
||||
randomUuid: uuidFactory(),
|
||||
});
|
||||
const authenticated = Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
credentialFence: FENCE,
|
||||
async confirm() {
|
||||
calls.push(['confirm']);
|
||||
},
|
||||
});
|
||||
return { route, calls, deploymentRoot, authenticated };
|
||||
}
|
||||
|
||||
function request(state, requestBody, overrides = {}) {
|
||||
return Object.freeze({
|
||||
requestId: 'local:019f9200-0000-4000-8000-000000000301',
|
||||
projectId: 'default',
|
||||
body: requestBody,
|
||||
presence: null,
|
||||
authenticated: state.authenticated,
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function readProof(state, challenge) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
state.deploymentRoot,
|
||||
'console-presence',
|
||||
challenge.body.proofFileName,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
).proof;
|
||||
}
|
||||
|
||||
test('lists bounded Secret metadata without storage or mutation material', async () => {
|
||||
const route = createLocalApiSecretListRoute({
|
||||
async listLocalSecretMetadata(options) {
|
||||
assert.deepEqual(options, { projectId: 'default', limit: 1 });
|
||||
return {
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'github-token' },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await route.handle({ projectId: 'default', limit: 1 });
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.deepEqual(Object.keys(result.body.secrets[0]).sort(), [
|
||||
'createdAtMs',
|
||||
'currentVersion',
|
||||
'name',
|
||||
'secretRef',
|
||||
]);
|
||||
assert.equal(
|
||||
result.body.secrets[0].secretRef.startsWith('qlsecret:v1:'),
|
||||
true,
|
||||
);
|
||||
assert.equal(result.body.next.after, 'Z2l0aHViLXRva2Vu');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(result.body),
|
||||
/cipher|plaintext|mutation|keyId/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on widened, cross-Project and over-budget metadata', async () => {
|
||||
for (const page of [
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'other',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'github-token',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 10_000,
|
||||
ciphertext: 'forbidden',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'first',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 10_000,
|
||||
},
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'second',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 10_001,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'second' },
|
||||
},
|
||||
]) {
|
||||
const route = createLocalApiSecretListRoute({
|
||||
async listLocalSecretMetadata() {
|
||||
return page;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await route.handle({ projectId: 'default', limit: 1 }), {
|
||||
statusCode: 503,
|
||||
body: { code: 'secret_query_unavailable' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('requires exact local presence and returns no Secret plaintext', async (t) => {
|
||||
const state = fixture(t);
|
||||
const command = body();
|
||||
const challenge = await state.route.handle(request(state, command));
|
||||
assert.equal(challenge.statusCode, 428);
|
||||
const result = await state.route.handle(
|
||||
request(state, command, { presence: readProof(state, challenge) }),
|
||||
);
|
||||
assert.equal(result.statusCode, 201);
|
||||
assert.deepEqual(result.body, {
|
||||
status: 'inserted',
|
||||
secret: {
|
||||
name: 'github-token',
|
||||
currentVersion: 1,
|
||||
secretRef: result.body.secret.secretRef,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes(command.plaintext), false);
|
||||
const mutation = state.calls.find(([kind]) => kind === 'mutation')[1];
|
||||
assert.equal(
|
||||
Buffer.from(mutation.envelope.ciphertext, 'base64url').includes(
|
||||
Buffer.from(command.plaintext),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.calls
|
||||
.filter(([kind]) => kind === 'audit')
|
||||
.map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]),
|
||||
[['secret.create', 'approval_required', 'local_presence_required']],
|
||||
);
|
||||
});
|
||||
|
||||
test('binds proof to exact plaintext digest and rejects widened bodies', async (t) => {
|
||||
const state = fixture(t);
|
||||
assert.deepEqual(
|
||||
await state.route.handle(request(state, { ...body(), extra: true })),
|
||||
{ statusCode: 400, body: { code: 'invalid_secret' } },
|
||||
);
|
||||
const command = body();
|
||||
const challenge = await state.route.handle(request(state, command));
|
||||
const proof = readProof(state, challenge);
|
||||
assert.deepEqual(
|
||||
await state.route.handle(
|
||||
request(state, body({ plaintext: 'changed-value' }), { presence: proof }),
|
||||
),
|
||||
{ statusCode: 401, body: { code: 'local_presence_rejected' } },
|
||||
);
|
||||
assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0);
|
||||
});
|
||||
@@ -187,8 +187,8 @@ function seed(databasePath, materialDigest) {
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'local-api-user', 1, 'active', 'operator',
|
||||
'grant-local-api-operator', 'user', 'local-api-user', ?
|
||||
'default', 'user', 'local-api-user', 1, 'active', 'owner',
|
||||
'grant-local-api-owner', 'user', 'local-api-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(NOW - 500);
|
||||
@@ -442,6 +442,19 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
triggers: runtime.triggers,
|
||||
triggerAdministrationForCredential:
|
||||
runtime.triggerAdministrationForCredential,
|
||||
localSecretMetadata: runtime.localSecretMetadata,
|
||||
localSecretAdministrationForCredential:
|
||||
runtime.localSecretAdministrationForCredential,
|
||||
localSecretKeys: {
|
||||
async active() {
|
||||
return { keyId: 'integration-key', key: Buffer.alloc(32, 83) };
|
||||
},
|
||||
async resolve(keyId) {
|
||||
return keyId === 'integration-key'
|
||||
? { keyId, key: Buffer.alloc(32, 83) }
|
||||
: null;
|
||||
},
|
||||
},
|
||||
apiCredentials: runtime.apiCredentials,
|
||||
ownerPepper: runtime.ownerPepper,
|
||||
projectPolicy: runtime.projectPolicy,
|
||||
@@ -531,6 +544,63 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
{ statusCode: 404, body: { code: 'task_not_found' } },
|
||||
);
|
||||
|
||||
const secretPlaintext = 'local-api-secret-value';
|
||||
const secretBody = JSON.stringify({
|
||||
name: 'github-token',
|
||||
plaintext: secretPlaintext,
|
||||
mutationId: '019f7300-0000-4000-8000-000000000700',
|
||||
expectedCurrentVersion: 0,
|
||||
});
|
||||
const secretPath = '/api/v3/projects/default/secrets';
|
||||
const secretOptions = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': String(Buffer.byteLength(secretBody)),
|
||||
},
|
||||
body: secretBody,
|
||||
};
|
||||
const secretChallenge = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
secretPath,
|
||||
secretOptions,
|
||||
);
|
||||
assert.equal(secretChallenge.statusCode, 428);
|
||||
assert.equal(secretChallenge.body.code, 'local_presence_required');
|
||||
const secretProof = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, 'console-presence', secretChallenge.body.proofFileName),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const secretCreated = await request(port, `Bearer ${TOKEN}`, secretPath, {
|
||||
...secretOptions,
|
||||
headers: {
|
||||
...secretOptions.headers,
|
||||
'x-qinglong-local-presence': secretProof.proof,
|
||||
},
|
||||
});
|
||||
assert.equal(secretCreated.statusCode, 201);
|
||||
assert.equal(secretCreated.body.secret.currentVersion, 1);
|
||||
assert.match(secretCreated.body.secret.secretRef, /^qlsecret:v1:/u);
|
||||
assert.equal(JSON.stringify(secretCreated).includes(secretPlaintext), false);
|
||||
|
||||
const secretList = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
`${secretPath}?limit=64`,
|
||||
);
|
||||
assert.equal(secretList.statusCode, 200);
|
||||
assert.deepEqual(secretList.body.secrets, [
|
||||
{ ...secretCreated.body.secret, createdAtMs: NOW },
|
||||
]);
|
||||
assert.equal(secretList.body.truncated, false);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(secretList),
|
||||
new RegExp(`${secretPlaintext}|ciphertext|keyId|mutationId`, 'u'),
|
||||
);
|
||||
|
||||
const taskCreateBody = JSON.stringify({
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7300-0000-4000-8000-000000000701',
|
||||
@@ -545,6 +615,13 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
file: '/bin/echo',
|
||||
args: ['console-created'],
|
||||
},
|
||||
environment: [
|
||||
{
|
||||
name: 'API_TOKEN',
|
||||
kind: 'secret',
|
||||
secretRef: secretCreated.body.secret.secretRef,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
labels: { source: 'local-console' },
|
||||
@@ -973,6 +1050,40 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
);
|
||||
const auditReader = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const encryptedSecret = auditReader
|
||||
.prepare(
|
||||
`SELECT ciphertext FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE project_id = 'default' AND secret_name = 'github-token'
|
||||
AND version = 1`,
|
||||
)
|
||||
.get();
|
||||
assert.equal(
|
||||
Buffer.from(encryptedSecret.ciphertext).includes(
|
||||
Buffer.from(secretPlaintext),
|
||||
),
|
||||
false,
|
||||
);
|
||||
const createdSpec = JSON.parse(
|
||||
auditReader
|
||||
.prepare(
|
||||
`SELECT revision.spec_json AS specJson
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = 'default'
|
||||
AND head.task_id = 'task-console-created'`,
|
||||
)
|
||||
.get().specJson,
|
||||
);
|
||||
assert.deepEqual(createdSpec.config.environment, [
|
||||
{
|
||||
name: 'API_TOKEN',
|
||||
kind: 'secret',
|
||||
secretRef: secretCreated.body.secret.secretRef,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
auditReader
|
||||
.prepare(
|
||||
@@ -981,7 +1092,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||
'task.list', 'task.start', 'task.update', 'run.log.read',
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update'
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
|
||||
'secret.create', 'secret.list'
|
||||
)
|
||||
ORDER BY operation_id, outcome`,
|
||||
)
|
||||
@@ -996,6 +1108,9 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.list:allowed',
|
||||
'run.log.read:allowed',
|
||||
'run.steps.list:allowed',
|
||||
'secret.create:allowed',
|
||||
'secret.create:approval_required',
|
||||
'secret.list:allowed',
|
||||
'task.authoring.read:allowed',
|
||||
'task.authoring.read:approval_required',
|
||||
'task.create:allowed',
|
||||
|
||||
@@ -525,6 +525,10 @@ export async function bootstrapLocalApplication(
|
||||
triggers: storage.triggers,
|
||||
triggerAdministrationForCredential:
|
||||
storage.triggerAdministrationForCredential,
|
||||
localSecretMetadata: storage.localSecretMetadata,
|
||||
localSecretAdministrationForCredential:
|
||||
storage.localSecretAdministrationForCredential,
|
||||
localSecretKeys: secretKeys,
|
||||
apiCredentials: storage.apiCredentials,
|
||||
ownerPepper: storage.ownerPepper,
|
||||
projectPolicy: storage.projectPolicy,
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
RunAttemptLogReadRequest,
|
||||
RunAttemptLogReadResult,
|
||||
} from '@qinglong/runtime-core/run-attempt-log-read';
|
||||
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
export type LocalApplicationProfile = 'edge' | 'standalone';
|
||||
|
||||
@@ -71,6 +72,9 @@ export interface LocalApplicationProductSurfaceAuthority {
|
||||
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
|
||||
readonly triggers: ReadyFreshStorage['triggers'];
|
||||
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
|
||||
readonly localSecretMetadata: ReadyFreshStorage['localSecretMetadata'];
|
||||
readonly localSecretAdministrationForCredential: ReadyFreshStorage['localSecretAdministrationForCredential'];
|
||||
readonly localSecretKeys: LocalSecretKeyProvider;
|
||||
readonly runAttemptLogRead: Readonly<{
|
||||
read(
|
||||
request: Readonly<RunAttemptLogReadRequest>,
|
||||
|
||||
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
|
||||
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
|
||||
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
|
||||
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
|
||||
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
|
||||
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
|
||||
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
|
||||
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
|
||||
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
|
||||
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
|
||||
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
|
||||
runAttemptLogRetention: database.runAttemptLogRetention,
|
||||
runLostRetry: database.runLostRetry,
|
||||
localSecrets: database.localSecrets,
|
||||
localSecretMetadata: database.localSecretMetadata,
|
||||
localSecretAdministration: database.localSecretAdministration,
|
||||
localSecretAdministrationForCredential:
|
||||
database.localSecretAdministrationForCredential,
|
||||
projectPolicy: database.projectPolicy,
|
||||
securityAudit: database.securityAudit,
|
||||
apiCredentials: database.apiCredentials,
|
||||
|
||||
@@ -17,7 +17,10 @@ import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local
|
||||
import type { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
|
||||
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import type { LocalSecretEnvelopeRepository } from '@qinglong/runtime-core/local-secret';
|
||||
import type {
|
||||
LocalSecretEnvelopeRepository,
|
||||
LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import type { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
|
||||
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
||||
@@ -49,6 +52,7 @@ import type {
|
||||
ProjectToolDefinitionSnapshotSourceRepository,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
|
||||
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
|
||||
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
|
||||
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
|
||||
readonly localSecrets: LocalSecretEnvelopeRepository;
|
||||
readonly localSecretMetadata: LocalSecretMetadataSource;
|
||||
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
||||
localSecretAdministrationForCredential(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): Promise<LocalSecretAdministrationRepository>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
const runRuntimeCapabilities =
|
||||
createLocalSqliteRunRuntimeCapabilities(authority);
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
|
||||
authority,
|
||||
);
|
||||
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
|
||||
authority,
|
||||
taskSpecSemanticRegistry,
|
||||
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
runAttemptLogRetention,
|
||||
runLostRetry,
|
||||
localSecrets: securityAuthority,
|
||||
localSecretMetadata,
|
||||
localSecretAdministration: securityAuthority,
|
||||
async localSecretAdministrationForCredential(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
const { confirmLocalSqliteAuthenticatedUserCredentialFence } =
|
||||
await import('../administration/packageManagement.js');
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
return new LocalSqliteSecurityAuthorityStore(authority, {
|
||||
beforeAuthorizedLocalSecretMutation() {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
authority,
|
||||
fence,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
projectPolicy,
|
||||
securityAudit: securityAuthority,
|
||||
apiCredentials,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
LocalSecretMetadataUnavailableError,
|
||||
MAX_LOCAL_SECRET_BATCH_SIZE,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretProjectId,
|
||||
assertLocalSecretVersion,
|
||||
type LocalSecretMetadata,
|
||||
type LocalSecretMetadataPage,
|
||||
type LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function metadata(row: Row): Readonly<LocalSecretMetadata> {
|
||||
try {
|
||||
const projectId = text(row, 'projectId');
|
||||
const name = text(row, 'name');
|
||||
const currentVersion = integer(row, 'currentVersion');
|
||||
const createdAtMs = integer(row, 'createdAtMs');
|
||||
assertLocalSecretProjectId(projectId);
|
||||
assertLocalSecretName(name);
|
||||
assertLocalSecretVersion(currentVersion);
|
||||
if (createdAtMs < 0) throw new Error('invalid Secret timestamp');
|
||||
return Object.freeze({ projectId, name, currentVersion, createdAtMs });
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretMetadataUnavailableError) throw error;
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSqliteSecretMetadataRepository
|
||||
implements LocalSecretMetadataSource
|
||||
{
|
||||
constructor(private readonly authority: LocalSqliteOperationAuthority) {
|
||||
if (!(authority instanceof LocalSqliteOperationAuthority)) {
|
||||
throw new TypeError('Local Secret metadata authority is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
listLocalSecretMetadata(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly name: string }>;
|
||||
}): Promise<Readonly<LocalSecretMetadataPage>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['after', 'limit', 'projectId'].includes(key),
|
||||
) ||
|
||||
!Object.hasOwn(options, 'limit') ||
|
||||
!Object.hasOwn(options, 'projectId') ||
|
||||
!Number.isSafeInteger(options.limit) ||
|
||||
options.limit < 1 ||
|
||||
options.limit > MAX_LOCAL_SECRET_BATCH_SIZE ||
|
||||
(options.after !== undefined &&
|
||||
(!options.after ||
|
||||
typeof options.after !== 'object' ||
|
||||
Array.isArray(options.after) ||
|
||||
Object.keys(options.after).join('') !== 'name'))
|
||||
) {
|
||||
throw new TypeError('Local Secret metadata list options are invalid');
|
||||
}
|
||||
assertLocalSecretProjectId(options.projectId);
|
||||
if (options.after) assertLocalSecretName(options.after.name);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`SELECT secret."project_id" AS "projectId",
|
||||
secret."secret_name" AS "name",
|
||||
secret."version" AS "currentVersion",
|
||||
secret."created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS secret
|
||||
WHERE secret."project_id" = ?
|
||||
AND secret."secret_name" > ?
|
||||
AND secret."version" = (
|
||||
SELECT MAX(current."version")
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS current
|
||||
WHERE current."project_id" = secret."project_id"
|
||||
AND current."secret_name" = secret."secret_name"
|
||||
)
|
||||
ORDER BY secret."secret_name"
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
options.projectId,
|
||||
options.after?.name ?? '',
|
||||
options.limit + 1,
|
||||
) as Row[] | undefined;
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const truncated = rows.length > options.limit;
|
||||
const secrets = Object.freeze(
|
||||
rows.slice(0, options.limit).map(metadata),
|
||||
);
|
||||
const last = secrets.at(-1);
|
||||
return Object.freeze({
|
||||
secrets,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? { next: Object.freeze({ name: last.name }) }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalSecretMetadataUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist/runtime/runtimeDatabase.js');
|
||||
|
||||
function insertSecret(database, name, version, createdAtMs) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes"
|
||||
("project_id", "secret_name", "version", "mutation_id", "key_id",
|
||||
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms")
|
||||
VALUES ('default', ?, ?, ?, 'active-key', 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
name,
|
||||
version,
|
||||
`00000000-0000-4000-8000-${String(createdAtMs).padStart(12, '0')}`,
|
||||
Buffer.alloc(12, version),
|
||||
Buffer.from(`cipher-${name}-${version}`),
|
||||
Buffer.alloc(16, version),
|
||||
createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
test('lists only current Secret metadata with a stable bounded cursor', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-meta-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const options = {
|
||||
directory,
|
||||
profile: 'edge',
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const database = new DatabaseSync(options.databasePath);
|
||||
insertSecret(database, 'alpha', 1, 101);
|
||||
insertSecret(database, 'alpha', 2, 102);
|
||||
insertSecret(database, 'beta', 1, 103);
|
||||
insertSecret(database, 'gamma', 1, 104);
|
||||
database.close();
|
||||
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
const first = await runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(first, {
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'alpha',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 102,
|
||||
},
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'beta',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 103,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'beta' },
|
||||
});
|
||||
assert.equal(JSON.stringify(first).includes('cipher'), false);
|
||||
assert.equal(JSON.stringify(first).includes('key'), false);
|
||||
assert.deepEqual(
|
||||
await runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: first.next,
|
||||
}),
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'gamma',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 104,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
|
||||
await runtime.close();
|
||||
await assert.rejects(
|
||||
runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
}),
|
||||
{ name: 'LocalSecretMetadataUnavailableError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened and over-budget metadata queries before storage', async () => {
|
||||
const source = Object.create(
|
||||
require('../dist/security/secretMetadataRepository.js')
|
||||
.LocalSqliteSecretMetadataRepository.prototype,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
source.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 65,
|
||||
}),
|
||||
/options are invalid/u,
|
||||
);
|
||||
});
|
||||
@@ -76,6 +76,36 @@ export interface LocalSecretEnvironmentProvider {
|
||||
}): Promise<readonly string[] | null>;
|
||||
}
|
||||
|
||||
export interface LocalSecretMetadata {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
readonly currentVersion: number;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface LocalSecretMetadataPage {
|
||||
readonly secrets: readonly Readonly<LocalSecretMetadata>[];
|
||||
readonly truncated: boolean;
|
||||
readonly next?: Readonly<{ readonly name: string }>;
|
||||
}
|
||||
|
||||
export interface LocalSecretMetadataSource {
|
||||
listLocalSecretMetadata(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly name: string }>;
|
||||
}): Promise<Readonly<LocalSecretMetadataPage>>;
|
||||
}
|
||||
|
||||
export class LocalSecretMetadataUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_SECRET_METADATA_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Local Secret metadata is unavailable');
|
||||
this.name = 'LocalSecretMetadataUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PutEncryptedLocalSecretCommand {
|
||||
readonly projectId: string;
|
||||
readonly name: string;
|
||||
@@ -151,7 +181,9 @@ function assertIdentifier(
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretProjectId(value: unknown): asserts value is string {
|
||||
export function assertLocalSecretProjectId(
|
||||
value: unknown,
|
||||
): asserts value is string {
|
||||
assertIdentifier('projectId', value, 128);
|
||||
}
|
||||
|
||||
@@ -159,7 +191,9 @@ export function assertLocalSecretName(value: unknown): asserts value is string {
|
||||
assertIdentifier('name', value, MAX_LOCAL_SECRET_NAME_BYTES);
|
||||
}
|
||||
|
||||
export function assertLocalSecretVersion(value: unknown): asserts value is number {
|
||||
export function assertLocalSecretVersion(
|
||||
value: unknown,
|
||||
): asserts value is number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
@@ -169,11 +203,15 @@ export function assertLocalSecretVersion(value: unknown): asserts value is numbe
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretMutationId(value: unknown): asserts value is string {
|
||||
export function assertLocalSecretMutationId(
|
||||
value: unknown,
|
||||
): asserts value is string {
|
||||
assertIdentifier('mutationId', value, MAX_LOCAL_SECRET_MUTATION_ID_BYTES);
|
||||
}
|
||||
|
||||
export function assertLocalSecretKeyId(value: unknown): asserts value is string {
|
||||
export function assertLocalSecretKeyId(
|
||||
value: unknown,
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
@@ -184,7 +222,9 @@ export function assertLocalSecretKeyId(value: unknown): asserts value is string
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretPlaintext(value: unknown): asserts value is string {
|
||||
export function assertLocalSecretPlaintext(
|
||||
value: unknown,
|
||||
): asserts value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.includes('\0') ||
|
||||
@@ -194,7 +234,9 @@ export function assertLocalSecretPlaintext(value: unknown): asserts value is str
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSecretExpectedVersion(value: unknown): asserts value is number {
|
||||
export function assertLocalSecretExpectedVersion(
|
||||
value: unknown,
|
||||
): asserts value is number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
|
||||
@@ -1566,10 +1566,11 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!(
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/security-management/secretCommand.ts'
|
||||
![
|
||||
'packages/ql3-local-owner-cli:src/security-management/secretCommand.ts',
|
||||
'packages/ql3-local-api:src/secret/secretRoutes.ts',
|
||||
].includes(
|
||||
`${packagePath}:${path.relative(packageDirectory, filePath)}`,
|
||||
) &&
|
||||
specifier === '@qinglong/local-admin/secret-administration'
|
||||
) {
|
||||
@@ -2682,6 +2683,11 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
specifier ===
|
||||
'@qinglong/runtime-core/plugin-package-automation-publication'
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/application-runtime/contract.ts' &&
|
||||
specifier === '@qinglong/runtime-core/local-secret'
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/production-process/pluginPackageRecoveryCatalog.ts' &&
|
||||
@@ -3168,6 +3174,14 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-api' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/secret/secretRoutes.ts' &&
|
||||
specifier === '@qinglong/local-admin/secret-administration'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-api' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
|
||||
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 24,
|
||||
sourceFiles: 25,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 84,
|
||||
nestedSourceFiles: 23,
|
||||
nestedSourceFiles: 24,
|
||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||
},
|
||||
);
|
||||
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 209,
|
||||
sourceFiles: 210,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 31,
|
||||
nestedSourceFiles: 208,
|
||||
nestedSourceFiles: 209,
|
||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user