mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): add secret-backed console automation
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# QingLong 3.0 Architecture RFC
|
# 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 交付或长期支持版本。
|
- 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
|
- 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-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-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-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-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-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-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 创建能力已进入新的阶段实物,而不再只是候选源码。
|
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-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)。
|
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:
|
当前最新可交付 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
|
# 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` |
|
| 普通单节点服务器 | 选择 `standalone-application-api` |
|
||||||
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
|
| 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 可执行文件和逐行参数,再选择“保存并生成本机证明”。
|
2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。
|
||||||
3. 在部署设备上以 QingLong 数据目录 owner 读取 `<deploymentRoot>/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。
|
3. 在部署设备上以 QingLong 数据目录 owner 读取 `<deploymentRoot>/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。
|
||||||
4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。
|
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。
|
5. 需要敏感环境变量时进入“凭据”,创建或轮换 Secret。每次操作都要求一份绑定 exact plaintext digest 的新 proof;Console 永不显示旧值,提交后立即清空输入。Task 编辑器使用 `ENV_NAME=secret-name` 或 `ENV_NAME=secret-name@version`,省略版本时也会在保存前固定为当前版本。
|
||||||
6. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。
|
6. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。
|
||||||
7. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。
|
7. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。
|
||||||
8. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
8. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。
|
||||||
9. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
9. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
|
||||||
10. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
10. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
|
||||||
11. 完成后选择“断开并清除凭据”,再关闭页面。
|
11. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
|
||||||
|
12. 完成后选择“断开并清除凭据”,再关闭页面。
|
||||||
|
|
||||||
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
|
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 管理;
|
- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理;
|
||||||
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
|
||||||
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
|
- 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 没有独立数据库、后台任务或需要额外清理的持久状态。
|
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ type TriggerAdministrationForCredential =
|
|||||||
type LocalScheduleStore = ReadyLocalStorage['schedules'];
|
type LocalScheduleStore = ReadyLocalStorage['schedules'];
|
||||||
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
|
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
|
||||||
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
|
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
|
||||||
|
type LocalSecretMetadataSource = ReadyLocalStorage['localSecretMetadata'];
|
||||||
type LocalSecretAdministrationRepository =
|
type LocalSecretAdministrationRepository =
|
||||||
ReadyLocalStorage['localSecretAdministration'];
|
ReadyLocalStorage['localSecretAdministration'];
|
||||||
|
type LocalSecretAdministrationForCredential =
|
||||||
|
ReadyLocalStorage['localSecretAdministrationForCredential'];
|
||||||
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
|
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
|
||||||
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
|
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
|
||||||
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
|
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
|
||||||
@@ -102,7 +105,9 @@ export type LocalAdoptedProfileBootstrapResult =
|
|||||||
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
|
readonly runAttemptLogRetention: ReadyLocalStorage['runAttemptLogRetention'];
|
||||||
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
|
readonly runLostRetry: ReadyLocalStorage['runLostRetry'];
|
||||||
readonly localSecrets: LocalSecretEnvelopeRepository;
|
readonly localSecrets: LocalSecretEnvelopeRepository;
|
||||||
|
readonly localSecretMetadata: LocalSecretMetadataSource;
|
||||||
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
||||||
|
readonly localSecretAdministrationForCredential: LocalSecretAdministrationForCredential;
|
||||||
readonly projectPolicy: ProjectPolicyRepository;
|
readonly projectPolicy: ProjectPolicyRepository;
|
||||||
readonly securityAudit: SecurityAuditSink;
|
readonly securityAudit: SecurityAuditSink;
|
||||||
readonly apiCredentials: ApiCredentialRepository;
|
readonly apiCredentials: ApiCredentialRepository;
|
||||||
@@ -255,7 +260,10 @@ export async function bootstrapLocalAdoptedProfileStorage(
|
|||||||
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
|
runAttemptLogRetention: readyStorage.runAttemptLogRetention,
|
||||||
runLostRetry: readyStorage.runLostRetry,
|
runLostRetry: readyStorage.runLostRetry,
|
||||||
localSecrets: readyStorage.localSecrets,
|
localSecrets: readyStorage.localSecrets,
|
||||||
|
localSecretMetadata: readyStorage.localSecretMetadata,
|
||||||
localSecretAdministration: readyStorage.localSecretAdministration,
|
localSecretAdministration: readyStorage.localSecretAdministration,
|
||||||
|
localSecretAdministrationForCredential:
|
||||||
|
readyStorage.localSecretAdministrationForCredential,
|
||||||
projectPolicy: readyStorage.projectPolicy,
|
projectPolicy: readyStorage.projectPolicy,
|
||||||
securityAudit: readyStorage.securityAudit,
|
securityAudit: readyStorage.securityAudit,
|
||||||
apiCredentials: readyStorage.apiCredentials,
|
apiCredentials: readyStorage.apiCredentials,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
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 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 CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/;
|
||||||
const TOKEN_PATTERN =
|
const TOKEN_PATTERN =
|
||||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||||
@@ -57,6 +59,11 @@
|
|||||||
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
|
'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。',
|
||||||
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
|
invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。',
|
||||||
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
|
trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。',
|
||||||
|
secret_query_unavailable: 'Secret 元数据暂时不可读取。请检查数据库状态。',
|
||||||
|
secret_fence_rejected:
|
||||||
|
'Secret、凭据或授权在确认期间发生变化。请刷新后重新保存。',
|
||||||
|
invalid_secret: 'Secret 名称、版本或明文无效。',
|
||||||
|
secret_unavailable: 'Secret 暂时无法加密保存。请检查密钥与数据库状态。',
|
||||||
run_cancellation_fence_rejected:
|
run_cancellation_fence_rejected:
|
||||||
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
'运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。',
|
||||||
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
request_unavailable: '本次请求没有完成,请确认服务仍在运行。',
|
||||||
@@ -78,6 +85,7 @@
|
|||||||
refresh: document.getElementById('refresh-button'),
|
refresh: document.getElementById('refresh-button'),
|
||||||
createTask: document.getElementById('create-task-button'),
|
createTask: document.getElementById('create-task-button'),
|
||||||
createTrigger: document.getElementById('create-trigger-button'),
|
createTrigger: document.getElementById('create-trigger-button'),
|
||||||
|
createSecret: document.getElementById('create-secret-button'),
|
||||||
dialog: document.getElementById('confirmation-dialog'),
|
dialog: document.getElementById('confirmation-dialog'),
|
||||||
dialogTitle: document.getElementById('confirmation-title'),
|
dialogTitle: document.getElementById('confirmation-title'),
|
||||||
dialogCopy: document.getElementById('confirmation-copy'),
|
dialogCopy: document.getElementById('confirmation-copy'),
|
||||||
@@ -94,6 +102,7 @@
|
|||||||
taskDescription: document.getElementById('task-description-input'),
|
taskDescription: document.getElementById('task-description-input'),
|
||||||
taskCommand: document.getElementById('task-command-input'),
|
taskCommand: document.getElementById('task-command-input'),
|
||||||
taskArgs: document.getElementById('task-args-input'),
|
taskArgs: document.getElementById('task-args-input'),
|
||||||
|
taskSecretBindings: document.getElementById('task-secret-bindings-input'),
|
||||||
taskEnabled: document.getElementById('task-enabled-input'),
|
taskEnabled: document.getElementById('task-enabled-input'),
|
||||||
taskEnabledLabel: document.getElementById('task-enabled-label'),
|
taskEnabledLabel: document.getElementById('task-enabled-label'),
|
||||||
triggerEditor: document.getElementById('trigger-editor-dialog'),
|
triggerEditor: document.getElementById('trigger-editor-dialog'),
|
||||||
@@ -108,6 +117,15 @@
|
|||||||
triggerTimezone: document.getElementById('trigger-timezone-input'),
|
triggerTimezone: document.getElementById('trigger-timezone-input'),
|
||||||
triggerMisfire: document.getElementById('trigger-misfire-input'),
|
triggerMisfire: document.getElementById('trigger-misfire-input'),
|
||||||
triggerEnabled: document.getElementById('trigger-enabled-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'),
|
presenceDialog: document.getElementById('presence-dialog'),
|
||||||
presenceForm: document.getElementById('presence-form'),
|
presenceForm: document.getElementById('presence-form'),
|
||||||
presenceCopy: document.getElementById('presence-copy'),
|
presenceCopy: document.getElementById('presence-copy'),
|
||||||
@@ -129,6 +147,8 @@
|
|||||||
pendingPresence: null,
|
pendingPresence: null,
|
||||||
authoringSnapshot: null,
|
authoringSnapshot: null,
|
||||||
triggerSnapshot: null,
|
triggerSnapshot: null,
|
||||||
|
secretSnapshot: null,
|
||||||
|
secretCatalog: [],
|
||||||
toastTimer: null,
|
toastTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +243,138 @@
|
|||||||
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
|
.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 = {}) {
|
async function api(path, options = {}) {
|
||||||
if (!state.token) {
|
if (!state.token) {
|
||||||
throw new ConsoleRequestError('authentication_required', 401, null);
|
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||||
@@ -393,11 +545,17 @@
|
|||||||
nodes.taskEditorIntro.textContent = editing
|
nodes.taskEditorIntro.textContent = editing
|
||||||
? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。`
|
? `完整定义已由本机证明读取,并绑定 revision ${snapshot.task.revision}。保存时还会生成一份只绑定新内容的证明。`
|
||||||
: '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。';
|
: '定义会先绑定到一次本机证明,再以同一事务写入 Task revision 与安全审计。';
|
||||||
|
const secretHint =
|
||||||
|
state.secretCatalog.length === 0
|
||||||
|
? '当前没有可绑定 Secret;可先到“凭据”创建。'
|
||||||
|
: `当前可绑定:${state.secretCatalog
|
||||||
|
.map((secret) => `${secret.name}@${secret.currentVersion}`)
|
||||||
|
.join('、')}`;
|
||||||
nodes.taskEditorNote.textContent = editing
|
nodes.taskEditorNote.textContent = editing
|
||||||
? `编辑租约将在 ${formatTime(
|
? `编辑租约将在 ${formatTime(
|
||||||
snapshot.authoring.expiresAtMs,
|
snapshot.authoring.expiresAtMs,
|
||||||
)} 失效;关闭后重新选择“编辑任务”可取得新快照。`
|
)} 失效。${secretHint}`
|
||||||
: 'Alpha 当前从 Console 创建 qinglong/command@v1;高级 Task schema 仍使用受信任管理入口。';
|
: `Console 创建 qinglong/command@v1。${secretHint}`;
|
||||||
nodes.taskEnabledLabel.textContent = editing
|
nodes.taskEnabledLabel.textContent = editing
|
||||||
? '保存后允许运行'
|
? '保存后允许运行'
|
||||||
: '创建后允许运行';
|
: '创建后允许运行';
|
||||||
@@ -411,9 +569,11 @@
|
|||||||
nodes.taskDescription.value = snapshot.task.description || '';
|
nodes.taskDescription.value = snapshot.task.description || '';
|
||||||
nodes.taskCommand.value = command.file;
|
nodes.taskCommand.value = command.file;
|
||||||
nodes.taskArgs.value = command.args.join('\n');
|
nodes.taskArgs.value = command.args.join('\n');
|
||||||
|
nodes.taskSecretBindings.value = secretBindingsFromTask(snapshot.task);
|
||||||
nodes.taskEnabled.checked = snapshot.task.enabled;
|
nodes.taskEnabled.checked = snapshot.task.enabled;
|
||||||
} else {
|
} else {
|
||||||
nodes.taskCommand.value = '/bin/echo';
|
nodes.taskCommand.value = '/bin/echo';
|
||||||
|
nodes.taskSecretBindings.value = '';
|
||||||
nodes.taskEnabled.checked = true;
|
nodes.taskEnabled.checked = true;
|
||||||
}
|
}
|
||||||
nodes.taskEditor.returnValue = '';
|
nodes.taskEditor.returnValue = '';
|
||||||
@@ -462,6 +622,9 @@
|
|||||||
.split(/\r?\n/u)
|
.split(/\r?\n/u)
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter((value) => value.length > 0);
|
.filter((value) => value.length > 0);
|
||||||
|
const secretEnvironment = parseSecretBindings(
|
||||||
|
nodes.taskSecretBindings.value,
|
||||||
|
);
|
||||||
if (!TASK_PATTERN.test(taskId)) {
|
if (!TASK_PATTERN.test(taskId)) {
|
||||||
throw new TypeError('Task ID 格式无效。');
|
throw new TypeError('Task ID 格式无效。');
|
||||||
}
|
}
|
||||||
@@ -469,11 +632,24 @@
|
|||||||
throw new TypeError('名称、命令或参数数量无效。');
|
throw new TypeError('名称、命令或参数数量无效。');
|
||||||
}
|
}
|
||||||
const snapshot = state.authoringSnapshot;
|
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
|
const spec = snapshot
|
||||||
? Object.freeze({
|
? Object.freeze({
|
||||||
...snapshot.task.spec,
|
...snapshot.task.spec,
|
||||||
config: Object.freeze({
|
config: Object.freeze({
|
||||||
...snapshot.task.spec.config,
|
...snapshot.task.spec.config,
|
||||||
|
environment,
|
||||||
command: Object.freeze({
|
command: Object.freeze({
|
||||||
...snapshot.task.spec.config.command,
|
...snapshot.task.spec.config.command,
|
||||||
kind: 'argv',
|
kind: 'argv',
|
||||||
@@ -486,6 +662,7 @@
|
|||||||
schema: 'qinglong/command@v1',
|
schema: 'qinglong/command@v1',
|
||||||
config: Object.freeze({
|
config: Object.freeze({
|
||||||
command: Object.freeze({ kind: 'argv', file, args }),
|
command: Object.freeze({ kind: 'argv', file, args }),
|
||||||
|
environment,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return Object.freeze({
|
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) {
|
function showPresenceChallenge(action, challenge) {
|
||||||
if (
|
if (
|
||||||
challenge?.code !== 'local_presence_required' ||
|
challenge?.code !== 'local_presence_required' ||
|
||||||
@@ -634,13 +1010,20 @@
|
|||||||
state.pendingPresence = Object.freeze({ ...action, challenge });
|
state.pendingPresence = Object.freeze({ ...action, challenge });
|
||||||
const authoringRead = action.kind === 'authoring';
|
const authoringRead = action.kind === 'authoring';
|
||||||
const triggerMutation = action.kind === 'trigger-mutation';
|
const triggerMutation = action.kind === 'trigger-mutation';
|
||||||
|
const secretMutation = action.kind === 'secret-mutation';
|
||||||
nodes.presenceCopy.textContent = authoringRead
|
nodes.presenceCopy.textContent = authoringRead
|
||||||
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
|
? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。'
|
||||||
: triggerMutation
|
: triggerMutation
|
||||||
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
|
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。'
|
||||||
|
: secretMutation
|
||||||
|
? '使用部署 QingLong 的系统用户读取下面的私有文件。证明绑定这次 Secret 内容摘要、当前版本和 User Credential,且只能使用一次。'
|
||||||
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
|
: '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。';
|
||||||
nodes.presenceSubmit.textContent = authoringRead
|
nodes.presenceSubmit.textContent = authoringRead
|
||||||
? '验证并加载定义'
|
? '验证并加载定义'
|
||||||
|
: secretMutation
|
||||||
|
? action.body.expectedCurrentVersion === 0
|
||||||
|
? '验证并加密创建'
|
||||||
|
: '验证并轮换版本'
|
||||||
: action.mutation.body.expectedRevision === null
|
: action.mutation.body.expectedRevision === null
|
||||||
? '验证并创建'
|
? '验证并创建'
|
||||||
: '验证并更新';
|
: '验证并更新';
|
||||||
@@ -653,6 +1036,7 @@
|
|||||||
nodes.presenceError.hidden = true;
|
nodes.presenceError.hidden = true;
|
||||||
nodes.taskEditor.close();
|
nodes.taskEditor.close();
|
||||||
nodes.triggerEditor.close();
|
nodes.triggerEditor.close();
|
||||||
|
nodes.secretEditor.close();
|
||||||
nodes.presenceDialog.returnValue = '';
|
nodes.presenceDialog.returnValue = '';
|
||||||
nodes.presenceDialog.showModal();
|
nodes.presenceDialog.showModal();
|
||||||
nodes.presenceProof.focus();
|
nodes.presenceProof.focus();
|
||||||
@@ -735,6 +1119,15 @@
|
|||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
nodes.presenceProof.value = '';
|
nodes.presenceProof.value = '';
|
||||||
nodes.presenceDialog.close();
|
nodes.presenceDialog.close();
|
||||||
|
try {
|
||||||
|
await loadSecretCatalog();
|
||||||
|
} catch {
|
||||||
|
state.secretCatalog = [];
|
||||||
|
showToast(
|
||||||
|
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
openTaskEditor(snapshot);
|
openTaskEditor(snapshot);
|
||||||
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
||||||
return;
|
return;
|
||||||
@@ -767,6 +1160,32 @@
|
|||||||
await selectTrigger(pending.mutation.triggerId);
|
await selectTrigger(pending.mutation.triggerId);
|
||||||
return;
|
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(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
|
`/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`,
|
||||||
{
|
{
|
||||||
@@ -1326,6 +1745,7 @@
|
|||||||
if (state.view === 'tasks') {
|
if (state.view === 'tasks') {
|
||||||
nodes.createTask.hidden = false;
|
nodes.createTask.hidden = false;
|
||||||
nodes.createTrigger.hidden = true;
|
nodes.createTrigger.hidden = true;
|
||||||
|
nodes.createSecret.hidden = true;
|
||||||
nodes.kicker.textContent = 'Project task authority';
|
nodes.kicker.textContent = 'Project task authority';
|
||||||
nodes.title.textContent = '任务调度台';
|
nodes.title.textContent = '任务调度台';
|
||||||
nodes.description.textContent =
|
nodes.description.textContent =
|
||||||
@@ -1333,13 +1753,23 @@
|
|||||||
} else if (state.view === 'triggers') {
|
} else if (state.view === 'triggers') {
|
||||||
nodes.createTask.hidden = true;
|
nodes.createTask.hidden = true;
|
||||||
nodes.createTrigger.hidden = false;
|
nodes.createTrigger.hidden = false;
|
||||||
|
nodes.createSecret.hidden = true;
|
||||||
nodes.kicker.textContent = 'Durable cron authority';
|
nodes.kicker.textContent = 'Durable cron authority';
|
||||||
nodes.title.textContent = '定时触发器';
|
nodes.title.textContent = '定时触发器';
|
||||||
nodes.description.textContent =
|
nodes.description.textContent =
|
||||||
'配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。';
|
'配置内置 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 {
|
} else {
|
||||||
nodes.createTask.hidden = true;
|
nodes.createTask.hidden = true;
|
||||||
nodes.createTrigger.hidden = true;
|
nodes.createTrigger.hidden = true;
|
||||||
|
nodes.createSecret.hidden = true;
|
||||||
nodes.kicker.textContent = 'Durable run evidence';
|
nodes.kicker.textContent = 'Durable run evidence';
|
||||||
nodes.title.textContent = '运行事实账本';
|
nodes.title.textContent = '运行事实账本';
|
||||||
nodes.description.textContent =
|
nodes.description.textContent =
|
||||||
@@ -1353,6 +1783,7 @@
|
|||||||
try {
|
try {
|
||||||
if (state.view === 'tasks') await renderTasks();
|
if (state.view === 'tasks') await renderTasks();
|
||||||
else if (state.view === 'triggers') await renderTriggers();
|
else if (state.view === 'triggers') await renderTriggers();
|
||||||
|
else if (state.view === 'secrets') await renderSecrets();
|
||||||
else await renderRuns();
|
else await renderRuns();
|
||||||
setConnection('connected', `${state.project} · 已连接`);
|
setConnection('connected', `${state.project} · 已连接`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1387,8 +1818,11 @@
|
|||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
state.authoringSnapshot = null;
|
state.authoringSnapshot = null;
|
||||||
state.triggerSnapshot = null;
|
state.triggerSnapshot = null;
|
||||||
|
state.secretSnapshot = null;
|
||||||
|
state.secretCatalog = [];
|
||||||
if (nodes.taskEditor.open) nodes.taskEditor.close();
|
if (nodes.taskEditor.open) nodes.taskEditor.close();
|
||||||
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
||||||
|
if (nodes.secretEditor.open) nodes.secretEditor.close();
|
||||||
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
|
if (nodes.presenceDialog.open) nodes.presenceDialog.close();
|
||||||
nodes.token.value = '';
|
nodes.token.value = '';
|
||||||
nodes.token.disabled = false;
|
nodes.token.disabled = false;
|
||||||
@@ -1399,6 +1833,7 @@
|
|||||||
nodes.refresh.hidden = true;
|
nodes.refresh.hidden = true;
|
||||||
nodes.createTask.hidden = true;
|
nodes.createTask.hidden = true;
|
||||||
nodes.createTrigger.hidden = true;
|
nodes.createTrigger.hidden = true;
|
||||||
|
nodes.createSecret.hidden = true;
|
||||||
setConnection('idle', '等待凭据');
|
setConnection('idle', '等待凭据');
|
||||||
nodes.kicker.textContent = 'Connection gate';
|
nodes.kicker.textContent = 'Connection gate';
|
||||||
nodes.title.textContent = '先建立一条本机连接';
|
nodes.title.textContent = '先建立一条本机连接';
|
||||||
@@ -1435,8 +1870,16 @@
|
|||||||
|
|
||||||
nodes.disconnect.addEventListener('click', disconnect);
|
nodes.disconnect.addEventListener('click', disconnect);
|
||||||
nodes.refresh.addEventListener('click', refresh);
|
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.createTrigger.addEventListener('click', () => openTriggerEditor());
|
||||||
|
nodes.createSecret.addEventListener('click', () => openSecretEditor());
|
||||||
nodes.taskEditorClose.addEventListener('click', () => {
|
nodes.taskEditorClose.addEventListener('click', () => {
|
||||||
state.authoringSnapshot = null;
|
state.authoringSnapshot = null;
|
||||||
nodes.taskEditor.close();
|
nodes.taskEditor.close();
|
||||||
@@ -1453,10 +1896,21 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await saveTriggerDraft();
|
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', () => {
|
nodes.presenceCancel.addEventListener('click', () => {
|
||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
state.authoringSnapshot = null;
|
state.authoringSnapshot = null;
|
||||||
state.triggerSnapshot = null;
|
state.triggerSnapshot = null;
|
||||||
|
state.secretSnapshot = null;
|
||||||
|
nodes.secretValue.value = '';
|
||||||
nodes.presenceProof.value = '';
|
nodes.presenceProof.value = '';
|
||||||
nodes.presenceDialog.close();
|
nodes.presenceDialog.close();
|
||||||
});
|
});
|
||||||
@@ -1495,6 +1949,7 @@
|
|||||||
nodes.dialog.open ||
|
nodes.dialog.open ||
|
||||||
nodes.taskEditor.open ||
|
nodes.taskEditor.open ||
|
||||||
nodes.triggerEditor.open ||
|
nodes.triggerEditor.open ||
|
||||||
|
nodes.secretEditor.open ||
|
||||||
nodes.presenceDialog.open
|
nodes.presenceDialog.open
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -1504,6 +1959,8 @@
|
|||||||
? 'tasks'
|
? 'tasks'
|
||||||
: event.key.toLowerCase() === 's'
|
: event.key.toLowerCase() === 's'
|
||||||
? 'triggers'
|
? 'triggers'
|
||||||
|
: event.key.toLowerCase() === 'k'
|
||||||
|
? 'secrets'
|
||||||
: event.key.toLowerCase() === 'r'
|
: event.key.toLowerCase() === 'r'
|
||||||
? 'runs'
|
? 'runs'
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
<button type="button" data-view="triggers">
|
<button type="button" data-view="triggers">
|
||||||
<span>定时</span><kbd>S</kbd>
|
<span>定时</span><kbd>S</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" data-view="secrets">
|
||||||
|
<span>凭据</span><kbd>K</kbd>
|
||||||
|
</button>
|
||||||
<button type="button" data-view="runs">
|
<button type="button" data-view="runs">
|
||||||
<span>运行</span><kbd>R</kbd>
|
<span>运行</span><kbd>R</kbd>
|
||||||
</button>
|
</button>
|
||||||
@@ -97,6 +100,9 @@
|
|||||||
<button class="action-button" id="create-trigger-button" type="button" hidden>
|
<button class="action-button" id="create-trigger-button" type="button" hidden>
|
||||||
<span aria-hidden="true">+</span> 创建定时
|
<span aria-hidden="true">+</span> 创建定时
|
||||||
</button>
|
</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>
|
<button class="refresh-button" id="refresh-button" type="button" hidden>
|
||||||
<span aria-hidden="true">↻</span> 刷新
|
<span aria-hidden="true">↻</span> 刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -175,6 +181,10 @@
|
|||||||
<span>参数 · 每行一个</span>
|
<span>参数 · 每行一个</span>
|
||||||
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
|
<textarea id="task-args-input" rows="5" maxlength="16384" spellcheck="false"></textarea>
|
||||||
</label>
|
</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">
|
<label class="editor-check">
|
||||||
<input id="task-enabled-input" type="checkbox" checked />
|
<input id="task-enabled-input" type="checkbox" checked />
|
||||||
<span id="task-enabled-label">创建后允许运行</span>
|
<span id="task-enabled-label">创建后允许运行</span>
|
||||||
@@ -243,6 +253,39 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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">
|
<dialog id="presence-dialog" class="presence-dialog">
|
||||||
<form id="presence-form" autocomplete="off">
|
<form id="presence-form" autocomplete="off">
|
||||||
<p class="eyebrow">Local presence · 02:00</p>
|
<p class="eyebrow">Local presence · 02:00</p>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ import type {
|
|||||||
LocalApiTriggerReadRoute,
|
LocalApiTriggerReadRoute,
|
||||||
} from '../trigger/triggerReadRoutes';
|
} from '../trigger/triggerReadRoutes';
|
||||||
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||||
|
import type {
|
||||||
|
LocalApiSecretListRoute,
|
||||||
|
LocalApiSecretPutRoute,
|
||||||
|
} from '../secret/secretRoutes';
|
||||||
import type { LocalApiResponse } from '../transport/contract';
|
import type { LocalApiResponse } from '../transport/contract';
|
||||||
|
|
||||||
export type LocalApiAdmissionOperation =
|
export type LocalApiAdmissionOperation =
|
||||||
@@ -111,6 +115,16 @@ export type LocalApiAdmissionOperation =
|
|||||||
operationId: 'trigger.put';
|
operationId: 'trigger.put';
|
||||||
projectId: string;
|
projectId: string;
|
||||||
triggerId: 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 {
|
export interface LocalApiAdmissionRequest {
|
||||||
@@ -152,6 +166,8 @@ export interface LocalApiAdmissionOptions {
|
|||||||
readonly triggerListRoute: LocalApiTriggerListRoute;
|
readonly triggerListRoute: LocalApiTriggerListRoute;
|
||||||
readonly triggerReadRoute: LocalApiTriggerReadRoute;
|
readonly triggerReadRoute: LocalApiTriggerReadRoute;
|
||||||
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
readonly triggerPutRoute: LocalApiTriggerPutRoute;
|
||||||
|
readonly secretListRoute: LocalApiSecretListRoute;
|
||||||
|
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||||
readonly now?: () => number;
|
readonly now?: () => number;
|
||||||
readonly randomUuid?: () => string;
|
readonly randomUuid?: () => string;
|
||||||
}
|
}
|
||||||
@@ -235,6 +251,8 @@ export function createLocalApiAdmission(
|
|||||||
typeof options.triggerListRoute?.handle !== 'function' ||
|
typeof options.triggerListRoute?.handle !== 'function' ||
|
||||||
typeof options.triggerReadRoute?.handle !== 'function' ||
|
typeof options.triggerReadRoute?.handle !== 'function' ||
|
||||||
typeof options.triggerPutRoute?.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.now !== undefined && typeof options.now !== 'function') ||
|
||||||
(options.randomUuid !== undefined &&
|
(options.randomUuid !== undefined &&
|
||||||
typeof options.randomUuid !== 'function')
|
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>;
|
let decision: Readonly<SecurityPolicyDecision>;
|
||||||
try {
|
try {
|
||||||
decision = normalizeSecurityPolicyDecision(
|
decision = normalizeSecurityPolicyDecision(
|
||||||
@@ -359,6 +395,8 @@ export function createLocalApiAdmission(
|
|||||||
request.operation.operationId === 'trigger.list' ||
|
request.operation.operationId === 'trigger.list' ||
|
||||||
request.operation.operationId === 'trigger.get'
|
request.operation.operationId === 'trigger.get'
|
||||||
? 'task.read'
|
? 'task.read'
|
||||||
|
: request.operation.operationId === 'secret.list'
|
||||||
|
? 'secret.manage'
|
||||||
: 'run.read',
|
: 'run.read',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -506,9 +544,19 @@ export function createLocalApiAdmission(
|
|||||||
projectId: request.operation.projectId,
|
projectId: request.operation.projectId,
|
||||||
triggerId: request.operation.triggerId,
|
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.put':
|
||||||
case 'task.authoring':
|
case 'task.authoring':
|
||||||
case 'trigger.put':
|
case 'trigger.put':
|
||||||
|
case 'secret.put':
|
||||||
return response(503, 'request_unavailable');
|
return response(503, 'request_unavailable');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
createLocalApiTriggerReadRoute,
|
createLocalApiTriggerReadRoute,
|
||||||
} from '../trigger/triggerReadRoutes';
|
} from '../trigger/triggerReadRoutes';
|
||||||
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute';
|
||||||
|
import {
|
||||||
|
createLocalApiSecretListRoute,
|
||||||
|
createLocalApiSecretPutRoute,
|
||||||
|
} from '../secret/secretRoutes';
|
||||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||||
|
|
||||||
export interface LocalApiProductSurfaceEvent {
|
export interface LocalApiProductSurfaceEvent {
|
||||||
@@ -195,6 +199,28 @@ export function createLocalApiProductSurface(
|
|||||||
? {}
|
? {}
|
||||||
: { randomUuid: options.randomUuid }),
|
: { 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({
|
const admission = createLocalApiAdmission({
|
||||||
authenticator,
|
authenticator,
|
||||||
policy,
|
policy,
|
||||||
@@ -213,6 +239,8 @@ export function createLocalApiProductSurface(
|
|||||||
triggerListRoute,
|
triggerListRoute,
|
||||||
triggerReadRoute,
|
triggerReadRoute,
|
||||||
triggerPutRoute,
|
triggerPutRoute,
|
||||||
|
secretListRoute,
|
||||||
|
secretPutRoute,
|
||||||
...(options.now === undefined ? {} : { now: options.now }),
|
...(options.now === undefined ? {} : { now: options.now }),
|
||||||
...(options.randomUuid === undefined
|
...(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 { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-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 type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||||
|
import { assertLocalSecretName } from '@qinglong/runtime-core/local-secret';
|
||||||
import {
|
import {
|
||||||
loadLocalConsoleAssets,
|
loadLocalConsoleAssets,
|
||||||
type LocalConsoleAsset,
|
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$/;
|
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers$/;
|
||||||
const TRIGGER_READ_ROUTE_PATTERN =
|
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})$/;
|
/^\/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 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 TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
||||||
@@ -60,7 +63,8 @@ type LocalApiRouteResolution =
|
|||||||
| 'invalid_run_step_list_query'
|
| 'invalid_run_step_list_query'
|
||||||
| 'invalid_run_log_read_query'
|
| 'invalid_run_log_read_query'
|
||||||
| 'invalid_task_list_query'
|
| 'invalid_task_list_query'
|
||||||
| 'invalid_trigger_list_query';
|
| 'invalid_trigger_list_query'
|
||||||
|
| 'invalid_secret_list_query';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface LocalApiHttpSurfaceOptions {
|
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(
|
function parseRunAttemptLogReadQuery(
|
||||||
rawQuery: string | undefined,
|
rawQuery: string | undefined,
|
||||||
profile: LocalApplicationProfile,
|
profile: LocalApplicationProfile,
|
||||||
@@ -534,6 +594,13 @@ function route(
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
if (request.method === 'PUT') {
|
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);
|
const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||||
if (triggerPutMatch && rawQuery === undefined) {
|
if (triggerPutMatch && rawQuery === undefined) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -552,6 +619,18 @@ function route(
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
if (request.method !== 'GET') return 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);
|
const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path);
|
||||||
if (triggerReadMatch) {
|
if (triggerReadMatch) {
|
||||||
return rawQuery === undefined
|
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 = {}) {
|
function request(overrides = {}) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
|
requestId: 'local:019f70c0-0000-7000-8000-000000000001',
|
||||||
@@ -246,6 +291,18 @@ function fixture(overrides = {}) {
|
|||||||
return { statusCode: 201, body: { status: 'created' } };
|
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,
|
now: () => 10_000,
|
||||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ test('loads one bounded offline Console asset closure', () => {
|
|||||||
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
|
assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u);
|
||||||
assert.match(text, /state\.view === 'triggers'/u);
|
assert.match(text, /state\.view === 'triggers'/u);
|
||||||
assert.match(text, /trigger_fence_rejected/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 === '/') {
|
if (requestPath === '/') {
|
||||||
assert.match(text, /id="task-editor-dialog"/u);
|
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="presence-copy"/u);
|
||||||
assert.match(text, /id="trigger-editor-dialog"/u);
|
assert.match(text, /id="trigger-editor-dialog"/u);
|
||||||
assert.match(text, /data-view="triggers"/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);
|
assert.ok(totalBytes <= 192 * 1024);
|
||||||
|
|||||||
@@ -52,14 +52,19 @@ function request(port, path, options = {}) {
|
|||||||
function preparedAdmission(handler) {
|
function preparedAdmission(handler) {
|
||||||
return {
|
return {
|
||||||
async prepare(value) {
|
async prepare(value) {
|
||||||
const json = ['run.cancel', 'task.start', 'task.put'].includes(
|
const json = [
|
||||||
value.operation.operationId,
|
'run.cancel',
|
||||||
);
|
'task.start',
|
||||||
|
'task.put',
|
||||||
|
'secret.put',
|
||||||
|
].includes(value.operation.operationId);
|
||||||
return {
|
return {
|
||||||
bodyMode: json ? 'json' : 'none',
|
bodyMode: json ? 'json' : 'none',
|
||||||
maximumBodyBytes:
|
maximumBodyBytes:
|
||||||
value.operation.operationId === 'task.put'
|
value.operation.operationId === 'task.put'
|
||||||
? 72 * 1024
|
? 72 * 1024
|
||||||
|
: value.operation.operationId === 'secret.put'
|
||||||
|
? 20 * 1024
|
||||||
: json
|
: json
|
||||||
? 512
|
? 512
|
||||||
: 0,
|
: 0,
|
||||||
@@ -83,7 +88,8 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
|||||||
if (
|
if (
|
||||||
value.operation.operationId === 'run.cancel' ||
|
value.operation.operationId === 'run.cancel' ||
|
||||||
value.operation.operationId === 'task.start' ||
|
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 } };
|
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 } },
|
body: { task: { taskId: value.operation.taskId } },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (value.operation.operationId === 'secret.list') {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { secrets: [], truncated: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { runs: [], hasMore: false, input: value.operation.input },
|
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');
|
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 [
|
for (const invalidPath of [
|
||||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||||
'/api/v3/projects/prj_default/runs/run%5f123',
|
'/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.equal(invalid.statusCode, 400);
|
||||||
assert.deepEqual(invalid.body, { code: 'invalid_task_list_query' });
|
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 [
|
for (const invalidQuery of [
|
||||||
'/api/v3/projects/prj_default/runs/run_123/events?',
|
'/api/v3/projects/prj_default/runs/run_123/events?',
|
||||||
'/api/v3/projects/prj_default/runs/run_123/events?after_sequence=07',
|
'/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.equal(invalid.statusCode, 400);
|
||||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||||
}
|
}
|
||||||
assert.equal(observed.length, 12);
|
assert.equal(observed.length, 14);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||||
['stopped', 'stopped'],
|
['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",
|
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||||
"created_at_ms"
|
"created_at_ms"
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'default', 'user', 'local-api-user', 1, 'active', 'operator',
|
'default', 'user', 'local-api-user', 1, 'active', 'owner',
|
||||||
'grant-local-api-operator', 'user', 'local-api-user', ?
|
'grant-local-api-owner', 'user', 'local-api-user', ?
|
||||||
)`,
|
)`,
|
||||||
)
|
)
|
||||||
.run(NOW - 500);
|
.run(NOW - 500);
|
||||||
@@ -442,6 +442,19 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
|||||||
triggers: runtime.triggers,
|
triggers: runtime.triggers,
|
||||||
triggerAdministrationForCredential:
|
triggerAdministrationForCredential:
|
||||||
runtime.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,
|
apiCredentials: runtime.apiCredentials,
|
||||||
ownerPepper: runtime.ownerPepper,
|
ownerPepper: runtime.ownerPepper,
|
||||||
projectPolicy: runtime.projectPolicy,
|
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' } },
|
{ 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({
|
const taskCreateBody = JSON.stringify({
|
||||||
expectedRevision: null,
|
expectedRevision: null,
|
||||||
mutationId: '019f7300-0000-4000-8000-000000000701',
|
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',
|
file: '/bin/echo',
|
||||||
args: ['console-created'],
|
args: ['console-created'],
|
||||||
},
|
},
|
||||||
|
environment: [
|
||||||
|
{
|
||||||
|
name: 'API_TOKEN',
|
||||||
|
kind: 'secret',
|
||||||
|
secretRef: secretCreated.body.secret.secretRef,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: { source: 'local-console' },
|
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 });
|
const auditReader = new DatabaseSync(databasePath, { readOnly: true });
|
||||||
try {
|
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(
|
assert.deepEqual(
|
||||||
auditReader
|
auditReader
|
||||||
.prepare(
|
.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.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||||
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||||
'task.list', 'task.start', 'task.update', 'run.log.read',
|
'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`,
|
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.list:allowed',
|
||||||
'run.log.read:allowed',
|
'run.log.read:allowed',
|
||||||
'run.steps.list:allowed',
|
'run.steps.list:allowed',
|
||||||
|
'secret.create:allowed',
|
||||||
|
'secret.create:approval_required',
|
||||||
|
'secret.list:allowed',
|
||||||
'task.authoring.read:allowed',
|
'task.authoring.read:allowed',
|
||||||
'task.authoring.read:approval_required',
|
'task.authoring.read:approval_required',
|
||||||
'task.create:allowed',
|
'task.create:allowed',
|
||||||
|
|||||||
@@ -525,6 +525,10 @@ export async function bootstrapLocalApplication(
|
|||||||
triggers: storage.triggers,
|
triggers: storage.triggers,
|
||||||
triggerAdministrationForCredential:
|
triggerAdministrationForCredential:
|
||||||
storage.triggerAdministrationForCredential,
|
storage.triggerAdministrationForCredential,
|
||||||
|
localSecretMetadata: storage.localSecretMetadata,
|
||||||
|
localSecretAdministrationForCredential:
|
||||||
|
storage.localSecretAdministrationForCredential,
|
||||||
|
localSecretKeys: secretKeys,
|
||||||
apiCredentials: storage.apiCredentials,
|
apiCredentials: storage.apiCredentials,
|
||||||
ownerPepper: storage.ownerPepper,
|
ownerPepper: storage.ownerPepper,
|
||||||
projectPolicy: storage.projectPolicy,
|
projectPolicy: storage.projectPolicy,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
RunAttemptLogReadRequest,
|
RunAttemptLogReadRequest,
|
||||||
RunAttemptLogReadResult,
|
RunAttemptLogReadResult,
|
||||||
} from '@qinglong/runtime-core/run-attempt-log-read';
|
} from '@qinglong/runtime-core/run-attempt-log-read';
|
||||||
|
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
|
||||||
|
|
||||||
export type LocalApplicationProfile = 'edge' | 'standalone';
|
export type LocalApplicationProfile = 'edge' | 'standalone';
|
||||||
|
|
||||||
@@ -71,6 +72,9 @@ export interface LocalApplicationProductSurfaceAuthority {
|
|||||||
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
|
readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential'];
|
||||||
readonly triggers: ReadyFreshStorage['triggers'];
|
readonly triggers: ReadyFreshStorage['triggers'];
|
||||||
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
|
readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential'];
|
||||||
|
readonly localSecretMetadata: ReadyFreshStorage['localSecretMetadata'];
|
||||||
|
readonly localSecretAdministrationForCredential: ReadyFreshStorage['localSecretAdministrationForCredential'];
|
||||||
|
readonly localSecretKeys: LocalSecretKeyProvider;
|
||||||
readonly runAttemptLogRead: Readonly<{
|
readonly runAttemptLogRead: Readonly<{
|
||||||
read(
|
read(
|
||||||
request: Readonly<RunAttemptLogReadRequest>,
|
request: Readonly<RunAttemptLogReadRequest>,
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
|
|||||||
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
|
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
|
||||||
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
|
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
|
||||||
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
|
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
|
||||||
|
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
|
||||||
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
|
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
|
||||||
|
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
|
||||||
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
|
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
|
||||||
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
|
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
|
||||||
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
|
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
|
||||||
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
|
|||||||
runAttemptLogRetention: database.runAttemptLogRetention,
|
runAttemptLogRetention: database.runAttemptLogRetention,
|
||||||
runLostRetry: database.runLostRetry,
|
runLostRetry: database.runLostRetry,
|
||||||
localSecrets: database.localSecrets,
|
localSecrets: database.localSecrets,
|
||||||
|
localSecretMetadata: database.localSecretMetadata,
|
||||||
localSecretAdministration: database.localSecretAdministration,
|
localSecretAdministration: database.localSecretAdministration,
|
||||||
|
localSecretAdministrationForCredential:
|
||||||
|
database.localSecretAdministrationForCredential,
|
||||||
projectPolicy: database.projectPolicy,
|
projectPolicy: database.projectPolicy,
|
||||||
securityAudit: database.securityAudit,
|
securityAudit: database.securityAudit,
|
||||||
apiCredentials: database.apiCredentials,
|
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 { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
|
||||||
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
|
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
|
||||||
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
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 { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
|
||||||
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||||
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
||||||
@@ -49,6 +52,7 @@ import type {
|
|||||||
ProjectToolDefinitionSnapshotSourceRepository,
|
ProjectToolDefinitionSnapshotSourceRepository,
|
||||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||||
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||||
|
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
|
||||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||||
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
|
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
|
||||||
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
|
|||||||
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
|
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
|
||||||
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
|
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
|
||||||
readonly localSecrets: LocalSecretEnvelopeRepository;
|
readonly localSecrets: LocalSecretEnvelopeRepository;
|
||||||
|
readonly localSecretMetadata: LocalSecretMetadataSource;
|
||||||
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
||||||
|
localSecretAdministrationForCredential(
|
||||||
|
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||||
|
): Promise<LocalSecretAdministrationRepository>;
|
||||||
readonly projectPolicy: ProjectPolicyRepository;
|
readonly projectPolicy: ProjectPolicyRepository;
|
||||||
readonly securityAudit: SecurityAuditSink;
|
readonly securityAudit: SecurityAuditSink;
|
||||||
readonly apiCredentials: ApiCredentialRepository;
|
readonly apiCredentials: ApiCredentialRepository;
|
||||||
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
|
|||||||
const runRuntimeCapabilities =
|
const runRuntimeCapabilities =
|
||||||
createLocalSqliteRunRuntimeCapabilities(authority);
|
createLocalSqliteRunRuntimeCapabilities(authority);
|
||||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||||
|
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
|
||||||
|
authority,
|
||||||
|
);
|
||||||
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
|
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
|
||||||
authority,
|
authority,
|
||||||
taskSpecSemanticRegistry,
|
taskSpecSemanticRegistry,
|
||||||
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
|
|||||||
runAttemptLogRetention,
|
runAttemptLogRetention,
|
||||||
runLostRetry,
|
runLostRetry,
|
||||||
localSecrets: securityAuthority,
|
localSecrets: securityAuthority,
|
||||||
|
localSecretMetadata,
|
||||||
localSecretAdministration: securityAuthority,
|
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,
|
projectPolicy,
|
||||||
securityAudit: securityAuthority,
|
securityAudit: securityAuthority,
|
||||||
apiCredentials,
|
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>;
|
}): 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 {
|
export interface PutEncryptedLocalSecretCommand {
|
||||||
readonly projectId: string;
|
readonly projectId: string;
|
||||||
readonly name: 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);
|
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);
|
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 (
|
if (
|
||||||
!Number.isSafeInteger(value) ||
|
!Number.isSafeInteger(value) ||
|
||||||
(value as number) < 1 ||
|
(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);
|
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 (
|
if (
|
||||||
typeof value !== 'string' ||
|
typeof value !== 'string' ||
|
||||||
value.length === 0 ||
|
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 (
|
if (
|
||||||
typeof value !== 'string' ||
|
typeof value !== 'string' ||
|
||||||
value.includes('\0') ||
|
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 (
|
if (
|
||||||
!Number.isSafeInteger(value) ||
|
!Number.isSafeInteger(value) ||
|
||||||
(value as number) < 0 ||
|
(value as number) < 0 ||
|
||||||
|
|||||||
@@ -1566,10 +1566,11 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!(
|
![
|
||||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
'packages/ql3-local-owner-cli:src/security-management/secretCommand.ts',
|
||||||
path.relative(packageDirectory, filePath) ===
|
'packages/ql3-local-api:src/secret/secretRoutes.ts',
|
||||||
'src/security-management/secretCommand.ts'
|
].includes(
|
||||||
|
`${packagePath}:${path.relative(packageDirectory, filePath)}`,
|
||||||
) &&
|
) &&
|
||||||
specifier === '@qinglong/local-admin/secret-administration'
|
specifier === '@qinglong/local-admin/secret-administration'
|
||||||
) {
|
) {
|
||||||
@@ -2682,6 +2683,11 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
specifier ===
|
specifier ===
|
||||||
'@qinglong/runtime-core/plugin-package-automation-publication'
|
'@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) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
'src/production-process/pluginPackageRecoveryCatalog.ts' &&
|
'src/production-process/pluginPackageRecoveryCatalog.ts' &&
|
||||||
@@ -3168,6 +3174,14 @@ function auditSourceImports(root, packagePath, findings) {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
packagePath === 'packages/ql3-local-api' &&
|
||||||
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
'src/secret/secretRoutes.ts' &&
|
||||||
|
specifier === '@qinglong/local-admin/secret-administration'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
packagePath === 'packages/ql3-local-api' &&
|
packagePath === 'packages/ql3-local-api' &&
|
||||||
path.relative(packageDirectory, filePath) ===
|
path.relative(packageDirectory, filePath) ===
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
rootSourceFileRoles: localApi.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 24,
|
sourceFiles: 25,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 84,
|
rootSourceLines: 84,
|
||||||
nestedSourceFiles: 23,
|
nestedSourceFiles: 24,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 209,
|
sourceFiles: 210,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 31,
|
rootSourceLines: 31,
|
||||||
nestedSourceFiles: 208,
|
nestedSourceFiles: 209,
|
||||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user