diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index de0c6ca1..8ceb0fad 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -29,7 +29,7 @@ - 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-306A/ADR-0394(已接受):Package Task source 以 `package-secret` placeholder 引用逻辑 requirement,materialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`;Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digest,Local/Cluster 启动发布复用既有 repository/pool,Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 不另造协议。SQLite/PostgreSQL INSERT trigger 同时防止直接写库绕过;Local 只读 readiness 继续不加载 DDL。Local contract v47、Cluster v59;不新增 package、表、索引、连接、daemon、watcher、timer、cache 或 workload。完整 18-package clean build/test 退出 0;backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 finding,workspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 source;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。D-306A 不冒充产品闭环:Local bind/rebind 命令、Cluster Approved Action/API 与新 generation rotation/revocation 编排属于 D-306B。 - D-306B1/ADR-0395(已接受):当前 active、尚未绑定 Package generation 的首次 Secret binding 已形成 Local 与 Cluster 产品闭环,且不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建;Local 使用短生命周期 `ql3-package`、Owner human confirmation 与单 SQLite transaction,Cluster 使用既有 package-management HTTPS/CLI、package-manager separation-of-duty Approval 和短生命周期 package-executor。三节点 K3s `v1.34.3+k3s1` arm64 现场门已在真实 PostgreSQL `18.4` 上完成两个 management Pod 跨节点部署、正式 client `plan→跨副本 replay→propose→双人 decide→inspect`、真实 executor Job 与只读 Kubernetes Secret projection。management/executor 均无 Secret API 读取权和 ServiceAccount token;management 不挂载 Package value,executor 只验证投影元数据;最终恰好一条 immutable binding,Approval consumed、execution succeeded,数据库敏感值扫描为 0。16/16 gate 的 owner-private、低敏报告通过独立 exact-shape 审计,SHA-256 为 `aaabb5ebea77c50bce671f91dd3051671fd20875c11a8f787fe8933f29dbfa4d`。完整 18-package clean build/test、backend 与七项边界审计,以及 PostgreSQL 18.4 physical HA 125 gate/timeline `1→2` 证据继续有效;没有新增 workspace package、migration、表、索引、依赖或常驻 workload。B2 rebind/rotation/revocation 必须通过新 Package generation 独立推进。 -- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。SQLite capability v49(0097/0098)现新增 immutable transition receipt ledger、typed schema、readiness 与数据库 trigger;Local Owner 私有短命令已完成 staged generation 的 server-derived `plan→execute`,在一个 `BEGIN IMMEDIATE` 中原子提交可选 binding、allowed audit 与 receipt,并支持跨时钟 exact replay。安装恢复缺 receipt 时保留 staged 与旧 active,直接协调器和应用启动都复用 activation prerequisite 再次失败关闭;receipt 到位后才允许 active CAS。Runtime Core 533/533、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 root-only skip、Local Application 47 pass/4 平台 skip,合计 1071 pass/9 条件 skip/0 fail;没有新增 workspace package、第三方依赖、连接、timer、watcher 或常驻资源。PostgreSQL v61 staged-target 门继续有效,但 Cluster receipt ledger、management/executor separation-of-duty 产品编排、真实 Kubernetes rotation/revoke、升级失败回滚和低配物理证据仍待完成。 +- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。SQLite capability v49(0097/0098)已完成 immutable transition receipt ledger、Local Owner staged `plan→execute`、单事务 binding/audit/receipt 和 activation prerequisite。PostgreSQL capability v62(pg-0063)现也具有 receipt ledger:package-executor 在一个 SERIALIZABLE transaction 中复验 current staged head、上一 active lineage、durable 最大 generation 与可选上一 binding,并原子提交可选目标 binding 和 immutable receipt;数据库 trigger 和最小角色 readiness 防止绕过,recovery/直接 activation 缺 receipt 均失败关闭。真实 PostgreSQL 18.4 已通过 63 条 migration、原子提交、exact replay、错误状态/lineage 拒绝,并暴露和修复了 `jsonb` 键重排下不应依赖属性顺序的 plan 归一化问题。阶段门已串行完成:18 个 QL3 workspace package 统一 clean build/test 退出 0,backend 1192 pass/2 条条件 skip/0 fail,PostgreSQL 18.4 arm64 physical HA 以 timeline `1→2` 通过 125 gate(报告 SHA-256 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`),package/dependency/edge import 审计无新增越界。没有新增 workspace package、第三方依赖、连接、timer、watcher 或常驻资源。Cluster management/executor separation-of-duty 产品编排、真实 Kubernetes rotation/revoke、升级失败回滚和低配物理证据仍待完成。 - 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、 diff --git a/docs/adr/ADR-0364-package-internal-source-layout-ratchet.md b/docs/adr/ADR-0364-package-internal-source-layout-ratchet.md index 7e18dc99..49db5c6d 100644 --- a/docs/adr/ADR-0364-package-internal-source-layout-ratchet.md +++ b/docs/adr/ADR-0364-package-internal-source-layout-ratchet.md @@ -19,7 +19,7 @@ QingLong 3.0 的 workspace package 必须表达独立制品、权限、依赖隔 - `ownership_review`:尚需按 capability 继续下沉,但不得在评审前增长。 4. 超过登记 hard cap 必须失败;目录重构后降到阈值以下时,旧登记必须删除,避免永久豁免。 5. `src/` 根层继续执行 D-269 的精确 entry role、file cap、line cap 和 public-export-only 校验。内部密度门不能替代根门,也不能作为新增 workspace package 的理由。 -6. 初始冻结 8 个目录:两个 migration ledger,以及 `cluster-admin` 的 prompt-output/worker-credential、`local-owner-cli` 的 deployment/cutover、`runtime-core` 的 security/tool-execution。首个收口把 cutover 的 target-run lifecycle 与 manual-resolution ceremony 下沉到两个 capability 子目录,直接文件 16→10;第二个收口把 Tool Registry facade、Project Tool snapshot 与内建 Run Read 投影下沉到 `tool-registry/` 和 `builtin-run-read/`,tool-execution 直接文件 15→11;第三个收口把 Worker credential management service、transport、HTTP、mTLS、process 与 CLI 下沉到同一 `management-server/` 部署能力目录,worker-credential 直接文件 16→10,同时保留 client、executor 与 delivery 的独立 ownership;第四个收口把 Prompt Output 的 external recovery、retention GC 和 key management 分别下沉到 `external-recovery/`、`retention/` 与 `key-management/`,prompt-output 直接文件 13→0;第五个收口把 Local deployment 的六个 Compose lifecycle 文件与四个共享 contract/file/Docker/render 基础分别下沉到 `compose/` 与 `foundation/`,deployment 直接文件 13→3,顶层只保留 facade、CLI 与 status;第六个收口把 Runtime Security 的身份/API 凭据、Project Policy 与安全审计分别下沉到 `identity-credential/`、`project-policy/` 与 `audit/`,security 直接文件 12→1,根层只保留跨域 principal/fence primitive。六项都删除 stale review;当前只剩两个按原序保留的 migration ledger,没有 ownership review。 +6. 初始冻结 8 个目录:两个 migration ledger,以及 `cluster-admin` 的 prompt-output/worker-credential、`local-owner-cli` 的 deployment/cutover、`runtime-core` 的 security/tool-execution。首个收口把 cutover 的 target-run lifecycle 与 manual-resolution ceremony 下沉到两个 capability 子目录,直接文件 16→10;第二个收口把 Tool Registry facade、Project Tool snapshot 与内建 Run Read 投影下沉到 `tool-registry/` 和 `builtin-run-read/`,tool-execution 直接文件 15→11;第三个收口把 Worker credential management service、transport、HTTP、mTLS、process 与 CLI 下沉到同一 `management-server/` 部署能力目录,worker-credential 直接文件 16→10,同时保留 client、executor 与 delivery 的独立 ownership;第四个收口把 Prompt Output 的 external recovery、retention GC 和 key management 分别下沉到 `external-recovery/`、`retention/` 与 `key-management/`,prompt-output 直接文件 13→0;第五个收口把 Local deployment 的六个 Compose lifecycle 文件与四个共享 contract/file/Docker/render 基础分别下沉到 `compose/` 与 `foundation/`,deployment 直接文件 13→3,顶层只保留 facade、CLI 与 status;第六个收口把 Runtime Security 的身份/API 凭据、Project Policy 与安全审计分别下沉到 `identity-credential/`、`project-policy/` 与 `audit/`,security 直接文件 12→1,根层只保留跨域 principal/fence primitive。六项都删除 stale review;当前只剩两个按原序保留的 migration ledger,没有 ownership review。ADR-0396 追加 PostgreSQL `pg-0063` 后,该 ordered ledger 的受审 hard cap 随事实从 63 精确推进到 64;这不改变 12 文件评审阈值,也不放宽其他目录。 7. 审计只在开发、CI 和发布检查中短生命周期运行;不进入 Edge、Standalone、Worker 或 Cluster 常驻制品,不增加依赖、进程、监听、timer、内存或闪存写入。 ## 机器门禁 diff --git a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md index 55974b7e..cca5bbd5 100644 --- a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -36,5 +36,7 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 - 本切片没有新增 workspace package、第三方依赖、表、连接、daemon、timer、watcher 或常驻资源;Edge/Standalone 只增加 SQLite schema guard,Cluster 复用既有 PostgreSQL Pool/role。完整 18-package 串行测试与 backend 门已通过;真实 PostgreSQL 18.4 的 migration/runtime role 门证明 active、合规 staged 与 exact replay 可写,activating、陈旧/非最大 staged 以及直接 SQL 可被拒绝。 - Local Owner 产品纵切面已完成:新增私有短命令 `plugin-package.secret-binding.transition.plan|execute`,从当前 staged head、上一 active install/proposal/lock、历史 binding 与 durable 最大尝试 generation 重建计划;Owner 重新鉴权后在一个 `BEGIN IMMEDIATE` 中原子写入可选目标 binding、allowed audit 和 immutable transition receipt。相同 plan/audit 可跨时钟 exact replay,revoke 通过 `bindingDigest: null` receipt 表达,不制造空 binding。 - SQLite capability v49(0097/0098)新增 transition receipt ledger、exact schema/readiness/typed schema 和插入 trigger。安装恢复在缺 receipt 时不扫描该 staged generation,旧 active 继续服务;共享 activation prerequisite 在直接安装/恢复路径再次 fail-closed 检查 receipt,receipt 到位后才允许 active CAS。没有新增连接、timer、watcher、常驻进程、workspace package 或第三方依赖,Edge/Standalone/Adopted 共用同一 SQLite authority。 -- Runtime Core 533/533、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1071 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。 -- Cluster management/executor 仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片实现 PostgreSQL receipt ledger、separation-of-duty Approval 与 package-executor,再进入 Kubernetes/低配现场门。 +- PostgreSQL capability v62(pg-0063)新增不可变 transition receipt ledger。package-executor 在一个 SERIALIZABLE transaction 内复验 current staged head、上一 active lineage、durable 最大 generation 与可选上一 binding,随后原子写入目标 binding(revoke 时为空)和 receipt;数据库 trigger 独立重验 staged target,角色只取得 receipt 的 `SELECT, INSERT`。安装恢复与直接 activation 共用 receipt prerequisite,缺 receipt 的 staged generation 不进入激活候选。真实 PostgreSQL 18.4 已通过 63 条 migration、最小角色 readiness、binding+receipt 原子提交、exact replay、activating/错误 lineage 拒绝;同时修复并回归了 PostgreSQL `jsonb` 对象键重排后的 transition plan 归一化,digest 与语义校验不依赖对象属性顺序。 +- 阶段完整性门已串行复验:18 个 `ql3-*` workspace package 的统一 clean build/test 退出 0;backend 共 1194 项,1192 pass、2 条条件 skip、0 fail;PostgreSQL 18.4 arm64 physical HA 完成 timeline `1→2` 提升并通过 125 项门禁,报告 SHA-256 为 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`。package boundary、cluster dependency 与 edge import 审计均无新增越界;这些证据证明当前 authority 与既有运行路径兼容,不替代尚未完成的真实 Kubernetes rotation/revoke 和固定低配物理设备验证。 +- Runtime Core 534/534、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1072 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。 +- Cluster management/executor 的 separation-of-duty 产品编排仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片把既有 package-manager Approval 与 package-executor 接入这条 durable authority,再进入 Kubernetes/低配现场门。 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index d1fb45d4..d231b972 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -6,7 +6,7 @@ "reviewedDenseDirectories": [ { "kind": "ordered_ledger", - "maxDirectSourceFiles": 63, + "maxDirectSourceFiles": 64, "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." }, diff --git a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts index ffed7a02..b26d9873 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts @@ -40,6 +40,7 @@ import { PostgresPluginPackageAutomationPublicationRepository, PostgresPluginPackageMaterializedRevisionRepository, PostgresPluginPackageSecretBindingRepository, + PostgresPluginPackageSecretBindingActivationPrerequisite, PostgresPluginPackagePublisherProvenanceRepository, PostgresPluginPackageTaskReconciliationRepository, PostgresProjectToolDefinitionSnapshotRepository, @@ -318,6 +319,10 @@ export async function recoverClusterPluginPackages( repository, stageProvider: stageAuthority, publisher, + activationPrerequisite: + new PostgresPluginPackageSecretBindingActivationPrerequisite( + database.pool, + ), now: options.now, }).recover({ ...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }), diff --git a/packages/ql3-cluster-admin/test/bootstrap.test.cjs b/packages/ql3-cluster-admin/test/bootstrap.test.cjs index 64d67fdf..cd22f5d2 100644 --- a/packages/ql3-cluster-admin/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-admin/test/bootstrap.test.cjs @@ -171,6 +171,7 @@ function database(serverVersionNum = '160014') { 'commit_plugin_package_task_reconciliation', 'commit_plugin_package_quarantine', 'enforce_plugin_package_secret_binding_target', + 'enforce_plugin_package_secret_binding_transition_receipt_target', 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'lock_active_plugin_package_project', diff --git a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs index d0c789e4..ed3234c6 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs @@ -35,6 +35,7 @@ function executorPrivileges() { 'plugin_package_automation_publications', 'plugin_package_automation_publication_heads', 'plugin_package_secret_bindings', + 'plugin_package_secret_binding_transition_receipts', 'project_tool_definition_snapshots', 'project_tool_definition_snapshot_sources', 'plugin_package_publisher_provenance', @@ -204,6 +205,7 @@ function database(serverVersionNum = '160014') { functionName, executeAllowed: ![ 'enforce_plugin_package_secret_binding_target', + 'enforce_plugin_package_secret_binding_transition_receipt_target', 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'plugin_package_automation_start_allowed', @@ -300,6 +302,7 @@ test('proves an empty executor queue and closes PostgreSQL before returning', as retry: 0, manualRequired: 0, superseded: 0, + deferred: 0, remaining: false, safeToAdmit: true, }); diff --git a/packages/ql3-cluster-control/test/application.test.cjs b/packages/ql3-cluster-control/test/application.test.cjs index bf49abca..f99a4118 100644 --- a/packages/ql3-cluster-control/test/application.test.cjs +++ b/packages/ql3-cluster-control/test/application.test.cjs @@ -201,6 +201,12 @@ function runtimePrivileges() { plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], + plugin_package_secret_binding_transition_receipts: [ + 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], diff --git a/packages/ql3-cluster-control/test/bootstrap.test.cjs b/packages/ql3-cluster-control/test/bootstrap.test.cjs index 140b546e..32c48b39 100644 --- a/packages/ql3-cluster-control/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-control/test/bootstrap.test.cjs @@ -115,6 +115,12 @@ function runtimePrivileges() { plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], + plugin_package_secret_binding_transition_receipts: [ + 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], diff --git a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts index 42ba7151..47c7e417 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts @@ -43,6 +43,12 @@ export { PostgresPluginPackageMaterializedRevisionRepository } from '../plugin-p export { PostgresApprovalRequestRepository } from '../approved-action/approvalRequestRepository'; export { PostgresProjectPolicyRepository } from '../security/projectPolicyRepository'; export { PostgresPluginPackageSecretBindingRepository } from '../plugin-package/installation/pluginPackageSecretBindingRepository'; +export { PostgresPluginPackageSecretBindingActivationPrerequisite } from '../plugin-package/secret-binding/pluginPackageSecretBindingActivationPrerequisite'; +export { + PostgresPluginPackageSecretBindingTransitionRepository, + type ApplyPostgresPluginPackageSecretBindingTransitionInput, + type ApplyPostgresPluginPackageSecretBindingTransitionResult, +} from '../plugin-package/secret-binding/pluginPackageSecretBindingTransitionRepository'; export { PostgresPluginPackageSecretBindingApprovalPlanReader } from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; export { PostgresPluginPackageAutomationPublicationRepository } from '../plugin-package/publication/pluginPackageAutomationPublicationRepository'; export { diff --git a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts index d613954b..d72569eb 100644 --- a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts +++ b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts @@ -318,5 +318,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest = checksum: 'cd4f92d8702da6b92dd9ae5153b5180400b94442f56393692b6ec038f998596b', }), + Object.freeze({ + id: 'pg-0063-plugin-package-secret-binding-transition-receipts', + checksum: + '20c4d6e640b7fb05fc2f44dd9bec6d2d081697b83acf4e7dd5deb9a07afe54df', + }), ]), }); diff --git a/packages/ql3-cluster-postgres/src/migrations/index.ts b/packages/ql3-cluster-postgres/src/migrations/index.ts index d369a541..e4444ad4 100644 --- a/packages/ql3-cluster-postgres/src/migrations/index.ts +++ b/packages/ql3-cluster-postgres/src/migrations/index.ts @@ -65,6 +65,7 @@ import { pg0059PluginPackageSecretBindingsMigration } from './pg-0059-plugin-pac import { pg0060PluginPackageSecretMaterializationGuardMigration } from './pg-0060-plugin-package-secret-materialization-guard'; import { pg0061PluginPackageSecretBindingApprovalPlansMigration } from './pg-0061-plugin-package-secret-binding-approval-plans'; import { pg0062PluginPackageSecretBindingTargetGuardMigration } from './pg-0062-plugin-package-secret-binding-target-guard'; +import { pg0063PluginPackageSecretBindingTransitionReceiptsMigration } from './pg-0063-plugin-package-secret-binding-transition-receipts'; export const postgresqlMainMigrationStream: MigrationStreamDefinition = Object.freeze({ @@ -135,5 +136,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition= 0 + ), + CONSTRAINT ql3_package_secret_transition_receipt_digest_check CHECK ( + generation_digest ~ '^[0-9a-f]{64}$' AND + transition_digest ~ '^[0-9a-f]{64}$' AND + lock_digest ~ '^[0-9a-f]{64}$' AND + manifest_digest ~ '^[0-9a-f]{64}$' AND + previous_active_lock_digest ~ '^[0-9a-f]{64}$' AND + evidence_digest ~ '^[0-9a-f]{64}$' AND + (binding_digest IS NULL OR binding_digest ~ '^[0-9a-f]{64}$') AND + receipt_digest ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT ql3_package_secret_transition_receipt_json_check CHECK ( + jsonb_typeof(receipt_json) = 'object' AND + octet_length(receipt_json::text) BETWEEN 2 AND 196608 AND + receipt_json @> jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-transition-receipt@v1', + 'transitionPlan', jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-transition-plan@v1', + 'transitionDigest', transition_digest, + 'previousActiveLockDigest', previous_active_lock_digest, + 'nextTarget', 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 + ), + 'bindingDigest', binding_digest, + 'committedAtMs', committed_at_ms, + 'receiptDigest', receipt_digest + ) + ) +) + `.trim(), + `CREATE UNIQUE INDEX ql3_package_secret_transition_receipt_transition_uidx ON "ql3"."plugin_package_secret_binding_transition_receipts" (transition_digest)`, + `CREATE UNIQUE INDEX ql3_package_secret_transition_receipt_digest_uidx ON "ql3"."plugin_package_secret_binding_transition_receipts" (receipt_digest)`, + `CREATE INDEX ql3_package_secret_transition_receipt_install_idx ON "ql3"."plugin_package_secret_binding_transition_receipts" (installation_id, generation_digest)`, + ` +CREATE FUNCTION "ql3"."enforce_plugin_package_secret_binding_transition_receipt_target"() +RETURNS trigger +LANGUAGE plpgsql +VOLATILE +SET search_path = pg_catalog, ql3 +AS $ql3$ +BEGIN + PERFORM 1 + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS install + ON install.installation_id = head.installation_id + AND install.project_id = head.project_id + AND install.package_name = head.package_name + JOIN "ql3"."plugin_package_installs" AS previous + ON previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + WHERE head.project_id = NEW.project_id + AND head.package_name = NEW.package_name + AND install.installation_id = NEW.installation_id + AND install.lock_digest = NEW.lock_digest + AND install.target_generation = NEW.generation + AND install.lock_json ->> 'manifestDigest' = NEW.manifest_digest + AND install.state = 'staged' + AND install.previous_active_lock_digest = NEW.previous_active_lock_digest + AND install.active_lock_digest = install.previous_active_lock_digest + AND install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" AS history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + AND previous.target_generation < install.target_generation + AND ( + (NEW.binding_digest IS NULL AND NOT EXISTS ( + SELECT 1 FROM "ql3"."plugin_package_secret_bindings" AS binding + WHERE binding.generation_digest = NEW.generation_digest + )) OR + EXISTS ( + SELECT 1 FROM "ql3"."plugin_package_secret_bindings" AS binding + WHERE binding.generation_digest = NEW.generation_digest + AND binding.binding_digest = NEW.binding_digest + AND binding.project_id = NEW.project_id + AND binding.package_name = NEW.package_name + AND binding.installation_id = NEW.installation_id + AND binding.lock_digest = NEW.lock_digest + AND binding.generation = NEW.generation + AND binding.manifest_digest = NEW.manifest_digest + AND binding.authority_kind = NEW.authority_kind + AND binding.evidence_digest = NEW.evidence_digest + AND binding.bound_at_ms = NEW.committed_at_ms + ) + ) + FOR SHARE OF head, install, previous; + + IF NOT FOUND THEN + RAISE EXCEPTION + 'Plugin Package Secret binding transition receipt target is not reviewed staged generation' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END +$ql3$ + `.trim(), + `REVOKE ALL ON "ql3"."plugin_package_secret_binding_transition_receipts" FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `GRANT SELECT, INSERT ON "ql3"."plugin_package_secret_binding_transition_receipts" TO ql3_package_executor`, + `REVOKE ALL ON FUNCTION "ql3"."enforce_plugin_package_secret_binding_transition_receipt_target"() FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `CREATE TRIGGER ql3_package_secret_transition_receipt_target_guard BEFORE INSERT ON "ql3"."plugin_package_secret_binding_transition_receipts" FOR EACH ROW EXECUTE FUNCTION "ql3"."enforce_plugin_package_secret_binding_transition_receipt_target"()`, + `DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 62, migration_id = 'pg-0063-plugin-package-secret-binding-transition-receipts', capabilities = '${CAPABILITIES_V62}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 61 AND migration_id = 'pg-0062-plugin-package-secret-binding-target-guard' AND capabilities = '${CAPABILITIES_V61}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 61' USING ERRCODE = 'check_violation'; END IF; END $ql3$`, + ], + }); diff --git a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageInstallRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageInstallRepository.ts index 74530f9a..f83e7afb 100644 --- a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageInstallRepository.ts +++ b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageInstallRepository.ts @@ -1345,6 +1345,44 @@ export class PostgresPluginPackageInstallRepository JOIN "ql3"."plugin_package_installs" AS install ON install.installation_id = head.installation_id WHERE install.state IN ('queued', 'staged', 'activating') + AND ( + install.state <> 'staged' OR + install.previous_active_lock_digest IS NULL OR + NOT ( + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_admission_receipts" AS admission + JOIN "ql3"."plugin_package_install_proposals" AS proposal + ON proposal.action_ref = admission.action_ref + WHERE admission.installation_id = install.installation_id + AND jsonb_array_length( + proposal.proposal_json #> '{actionInput,manifest,spec,permissions,secrets}' + ) > 0 + ) OR + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_installs" AS previous + JOIN "ql3"."plugin_package_secret_bindings" AS binding + ON binding.installation_id = previous.installation_id + WHERE previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + ) + ) OR + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_secret_binding_transition_receipts" + AS receipt + WHERE receipt.project_id = install.project_id + AND receipt.package_name = install.package_name + AND receipt.installation_id = install.installation_id + AND receipt.lock_digest = install.lock_digest + AND receipt.generation = install.target_generation + AND receipt.manifest_digest = install.lock_json ->> 'manifestDigest' + AND receipt.previous_active_lock_digest = + install.previous_active_lock_digest + ) + ) AND NOT EXISTS ( SELECT 1 FROM "ql3"."plugin_package_quarantine_events" AS quarantine diff --git a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingActivationPrerequisite.ts b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingActivationPrerequisite.ts new file mode 100644 index 00000000..dc9339a1 --- /dev/null +++ b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingActivationPrerequisite.ts @@ -0,0 +1,154 @@ +import type { PostgresPool } from '@qinglong/runtime-core'; +import { + normalizePluginPackageInstallProposal, + type PluginPackageInstallProposal, +} from '@qinglong/runtime-core/plugin-package-proposal'; +import { + assertPluginPackageInstallMatchesLock, + normalizePluginPackageInstallRecord, + normalizePluginPackageLock, + type PluginPackageInstallRecord, + type PluginPackageLock, +} from '@qinglong/runtime-core/plugin-package-install'; +import type { + PluginPackageActivationPrerequisite, + PluginPackageActivationPrerequisiteObservation, +} from '@qinglong/runtime-core/plugin-package-installation'; +import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding'; + +import { + postgresRequiredJsonObject, + postgresRequiredString, +} from '../../repository/definitionRepositorySupport'; +import { PostgresPluginPackageSecretBindingTransitionRepository } from './pluginPackageSecretBindingTransitionRepository'; + +type Row = Record; + +function unavailable(cause?: unknown): Error { + return new Error('Secret binding activation prerequisite is unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); +} + +export class PostgresPluginPackageSecretBindingActivationPrerequisite + implements PluginPackageActivationPrerequisite +{ + readonly #receipts: PostgresPluginPackageSecretBindingTransitionRepository; + + constructor(private readonly pool: PostgresPool) { + if ( + !pool || + typeof pool.query !== 'function' || + typeof pool.connect !== 'function' + ) { + throw new TypeError( + 'PostgreSQL Secret binding activation prerequisite pool is invalid', + ); + } + this.#receipts = new PostgresPluginPackageSecretBindingTransitionRepository( + pool, + ); + } + + async inspect( + recordValue: Readonly, + lockValue: Readonly, + ): Promise> { + const record = normalizePluginPackageInstallRecord(recordValue); + const lock = normalizePluginPackageLock(lockValue); + assertPluginPackageInstallMatchesLock(lock, record); + if (record.state !== 'staged') { + throw new Error( + 'Secret binding activation prerequisite requires staged install', + ); + } + if (record.previousActiveLockDigest === null) { + return Object.freeze({ status: 'ready' as const }); + } + try { + const result = await this.pool.query( + `SELECT proposal.proposal_json AS "proposalJson", + EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_installs" AS previous + JOIN "ql3"."plugin_package_secret_bindings" AS binding + ON binding.installation_id = previous.installation_id + WHERE previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + ) AS "previousBindingPresent" + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS install + ON install.installation_id = head.installation_id + JOIN "ql3"."plugin_package_admission_receipts" AS admission + ON admission.installation_id = install.installation_id + JOIN "ql3"."plugin_package_install_proposals" AS proposal + ON proposal.action_ref = admission.action_ref + WHERE head.project_id = $1 AND head.package_name = $2 + AND install.installation_id = $3 AND install.lock_digest = $4 + AND install.state = 'staged' + AND install.previous_active_lock_digest = $5 + LIMIT 2`, + [ + record.projectId, + record.packageName, + record.installationId, + record.lockDigest, + record.previousActiveLockDigest, + ], + ); + if (result.rows.length !== 1) throw unavailable(); + const proposal = normalizePluginPackageInstallProposal( + postgresRequiredJsonObject( + result.rows[0]!.proposalJson, + unavailable, + ) as unknown as PluginPackageInstallProposal, + ); + if ( + proposal.actionDigest !== lock.approval.actionDigest || + proposal.previewDigest !== lock.approval.previewDigest || + proposal.actionInput.targetGeneration !== record.targetGeneration || + proposal.actionInput.source.contentDigest !== lock.source.contentDigest + ) { + throw unavailable( + new Error('Secret binding activation prerequisite provenance drift'), + ); + } + const required = + proposal.actionInput.manifest.spec.permissions.secrets.length > 0 || + result.rows[0]!.previousBindingPresent === true; + if (!required) return Object.freeze({ status: 'ready' as const }); + const generation = createPluginPackageResourceGenerationFromReferences({ + installationId: record.installationId, + projectId: record.projectId, + packageName: record.packageName, + lockDigest: record.lockDigest, + generation: record.targetGeneration, + previousActiveLockDigest: record.previousActiveLockDigest, + contentDigest: lock.source.contentDigest, + resources: lock.resources, + }); + const target = createPluginPackageSecretBindingTarget( + generation, + proposal.actionInput.manifest, + ); + const receipt = await this.#receipts.find(target.generationDigest); + if ( + !receipt || + JSON.stringify(receipt.transitionPlan.nextTarget) !== + JSON.stringify(target) || + receipt.transitionPlan.previousActiveLockDigest !== + record.previousActiveLockDigest + ) { + return Object.freeze({ + status: 'deferred' as const, + reason: 'secret_binding_transition_required' as const, + }); + } + return Object.freeze({ status: 'ready' as const }); + } catch (error) { + throw unavailable(error); + } + } +} diff --git a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionRepository.ts new file mode 100644 index 00000000..0386e1ee --- /dev/null +++ b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionRepository.ts @@ -0,0 +1,391 @@ +import type { PostgresClient, PostgresPool } from '@qinglong/runtime-core'; +import { + createPluginPackageSecretBindingFromTransitionPlan, + createPluginPackageSecretBindingTransitionReceipt, + normalizePluginPackageSecretBindingTransitionReceipt, + type PluginPackageSecretBindingTransitionReceipt, + type PluginPackageSecretBindingTransitionReceiptRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'; +import { + normalizePluginPackageSecretBindingTransitionPlan, + type PluginPackageSecretBindingTransitionPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'; +import { + PluginPackageSecretBindingConflictError, + PluginPackageSecretBindingUnavailableError, + normalizePluginPackageSecretBinding, + type PluginPackageSecretBinding, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; + +import { + POSTGRES_DEFINITION_RETRYABLE_SQL_STATES, + POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS, + configurePostgresDefinitionTransaction, + postgresRequiredJsonObject, + postgresRequiredString, + postgresSqlState, + rollbackPostgresDefinitionTransaction, +} from '../../repository/definitionRepositorySupport'; +import { PostgresPluginPackageSecretBindingRepository } from '../installation/pluginPackageSecretBindingRepository'; + +type Row = Record; +const DIGEST = /^[0-9a-f]{64}$/; + +export interface ApplyPostgresPluginPackageSecretBindingTransitionInput { + readonly transitionPlan: Readonly; + readonly evidenceDigest: string; + readonly committedAtMs: number; +} + +export interface ApplyPostgresPluginPackageSecretBindingTransitionResult { + readonly status: 'created' | 'existing'; + readonly binding: Readonly | null; + readonly receipt: Readonly; +} + +function unavailable( + cause?: unknown, +): PluginPackageSecretBindingUnavailableError { + return new PluginPackageSecretBindingUnavailableError({ + cause: cause instanceof Error ? cause : undefined, + }); +} + +function mappedError(error: unknown): Error { + if ( + error instanceof PluginPackageSecretBindingConflictError || + error instanceof PluginPackageSecretBindingUnavailableError || + error instanceof TypeError + ) { + return error; + } + const state = postgresSqlState(error); + if (state === '23503' || state === '23505' || state === '23514') { + return new PluginPackageSecretBindingConflictError( + 'durable transition identity or staged generation conflicts', + ); + } + return unavailable(error); +} + +function digest(value: unknown, label: string): string { + if (typeof value !== 'string' || !DIGEST.test(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +function timestamp(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError('Secret binding transition commit time is invalid'); + } + return value as number; +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function parseReceipt( + row: Row, +): Readonly { + try { + const receipt = normalizePluginPackageSecretBindingTransitionReceipt( + postgresRequiredJsonObject( + row.receiptJson, + unavailable, + ) as unknown as PluginPackageSecretBindingTransitionReceipt, + ); + if ( + receipt.receiptDigest !== + postgresRequiredString(row.receiptDigest, unavailable) + ) { + throw unavailable(); + } + return receipt; + } catch (error) { + if (error instanceof PluginPackageSecretBindingUnavailableError) + throw error; + throw unavailable(error); + } +} + +export class PostgresPluginPackageSecretBindingTransitionRepository + implements PluginPackageSecretBindingTransitionReceiptRepository +{ + constructor(private readonly pool: PostgresPool) { + if ( + !pool || + typeof pool.query !== 'function' || + typeof pool.connect !== 'function' + ) { + throw new TypeError( + 'PostgreSQL Secret binding transition pool is invalid', + ); + } + } + + private async findStored( + queryable: Pick | Pick, + generationDigest: string, + ): Promise | null> { + const result = await queryable.query( + `SELECT receipt_json AS "receiptJson", receipt_digest AS "receiptDigest" + FROM "ql3"."plugin_package_secret_binding_transition_receipts" + WHERE generation_digest = $1 + LIMIT 2`, + [generationDigest], + ); + if (result.rows.length === 0) return null; + if (result.rows.length !== 1) throw unavailable(); + return parseReceipt(result.rows[0]!); + } + + async find( + generationDigestValue: string, + ): Promise | null> { + try { + return await this.findStored( + this.pool, + digest(generationDigestValue, 'generation digest'), + ); + } catch (error) { + throw mappedError(error); + } + } + + async #transaction( + work: (client: PostgresClient) => Promise, + ): Promise { + for ( + let attempt = 0; + attempt < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS; + attempt += 1 + ) { + let client: PostgresClient; + try { + client = await this.pool.connect(); + } catch (error) { + throw mappedError(error); + } + let began = false; + try { + await configurePostgresDefinitionTransaction(client); + began = true; + const result = await work(client); + await client.query('COMMIT'); + began = false; + return result; + } catch (error) { + if (began) await rollbackPostgresDefinitionTransaction(client); + const state = postgresSqlState(error); + if ( + state && + POSTGRES_DEFINITION_RETRYABLE_SQL_STATES.has(state) && + attempt + 1 < POSTGRES_DEFINITION_TRANSACTION_ATTEMPTS + ) { + continue; + } + throw mappedError(error); + } finally { + client.release(); + } + } + throw unavailable(); + } + + apply( + input: Readonly, + ): Promise< + Readonly + > { + if ( + !input || + typeof input !== 'object' || + Array.isArray(input) || + Object.keys(input).sort().join('\0') !== + 'committedAtMs\0evidenceDigest\0transitionPlan' + ) { + throw new TypeError('Secret binding transition execution is invalid'); + } + const plan = normalizePluginPackageSecretBindingTransitionPlan( + input.transitionPlan, + ); + const evidenceDigest = digest(input.evidenceDigest, 'evidence digest'); + const committedAtMs = timestamp(input.committedAtMs); + const proposedBinding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'approved-action-execution', + evidenceDigest, + committedAtMs, + ); + const proposedReceipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'approved-action-execution', + evidenceDigest, + }, + binding: proposedBinding, + committedAtMs, + }); + + return this.#transaction(async (client) => { + const existing = await this.findStored( + client, + plan.nextTarget.generationDigest, + ); + if (existing) { + if (!same(existing, proposedReceipt)) { + throw new PluginPackageSecretBindingConflictError( + 'generation is committed by another transition authority', + ); + } + const binding = existing.bindingDigest + ? await new PostgresPluginPackageSecretBindingRepository(client).find( + plan.nextTarget.generationDigest, + ) + : null; + if ( + (existing.bindingDigest === null) !== (binding === null) || + (binding && binding.bindingDigest !== existing.bindingDigest) + ) { + throw unavailable(); + } + return Object.freeze({ + status: 'existing' as const, + binding, + receipt: existing, + }); + } + + const authority = await client.query( + `SELECT previous_binding.binding_json AS "previousBindingJson" + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS install + ON install.installation_id = head.installation_id + AND install.project_id = head.project_id + AND install.package_name = head.package_name + JOIN "ql3"."plugin_package_installs" AS previous + ON previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + LEFT JOIN "ql3"."plugin_package_secret_bindings" AS previous_binding + ON previous_binding.installation_id = previous.installation_id + AND previous_binding.project_id = previous.project_id + AND previous_binding.package_name = previous.package_name + AND previous_binding.lock_digest = previous.lock_digest + AND previous_binding.generation = previous.target_generation + WHERE head.project_id = $1 AND head.package_name = $2 + AND install.installation_id = $3 AND install.lock_digest = $4 + AND install.target_generation = $5 + AND install.lock_json ->> 'manifestDigest' = $6 + AND install.state = 'staged' + AND install.previous_active_lock_digest = $7 + AND install.active_lock_digest = install.previous_active_lock_digest + AND install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" AS history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + AND previous.installation_id = $8 + AND previous.lock_digest = $7 + AND previous.target_generation = $9 + AND previous.lock_json ->> 'manifestDigest' = $10 + FOR SHARE OF head, install, previous`, + [ + plan.nextTarget.projectId, + plan.nextTarget.packageName, + plan.nextTarget.installationId, + plan.nextTarget.lockDigest, + plan.nextTarget.generation, + plan.nextTarget.manifestDigest, + plan.previousActiveLockDigest, + plan.previousTarget.installationId, + plan.previousTarget.generation, + plan.previousTarget.manifestDigest, + ], + ); + if (authority.rows.length !== 1) { + throw new PluginPackageSecretBindingConflictError( + 'transition is not the current reviewed staged generation', + ); + } + const previousBindingJson = authority.rows[0]!.previousBindingJson; + const durablePreviousBinding = + previousBindingJson === null + ? null + : normalizePluginPackageSecretBinding( + postgresRequiredJsonObject(previousBindingJson, unavailable), + ); + if (!same(durablePreviousBinding, plan.previousBinding)) { + throw new PluginPackageSecretBindingConflictError( + 'previous active binding changed after transition planning', + ); + } + + const bindingResult = proposedBinding + ? await new PostgresPluginPackageSecretBindingRepository( + client, + ).publish(proposedBinding) + : null; + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'approved-action-execution', + evidenceDigest, + }, + binding: bindingResult?.binding ?? null, + committedAtMs, + }); + const target = plan.nextTarget; + const inserted = await client.query( + `INSERT INTO "ql3"."plugin_package_secret_binding_transition_receipts" ( + generation_digest, transition_digest, project_id, package_name, + installation_id, lock_digest, generation, manifest_digest, + previous_active_lock_digest, authority_kind, evidence_digest, + binding_digest, committed_at_ms, receipt_digest, receipt_json + ) VALUES ( + $1::char(64), $2::char(64), $3::varchar(128), $4::varchar(63), + $5::varchar(128), $6::char(64), $7::integer, $8::char(64), + $9::char(64), $10::varchar(32), $11::char(64), $12::char(64), + $13::bigint, $14::char(64), $15::jsonb + ) ON CONFLICT (generation_digest) DO NOTHING + RETURNING generation_digest`, + [ + target.generationDigest, + plan.transitionDigest, + target.projectId, + target.packageName, + target.installationId, + target.lockDigest, + target.generation, + target.manifestDigest, + plan.previousActiveLockDigest, + receipt.authority.kind, + receipt.authority.evidenceDigest, + receipt.bindingDigest, + receipt.committedAtMs, + receipt.receiptDigest, + JSON.stringify(receipt), + ], + ); + const stored = await this.findStored(client, target.generationDigest); + if (!stored || !same(stored, receipt)) { + throw new PluginPackageSecretBindingConflictError( + 'generation is bound to another transition receipt', + ); + } + return Object.freeze({ + status: + inserted.rows.length === 1 + ? ('created' as const) + : ('existing' as const), + binding: bindingResult?.binding ?? null, + receipt: stored, + }); + }); + } +} diff --git a/packages/ql3-cluster-postgres/src/schema/schema.ts b/packages/ql3-cluster-postgres/src/schema/schema.ts index 06aa925d..ecca0d96 100644 --- a/packages/ql3-cluster-postgres/src/schema/schema.ts +++ b/packages/ql3-cluster-postgres/src/schema/schema.ts @@ -452,6 +452,69 @@ export const pluginPackageSecretBindingApprovalPlans = ql3Schema.table( ], ); +export const pluginPackageSecretBindingTransitionReceipts = ql3Schema.table( + 'plugin_package_secret_binding_transition_receipts', + { + generationDigest: char('generation_digest', { length: 64 }).primaryKey(), + transitionDigest: char('transition_digest', { length: 64 }).notNull(), + 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(), + previousActiveLockDigest: char('previous_active_lock_digest', { + length: 64, + }).notNull(), + authorityKind: varchar('authority_kind', { length: 32 }).notNull(), + evidenceDigest: char('evidence_digest', { length: 64 }).notNull(), + bindingDigest: char('binding_digest', { length: 64 }), + committedAtMs: bigint('committed_at_ms', { mode: 'number' }).notNull(), + receiptDigest: char('receipt_digest', { length: 64 }).notNull(), + receiptJson: jsonb('receipt_json') + .$type>() + .notNull(), + }, + (table) => [ + foreignKey({ + name: 'ql3_plugin_package_secret_binding_transition_receipt_project_fk', + columns: [table.projectId], + foreignColumns: [projects.id], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + name: 'ql3_plugin_package_secret_binding_transition_receipt_install_fk', + columns: [table.installationId], + foreignColumns: [pluginPackageInstalls.installationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_package_secret_transition_receipt_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 2 and 2147483647 and ${table.authorityKind} = 'approved-action-execution' and ${table.committedAtMs} >= 0`, + ), + check( + 'ql3_package_secret_transition_receipt_digest_check', + sql`${table.generationDigest} ~ '^[0-9a-f]{64}$' and ${table.transitionDigest} ~ '^[0-9a-f]{64}$' and ${table.lockDigest} ~ '^[0-9a-f]{64}$' and ${table.manifestDigest} ~ '^[0-9a-f]{64}$' and ${table.previousActiveLockDigest} ~ '^[0-9a-f]{64}$' and ${table.evidenceDigest} ~ '^[0-9a-f]{64}$' and (${table.bindingDigest} is null or ${table.bindingDigest} ~ '^[0-9a-f]{64}$') and ${table.receiptDigest} ~ '^[0-9a-f]{64}$'`, + ), + check( + 'ql3_package_secret_transition_receipt_json_check', + sql`jsonb_typeof(${table.receiptJson}) = 'object' and octet_length(${table.receiptJson}::text) between 2 and 196608 and ${table.receiptJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-transition-receipt@v1', 'transitionPlan', jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-transition-plan@v1', 'transitionDigest', ${table.transitionDigest}, 'previousActiveLockDigest', ${table.previousActiveLockDigest}, 'nextTarget', 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}), 'bindingDigest', ${table.bindingDigest}, 'committedAtMs', ${table.committedAtMs}, 'receiptDigest', ${table.receiptDigest})`, + ), + uniqueIndex('ql3_package_secret_transition_receipt_transition_uidx').on( + table.transitionDigest, + ), + uniqueIndex('ql3_package_secret_transition_receipt_digest_uidx').on( + table.receiptDigest, + ), + index('ql3_package_secret_transition_receipt_install_idx').on( + table.installationId, + table.generationDigest, + ), + ], +); + export const projectToolDefinitionSnapshots = ql3Schema.table( 'project_tool_definition_snapshots', { @@ -5940,6 +6003,7 @@ export const ql3PostgresTables = [ pluginPackageMaterializedRevisions, pluginPackageSecretBindings, pluginPackageSecretBindingApprovalPlans, + pluginPackageSecretBindingTransitionReceipts, projectToolDefinitionSnapshots, projectToolDefinitionSnapshotSources, pluginPackageQuarantineEvents, diff --git a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts index 5eff6ddf..97693fb4 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts @@ -21,8 +21,8 @@ export interface PostgresSchemaContractTrigger { export interface PostgresSchemaContract { readonly schema: 'ql3'; readonly contractName: 'control-core'; - readonly contractVersion: 61; - readonly migrationId: 'pg-0062-plugin-package-secret-binding-target-guard'; + readonly contractVersion: 62; + readonly migrationId: 'pg-0063-plugin-package-secret-binding-transition-receipts'; readonly minimumServerMajor: 16; readonly maximumServerMajor: 18; readonly capabilities: Readonly<{ @@ -64,6 +64,7 @@ export interface PostgresSchemaContract { plugin_package_secret_binding: 1; plugin_package_secret_binding_approval_plan: 1; plugin_package_secret_binding_transition: 1; + plugin_package_secret_binding_transition_receipt: 1; plugin_package_secret_materialization: 1; plugin_package_proposal: 1; plugin_package_publisher_provenance: 1; @@ -115,8 +116,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = Object.freeze({ schema: 'ql3', contractName: 'control-core', - contractVersion: 61, - migrationId: 'pg-0062-plugin-package-secret-binding-target-guard', + contractVersion: 62, + migrationId: 'pg-0063-plugin-package-secret-binding-transition-receipts', minimumServerMajor: 16, maximumServerMajor: 18, capabilities: Object.freeze({ @@ -151,6 +152,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = plugin_package_secret_binding: 1, plugin_package_secret_binding_approval_plan: 1, plugin_package_secret_binding_transition: 1, + plugin_package_secret_binding_transition_receipt: 1, plugin_package_secret_materialization: 1, plugin_package_proposal: 1, plugin_package_publisher_provenance: 1, @@ -288,6 +290,23 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'expires_at_ms', 'plan_json', ]), + table('plugin_package_secret_binding_transition_receipts', [ + 'generation_digest', + 'transition_digest', + 'project_id', + 'package_name', + 'installation_id', + 'lock_digest', + 'generation', + 'manifest_digest', + 'previous_active_lock_digest', + 'authority_kind', + 'evidence_digest', + 'binding_digest', + 'committed_at_ms', + 'receipt_digest', + 'receipt_json', + ]), table('project_tool_definition_snapshots', [ 'project_id', 'active_vector_digest', @@ -1471,6 +1490,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_approval_plan_digest_uidx', 'ql3_plugin_package_secret_binding_approval_plan_target_idx', 'ql3_plugin_package_secret_binding_approval_plan_expiry_idx', + 'plugin_package_secret_binding_transition_receipts_pkey', + 'ql3_package_secret_transition_receipt_transition_uidx', + 'ql3_package_secret_transition_receipt_digest_uidx', + 'ql3_package_secret_transition_receipt_install_idx', 'project_tool_definition_snapshots_pkey', 'ql3_project_tool_snapshot_withdrawal_key', 'ql3_project_tool_definition_snapshot_digest_uidx', @@ -1769,6 +1792,9 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_approval_plan_digest_check', 'ql3_plugin_package_secret_binding_approval_plan_time_check', 'ql3_plugin_package_secret_binding_approval_plan_json_check', + 'ql3_package_secret_transition_receipt_identity_check', + 'ql3_package_secret_transition_receipt_digest_check', + 'ql3_package_secret_transition_receipt_json_check', 'ql3_plugin_package_quarantine_identity_check', 'ql3_plugin_package_quarantine_state_check', 'ql3_plugin_package_quarantine_subject_check', @@ -2226,6 +2252,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_install_fk', 'ql3_plugin_package_secret_binding_approval_plan_project_fk', 'ql3_plugin_package_secret_binding_approval_plan_install_fk', + 'ql3_plugin_package_secret_binding_transition_receipt_project_fk', + 'ql3_plugin_package_secret_binding_transition_receipt_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', @@ -2401,6 +2429,14 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = volatility: 'volatile', configuration: Object.freeze(['search_path=pg_catalog, ql3']), }), + Object.freeze({ + name: 'enforce_plugin_package_secret_binding_transition_receipt_target', + identityArguments: '', + owner: 'ql3_migration', + securityDefiner: false, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), Object.freeze({ name: 'enforce_plugin_package_secret_materialization', identityArguments: '', @@ -2539,6 +2575,12 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = tableName: 'plugin_package_secret_bindings', functionName: 'enforce_plugin_package_secret_binding_target', }), + Object.freeze({ + name: 'ql3_package_secret_transition_receipt_target_guard', + tableName: 'plugin_package_secret_binding_transition_receipts', + functionName: + 'enforce_plugin_package_secret_binding_transition_receipt_target', + }), Object.freeze({ name: 'ql3_plugin_package_secret_materialization_guard', tableName: 'plugin_package_materialized_revisions', diff --git a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts index 113ce48a..4be0d5d7 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts @@ -186,6 +186,12 @@ const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_transition_receipts: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), project_tool_definition_snapshots: Object.freeze({ select: false, insert: false, @@ -711,6 +717,12 @@ const REQUIRED_ADMIN_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_transition_receipts: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), project_tool_definition_snapshots: Object.freeze({ select: false, insert: false, @@ -1264,6 +1276,7 @@ const REQUIRED_PACKAGE_EXECUTOR_PRIVILEGES: RequiredPrivileges = Object.freeze( name === 'plugin_package_install_mutations' || name === 'plugin_package_materialized_revisions' || name === 'plugin_package_secret_bindings' || + name === 'plugin_package_secret_binding_transition_receipts' || name === 'project_tool_definition_snapshots' || name === 'project_tool_definition_snapshot_sources' || name === 'plugin_package_lifecycle_plans' || @@ -1531,6 +1544,7 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_task_reconciliation: false, enforce_plugin_package_secret_materialization: false, enforce_plugin_package_secret_binding_target: false, + enforce_plugin_package_secret_binding_transition_receipt_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: false, @@ -1553,6 +1567,7 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_task_reconciliation: false, enforce_plugin_package_secret_materialization: false, enforce_plugin_package_secret_binding_target: false, + enforce_plugin_package_secret_binding_transition_receipt_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: true, @@ -1575,6 +1590,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges commit_plugin_package_task_reconciliation: true, enforce_plugin_package_secret_materialization: false, enforce_plugin_package_secret_binding_target: false, + enforce_plugin_package_secret_binding_transition_receipt_target: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: true, lock_approval_policy_fence: true, @@ -1744,10 +1760,9 @@ async function assertSchemaContract( constraintsResult, functionsResult, triggersResult, - ] = - await Promise.all([ - queryable.query( - ` + ] = await Promise.all([ + queryable.query( + ` SELECT tables.relname AS "tableName", columns.attname AS "columnName" @@ -1760,19 +1775,19 @@ WHERE schemas.nspname = $1 AND NOT columns.attisdropped ORDER BY tables.relname, columns.attnum `.trim(), - [contract.schema], - ), - queryable.query( - ` + [contract.schema], + ), + queryable.query( + ` SELECT indexname AS "indexName" FROM pg_indexes WHERE schemaname = $1 ORDER BY indexname `.trim(), - [contract.schema], - ), - queryable.query( - ` + [contract.schema], + ), + queryable.query( + ` SELECT constraints.conname AS "constraintName", CASE constraints.contype @@ -1786,10 +1801,10 @@ WHERE schemas.nspname = $1 AND constraints.contype IN ('c', 'f') ORDER BY constraints.contype, constraints.conname `.trim(), - [contract.schema], - ), - queryable.query( - ` + [contract.schema], + ), + queryable.query( + ` SELECT routines.proname AS "functionName", pg_get_function_identity_arguments(routines.oid) AS "identityArguments", @@ -1817,10 +1832,10 @@ JOIN pg_namespace schemas ON schemas.oid = routines.pronamespace WHERE schemas.nspname = $1 ORDER BY routines.proname, pg_get_function_identity_arguments(routines.oid) `.trim(), - [contract.schema], - ), - queryable.query( - ` + [contract.schema], + ), + queryable.query( + ` SELECT triggers.tgname AS "triggerName", tables.relname AS "tableName", @@ -1835,12 +1850,9 @@ WHERE schemas.nspname = $1 AND triggers.tgname = ANY($2::text[]) ORDER BY triggers.tgname `.trim(), - [ - contract.schema, - contract.triggers.map(({ name }) => name), - ], - ), - ]); + [contract.schema, contract.triggers.map(({ name }) => name)], + ), + ]); const actualTables = new Map>(); for (const row of columnsResult.rows) { if ( diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs index fb69046e..b809b846 100644 --- a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingTransition.integration.test.cjs @@ -8,18 +8,22 @@ const { const { createPluginPackageResourceGeneration, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingTarget, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); const { assertPostgresPackageExecutorSchemaReady, createPostgresDatabaseOpener, PostgresPluginPackageSecretBindingRepository, + PostgresPluginPackageSecretBindingTransitionRepository, } = require('../dist/entrypoints/packageExecutor'); -const { - runPostgresMigrations, -} = require('../dist/migration/migration'); +const { runPostgresMigrations } = require('../dist/migration/migration'); -const migrationConnectionString = - process.env.QL3_TEST_POSTGRES_MIGRATION_URL; +const migrationConnectionString = process.env.QL3_TEST_POSTGRES_MIGRATION_URL; const executorConnectionString = process.env.QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL; @@ -221,9 +225,12 @@ async function open(role, connectionString) { } if (!migrationConnectionString || !executorConnectionString) { - test('PostgreSQL Secret binding transition gate requires migration and executor URLs', { - skip: true, - }); + test( + 'PostgreSQL Secret binding transition gate requires migration and executor URLs', + { + skip: true, + }, + ); } else { test('PostgreSQL enforces active and reviewed staged Secret binding targets', async () => { const migrationDatabase = await open( @@ -241,7 +248,7 @@ if (!migrationConnectionString || !executorConnectionString) { executorDatabase.pool, ); assert.equal(readiness.ready, true); - assert.equal(readiness.contractVersion, 61); + assert.equal(readiness.contractVersion, 62); const repository = new PostgresPluginPackageSecretBindingRepository( executorDatabase.pool, @@ -326,8 +333,75 @@ if (!migrationConnectionString || !executorConnectionString) { packageName: stagedPackageName, installationId: stagedInstallationId, }); - assert.equal((await repository.publish(stagedBinding)).status, 'created'); - assert.equal((await repository.publish(stagedBinding)).status, 'existing'); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: previousInstallationId, + projectId: stagedProjectId, + packageName: stagedPackageName, + lockDigest: previousLockDigest, + generation: 1, + previousActiveLockDigest: null, + contentDigest: '7'.repeat(64), + contents: manifest(stagedPackageName).spec.contents, + }); + const nextGeneration = createPluginPackageResourceGeneration({ + installationId: stagedInstallationId, + projectId: stagedProjectId, + packageName: stagedPackageName, + lockDigest: stagedLockDigest, + generation: 2, + previousActiveLockDigest: previousLockDigest, + contentDigest: 'e'.repeat(64), + contents: manifest(stagedPackageName).spec.contents, + }); + const transitionPlan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: createPluginPackageSecretBindingTarget( + previousGeneration, + manifest(stagedPackageName), + ), + previousBinding: null, + previousAttemptGeneration: 1, + nextGeneration, + nextManifest: manifest(stagedPackageName), + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: stagedProjectId, + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 100, + }); + const transitions = + new PostgresPluginPackageSecretBindingTransitionRepository( + executorDatabase.pool, + ); + const committed = await transitions.apply({ + transitionPlan, + evidenceDigest: transitionPlan.transitionDigest, + committedAtMs: 102, + }); + assert.equal(committed.status, 'created'); + assert.equal( + committed.receipt.bindingDigest, + committed.binding.bindingDigest, + ); + assert.equal( + ( + await transitions.apply({ + transitionPlan, + evidenceDigest: transitionPlan.transitionDigest, + committedAtMs: 102, + }) + ).status, + 'existing', + ); + assert.deepEqual( + await transitions.find(transitionPlan.nextTarget.generationDigest), + committed.receipt, + ); const activatingProjectId = `${namespace}-activating`; const activatingPackageName = 'activating-binding'; diff --git a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs index f6a49e14..abcc3192 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs @@ -113,6 +113,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async () 'pg-0060-plugin-package-secret-materialization-guard', 'pg-0061-plugin-package-secret-binding-approval-plans', 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ], ); for (const migration of postgresqlMainMigrationStream.migrations) { @@ -166,6 +167,7 @@ test('keeps local-only and legacy tables out of the cluster baseline', async () 'plugin_package_materialized_revisions', 'plugin_package_secret_bindings', 'plugin_package_secret_binding_approval_plans', + 'plugin_package_secret_binding_transition_receipts', 'plugin_package_lifecycle_events', 'plugin_package_lifecycle_heads', 'plugin_package_lifecycle_receipts', @@ -560,6 +562,11 @@ test('freezes every published PostgreSQL migration checksum', () => { checksum: 'cd4f92d8702da6b92dd9ae5153b5180400b94442f56393692b6ec038f998596b', }, + { + id: 'pg-0063-plugin-package-secret-binding-transition-receipts', + checksum: + '20c4d6e640b7fb05fc2f44dd9bec6d2d081697b83acf4e7dd5deb9a07afe54df', + }, ]; assert.deepEqual( postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ @@ -2127,3 +2134,41 @@ test('advances capability v61 with a staged-target Secret binding database guard /migration_id = 'pg-0061-plugin-package-secret-binding-approval-plans'/, ); }); + +test('advances capability v62 with immutable Secret binding transition receipts', async () => { + const migration = migrationById( + 'pg-0063-plugin-package-secret-binding-transition-receipts', + ); + 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_binding_transition_receipts"/, + ); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."enforce_plugin_package_secret_binding_transition_receipt_target"\(\)/, + ); + assert.match(sql, /install\.state = 'staged'/); + assert.match(sql, /previous\.state = 'active'/); + assert.match(sql, /MAX\(history\.target_generation\)/); + assert.match(sql, /binding\.binding_digest = NEW\.binding_digest/); + assert.doesNotMatch(sql, /SECURITY DEFINER/); + assert.match( + sql, + /GRANT SELECT, INSERT ON "ql3"\."plugin_package_secret_binding_transition_receipts" TO ql3_package_executor/, + ); + assert.match(sql, /contract_version = 62/); + assert.match(sql, /"plugin_package_secret_binding_transition_receipt":1/); + assert.match(sql, /contract_version = 61/); + assert.match( + sql, + /migration_id = 'pg-0062-plugin-package-secret-binding-target-guard'/, + ); +}); diff --git a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs index d8f2c81c..b3f53cd4 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs @@ -76,6 +76,12 @@ function validPrivileges() { plugin_package_materialized_revisions: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_receipts: [ + 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], @@ -196,6 +202,12 @@ function validAdminPrivileges() { plugin_package_materialized_revisions: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_receipts: [ + 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], @@ -308,6 +320,7 @@ function packagePrivileges(kind) { 'plugin_package_install_mutations', 'plugin_package_materialized_revisions', 'plugin_package_secret_bindings', + 'plugin_package_secret_binding_transition_receipts', 'project_tool_definition_snapshots', 'project_tool_definition_snapshot_sources', 'plugin_package_admission_receipts', @@ -351,6 +364,7 @@ function packagePrivileges(kind) { 'plugin_package_install_mutations', 'plugin_package_materialized_revisions', 'plugin_package_secret_bindings', + 'plugin_package_secret_binding_transition_receipts', 'plugin_package_automation_publications', 'plugin_package_automation_publication_heads', 'project_tool_definition_snapshots', @@ -776,7 +790,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro serverMajor: 16, currentUser: 'ql3_runtime', contractName: 'control-core', - contractVersion: 61, + contractVersion: 62, migrationIds: [ 'pg-0001-schema-capability', 'pg-0002-run-core', @@ -840,6 +854,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro 'pg-0060-plugin-package-secret-materialization-guard', 'pg-0061-plugin-package-secret-binding-approval-plans', 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ], }); }); @@ -870,10 +885,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async ( }), ); assert.equal(report.currentUser, 'ql3_admin'); - assert.equal(report.contractVersion, 61); + assert.equal(report.contractVersion, 62); assert.equal( report.migrationIds.at(-1), - 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ); }); @@ -886,10 +901,10 @@ test('accepts the isolated least-privilege automation manager role', async () => }), ); assert.equal(report.currentUser, 'ql3_automation_manager'); - assert.equal(report.contractVersion, 61); + assert.equal(report.contractVersion, 62); assert.equal( report.migrationIds.at(-1), - 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ); const widened = automationManagerPrivileges(); @@ -918,10 +933,10 @@ test('accepts the isolated least-privilege human Approval manager role', async ( }), ); assert.equal(report.currentUser, 'ql3_approval_manager'); - assert.equal(report.contractVersion, 61); + assert.equal(report.contractVersion, 62); assert.equal( report.migrationIds.at(-1), - 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ); const widened = approvalManagerPrivileges(); @@ -952,10 +967,10 @@ test('accepts the isolated least-privilege Run manager role', async () => { }), ); assert.equal(report.currentUser, 'ql3_run_manager'); - assert.equal(report.contractVersion, 61); + assert.equal(report.contractVersion, 62); assert.equal( report.migrationIds.at(-1), - 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ); const widened = runManagerPrivileges(); @@ -1087,10 +1102,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => { }), ); assert.equal(report.currentUser, 'ql3_worker_ingress'); - assert.equal(report.contractVersion, 61); + assert.equal(report.contractVersion, 62); assert.equal( report.migrationIds.at(-1), - 'pg-0062-plugin-package-secret-binding-target-guard', + 'pg-0063-plugin-package-secret-binding-transition-receipts', ); }); diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts index 8f323769..8c887631 100644 --- a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import { normalizePluginPackageResourceGeneration } from '../pluginPackageResourceGeneration'; import { @@ -466,7 +467,7 @@ export function normalizePluginPackageSecretBindingTransitionPlan( nextBindingPlan, ); if ( - JSON.stringify(plan.changes) !== JSON.stringify(unsigned.changes) || + !isDeepStrictEqual(plan.changes, unsigned.changes) || plan.kind !== unsigned.kind ) { return invalid('derived transition classification does not match content'); diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs index 9993614c..fb49e730 100644 --- a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs @@ -104,6 +104,20 @@ function transition(assignments, overrides = {}) { }); } +function postgresJsonbRoundTrip(value) { + if (Array.isArray(value)) { + return value.map(postgresJsonbRoundTrip); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, postgresJsonbRoundTrip(entry)]), + ); + } + return value; +} + test('derives an exact carry-forward for an unchanged next generation', () => { const value = transition([ { name: 'OPTIONAL_TOKEN', secretRef: null }, @@ -136,6 +150,19 @@ test('derives an exact carry-forward for an unchanged next generation', () => { ); }); +test('normalizes a transition after PostgreSQL jsonb reorders object keys', () => { + const value = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 3) }, + ]); + assert.deepEqual( + normalizePluginPackageSecretBindingTransitionPlan( + postgresJsonbRoundTrip(value), + ), + value, + ); +}); + test('distinguishes forward rotation from rebind and version rollback', () => { const rotated = transition([ { name: 'OPTIONAL_TOKEN', secretRef: null }, diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index f2579c35..be2f82a3 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2855,6 +2855,7 @@ function auditSourceImports(root, packagePath, findings) { [ '@qinglong/local-admin/package-secret-binding', '@qinglong/runtime-core/plugin-package-secret-binding-plan', + '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan', ].includes(specifier) ) { continue; diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 6876b6c6..40382ffc 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -74,12 +74,12 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( [ { directory: 'packages/ql3-cluster-postgres/src/migrations', - directSourceFiles: 63, + directSourceFiles: 64, reviewKind: 'ordered_ledger', }, { directory: 'packages/ql3-local-sqlite/src/migrations', - directSourceFiles: 97, + directSourceFiles: 99, reviewKind: 'ordered_ledger', }, ], @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 157, + sourceFiles: 158, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 156, + nestedSourceFiles: 157, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 160, + sourceFiles: 163, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 159, + nestedSourceFiles: 162, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 191, + sourceFiles: 196, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 190, + nestedSourceFiles: 195, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );