mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind package secrets to generations
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
`e7c1743e932f2d7c35dc9153cdf5bc4a03356a38d93fce5507354652aa207a05`,独立审计与 Docker 清理通过。完整验证证据见 ADR-0391。
|
||||
|
||||
- D-304/ADR-0392(已接受):Plugin Package 进入安全 quarantine 时,Workflow/Prompt automation publication 不再仅依赖运行时 start guard 间接拒绝,而是与 quarantine event、Package-owned Task disabled revisions、Project Tool snapshot 和 withdrawal receipt 在同一 SQLite/PostgreSQL 事务中收敛为 `withdrawn`。原先仅能引用普通 lifecycle event 的外键升级为 append-only disposition-event 联合引用,历史 migration 与 publication digest schema 保持不变;SQLite edge/standalone 崩溃矩阵覆盖 automation publication insert 后、event/task/receipt/COMMIT 前后,PostgreSQL 通过触发器在既有 `SECURITY DEFINER` quarantine commit 内登记 disposition,再由同一外层 SERIALIZABLE transaction CAS publication head。能力位为 `plugin_package_automation_security_withdrawal@1`;不新增 package、daemon、timer、连接或常驻缓存,适用于低配路由设备和集群节点。SQLite 全量 228/228;PostgreSQL package 311 pass/1 条外部 URL 条件 skip;完整 18-package build/test 退出 0,backend 1,188 pass/2 skip,package/dependency boundary 零 finding;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`。
|
||||
- D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。
|
||||
- D-302/ADR-0390(已接受)
|
||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR-0393:按 Generation 固定的 Plugin Package Secret 绑定账本
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-13
|
||||
- 关联 RFC:QL-RFC-0001 D-305
|
||||
- 关联 ADR:ADR-0216、ADR-0221、ADR-0392
|
||||
|
||||
## 问题
|
||||
|
||||
Plugin Package Manifest 已能声明逻辑 Secret 需求,但 materialization 仍会拒绝任何 Secret requirement。仅把 Secret 名称传入执行器会留下三类不确定性:Secret rotation 会让同一 generation 在不同时间解析到不同值,跨 Project 引用可能越权,安装确认与实际执行也缺少可审计的同一事实。
|
||||
|
||||
QingLong 3.0 同时面向低配路由设备与集群节点。解决方案不能为 Local/Edge 引入 Secret watcher、常驻缓存、额外数据库连接或独立 daemon,也不能在集群数据库中向宽权限角色暴露 Secret 绑定事实。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 引入 `qinglong/plugin-package-secret-binding@v1`。一个不可变 binding 精确绑定一个 Package resource generation、Manifest digest 与 lock digest,并完整覆盖 Manifest 声明的所有 Secret requirement。
|
||||
2. binding 只保存 `qlsecret://` 引用,不保存 Secret 明文。引用必须与 Package installation 位于同一 Project,并固定显式 version;required requirement 不允许为空,optional requirement 可以显式记录 `null`。
|
||||
3. binding 必须携带 `approved-action-execution` 或 `local-owner-confirmation` authority kind 及其 evidence digest。D-305 只定义并持久化 authority 事实,不新增绕过既有审批或 owner-confirmation 的入口。
|
||||
4. 发布时必须证明目标仍是当前 active installation head,且 installation、Project、Package、lock、generation 与 Manifest digest 全部一致。相同 generation 的完全相同内容幂等返回 existing;不同内容冲突并失败关闭。
|
||||
5. binding 使用 domain-separated SHA-256 摘要,持久 JSON 上限 64 KiB,最多沿用 Manifest 的 64 个 Secret requirement 上限。SQLite 与 PostgreSQL 分别追加同构 ledger,不改写历史 migration。
|
||||
6. Local 使用既有单写者 SQLite authority;Cluster 仅向 `ql3_package_executor` 授予 ledger 的 `SELECT, INSERT`,runtime、admin、package manager 与 worker ingress 均无权限。实现不新增 package、进程、timer、watcher、连接或常驻缓存。
|
||||
7. ledger 是历史事实,不直接成为 Package lifecycle 的 active-consumer blocker。D-306 在消费 binding 并产生实际 materialization/执行依赖时,再定义 Secret resolution、撤回与 rebinding 的生命周期规则。
|
||||
|
||||
## 方言映射
|
||||
|
||||
| 项目 | Local / Edge / Standalone | Cluster |
|
||||
| --- | --- | --- |
|
||||
| migration | `0091-plugin-package-secret-bindings` + `0092-capability-v46` | `pg-0059-plugin-package-secret-bindings` |
|
||||
| capability | `plugin_package_secret_binding@1`,contract v46 | `plugin_package_secret_binding@1`,contract v58 |
|
||||
| 写入串行化 | 既有 SQLite 单写者 authority | PostgreSQL 唯一约束与 active-head 条件写入 |
|
||||
| 资源增量 | 一个有界 append-only 表、三个索引 | 一个有界 append-only 表、三个索引 |
|
||||
| 常驻开销 | 无新增进程、连接、timer、watcher、cache | 无新增 Pod、worker、连接、timer、watcher、cache |
|
||||
|
||||
## 明确不在 D-305 中完成
|
||||
|
||||
- 不读取或解密 Secret 明文,不把 Secret 注入 Task 环境。
|
||||
- 不提供面向用户的 bind/rebind 命令或远程管理 API。
|
||||
- 不声明 Secret rotation 自动改变既有 generation;新版本必须由后续显式授权和新事实消费。
|
||||
- 不改变现有“Manifest 含 Secret requirement 时 materialization 拒绝”的执行边界。
|
||||
|
||||
## 替代方案
|
||||
|
||||
### 执行时解析浮动 Secret 名称
|
||||
|
||||
拒绝。它让同一 generation 的行为随 rotation 漂移,无法复现,也无法把授权证据与实际版本绑定。
|
||||
|
||||
### 把加密 Secret 值复制进 Package ledger
|
||||
|
||||
拒绝。它扩大密文托管面、备份恢复面与轮换复杂度,也不必要地增加路由设备存储和集群权限面。
|
||||
|
||||
### 只在产品层保存临时绑定
|
||||
|
||||
拒绝。崩溃、重启或主库切换后无法证明执行消费的是安装时确认的同一绑定。
|
||||
|
||||
## 影响
|
||||
|
||||
- 相同 Package generation 的 Secret 选择成为可复现、可摘要、可审计的不可变事实。
|
||||
- Secret 明文仍留在既有 Secret custody 边界内;ledger 泄露只暴露有界引用元数据,不暴露值。
|
||||
- D-305 建立的是消费前置条件,不代表 Secret-aware Package 已可运行;产品授权与 runtime consumption 仍属于 D-306。
|
||||
- Local 与 Cluster 保持同一领域契约、不同存储 authority,低配设备不承担集群组件成本。
|
||||
|
||||
## 验证
|
||||
|
||||
- core contract:精确 Manifest coverage、required/optional、同 Project、固定 version、canonical order、authority/digest/shape drift 与 generation/Manifest 重校验。
|
||||
- SQLite:幂等、冲突、active generation fencing、tamper fail-closed、migration checksum、typed schema、readiness 与 rollout safety。
|
||||
- PostgreSQL:同构 repository contract、migration checksum、schema/readiness 与 package-executor 最小权限。
|
||||
- 阶段完成前运行完整 18-package build/test、backend 回归、package/dependency boundary 审计和 PostgreSQL 18 physical-streaming HA gate。
|
||||
|
||||
## 当前证据
|
||||
|
||||
- runtime-core 全量 509/509;local-sqlite 全量 232/232。
|
||||
- cluster-postgres 317 项:316 pass、1 条仅在未提供独立测试数据库 URL 时条件 skip、0 fail。
|
||||
- 完整 18-package clean build/test 退出 0;Worker 133/133,AI 209 pass/3 条件 skip,Cluster Admin 302 pass/2 条件 skip,Local Owner 163 pass/5 条件 skip,其余 package 同样 0 fail。
|
||||
- backend 1,190 项:1,188 pass、2 条件 skip、0 fail。package boundary、cluster dependency boundary、edge import、service bridge import 与 local image 五项审计均为 0 finding;workspace 仍为 18 package、无 single-source/shallow-source package,PostgreSQL 60 个与 SQLite 93 个 migration 文件继续作为显式 `ordered_ledger` 管理。
|
||||
- PostgreSQL 18.4 arm64 physical-streaming HA 125 项 gate 全绿,timeline `1→2`,旧主完成 fence 与 `pg_rewind` 只读重入;报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。
|
||||
@@ -396,6 +396,7 @@
|
||||
| [ADR-0390](./ADR-0390-offline-cluster-operator-context-preflight.md) | Offline Cluster Operator Context Preflight | Accepted |
|
||||
| [ADR-0391](./ADR-0391-read-only-cluster-operator-context-readiness.md) | Read-only Cluster Operator Context Readiness | Accepted |
|
||||
| [ADR-0392](./ADR-0392-atomic-plugin-package-automation-security-withdrawal.md) | Plugin Package Automation 安全隔离原子撤回 | Accepted |
|
||||
| [ADR-0393](./ADR-0393-generation-bound-plugin-package-secret-binding-ledger.md) | 按 Generation 固定的 Plugin Package Secret 绑定账本 | Accepted |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
"reviewedDenseDirectories": [
|
||||
{
|
||||
"kind": "ordered_ledger",
|
||||
"maxDirectSourceFiles": 59,
|
||||
"maxDirectSourceFiles": 60,
|
||||
"path": "packages/ql3-cluster-postgres/src/migrations",
|
||||
"rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory."
|
||||
},
|
||||
{
|
||||
"kind": "ordered_ledger",
|
||||
"maxDirectSourceFiles": 91,
|
||||
"maxDirectSourceFiles": 93,
|
||||
"path": "packages/ql3-local-sqlite/src/migrations",
|
||||
"rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory."
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
"path": "packages/ql3-cluster-postgres",
|
||||
"name": "@qinglong/cluster-postgres",
|
||||
"rootSourceFileHardCap": 1,
|
||||
"rootSourceLineHardCap": 125,
|
||||
"rootSourceLineHardCap": 126,
|
||||
"rootSourceFileRoles": {
|
||||
"index.ts": "public_export"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ function executorPrivileges() {
|
||||
'plugin_package_materialized_revisions',
|
||||
'plugin_package_automation_publications',
|
||||
'plugin_package_automation_publication_heads',
|
||||
'plugin_package_secret_bindings',
|
||||
'project_tool_definition_snapshots',
|
||||
'project_tool_definition_snapshot_sources',
|
||||
'plugin_package_publisher_provenance',
|
||||
|
||||
@@ -199,6 +199,7 @@ function runtimePrivileges() {
|
||||
plugin_package_automation_publications: [true, false, false, false],
|
||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
plugin_package_workflow_admissions: [true, true, false, false],
|
||||
plugin_package_workflow_admission_steps: [true, true, false, false],
|
||||
plugin_package_workflow_task_attempt_admissions: [true, true, false, false],
|
||||
|
||||
@@ -113,6 +113,7 @@ function runtimePrivileges() {
|
||||
plugin_package_automation_publications: [true, false, false, false],
|
||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||
plugin_package_automation_publication_heads: [true, false, false, false],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
plugin_package_workflow_admissions: [true, true, false, false],
|
||||
plugin_package_workflow_admission_steps: [true, true, false, false],
|
||||
plugin_package_workflow_task_attempt_admissions: [true, true, false, false],
|
||||
|
||||
@@ -140,6 +140,11 @@
|
||||
"require": "./dist/plugin-package/installation/pluginPackageMaterializedRevisionRepository.js",
|
||||
"default": "./dist/plugin-package/installation/pluginPackageMaterializedRevisionRepository.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/installation/pluginPackageSecretBindingRepository.d.ts",
|
||||
"require": "./dist/plugin-package/installation/pluginPackageSecretBindingRepository.js",
|
||||
"default": "./dist/plugin-package/installation/pluginPackageSecretBindingRepository.js"
|
||||
},
|
||||
"./plugin-package-automation-publication": {
|
||||
"types": "./dist/plugin-package/publication/pluginPackageAutomationPublicationRepository.d.ts",
|
||||
"require": "./dist/plugin-package/publication/pluginPackageAutomationPublicationRepository.js",
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
} from '../schema/schemaReadiness';
|
||||
|
||||
export { PostgresPluginPackageMaterializedRevisionRepository } from '../plugin-package/installation/pluginPackageMaterializedRevisionRepository';
|
||||
export { PostgresPluginPackageSecretBindingRepository } from '../plugin-package/installation/pluginPackageSecretBindingRepository';
|
||||
export { PostgresPluginPackageAutomationPublicationRepository } from '../plugin-package/publication/pluginPackageAutomationPublicationRepository';
|
||||
export {
|
||||
CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT,
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
pluginPackageIdentityKeysetLedger,
|
||||
pluginPackageManagementQuotaBuckets,
|
||||
pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings,
|
||||
pluginPackageQuarantineEvents,
|
||||
pluginPackageWorkflowAdmissions,
|
||||
pluginPackageWorkflowAdmissionSteps,
|
||||
|
||||
@@ -298,5 +298,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest =
|
||||
checksum:
|
||||
'd184324909f1e450f3c1b58d422796e3869a1360df60c3f2dfe4af0bacc37471',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pg-0059-plugin-package-secret-bindings',
|
||||
checksum:
|
||||
'87582d256c868bd7f5af352c4b052fdab9f3714e1e7179e35d33bfa5d62957be',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ import { pg0055RunAttemptLogRetentionMigration } from './pg-0055-run-attempt-log
|
||||
import { pg0056RunManagementBoundaryMigration } from '../run-management/pg-0056-run-management-boundary';
|
||||
import { pg0057RunManagementStopBoundaryMigration } from '../run-management/pg-0057-run-management-stop-boundary';
|
||||
import { pg0058PluginPackageAutomationDispositionEventsMigration } from './pg-0058-plugin-package-automation-disposition-events';
|
||||
import { pg0059PluginPackageSecretBindingsMigration } from './pg-0059-plugin-package-secret-bindings';
|
||||
|
||||
export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMigrationContext> =
|
||||
Object.freeze({
|
||||
@@ -127,5 +128,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition<PostgresMi
|
||||
pg0056RunManagementBoundaryMigration,
|
||||
pg0057RunManagementStopBoundaryMigration,
|
||||
pg0058PluginPackageAutomationDispositionEventsMigration,
|
||||
pg0059PluginPackageSecretBindingsMigration,
|
||||
]),
|
||||
});
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { CAPABILITIES_V57 } from './pg-0058-plugin-package-automation-disposition-events';
|
||||
import { definePostgresSqlMigration } from './sqlMigration';
|
||||
|
||||
export const CAPABILITIES_V58 = CAPABILITIES_V57.replace(
|
||||
'"plugin_package_task_reconciliation":1,',
|
||||
'"plugin_package_secret_binding":1,"plugin_package_task_reconciliation":1,',
|
||||
);
|
||||
|
||||
export const pg0059PluginPackageSecretBindingsMigration =
|
||||
definePostgresSqlMigration({
|
||||
id: 'pg-0059-plugin-package-secret-bindings',
|
||||
statements: [
|
||||
`
|
||||
CREATE TABLE "ql3"."plugin_package_secret_bindings" (
|
||||
generation_digest char(64) PRIMARY KEY,
|
||||
project_id varchar(128) NOT NULL,
|
||||
package_name varchar(63) NOT NULL,
|
||||
installation_id varchar(128) NOT NULL,
|
||||
lock_digest char(64) NOT NULL,
|
||||
generation integer NOT NULL,
|
||||
manifest_digest char(64) NOT NULL,
|
||||
authority_kind varchar(32) NOT NULL,
|
||||
evidence_digest char(64) NOT NULL,
|
||||
bound_at_ms bigint NOT NULL,
|
||||
binding_digest char(64) NOT NULL,
|
||||
binding_json jsonb NOT NULL,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "ql3"."projects" (id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_install_fk
|
||||
FOREIGN KEY (installation_id)
|
||||
REFERENCES "ql3"."plugin_package_installs" (installation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_identity_check CHECK (
|
||||
project_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
package_name ~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' AND
|
||||
installation_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' AND
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
authority_kind IN ('approved-action-execution','local-owner-confirmation') AND
|
||||
bound_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_digest_check CHECK (
|
||||
generation_digest ~ '^[0-9a-f]{64}$' AND
|
||||
lock_digest ~ '^[0-9a-f]{64}$' AND
|
||||
manifest_digest ~ '^[0-9a-f]{64}$' AND
|
||||
evidence_digest ~ '^[0-9a-f]{64}$' AND
|
||||
binding_digest ~ '^[0-9a-f]{64}$'
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_json_check CHECK (
|
||||
jsonb_typeof(binding_json) = 'object' AND
|
||||
octet_length(binding_json::text) BETWEEN 2 AND 65536 AND
|
||||
binding_json @> jsonb_build_object(
|
||||
'schema', 'qinglong/plugin-package-secret-binding@v1',
|
||||
'target', jsonb_build_object(
|
||||
'generationDigest', generation_digest,
|
||||
'projectId', project_id,
|
||||
'packageName', package_name,
|
||||
'installationId', installation_id,
|
||||
'lockDigest', lock_digest,
|
||||
'generation', generation,
|
||||
'manifestDigest', manifest_digest
|
||||
),
|
||||
'authority', jsonb_build_object(
|
||||
'kind', authority_kind,
|
||||
'evidenceDigest', evidence_digest
|
||||
),
|
||||
'boundAtMs', bound_at_ms,
|
||||
'bindingDigest', binding_digest
|
||||
) AND
|
||||
jsonb_typeof(binding_json -> 'entries') = 'array' AND
|
||||
jsonb_array_length(binding_json -> 'entries') BETWEEN 1 AND 64
|
||||
)
|
||||
)
|
||||
`.trim(),
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_generation_uidx ON "ql3"."plugin_package_secret_bindings" (project_id, package_name, generation)`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_digest_uidx ON "ql3"."plugin_package_secret_bindings" (binding_digest)`,
|
||||
`CREATE INDEX ql3_plugin_package_secret_binding_install_idx ON "ql3"."plugin_package_secret_bindings" (installation_id, generation_digest)`,
|
||||
`REVOKE ALL ON "ql3"."plugin_package_secret_bindings" FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`,
|
||||
`GRANT SELECT, INSERT ON "ql3"."plugin_package_secret_bindings" TO ql3_package_executor`,
|
||||
`DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 58, migration_id = 'pg-0059-plugin-package-secret-bindings', capabilities = '${CAPABILITIES_V58}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 57 AND migration_id = 'pg-0058-plugin-package-automation-disposition-events' AND capabilities = '${CAPABILITIES_V57}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 57' USING ERRCODE = 'check_violation'; END IF; END $ql3$`,
|
||||
],
|
||||
});
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES,
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
normalizePluginPackageSecretBinding,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import {
|
||||
postgresRequiredInteger,
|
||||
postgresRequiredJsonObject,
|
||||
postgresRequiredString,
|
||||
postgresSqlState,
|
||||
} from '../../repository/definitionRepositorySupport';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageSecretBindingError(message);
|
||||
}
|
||||
|
||||
function unavailable(): PluginPackageSecretBindingUnavailableError {
|
||||
return new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
|
||||
function generationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('generation digest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(binding: Readonly<PluginPackageSecretBinding>): string {
|
||||
const value = JSON.stringify(binding);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageSecretBindingError ||
|
||||
error instanceof PluginPackageSecretBindingConflictError ||
|
||||
error instanceof PluginPackageSecretBindingUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const state = postgresSqlState(error);
|
||||
if (state === '23503' || state === '23505' || state === '23514') {
|
||||
return new PluginPackageSecretBindingConflictError(
|
||||
'durable binding identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class PostgresPluginPackageSecretBindingRepository
|
||||
implements PluginPackageSecretBindingRepository
|
||||
{
|
||||
constructor(private readonly pool: Pick<PostgresPool, 'query'>) {
|
||||
if (!pool || typeof pool.query !== 'function') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL Plugin Package Secret binding repository options are invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parse(row: Row): Readonly<PluginPackageSecretBinding> {
|
||||
try {
|
||||
const binding = normalizePluginPackageSecretBinding(
|
||||
postgresRequiredJsonObject(row.bindingJson, unavailable),
|
||||
);
|
||||
if (
|
||||
binding.target.generationDigest !==
|
||||
postgresRequiredString(row.generationDigest, unavailable) ||
|
||||
binding.target.projectId !==
|
||||
postgresRequiredString(row.projectId, unavailable) ||
|
||||
binding.target.packageName !==
|
||||
postgresRequiredString(row.packageName, unavailable) ||
|
||||
binding.target.installationId !==
|
||||
postgresRequiredString(row.installationId, unavailable) ||
|
||||
binding.target.lockDigest !==
|
||||
postgresRequiredString(row.lockDigest, unavailable) ||
|
||||
binding.target.generation !==
|
||||
postgresRequiredInteger(row.generation, unavailable) ||
|
||||
binding.target.manifestDigest !==
|
||||
postgresRequiredString(row.manifestDigest, unavailable) ||
|
||||
binding.authority.kind !==
|
||||
postgresRequiredString(row.authorityKind, unavailable) ||
|
||||
binding.authority.evidenceDigest !==
|
||||
postgresRequiredString(row.evidenceDigest, unavailable) ||
|
||||
binding.boundAtMs !==
|
||||
postgresRequiredInteger(row.boundAtMs, unavailable) ||
|
||||
binding.bindingDigest !==
|
||||
postgresRequiredString(row.bindingDigest, unavailable)
|
||||
) {
|
||||
throw unavailable();
|
||||
}
|
||||
return binding;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageSecretBindingUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private async findStored(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBinding> | null> {
|
||||
const result = await this.pool.query<Row>(
|
||||
`SELECT generation_digest AS "generationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
manifest_digest AS "manifestDigest",
|
||||
authority_kind AS "authorityKind",
|
||||
evidence_digest AS "evidenceDigest",
|
||||
bound_at_ms AS "boundAtMs",
|
||||
binding_digest AS "bindingDigest",
|
||||
binding_json AS "bindingJson"
|
||||
FROM "ql3"."plugin_package_secret_bindings"
|
||||
WHERE generation_digest = $1
|
||||
LIMIT 2`,
|
||||
[digest],
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
if (result.rows.length !== 1) throw unavailable();
|
||||
return this.parse(result.rows[0]!);
|
||||
}
|
||||
|
||||
async find(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBinding> | null> {
|
||||
try {
|
||||
return await this.findStored(generationDigest(digest));
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(value: Readonly<PluginPackageSecretBinding>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}>
|
||||
> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
const bindingJson = serialize(binding);
|
||||
try {
|
||||
const existing = await this.findStored(binding.target.generationDigest);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== bindingJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation digest is bound to another Secret mapping',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
binding: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const inserted = await this.pool.query(
|
||||
`INSERT INTO "ql3"."plugin_package_secret_bindings" (
|
||||
generation_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, manifest_digest, authority_kind,
|
||||
evidence_digest, bound_at_ms, binding_digest, binding_json
|
||||
)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb
|
||||
FROM "ql3"."plugin_package_installs" AS install
|
||||
INNER JOIN "ql3"."plugin_package_install_heads" AS head
|
||||
ON head.installation_id = install.installation_id
|
||||
AND head.project_id = install.project_id
|
||||
AND head.package_name = install.package_name
|
||||
WHERE install.installation_id = $4
|
||||
AND install.project_id = $2
|
||||
AND install.package_name = $3
|
||||
AND install.lock_digest = $5
|
||||
AND install.active_lock_digest = $5
|
||||
AND install.target_generation = $6
|
||||
AND install.state = 'active'
|
||||
AND install.lock_json ->> 'manifestDigest' = $7
|
||||
ON CONFLICT (generation_digest) DO NOTHING
|
||||
RETURNING generation_digest`,
|
||||
[
|
||||
binding.target.generationDigest,
|
||||
binding.target.projectId,
|
||||
binding.target.packageName,
|
||||
binding.target.installationId,
|
||||
binding.target.lockDigest,
|
||||
binding.target.generation,
|
||||
binding.target.manifestDigest,
|
||||
binding.authority.kind,
|
||||
binding.authority.evidenceDigest,
|
||||
binding.boundAtMs,
|
||||
binding.bindingDigest,
|
||||
bindingJson,
|
||||
],
|
||||
);
|
||||
const stored = await this.findStored(binding.target.generationDigest);
|
||||
if (!stored) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'binding target is not the current active Package generation',
|
||||
);
|
||||
}
|
||||
if (JSON.stringify(stored) !== bindingJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation digest is bound to another Secret mapping',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
inserted.rows.length === 1
|
||||
? ('created' as const)
|
||||
: ('existing' as const),
|
||||
binding: stored,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,66 @@ export const pluginPackageMaterializedRevisions = ql3Schema.table(
|
||||
],
|
||||
);
|
||||
|
||||
export const pluginPackageSecretBindings = ql3Schema.table(
|
||||
'plugin_package_secret_bindings',
|
||||
{
|
||||
generationDigest: char('generation_digest', { length: 64 }).primaryKey(),
|
||||
projectId: varchar('project_id', { length: 128 }).notNull(),
|
||||
packageName: varchar('package_name', { length: 63 }).notNull(),
|
||||
installationId: varchar('installation_id', { length: 128 }).notNull(),
|
||||
lockDigest: char('lock_digest', { length: 64 }).notNull(),
|
||||
generation: integer('generation').notNull(),
|
||||
manifestDigest: char('manifest_digest', { length: 64 }).notNull(),
|
||||
authorityKind: varchar('authority_kind', { length: 32 }).notNull(),
|
||||
evidenceDigest: char('evidence_digest', { length: 64 }).notNull(),
|
||||
boundAtMs: bigint('bound_at_ms', { mode: 'number' }).notNull(),
|
||||
bindingDigest: char('binding_digest', { length: 64 }).notNull(),
|
||||
bindingJson: jsonb('binding_json')
|
||||
.$type<Record<string, unknown>>()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
name: 'ql3_plugin_package_secret_binding_project_fk',
|
||||
columns: [table.projectId],
|
||||
foreignColumns: [projects.id],
|
||||
})
|
||||
.onDelete('restrict')
|
||||
.onUpdate('restrict'),
|
||||
foreignKey({
|
||||
name: 'ql3_plugin_package_secret_binding_install_fk',
|
||||
columns: [table.installationId],
|
||||
foreignColumns: [pluginPackageInstalls.installationId],
|
||||
})
|
||||
.onDelete('restrict')
|
||||
.onUpdate('restrict'),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_identity_check',
|
||||
sql`${table.projectId} ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' and ${table.packageName} ~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' and ${table.installationId} ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' and ${table.generation} between 1 and 2147483647 and ${table.authorityKind} in ('approved-action-execution','local-owner-confirmation') and ${table.boundAtMs} >= 0`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_digest_check',
|
||||
sql`${table.generationDigest} ~ '^[0-9a-f]{64}$' and ${table.lockDigest} ~ '^[0-9a-f]{64}$' and ${table.manifestDigest} ~ '^[0-9a-f]{64}$' and ${table.evidenceDigest} ~ '^[0-9a-f]{64}$' and ${table.bindingDigest} ~ '^[0-9a-f]{64}$'`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_json_check',
|
||||
sql`jsonb_typeof(${table.bindingJson}) = 'object' and octet_length(${table.bindingJson}::text) between 2 and 65536 and ${table.bindingJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding@v1', 'target', jsonb_build_object('generationDigest', ${table.generationDigest}, 'projectId', ${table.projectId}, 'packageName', ${table.packageName}, 'installationId', ${table.installationId}, 'lockDigest', ${table.lockDigest}, 'generation', ${table.generation}, 'manifestDigest', ${table.manifestDigest}), 'authority', jsonb_build_object('kind', ${table.authorityKind}, 'evidenceDigest', ${table.evidenceDigest}), 'boundAtMs', ${table.boundAtMs}, 'bindingDigest', ${table.bindingDigest}) and jsonb_typeof(${table.bindingJson} -> 'entries') = 'array' and jsonb_array_length(${table.bindingJson} -> 'entries') between 1 and 64`,
|
||||
),
|
||||
uniqueIndex('ql3_plugin_package_secret_binding_generation_uidx').on(
|
||||
table.projectId,
|
||||
table.packageName,
|
||||
table.generation,
|
||||
),
|
||||
uniqueIndex('ql3_plugin_package_secret_binding_digest_uidx').on(
|
||||
table.bindingDigest,
|
||||
),
|
||||
index('ql3_plugin_package_secret_binding_install_idx').on(
|
||||
table.installationId,
|
||||
table.generationDigest,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const projectToolDefinitionSnapshots = ql3Schema.table(
|
||||
'project_tool_definition_snapshots',
|
||||
{
|
||||
@@ -5812,6 +5872,7 @@ export const ql3PostgresTables = [
|
||||
pluginPackageInstallHeads,
|
||||
pluginPackageInstallMutations,
|
||||
pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings,
|
||||
projectToolDefinitionSnapshots,
|
||||
projectToolDefinitionSnapshotSources,
|
||||
pluginPackageQuarantineEvents,
|
||||
|
||||
@@ -15,8 +15,8 @@ export interface PostgresSchemaContractFunction {
|
||||
export interface PostgresSchemaContract {
|
||||
readonly schema: 'ql3';
|
||||
readonly contractName: 'control-core';
|
||||
readonly contractVersion: 57;
|
||||
readonly migrationId: 'pg-0058-plugin-package-automation-disposition-events';
|
||||
readonly contractVersion: 58;
|
||||
readonly migrationId: 'pg-0059-plugin-package-secret-bindings';
|
||||
readonly minimumServerMajor: 16;
|
||||
readonly maximumServerMajor: 18;
|
||||
readonly capabilities: Readonly<{
|
||||
@@ -55,6 +55,7 @@ export interface PostgresSchemaContract {
|
||||
plugin_package_lifecycle_plan: 1;
|
||||
plugin_package_management_quota: 1;
|
||||
plugin_package_materialized_revision: 1;
|
||||
plugin_package_secret_binding: 1;
|
||||
plugin_package_proposal: 1;
|
||||
plugin_package_publisher_provenance: 1;
|
||||
plugin_package_publisher_trust_authority: 1;
|
||||
@@ -104,8 +105,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
Object.freeze({
|
||||
schema: 'ql3',
|
||||
contractName: 'control-core',
|
||||
contractVersion: 57,
|
||||
migrationId: 'pg-0058-plugin-package-automation-disposition-events',
|
||||
contractVersion: 58,
|
||||
migrationId: 'pg-0059-plugin-package-secret-bindings',
|
||||
minimumServerMajor: 16,
|
||||
maximumServerMajor: 18,
|
||||
capabilities: Object.freeze({
|
||||
@@ -137,6 +138,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
plugin_package_lifecycle_plan: 1,
|
||||
plugin_package_management_quota: 1,
|
||||
plugin_package_materialized_revision: 1,
|
||||
plugin_package_secret_binding: 1,
|
||||
plugin_package_proposal: 1,
|
||||
plugin_package_publisher_provenance: 1,
|
||||
plugin_package_publisher_trust_authority: 1,
|
||||
@@ -242,6 +244,20 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
'revision_json',
|
||||
'created_at_ms',
|
||||
]),
|
||||
table('plugin_package_secret_bindings', [
|
||||
'generation_digest',
|
||||
'project_id',
|
||||
'package_name',
|
||||
'installation_id',
|
||||
'lock_digest',
|
||||
'generation',
|
||||
'manifest_digest',
|
||||
'authority_kind',
|
||||
'evidence_digest',
|
||||
'bound_at_ms',
|
||||
'binding_digest',
|
||||
'binding_json',
|
||||
]),
|
||||
table('project_tool_definition_snapshots', [
|
||||
'project_id',
|
||||
'active_vector_digest',
|
||||
@@ -1417,6 +1433,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
'ql3_plugin_package_materialized_revision_generation_uidx',
|
||||
'ql3_plugin_package_materialized_revision_lock_idx',
|
||||
'ql3_plugin_package_materialized_revision_snapshot_source_uidx',
|
||||
'plugin_package_secret_bindings_pkey',
|
||||
'ql3_plugin_package_secret_binding_generation_uidx',
|
||||
'ql3_plugin_package_secret_binding_digest_uidx',
|
||||
'ql3_plugin_package_secret_binding_install_idx',
|
||||
'project_tool_definition_snapshots_pkey',
|
||||
'ql3_project_tool_snapshot_withdrawal_key',
|
||||
'ql3_project_tool_definition_snapshot_digest_uidx',
|
||||
@@ -1708,6 +1728,9 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
'ql3_plugin_package_installs_version_check',
|
||||
'ql3_plugin_package_installs_digest_check',
|
||||
'ql3_plugin_package_installs_record_check',
|
||||
'ql3_plugin_package_secret_binding_identity_check',
|
||||
'ql3_plugin_package_secret_binding_digest_check',
|
||||
'ql3_plugin_package_secret_binding_json_check',
|
||||
'ql3_plugin_package_quarantine_identity_check',
|
||||
'ql3_plugin_package_quarantine_state_check',
|
||||
'ql3_plugin_package_quarantine_subject_check',
|
||||
@@ -2161,6 +2184,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract =
|
||||
'ql3_plugin_package_install_heads_install_fk',
|
||||
'ql3_plugin_package_install_mutations_install_fk',
|
||||
'ql3_plugin_package_materialized_revision_project_fk',
|
||||
'ql3_plugin_package_secret_binding_project_fk',
|
||||
'ql3_plugin_package_secret_binding_install_fk',
|
||||
'ql3_project_tool_definition_snapshot_project_fk',
|
||||
'ql3_project_tool_definition_snapshot_source_snapshot_fk',
|
||||
'ql3_project_tool_definition_snapshot_source_install_fk',
|
||||
|
||||
@@ -167,6 +167,12 @@ const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
plugin_package_secret_bindings: Object.freeze({
|
||||
select: false,
|
||||
insert: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
project_tool_definition_snapshots: Object.freeze({
|
||||
select: false,
|
||||
insert: false,
|
||||
@@ -680,6 +686,12 @@ const REQUIRED_ADMIN_PRIVILEGES = Object.freeze({
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
plugin_package_secret_bindings: Object.freeze({
|
||||
select: false,
|
||||
insert: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
}),
|
||||
project_tool_definition_snapshots: Object.freeze({
|
||||
select: false,
|
||||
insert: false,
|
||||
@@ -1230,6 +1242,7 @@ const REQUIRED_PACKAGE_EXECUTOR_PRIVILEGES: RequiredPrivileges = Object.freeze(
|
||||
name === 'approved_action_dispatches' ||
|
||||
name === 'plugin_package_install_mutations' ||
|
||||
name === 'plugin_package_materialized_revisions' ||
|
||||
name === 'plugin_package_secret_bindings' ||
|
||||
name === 'project_tool_definition_snapshots' ||
|
||||
name === 'project_tool_definition_snapshot_sources' ||
|
||||
name === 'plugin_package_lifecycle_plans' ||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
PostgresPluginPackageSecretBindingRepository,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageSecretBindingRepository');
|
||||
|
||||
const MANIFEST = {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'Secret binding repository fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['cluster-control'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [{ name: 'TOKEN', required: true }],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
|
||||
function fixture(boundAtMs = 100) {
|
||||
const generation = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: MANIFEST.spec.contents,
|
||||
});
|
||||
return createPluginPackageSecretBinding({
|
||||
generation,
|
||||
manifest: MANIFEST,
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: 'c'.repeat(64),
|
||||
},
|
||||
boundAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function fakePool(active = true) {
|
||||
let row;
|
||||
const queries = [];
|
||||
return {
|
||||
queries,
|
||||
pool: {
|
||||
async query(text, values = []) {
|
||||
queries.push({ text, values });
|
||||
if (text.startsWith('SELECT')) return { rows: row ? [{ ...row }] : [] };
|
||||
if (text.startsWith('INSERT')) {
|
||||
if (!active || row) return { rows: [] };
|
||||
const binding = JSON.parse(values[11]);
|
||||
row = {
|
||||
generationDigest: values[0],
|
||||
projectId: values[1],
|
||||
packageName: values[2],
|
||||
installationId: values[3],
|
||||
lockDigest: values[4],
|
||||
generation: values[5],
|
||||
manifestDigest: values[6],
|
||||
authorityKind: values[7],
|
||||
evidenceDigest: values[8],
|
||||
boundAtMs: values[9],
|
||||
bindingDigest: values[10],
|
||||
bindingJson: binding,
|
||||
};
|
||||
return { rows: [{ generation_digest: values[0] }] };
|
||||
}
|
||||
throw new Error(`unexpected SQL: ${text}`);
|
||||
},
|
||||
},
|
||||
corrupt() {
|
||||
row.bindingJson.boundAtMs = 999;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes, exact-replays and finds one binding', async () => {
|
||||
const value = fakePool();
|
||||
const repository = new PostgresPluginPackageSecretBindingRepository(
|
||||
value.pool,
|
||||
);
|
||||
const binding = fixture();
|
||||
assert.equal((await repository.publish(binding)).status, 'created');
|
||||
assert.equal((await repository.publish(binding)).status, 'existing');
|
||||
assert.deepEqual(
|
||||
await repository.find(binding.target.generationDigest),
|
||||
binding,
|
||||
);
|
||||
assert.match(
|
||||
value.queries.find(({ text }) => text.startsWith('INSERT')).text,
|
||||
/install\.state = 'active'/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects inactive targets and conflicting content', async () => {
|
||||
const inactive = fakePool(false);
|
||||
await assert.rejects(
|
||||
new PostgresPluginPackageSecretBindingRepository(inactive.pool).publish(
|
||||
fixture(),
|
||||
),
|
||||
PluginPackageSecretBindingConflictError,
|
||||
);
|
||||
const active = fakePool();
|
||||
const repository = new PostgresPluginPackageSecretBindingRepository(
|
||||
active.pool,
|
||||
);
|
||||
await repository.publish(fixture());
|
||||
await assert.rejects(
|
||||
repository.publish(fixture(101)),
|
||||
PluginPackageSecretBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on corrupted JSON and maps PostgreSQL constraints', async () => {
|
||||
const value = fakePool();
|
||||
const repository = new PostgresPluginPackageSecretBindingRepository(
|
||||
value.pool,
|
||||
);
|
||||
const binding = fixture();
|
||||
await repository.publish(binding);
|
||||
value.corrupt();
|
||||
await assert.rejects(
|
||||
repository.find(binding.target.generationDigest),
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
);
|
||||
const failing = new PostgresPluginPackageSecretBindingRepository({
|
||||
async query() {
|
||||
const error = new Error('duplicate');
|
||||
error.code = '23505';
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
failing.find('a'.repeat(64)),
|
||||
PluginPackageSecretBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes storage through package-executor and explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('@qinglong/cluster-postgres/plugin-package-secret-binding')
|
||||
.PostgresPluginPackageSecretBindingRepository,
|
||||
PostgresPluginPackageSecretBindingRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist/entrypoints/packageExecutor')
|
||||
.PostgresPluginPackageSecretBindingRepository,
|
||||
PostgresPluginPackageSecretBindingRepository,
|
||||
);
|
||||
});
|
||||
@@ -109,6 +109,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async ()
|
||||
'pg-0056-run-management-boundary',
|
||||
'pg-0057-run-management-stop-boundary',
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
],
|
||||
);
|
||||
for (const migration of postgresqlMainMigrationStream.migrations) {
|
||||
@@ -160,6 +161,7 @@ test('keeps local-only and legacy tables out of the cluster baseline', async ()
|
||||
'plugin_package_install_heads',
|
||||
'plugin_package_install_mutations',
|
||||
'plugin_package_materialized_revisions',
|
||||
'plugin_package_secret_bindings',
|
||||
'plugin_package_lifecycle_events',
|
||||
'plugin_package_lifecycle_heads',
|
||||
'plugin_package_lifecycle_receipts',
|
||||
@@ -534,6 +536,11 @@ test('freezes every published PostgreSQL migration checksum', () => {
|
||||
checksum:
|
||||
'd184324909f1e450f3c1b58d422796e3869a1360df60c3f2dfe4af0bacc37471',
|
||||
},
|
||||
{
|
||||
id: 'pg-0059-plugin-package-secret-bindings',
|
||||
checksum:
|
||||
'87582d256c868bd7f5af352c4b052fdab9f3714e1e7179e35d33bfa5d62957be',
|
||||
},
|
||||
];
|
||||
assert.deepEqual(
|
||||
postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({
|
||||
@@ -1958,3 +1965,26 @@ test('advances capability v56 with column-scoped Run stop authority', async () =
|
||||
assert.match(sql, /contract_version = 55/);
|
||||
assert.match(sql, /migration_id = 'pg-0056-run-management-boundary'/);
|
||||
});
|
||||
|
||||
test('advances capability v58 with immutable generation-bound Package Secret bindings', async () => {
|
||||
const migration = migrationById('pg-0059-plugin-package-secret-bindings');
|
||||
const statements = [];
|
||||
await migration.up({
|
||||
async query(statement) {
|
||||
statements.push(statement);
|
||||
return { rows: [] };
|
||||
},
|
||||
});
|
||||
const sql = statements.join('\n');
|
||||
assert.match(sql, /CREATE TABLE "ql3"\."plugin_package_secret_bindings"/);
|
||||
assert.match(sql, /qinglong\/plugin-package-secret-binding@v1/);
|
||||
assert.match(sql, /GRANT SELECT, INSERT[^;]+TO ql3_package_executor/);
|
||||
assert.doesNotMatch(sql, /GRANT UPDATE|GRANT DELETE/);
|
||||
assert.match(sql, /contract_version = 58/);
|
||||
assert.match(sql, /"plugin_package_secret_binding":1/);
|
||||
assert.match(sql, /contract_version = 57/);
|
||||
assert.match(
|
||||
sql,
|
||||
/migration_id = 'pg-0058-plugin-package-automation-disposition-events'/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@ function validPrivileges() {
|
||||
plugin_package_install_heads: [false, false, false, false],
|
||||
plugin_package_install_mutations: [false, false, false, false],
|
||||
plugin_package_materialized_revisions: [false, false, false, false],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
project_tool_definition_snapshots: [false, false, false, false],
|
||||
project_tool_definition_snapshot_sources: [false, false, false, false],
|
||||
plugin_package_quarantine_events: [false, false, false, false],
|
||||
@@ -192,6 +193,7 @@ function validAdminPrivileges() {
|
||||
plugin_package_install_heads: [false, false, false, false],
|
||||
plugin_package_install_mutations: [false, false, false, false],
|
||||
plugin_package_materialized_revisions: [false, false, false, false],
|
||||
plugin_package_secret_bindings: [false, false, false, false],
|
||||
project_tool_definition_snapshots: [false, false, false, false],
|
||||
project_tool_definition_snapshot_sources: [false, false, false, false],
|
||||
plugin_package_quarantine_events: [false, false, false, false],
|
||||
@@ -302,6 +304,7 @@ function packagePrivileges(kind) {
|
||||
'plugin_package_install_heads',
|
||||
'plugin_package_install_mutations',
|
||||
'plugin_package_materialized_revisions',
|
||||
'plugin_package_secret_bindings',
|
||||
'project_tool_definition_snapshots',
|
||||
'project_tool_definition_snapshot_sources',
|
||||
'plugin_package_admission_receipts',
|
||||
@@ -344,6 +347,7 @@ function packagePrivileges(kind) {
|
||||
'plugin_package_install_heads',
|
||||
'plugin_package_install_mutations',
|
||||
'plugin_package_materialized_revisions',
|
||||
'plugin_package_secret_bindings',
|
||||
'plugin_package_automation_publications',
|
||||
'plugin_package_automation_publication_heads',
|
||||
'project_tool_definition_snapshots',
|
||||
@@ -750,7 +754,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro
|
||||
serverMajor: 16,
|
||||
currentUser: 'ql3_runtime',
|
||||
contractName: 'control-core',
|
||||
contractVersion: 57,
|
||||
contractVersion: 58,
|
||||
migrationIds: [
|
||||
'pg-0001-schema-capability',
|
||||
'pg-0002-run-core',
|
||||
@@ -810,6 +814,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro
|
||||
'pg-0056-run-management-boundary',
|
||||
'pg-0057-run-management-stop-boundary',
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -840,10 +845,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async (
|
||||
}),
|
||||
);
|
||||
assert.equal(report.currentUser, 'ql3_admin');
|
||||
assert.equal(report.contractVersion, 57);
|
||||
assert.equal(report.contractVersion, 58);
|
||||
assert.equal(
|
||||
report.migrationIds.at(-1),
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -856,10 +861,10 @@ test('accepts the isolated least-privilege automation manager role', async () =>
|
||||
}),
|
||||
);
|
||||
assert.equal(report.currentUser, 'ql3_automation_manager');
|
||||
assert.equal(report.contractVersion, 57);
|
||||
assert.equal(report.contractVersion, 58);
|
||||
assert.equal(
|
||||
report.migrationIds.at(-1),
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
);
|
||||
|
||||
const widened = automationManagerPrivileges();
|
||||
@@ -888,10 +893,10 @@ test('accepts the isolated least-privilege human Approval manager role', async (
|
||||
}),
|
||||
);
|
||||
assert.equal(report.currentUser, 'ql3_approval_manager');
|
||||
assert.equal(report.contractVersion, 57);
|
||||
assert.equal(report.contractVersion, 58);
|
||||
assert.equal(
|
||||
report.migrationIds.at(-1),
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
);
|
||||
|
||||
const widened = approvalManagerPrivileges();
|
||||
@@ -922,10 +927,10 @@ test('accepts the isolated least-privilege Run manager role', async () => {
|
||||
}),
|
||||
);
|
||||
assert.equal(report.currentUser, 'ql3_run_manager');
|
||||
assert.equal(report.contractVersion, 57);
|
||||
assert.equal(report.contractVersion, 58);
|
||||
assert.equal(
|
||||
report.migrationIds.at(-1),
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
);
|
||||
|
||||
const widened = runManagerPrivileges();
|
||||
@@ -1057,10 +1062,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => {
|
||||
}),
|
||||
);
|
||||
assert.equal(report.currentUser, 'ql3_worker_ingress');
|
||||
assert.equal(report.contractVersion, 57);
|
||||
assert.equal(report.contractVersion, 58);
|
||||
assert.equal(
|
||||
report.migrationIds.at(-1),
|
||||
'pg-0058-plugin-package-automation-disposition-events',
|
||||
'pg-0059-plugin-package-secret-bindings',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -363,9 +363,9 @@ function composeDockerHarness(
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '45',
|
||||
'io.qinglong.local.sqlite-contract-max': '45',
|
||||
'io.qinglong.local.sqlite-write-contract': '45',
|
||||
'io.qinglong.local.sqlite-contract-min': '46',
|
||||
'io.qinglong.local.sqlite-contract-max': '46',
|
||||
'io.qinglong.local.sqlite-write-contract': '46',
|
||||
'io.qinglong.local.application-config': '2',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -975,9 +975,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '45',
|
||||
'io.qinglong.local.sqlite-contract-max': '45',
|
||||
'io.qinglong.local.sqlite-write-contract': '45',
|
||||
'io.qinglong.local.sqlite-contract-min': '46',
|
||||
'io.qinglong.local.sqlite-contract-max': '46',
|
||||
'io.qinglong.local.sqlite-write-contract': '46',
|
||||
'io.qinglong.local.application-config': '2',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -1029,7 +1029,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.generation, 1);
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.sqlite.contractVersion, 45);
|
||||
assert.equal(result.sqlite.contractVersion, 46);
|
||||
assert.equal(result.image.architecture, 'arm64');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
|
||||
@@ -1129,8 +1129,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
|
||||
assert.equal(mode(receiptPath), 0o600);
|
||||
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
||||
assert.deepEqual(receipt.sqlite, {
|
||||
contractVersion: 45,
|
||||
writeContractVersion: 45,
|
||||
contractVersion: 46,
|
||||
writeContractVersion: 46,
|
||||
writeObservation: 'unchanged',
|
||||
backup: null,
|
||||
});
|
||||
@@ -1427,8 +1427,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
|
||||
`${command.request.rolloutId}.sqlite`,
|
||||
);
|
||||
assert.equal(mode(backupPath), 0o600);
|
||||
assert.equal(receipt.sqlite.contractVersion, 45);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 45);
|
||||
assert.equal(receipt.sqlite.contractVersion, 46);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 46);
|
||||
assert.equal(receipt.sqlite.writeObservation, 'changed');
|
||||
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(receipt.sqlite.backup.bytes > 0, true);
|
||||
|
||||
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.storage.contractName, 'local-control-core');
|
||||
assert.equal(result.storage.contractVersion, 45);
|
||||
assert.equal(result.storage.migrationCount, 90);
|
||||
assert.equal(result.storage.contractVersion, 46);
|
||||
assert.equal(result.storage.migrationCount, 92);
|
||||
assert.equal(result.storage.journalMode, 'delete');
|
||||
assert.equal(JSON.stringify(result).includes(state.directory), false);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,11 @@
|
||||
"require": "./dist/plugin-package/pluginPackageMaterializedRevisionRepository.js",
|
||||
"default": "./dist/plugin-package/pluginPackageMaterializedRevisionRepository.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBindingRepository.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBindingRepository.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBindingRepository.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.js",
|
||||
|
||||
@@ -100,6 +100,8 @@ import { local0087RunAttemptLogRetentionMigration } from '../migrations/0087-run
|
||||
import { local0088CapabilityV44Migration } from '../migrations/0088-capability-v44';
|
||||
import { local0089PluginPackageAutomationDispositionEventsMigration } from '../migrations/0089-plugin-package-automation-disposition-events';
|
||||
import { local0090CapabilityV45Migration } from '../migrations/0090-capability-v45';
|
||||
import { local0091PluginPackageSecretBindingsMigration } from '../migrations/0091-plugin-package-secret-bindings';
|
||||
import { local0092CapabilityV46Migration } from '../migrations/0092-capability-v46';
|
||||
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
|
||||
import {
|
||||
LOCAL_SQLITE_MIGRATION_STREAM_ID,
|
||||
@@ -212,6 +214,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
|
||||
local0088CapabilityV44Migration,
|
||||
local0089PluginPackageAutomationDispositionEventsMigration,
|
||||
local0090CapabilityV45Migration,
|
||||
local0091PluginPackageSecretBindingsMigration,
|
||||
local0092CapabilityV46Migration,
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
@@ -462,5 +462,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
|
||||
checksum:
|
||||
'1919987d29ef581e150116c590f1dc98f5d327791fc6425d5f1a27b8f6de5475',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0091-plugin-package-secret-bindings',
|
||||
checksum:
|
||||
'21e9957eb4c8cad0e41377d59c9f3226c350f9ae4718642c2caeb88a308f43ee',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0092-capability-v46',
|
||||
checksum:
|
||||
'a1b058bb7b0259069202632d27d4a55466828cde831c49821eb862feeba6ce35',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
|
||||
export const local0091PluginPackageSecretBindingsMigration =
|
||||
defineLocalSqliteMigration({
|
||||
id: '0091-plugin-package-secret-bindings',
|
||||
statements: [
|
||||
`
|
||||
CREATE TABLE "QingLong3PluginPackageSecretBindings" (
|
||||
generation_digest TEXT PRIMARY KEY NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
package_name TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
lock_digest TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
authority_kind TEXT NOT NULL,
|
||||
evidence_digest TEXT NOT NULL,
|
||||
bound_at_ms INTEGER NOT NULL,
|
||||
binding_digest TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_project_fk
|
||||
FOREIGN KEY (project_id) REFERENCES "QingLong3Projects" (id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_install_fk
|
||||
FOREIGN KEY (installation_id)
|
||||
REFERENCES "QingLong3PluginPackageInstalls" (installation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_identity_check CHECK (
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(package_name) BETWEEN 1 AND 63 AND
|
||||
length(installation_id) BETWEEN 1 AND 128 AND
|
||||
generation BETWEEN 1 AND 2147483647 AND
|
||||
authority_kind IN ('approved-action-execution','local-owner-confirmation') AND
|
||||
bound_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_digest_check CHECK (
|
||||
length(generation_digest) = 64 AND generation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(lock_digest) = 64 AND lock_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(manifest_digest) = 64 AND manifest_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(evidence_digest) = 64 AND evidence_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(binding_digest) = 64 AND binding_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_json_check CHECK (
|
||||
length(CAST(binding_json AS BLOB)) BETWEEN 2 AND 65536 AND
|
||||
json_valid(binding_json) AND json_type(binding_json) = 'object' AND
|
||||
json_extract(binding_json, '$.schema') = 'qinglong/plugin-package-secret-binding@v1' AND
|
||||
json_extract(binding_json, '$.target.generationDigest') = generation_digest AND
|
||||
json_extract(binding_json, '$.target.projectId') = project_id AND
|
||||
json_extract(binding_json, '$.target.packageName') = package_name AND
|
||||
json_extract(binding_json, '$.target.installationId') = installation_id AND
|
||||
json_extract(binding_json, '$.target.lockDigest') = lock_digest AND
|
||||
json_extract(binding_json, '$.target.generation') = generation AND
|
||||
json_extract(binding_json, '$.target.manifestDigest') = manifest_digest AND
|
||||
json_extract(binding_json, '$.authority.kind') = authority_kind AND
|
||||
json_extract(binding_json, '$.authority.evidenceDigest') = evidence_digest AND
|
||||
json_extract(binding_json, '$.boundAtMs') = bound_at_ms AND
|
||||
json_extract(binding_json, '$.bindingDigest') = binding_digest AND
|
||||
json_type(binding_json, '$.entries') = 'array' AND
|
||||
json_array_length(json_extract(binding_json, '$.entries')) BETWEEN 1 AND 64
|
||||
)
|
||||
)
|
||||
`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_generation_uidx ON "QingLong3PluginPackageSecretBindings" (project_id, package_name, generation)`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_digest_uidx ON "QingLong3PluginPackageSecretBindings" (binding_digest)`,
|
||||
`CREATE INDEX ql3_plugin_package_secret_binding_install_idx ON "QingLong3PluginPackageSecretBindings" (installation_id, generation_digest)`,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CAPABILITIES_V45 } from './0090-capability-v45';
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
|
||||
export const CAPABILITIES_V46 = CAPABILITIES_V45.replace(
|
||||
'"plugin_package_task_reconciliation":1,',
|
||||
'"plugin_package_secret_binding":1,"plugin_package_task_reconciliation":1,',
|
||||
);
|
||||
|
||||
export const local0092CapabilityV46Migration = defineLocalSqliteMigration({
|
||||
id: '0092-capability-v46',
|
||||
statements: [
|
||||
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 46, migration_id = '0091-plugin-package-secret-bindings', capabilities = '${CAPABILITIES_V46}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 45 AND migration_id = '0089-plugin-package-automation-disposition-events' AND capabilities = '${CAPABILITIES_V45}'`,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES,
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
normalizePluginPackageSecretBinding,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageSecretBindingError(message);
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function generationDigest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid('generation digest is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(binding: Readonly<PluginPackageSecretBinding>): string {
|
||||
const value = JSON.stringify(binding);
|
||||
if (
|
||||
Buffer.byteLength(value, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageSecretBindingError ||
|
||||
error instanceof PluginPackageSecretBindingConflictError ||
|
||||
error instanceof PluginPackageSecretBindingUnavailableError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageSecretBindingConflictError(
|
||||
'durable binding identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageSecretBindingRepository
|
||||
implements PluginPackageSecretBindingRepository
|
||||
{
|
||||
private readonly authority: LocalSqliteOperationAuthority;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
private parse(row: Row): Readonly<PluginPackageSecretBinding> {
|
||||
try {
|
||||
const binding = normalizePluginPackageSecretBinding(
|
||||
JSON.parse(text(row, 'bindingJson')),
|
||||
);
|
||||
if (
|
||||
binding.target.generationDigest !== text(row, 'generationDigest') ||
|
||||
binding.target.projectId !== text(row, 'projectId') ||
|
||||
binding.target.packageName !== text(row, 'packageName') ||
|
||||
binding.target.installationId !== text(row, 'installationId') ||
|
||||
binding.target.lockDigest !== text(row, 'lockDigest') ||
|
||||
binding.target.generation !== integer(row, 'generation') ||
|
||||
binding.target.manifestDigest !== text(row, 'manifestDigest') ||
|
||||
binding.authority.kind !== text(row, 'authorityKind') ||
|
||||
binding.authority.evidenceDigest !== text(row, 'evidenceDigest') ||
|
||||
binding.boundAtMs !== integer(row, 'boundAtMs') ||
|
||||
binding.bindingDigest !== text(row, 'bindingDigest')
|
||||
) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return binding;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageSecretBindingUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
private findStored(
|
||||
digest: string,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
const row = this.authority.client
|
||||
.prepare(
|
||||
`SELECT generation_digest AS "generationDigest",
|
||||
project_id AS "projectId",
|
||||
package_name AS "packageName",
|
||||
installation_id AS "installationId",
|
||||
lock_digest AS "lockDigest",
|
||||
generation,
|
||||
manifest_digest AS "manifestDigest",
|
||||
authority_kind AS "authorityKind",
|
||||
evidence_digest AS "evidenceDigest",
|
||||
bound_at_ms AS "boundAtMs",
|
||||
binding_digest AS "bindingDigest",
|
||||
binding_json AS "bindingJson"
|
||||
FROM "QingLong3PluginPackageSecretBindings"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(digest) as Row | undefined;
|
||||
return row ? this.parse(row) : null;
|
||||
}
|
||||
|
||||
private enqueue<T>(work: () => T): Promise<T> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return work();
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
},
|
||||
() => new PluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
async find(
|
||||
digest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBinding> | null> {
|
||||
const normalized = generationDigest(digest);
|
||||
return await this.enqueue(() => this.findStored(normalized));
|
||||
}
|
||||
|
||||
publish(value: Readonly<PluginPackageSecretBinding>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}>
|
||||
> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
const bindingJson = serialize(binding);
|
||||
return this.enqueue(() => {
|
||||
const existing = this.findStored(binding.target.generationDigest);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== bindingJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation digest is bound to another Secret mapping',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
binding: existing,
|
||||
});
|
||||
}
|
||||
|
||||
const result = this.authority.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageSecretBindings" (
|
||||
generation_digest, project_id, package_name, installation_id,
|
||||
lock_digest, generation, manifest_digest, authority_kind,
|
||||
evidence_digest, bound_at_ms, binding_digest, binding_json
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
FROM "QingLong3PluginPackageInstalls" AS install
|
||||
INNER JOIN "QingLong3PluginPackageInstallHeads" AS head
|
||||
ON head.installation_id = install.installation_id
|
||||
AND head.project_id = install.project_id
|
||||
AND head.package_name = install.package_name
|
||||
WHERE install.installation_id = ?
|
||||
AND install.project_id = ?
|
||||
AND install.package_name = ?
|
||||
AND install.lock_digest = ?
|
||||
AND install.active_lock_digest = ?
|
||||
AND install.target_generation = ?
|
||||
AND install.state = 'active'
|
||||
AND json_extract(install.lock_json, '$.manifestDigest') = ?
|
||||
ON CONFLICT (generation_digest) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
binding.target.generationDigest,
|
||||
binding.target.projectId,
|
||||
binding.target.packageName,
|
||||
binding.target.installationId,
|
||||
binding.target.lockDigest,
|
||||
binding.target.generation,
|
||||
binding.target.manifestDigest,
|
||||
binding.authority.kind,
|
||||
binding.authority.evidenceDigest,
|
||||
binding.boundAtMs,
|
||||
binding.bindingDigest,
|
||||
bindingJson,
|
||||
binding.target.installationId,
|
||||
binding.target.projectId,
|
||||
binding.target.packageName,
|
||||
binding.target.lockDigest,
|
||||
binding.target.lockDigest,
|
||||
binding.target.generation,
|
||||
binding.target.manifestDigest,
|
||||
);
|
||||
const stored = this.findStored(binding.target.generationDigest);
|
||||
if (!stored) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'binding target is not the current active Package generation',
|
||||
);
|
||||
}
|
||||
if (JSON.stringify(stored) !== bindingJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation digest is bound to another Secret mapping',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
result.changes === 1 ? ('created' as const) : ('existing' as const),
|
||||
binding: stored,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../run/stepRunSchemaContract';
|
||||
|
||||
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 45;
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 46;
|
||||
|
||||
const PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS = Object.freeze([
|
||||
Object.freeze({
|
||||
@@ -1142,6 +1142,27 @@ const REQUIRED_SCHEMA = Object.freeze({
|
||||
'ql3_plugin_package_materialized_revision_snapshot_source_uidx',
|
||||
]),
|
||||
}),
|
||||
QingLong3PluginPackageSecretBindings: Object.freeze({
|
||||
columns: Object.freeze([
|
||||
'generation_digest',
|
||||
'project_id',
|
||||
'package_name',
|
||||
'installation_id',
|
||||
'lock_digest',
|
||||
'generation',
|
||||
'manifest_digest',
|
||||
'authority_kind',
|
||||
'evidence_digest',
|
||||
'bound_at_ms',
|
||||
'binding_digest',
|
||||
'binding_json',
|
||||
]),
|
||||
indexes: Object.freeze([
|
||||
'ql3_plugin_package_secret_binding_generation_uidx',
|
||||
'ql3_plugin_package_secret_binding_digest_uidx',
|
||||
'ql3_plugin_package_secret_binding_install_idx',
|
||||
]),
|
||||
}),
|
||||
QingLong3ProjectToolDefinitionSnapshots: Object.freeze({
|
||||
columns: Object.freeze([
|
||||
'project_id',
|
||||
@@ -2533,10 +2554,10 @@ export async function auditLocalSqliteReadiness(
|
||||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
|
||||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
|
||||
capability.migration_id !==
|
||||
'0089-plugin-package-automation-disposition-events' ||
|
||||
'0091-plugin-package-secret-bindings' ||
|
||||
typeof capability.capabilities !== 'string' ||
|
||||
capability.capabilities !==
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
typeof capability.updated_at_ms !== 'number' ||
|
||||
!Number.isSafeInteger(capability.updated_at_ms) ||
|
||||
capability.updated_at_ms < 0
|
||||
|
||||
@@ -1217,6 +1217,62 @@ export const pluginPackageMaterializedRevisions = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const pluginPackageSecretBindings = sqliteTable(
|
||||
'QingLong3PluginPackageSecretBindings',
|
||||
{
|
||||
generationDigest: text('generation_digest').primaryKey(),
|
||||
projectId: text('project_id')
|
||||
.notNull()
|
||||
.references(() => localProjects.id, {
|
||||
onDelete: 'restrict',
|
||||
onUpdate: 'restrict',
|
||||
}),
|
||||
packageName: text('package_name').notNull(),
|
||||
installationId: text('installation_id')
|
||||
.notNull()
|
||||
.references(() => pluginPackageInstalls.installationId, {
|
||||
onDelete: 'restrict',
|
||||
onUpdate: 'restrict',
|
||||
}),
|
||||
lockDigest: text('lock_digest').notNull(),
|
||||
generation: integer('generation').notNull(),
|
||||
manifestDigest: text('manifest_digest').notNull(),
|
||||
authorityKind: text('authority_kind').notNull(),
|
||||
evidenceDigest: text('evidence_digest').notNull(),
|
||||
boundAtMs: integer('bound_at_ms').notNull(),
|
||||
bindingDigest: text('binding_digest').notNull(),
|
||||
bindingJson: text('binding_json', { mode: 'json' })
|
||||
.$type<Record<string, unknown>>()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_identity_check',
|
||||
sql`length(${table.projectId}) between 1 and 128 and length(${table.packageName}) between 1 and 63 and length(${table.installationId}) between 1 and 128 and ${table.generation} between 1 and 2147483647 and ${table.authorityKind} in ('approved-action-execution','local-owner-confirmation') and ${table.boundAtMs} >= 0`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_digest_check',
|
||||
sql`length(${table.generationDigest}) = 64 and ${table.generationDigest} not glob '*[^0-9a-f]*' and length(${table.lockDigest}) = 64 and ${table.lockDigest} not glob '*[^0-9a-f]*' and length(${table.manifestDigest}) = 64 and ${table.manifestDigest} not glob '*[^0-9a-f]*' and length(${table.evidenceDigest}) = 64 and ${table.evidenceDigest} not glob '*[^0-9a-f]*' and length(${table.bindingDigest}) = 64 and ${table.bindingDigest} not glob '*[^0-9a-f]*'`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_json_check',
|
||||
sql`length(cast(${table.bindingJson} as blob)) between 2 and 65536 and json_valid(${table.bindingJson}) and json_type(${table.bindingJson}) = 'object' and json_extract(${table.bindingJson}, '$.schema') = 'qinglong/plugin-package-secret-binding@v1' and json_extract(${table.bindingJson}, '$.target.generationDigest') = ${table.generationDigest} and json_extract(${table.bindingJson}, '$.target.projectId') = ${table.projectId} and json_extract(${table.bindingJson}, '$.target.packageName') = ${table.packageName} and json_extract(${table.bindingJson}, '$.target.installationId') = ${table.installationId} and json_extract(${table.bindingJson}, '$.target.lockDigest') = ${table.lockDigest} and json_extract(${table.bindingJson}, '$.target.generation') = ${table.generation} and json_extract(${table.bindingJson}, '$.target.manifestDigest') = ${table.manifestDigest} and json_extract(${table.bindingJson}, '$.authority.kind') = ${table.authorityKind} and json_extract(${table.bindingJson}, '$.authority.evidenceDigest') = ${table.evidenceDigest} and json_extract(${table.bindingJson}, '$.boundAtMs') = ${table.boundAtMs} and json_extract(${table.bindingJson}, '$.bindingDigest') = ${table.bindingDigest} and json_type(${table.bindingJson}, '$.entries') = 'array' and json_array_length(json_extract(${table.bindingJson}, '$.entries')) between 1 and 64`,
|
||||
),
|
||||
uniqueIndex('ql3_plugin_package_secret_binding_generation_uidx').on(
|
||||
table.projectId,
|
||||
table.packageName,
|
||||
table.generation,
|
||||
),
|
||||
uniqueIndex('ql3_plugin_package_secret_binding_digest_uidx').on(
|
||||
table.bindingDigest,
|
||||
),
|
||||
index('ql3_plugin_package_secret_binding_install_idx').on(
|
||||
table.installationId,
|
||||
table.generationDigest,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const projectToolDefinitionSnapshots = sqliteTable(
|
||||
'QingLong3ProjectToolDefinitionSnapshots',
|
||||
{
|
||||
@@ -4888,6 +4944,7 @@ export const localSqliteSchema = Object.freeze({
|
||||
pluginPackageInstallHeads,
|
||||
pluginPackageInstallMutations,
|
||||
pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings,
|
||||
pluginPackageQuarantineEvents,
|
||||
pluginPackageWithdrawalReceipts,
|
||||
pluginPackageWithdrawalTasks,
|
||||
|
||||
@@ -140,9 +140,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
|
||||
'0088-capability-v44',
|
||||
'0089-plugin-package-automation-disposition-events',
|
||||
'0090-capability-v45',
|
||||
'0091-plugin-package-secret-bindings',
|
||||
'0092-capability-v46',
|
||||
]);
|
||||
assert.equal(migrated.readiness.contractName, 'local-control-core');
|
||||
assert.equal(migrated.readiness.contractVersion, 45);
|
||||
assert.equal(migrated.readiness.contractVersion, 46);
|
||||
assert.equal(migrated.readiness.journalMode, 'delete');
|
||||
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
|
||||
|
||||
@@ -505,8 +507,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
contract_version: 45,
|
||||
migration_id: '0089-plugin-package-automation-disposition-events',
|
||||
contract_version: 46,
|
||||
migration_id: '0091-plugin-package-secret-bindings',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
@@ -693,19 +695,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
client.exec(
|
||||
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
|
||||
);
|
||||
client.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 79);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
|
||||
const unknownClient = new DatabaseSync(databasePath);
|
||||
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
|
||||
unknownClient.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
|
||||
|
||||
const triggerClient = new DatabaseSync(databasePath);
|
||||
triggerClient.exec(`
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
LocalSqlitePluginPackageSecretBindingRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBindingRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
const MANIFEST = {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'Secret binding repository fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [{ name: 'TOKEN', required: true }],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
|
||||
function fixture(boundAtMs = 100) {
|
||||
const generation = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: MANIFEST.spec.contents,
|
||||
});
|
||||
const binding = createPluginPackageSecretBinding({
|
||||
generation,
|
||||
manifest: MANIFEST,
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: 'c'.repeat(64),
|
||||
},
|
||||
boundAtMs,
|
||||
});
|
||||
return { binding, generation };
|
||||
}
|
||||
|
||||
async function harness(active = true) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
const { binding, generation } = fixture();
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES ('project-1', 'Project 1', 'project-1', 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run();
|
||||
const recordDigest = 'd'.repeat(64);
|
||||
const lockJson = JSON.stringify({
|
||||
lockDigest: LOCK_DIGEST,
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
manifestDigest: binding.target.manifestDigest,
|
||||
});
|
||||
const recordJson = JSON.stringify({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
state: active ? 'active' : 'failed',
|
||||
version: 1,
|
||||
recordDigest,
|
||||
});
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageInstalls" (
|
||||
installation_id, project_id, package_name, package_version,
|
||||
operation, lock_digest, target_generation,
|
||||
previous_active_lock_digest, active_lock_digest, state, version,
|
||||
last_mutation_id, last_mutation_digest, lock_json, record_json,
|
||||
record_digest, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, '1.0.0', 'install', ?, 1, NULL, ?, ?, 1,
|
||||
'mutation-1', ?, ?, ?, ?, 1, 1)`,
|
||||
)
|
||||
.run(
|
||||
'install-1',
|
||||
'project-1',
|
||||
'example-monitor',
|
||||
LOCK_DIGEST,
|
||||
active ? LOCK_DIGEST : null,
|
||||
active ? 'active' : 'failed',
|
||||
'e'.repeat(64),
|
||||
lockJson,
|
||||
recordJson,
|
||||
recordDigest,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageInstallHeads"
|
||||
(project_id, package_name, installation_id)
|
||||
VALUES ('project-1', 'example-monitor', 'install-1')`,
|
||||
)
|
||||
.run();
|
||||
return {
|
||||
client,
|
||||
binding,
|
||||
generation,
|
||||
repository: new LocalSqlitePluginPackageSecretBindingRepository(client),
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes and exact-replays one active generation binding', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.client.close());
|
||||
const created = await value.repository.publish(value.binding);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.deepEqual(created.binding, value.binding);
|
||||
const replay = await value.repository.publish(value.binding);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(
|
||||
await value.repository.find(value.generation.generationDigest),
|
||||
value.binding,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects inactive targets and conflicting content', async (t) => {
|
||||
const inactive = await harness(false);
|
||||
t.after(() => inactive.client.close());
|
||||
await assert.rejects(
|
||||
inactive.repository.publish(inactive.binding),
|
||||
PluginPackageSecretBindingConflictError,
|
||||
);
|
||||
|
||||
const active = await harness();
|
||||
t.after(() => active.client.close());
|
||||
await active.repository.publish(active.binding);
|
||||
await assert.rejects(
|
||||
active.repository.publish(fixture(101).binding),
|
||||
PluginPackageSecretBindingConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when durable binding JSON is changed in place', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.client.close());
|
||||
await value.repository.publish(value.binding);
|
||||
value.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageSecretBindings"
|
||||
SET binding_json = json_set(binding_json, '$.boundAtMs', 999)
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.run(value.generation.generationDigest);
|
||||
await assert.rejects(
|
||||
value.repository.find(value.generation.generationDigest),
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes storage only through the explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('@qinglong/local-sqlite/plugin-package-secret-binding')
|
||||
.LocalSqlitePluginPackageSecretBindingRepository,
|
||||
LocalSqlitePluginPackageSecretBindingRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageSecretBindingRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 46);
|
||||
});
|
||||
|
||||
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
|
||||
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 46);
|
||||
});
|
||||
|
||||
test('fails closed before writing when the exact installation is not active', async (t) => {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
|
||||
stepAttemptCount: 0,
|
||||
},
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 45);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 46);
|
||||
});
|
||||
|
||||
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
|
||||
|
||||
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
|
||||
await migrateLocalSqlitePath(state);
|
||||
const prepared = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.contractVersion, 45);
|
||||
assert.equal(prepared.writeContractVersion, 45);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 45);
|
||||
assert.equal(prepared.contractVersion, 46);
|
||||
assert.equal(prepared.writeContractVersion, 46);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 46);
|
||||
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(prepared.bytes > 0, true);
|
||||
assert.equal(prepared.pageCount > 0, true);
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
"plugin-package-resource-materialization": [
|
||||
"dist/plugin-package/pluginPackageResourceMaterialization.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding": [
|
||||
"dist/plugin-package/pluginPackageSecretBinding.d.ts"
|
||||
],
|
||||
"plugin-package-task-reconciliation": [
|
||||
"dist/plugin-package/pluginPackageTaskReconciliation.d.ts"
|
||||
],
|
||||
@@ -371,6 +374,11 @@
|
||||
"require": "./dist/plugin-package/pluginPackageResourceMaterialization.js",
|
||||
"default": "./dist/plugin-package/pluginPackageResourceMaterialization.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBinding.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBinding.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageTaskReconciliation.js",
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_SECRETS,
|
||||
normalizePluginPackageManifest,
|
||||
type PluginPackageManifest,
|
||||
type PluginPackageSecretRequirement,
|
||||
} from './pluginPackage';
|
||||
import { pluginPackageManifestDigest } from './installation/pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from './pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding@v1' as const;
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS = [
|
||||
'approved-action-execution',
|
||||
'local-owner-confirmation',
|
||||
] as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES = 64 * 1024;
|
||||
|
||||
export type PluginPackageSecretBindingAuthorityKind =
|
||||
(typeof PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS)[number];
|
||||
|
||||
export interface PluginPackageSecretBindingTarget {
|
||||
readonly installationId: string;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly lockDigest: string;
|
||||
readonly generation: number;
|
||||
readonly generationDigest: string;
|
||||
readonly manifestDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingEntry {
|
||||
readonly name: string;
|
||||
readonly required: boolean;
|
||||
readonly secretRef: string | null;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingAuthority {
|
||||
readonly kind: PluginPackageSecretBindingAuthorityKind;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBinding {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA;
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
readonly bindingDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingAssignment {
|
||||
readonly name: string;
|
||||
readonly secretRef: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingInput {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly manifest: Readonly<PluginPackageManifest>;
|
||||
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBinding> | null>;
|
||||
publish(binding: Readonly<PluginPackageSecretBinding>): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidPluginPackageSecretBindingError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package Secret binding is invalid: ${message}`);
|
||||
this.name = 'InvalidPluginPackageSecretBindingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageSecretBindingConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package Secret binding conflicts with state: ${message}`);
|
||||
this.name = 'PluginPackageSecretBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageSecretBindingUnavailableError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_SECRET_BINDING_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Plugin Package Secret binding is unavailable', options);
|
||||
this.name = 'PluginPackageSecretBindingUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const SECRET_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
||||
const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackageSecretBindingError(message);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const stringKeys = actual.filter(
|
||||
(key): key is string => typeof key === 'string',
|
||||
);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
stringKeys.length !== canonical.length ||
|
||||
stringKeys.sort().some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function denseArray(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_PLUGIN_PACKAGE_SECRETS) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (
|
||||
ownKeys.length !== value.length + 1 ||
|
||||
!ownKeys.includes('length') ||
|
||||
keys.length !== value.length ||
|
||||
keys.some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid(`${label} must be a dense data array`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
keys.some((key) => {
|
||||
const descriptor = descriptors[key];
|
||||
return (
|
||||
descriptor === undefined ||
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true
|
||||
);
|
||||
})
|
||||
) {
|
||||
return invalid(`${label} must be a dense data array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function packageName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !PACKAGE_NAME.test(value)) {
|
||||
return invalid('Package name is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function generation(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
return invalid('generation is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid('boundAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function secretName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !SECRET_NAME.test(value)) {
|
||||
return invalid('Secret requirement name is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeAuthority(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingAuthority> {
|
||||
const authority = dataRecord(value, 'authority');
|
||||
exactKeys(authority, ['evidenceDigest', 'kind'], 'authority');
|
||||
if (
|
||||
!PLUGIN_PACKAGE_SECRET_BINDING_AUTHORITY_KINDS.includes(
|
||||
authority.kind as PluginPackageSecretBindingAuthorityKind,
|
||||
)
|
||||
) {
|
||||
return invalid('authority kind is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: authority.kind as PluginPackageSecretBindingAuthorityKind,
|
||||
evidenceDigest: digest(
|
||||
authority.evidenceDigest,
|
||||
'authority evidence digest',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEntries(
|
||||
value: unknown,
|
||||
projectId: string,
|
||||
): readonly Readonly<PluginPackageSecretBindingEntry>[] {
|
||||
const values = denseArray(value, 'entries');
|
||||
if (values.length === 0) return invalid('entries must not be empty');
|
||||
const seen = new Set<string>();
|
||||
const entries = values.map((entryValue) => {
|
||||
const entry = dataRecord(entryValue, 'entry');
|
||||
exactKeys(entry, ['name', 'required', 'secretRef'], 'entry');
|
||||
const name = secretName(entry.name);
|
||||
if (seen.has(name)) return invalid('Secret requirement is duplicated');
|
||||
seen.add(name);
|
||||
if (typeof entry.required !== 'boolean') {
|
||||
return invalid('Secret requirement required flag is invalid');
|
||||
}
|
||||
if (entry.secretRef === null) {
|
||||
if (entry.required) return invalid(`required Secret ${name} is unbound`);
|
||||
return Object.freeze({ name, required: false, secretRef: null });
|
||||
}
|
||||
let reference;
|
||||
try {
|
||||
reference = parseSecretRef(entry.secretRef);
|
||||
} catch {
|
||||
return invalid(`Secret ${name} reference is invalid`);
|
||||
}
|
||||
if (reference.projectId !== projectId) {
|
||||
return invalid(`Secret ${name} reference crosses Project boundary`);
|
||||
}
|
||||
if (reference.version === undefined) {
|
||||
return invalid(`Secret ${name} reference must pin an explicit version`);
|
||||
}
|
||||
return Object.freeze({
|
||||
name,
|
||||
required: entry.required,
|
||||
secretRef: entry.secretRef as string,
|
||||
});
|
||||
});
|
||||
const sorted = [...entries].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
if (entries.some((entry, index) => entry !== sorted[index])) {
|
||||
return invalid('entries are not in canonical order');
|
||||
}
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function targetFromGeneration(
|
||||
generation: Readonly<PluginPackageResourceGeneration>,
|
||||
manifestDigest: string,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
return Object.freeze({
|
||||
installationId: generation.installationId,
|
||||
projectId: generation.projectId,
|
||||
packageName: generation.packageName,
|
||||
lockDigest: generation.lockDigest,
|
||||
generation: generation.generation,
|
||||
generationDigest: generation.generationDigest,
|
||||
manifestDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function unsignedBinding(
|
||||
target: Readonly<PluginPackageSecretBindingTarget>,
|
||||
entries: readonly Readonly<PluginPackageSecretBindingEntry>[],
|
||||
authority: Readonly<PluginPackageSecretBindingAuthority>,
|
||||
boundAtMs: number,
|
||||
): Omit<PluginPackageSecretBinding, 'bindingDigest'> {
|
||||
return {
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA,
|
||||
target,
|
||||
entries,
|
||||
authority,
|
||||
boundAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function bindingDigest(
|
||||
value: Omit<PluginPackageSecretBinding, 'bindingDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(BINDING_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function entriesFromAssignments(
|
||||
requirements: readonly Readonly<PluginPackageSecretRequirement>[],
|
||||
assignmentsValue: unknown,
|
||||
projectId: string,
|
||||
): readonly Readonly<PluginPackageSecretBindingEntry>[] {
|
||||
if (requirements.length === 0) {
|
||||
return invalid('Manifest does not declare Secret requirements');
|
||||
}
|
||||
const assignments = denseArray(assignmentsValue, 'assignments');
|
||||
const mapped = new Map<string, string | null>();
|
||||
for (const assignmentValue of assignments) {
|
||||
const assignment = dataRecord(assignmentValue, 'assignment');
|
||||
exactKeys(assignment, ['name', 'secretRef'], 'assignment');
|
||||
const name = secretName(assignment.name);
|
||||
if (mapped.has(name)) return invalid('Secret assignment is duplicated');
|
||||
if (
|
||||
assignment.secretRef !== null &&
|
||||
typeof assignment.secretRef !== 'string'
|
||||
) {
|
||||
return invalid(`Secret ${name} assignment is invalid`);
|
||||
}
|
||||
mapped.set(name, assignment.secretRef as string | null);
|
||||
}
|
||||
if (
|
||||
mapped.size !== requirements.length ||
|
||||
requirements.some((requirement) => !mapped.has(requirement.name))
|
||||
) {
|
||||
return invalid('assignments do not exactly match Manifest requirements');
|
||||
}
|
||||
return normalizeEntries(
|
||||
requirements.map((requirement) => ({
|
||||
name: requirement.name,
|
||||
required: requirement.required,
|
||||
secretRef: mapped.get(requirement.name) ?? null,
|
||||
})),
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBinding(
|
||||
input: CreatePluginPackageSecretBindingInput,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const generation = normalizePluginPackageResourceGeneration(input.generation);
|
||||
const manifest = normalizePluginPackageManifest(input.manifest);
|
||||
if (manifest.metadata.name !== generation.packageName) {
|
||||
return invalid('Manifest Package does not match generation');
|
||||
}
|
||||
const target = targetFromGeneration(
|
||||
generation,
|
||||
pluginPackageManifestDigest(manifest),
|
||||
);
|
||||
const entries = entriesFromAssignments(
|
||||
manifest.spec.permissions.secrets,
|
||||
input.assignments,
|
||||
generation.projectId,
|
||||
);
|
||||
const authority = normalizeAuthority(input.authority);
|
||||
const boundAtMs = timestamp(input.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
return Object.freeze({ ...unsigned, bindingDigest: bindingDigest(unsigned) });
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = dataRecord(value, 'binding');
|
||||
exactKeys(
|
||||
binding,
|
||||
['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'],
|
||||
'binding',
|
||||
);
|
||||
if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const targetValue = dataRecord(binding.target, 'target');
|
||||
exactKeys(
|
||||
targetValue,
|
||||
[
|
||||
'generation',
|
||||
'generationDigest',
|
||||
'installationId',
|
||||
'lockDigest',
|
||||
'manifestDigest',
|
||||
'packageName',
|
||||
'projectId',
|
||||
],
|
||||
'target',
|
||||
);
|
||||
const target = Object.freeze({
|
||||
installationId: identifier(targetValue.installationId, 'installation ID'),
|
||||
projectId: identifier(targetValue.projectId, 'Project ID'),
|
||||
packageName: packageName(targetValue.packageName),
|
||||
lockDigest: digest(targetValue.lockDigest, 'lock digest'),
|
||||
generation: generation(targetValue.generation),
|
||||
generationDigest: digest(targetValue.generationDigest, 'generation digest'),
|
||||
manifestDigest: digest(targetValue.manifestDigest, 'Manifest digest'),
|
||||
});
|
||||
const entries = normalizeEntries(binding.entries, target.projectId);
|
||||
const authority = normalizeAuthority(binding.authority);
|
||||
const boundAtMs = timestamp(binding.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
if (
|
||||
digest(binding.bindingDigest, 'binding digest') !== bindingDigest(unsigned)
|
||||
) {
|
||||
return invalid('binding digest does not match content');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
...unsigned,
|
||||
bindingDigest: binding.bindingDigest as string,
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function assertPluginPackageSecretBindingMatches(
|
||||
value: unknown,
|
||||
generationValue: Readonly<PluginPackageResourceGeneration>,
|
||||
manifestValue: Readonly<PluginPackageManifest>,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
const generation = normalizePluginPackageResourceGeneration(generationValue);
|
||||
const manifest = normalizePluginPackageManifest(manifestValue);
|
||||
const expectedTarget = targetFromGeneration(
|
||||
generation,
|
||||
pluginPackageManifestDigest(manifest),
|
||||
);
|
||||
if (JSON.stringify(binding.target) !== JSON.stringify(expectedTarget)) {
|
||||
return invalid('target does not match generation and Manifest');
|
||||
}
|
||||
if (
|
||||
binding.entries.length !== manifest.spec.permissions.secrets.length ||
|
||||
binding.entries.some((entry, index) => {
|
||||
const requirement = manifest.spec.permissions.secrets[index];
|
||||
return (
|
||||
requirement === undefined ||
|
||||
entry.name !== requirement.name ||
|
||||
entry.required !== requirement.required
|
||||
);
|
||||
})
|
||||
) {
|
||||
return invalid('entries do not exactly match Manifest requirements');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA,
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
createPluginPackageSecretBinding,
|
||||
normalizePluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
const EVIDENCE_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function manifest(
|
||||
secretRequirements = [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'REQUIRED_TOKEN', required: true },
|
||||
],
|
||||
) {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Tests durable Secret binding',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: secretRequirements,
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generation(projectId = 'project-1') {
|
||||
return createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId,
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'c'.repeat(64),
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
});
|
||||
}
|
||||
|
||||
function versionedRef(projectId, name, version = 3) {
|
||||
return createSecretRef({ projectId, name, version });
|
||||
}
|
||||
|
||||
function binding(overrides = {}) {
|
||||
return createPluginPackageSecretBinding({
|
||||
generation: generation(),
|
||||
manifest: manifest(),
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: versionedRef('project-1', 'runtime-token'),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: EVIDENCE_DIGEST,
|
||||
},
|
||||
boundAtMs: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one canonical generation-bound Secret binding without plaintext', () => {
|
||||
const value = binding();
|
||||
assert.equal(value.schema, PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA);
|
||||
assert.deepEqual(value.entries, [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false, secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
required: true,
|
||||
secretRef: versionedRef('project-1', 'runtime-token'),
|
||||
},
|
||||
]);
|
||||
assert.match(value.bindingDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(JSON.stringify(value).includes('secret-value'), false);
|
||||
assert.deepEqual(normalizePluginPackageSecretBinding(value), value);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
assert.equal(Object.isFrozen(value.entries), true);
|
||||
});
|
||||
|
||||
test('requires assignments to exactly cover the Manifest contract', () => {
|
||||
assert.throws(
|
||||
() => binding({ assignments: [] }),
|
||||
/exactly match Manifest requirements/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'UNDECLARED_TOKEN', secretRef: null },
|
||||
],
|
||||
}),
|
||||
InvalidPluginPackageSecretBindingError,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{ name: 'REQUIRED_TOKEN', secretRef: null },
|
||||
],
|
||||
}),
|
||||
/required Secret REQUIRED_TOKEN is unbound/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects floating and cross-Project Secret references', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/must pin an explicit version/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'REQUIRED_TOKEN',
|
||||
secretRef: versionedRef('project-2', 'runtime-token'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/crosses Project boundary/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on authority, target, order and digest drift', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
authority: { kind: 'operator', evidenceDigest: EVIDENCE_DIGEST },
|
||||
}),
|
||||
/authority kind is invalid/,
|
||||
);
|
||||
const value = binding();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBinding({
|
||||
...value,
|
||||
entries: [...value.entries].reverse(),
|
||||
}),
|
||||
/canonical order/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBinding({
|
||||
...value,
|
||||
target: { ...value.target, generationDigest: 'd'.repeat(64) },
|
||||
}),
|
||||
/binding digest does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizePluginPackageSecretBinding({ ...value, extra: true }),
|
||||
/binding shape is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('revalidates durable binding against the complete generation and Manifest', () => {
|
||||
const value = binding();
|
||||
assert.deepEqual(
|
||||
assertPluginPackageSecretBindingMatches(value, generation(), manifest()),
|
||||
value,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageSecretBindingMatches(
|
||||
value,
|
||||
generation('project-2'),
|
||||
manifest(),
|
||||
),
|
||||
/target does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertPluginPackageSecretBindingMatches(
|
||||
value,
|
||||
generation(),
|
||||
manifest([{ name: 'REQUIRED_TOKEN', required: true }]),
|
||||
),
|
||||
/target does not match|entries do not exactly match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a binding when the Package declares no Secret requirement', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
binding({
|
||||
manifest: manifest([]),
|
||||
assignments: [],
|
||||
}),
|
||||
/does not declare Secret requirements/,
|
||||
);
|
||||
});
|
||||
|
||||
test('exports Secret binding only through its explicit subpath', () => {
|
||||
assert.equal(require('../dist').createPluginPackageSecretBinding, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding')
|
||||
.createPluginPackageSecretBinding,
|
||||
createPluginPackageSecretBinding,
|
||||
);
|
||||
});
|
||||
@@ -74,12 +74,12 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
[
|
||||
{
|
||||
directory: 'packages/ql3-cluster-postgres/src/migrations',
|
||||
directSourceFiles: 59,
|
||||
directSourceFiles: 60,
|
||||
reviewKind: 'ordered_ledger',
|
||||
},
|
||||
{
|
||||
directory: 'packages/ql3-local-sqlite/src/migrations',
|
||||
directSourceFiles: 91,
|
||||
directSourceFiles: 93,
|
||||
reviewKind: 'ordered_ledger',
|
||||
},
|
||||
],
|
||||
@@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: runtimeCore.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 152,
|
||||
sourceFiles: 153,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 160,
|
||||
nestedSourceFiles: 151,
|
||||
nestedSourceFiles: 152,
|
||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||
},
|
||||
);
|
||||
@@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: clusterPostgres.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 154,
|
||||
sourceFiles: 156,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 125,
|
||||
nestedSourceFiles: 153,
|
||||
rootSourceLines: 126,
|
||||
nestedSourceFiles: 155,
|
||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||
},
|
||||
);
|
||||
@@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
||||
rootSourceFileRoles: localSqlite.rootSourceFileRoles,
|
||||
},
|
||||
{
|
||||
sourceFiles: 181,
|
||||
sourceFiles: 184,
|
||||
rootSourceFiles: 1,
|
||||
rootSourceLines: 31,
|
||||
nestedSourceFiles: 180,
|
||||
nestedSourceFiles: 183,
|
||||
rootSourceFileRoles: { 'index.ts': 'public_export' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user