From c877147d0ff387c456f80bf994cbd3702de40a68 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 20:29:47 +0800 Subject: [PATCH] feat(ql3): gate local secret transitions before activation --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- ...ransition-plugin-package-secret-binding.md | 5 +- docs/ql3-package-boundaries.json | 2 +- .../adopted-profile/localAdoptedProfile.ts | 5 + .../pluginPackageInstallation.ts | 2 + .../pluginPackageSecretBinding.ts | 2 + .../pluginPackageStartup.ts | 3 + .../test/activation.test.cjs | 1 + .../plugin-package/pluginPackageCommand.ts | 125 ++++- .../test/localDeployment.test.cjs | 22 +- .../test/localReadiness.test.cjs | 4 +- .../test/pluginPackageCommand.test.cjs | 353 ++++++++++++++ packages/ql3-local-sqlite/package.json | 10 + .../src/migration/migration.ts | 4 + .../src/migration/migrationManifest.ts | 10 + ...kage-secret-binding-transition-receipts.ts | 78 +++ .../src/migrations/0098-capability-v49.ts | 14 + .../pluginPackageInstallRepository.ts | 35 ++ .../secret-binding/activationPrerequisite.ts | 159 ++++++ .../secret-binding/administration.ts | 452 ++++++++++++++++++ .../transitionReceiptRepository.ts | 247 ++++++++++ .../transitionReceiptSchemaContract.ts | 62 +++ .../src/profile/localProfile.ts | 3 + .../src/readiness/readiness.ts | 36 +- .../src/runtime/runtimeDatabase.ts | 16 + .../ql3-local-sqlite/src/storage/schema.ts | 55 +++ .../ql3-local-sqlite/test/database.test.cjs | 14 +- ...ginPackageSecretBindingRepository.test.cjs | 110 ++++- ...ackageWorkflowAdmissionRepository.test.cjs | 4 +- ...lowTaskAttemptAdmissionRepository.test.cjs | 2 +- .../test/rolloutSafety.test.cjs | 6 +- packages/ql3-runtime-core/package.json | 8 + .../installation/pluginPackageInstallation.ts | 42 +- .../installation/pluginPackageRecovery.ts | 42 +- .../secret-binding/transitionReceipt.ts | 305 ++++++++++++ .../test/pluginPackageRecovery.test.cjs | 57 +++ ...ageSecretBindingTransitionReceipt.test.cjs | 237 +++++++++ 37 files changed, 2495 insertions(+), 39 deletions(-) create mode 100644 packages/ql3-local-sqlite/src/migrations/0097-plugin-package-secret-binding-transition-receipts.ts create mode 100644 packages/ql3-local-sqlite/src/migrations/0098-capability-v49.ts create mode 100644 packages/ql3-local-sqlite/src/plugin-package/secret-binding/activationPrerequisite.ts create mode 100644 packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptRepository.ts create mode 100644 packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptSchemaContract.ts create mode 100644 packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionReceipt.ts create mode 100644 packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionReceipt.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 001986ff..de0c6ca1 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` 指回旧代。服务端分别推导 requirement 的 add/remove/tighten/relax 和 SecretRef 的 bind/revoke/forward-rotate/rebind;版本倒退与换名均不能冒充 rotation,删除最后一个 requirement 以 `nextBindingPlan: null` 表达而不制造空 binding。SQLite capability v48(0095/0096)与 PostgreSQL capability v61(pg0062)现已在 repository 与数据库 trigger 两层只允许当前 active 或具完整上一 active lineage、且为 durable install history 最大 generation 的 staged target;queued/activating/failed、陈旧/跳代、断链和直接 SQL 绕过均失败关闭,readiness 校验 exact trigger/function。完整 18-package 串行门、backend 1194 项(1192 pass/2 条件 skip)及真实 PostgreSQL 18.4 B2 role/trigger 门通过。最终顺序仍为 install approval/stage→transition approval→目标 binding publish/materialize→active CAS,失败不得移动旧 active;紧急 revoke 先复用 disable/quarantine 阻止新 admission,再做代际收敛,不声称追回运行中明文。公共 contract 归入既有 runtime-core Secret binding 聚合目录;本切片未新增 package、第三方依赖、表、连接或常驻资源。Local Owner 与 Cluster management/executor 产品编排、真实 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、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-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-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md index 2d01c01d..55974b7e 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 @@ -34,4 +34,7 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 - 已冻结共享 transition plan v1 的 lineage 与差异分类语义,并完成 Local/Cluster 的 activation 前持久化门:SQLite capability v48 通过 migration 0095/0096、PostgreSQL capability v61 通过 migration pg0062,把 repository 写入和数据库 trigger 同时约束为“当前 active target”或“由完整上一 active lineage 支撑、且为 durable install history 最大 generation 的 staged target”。`queued|activating|failed`、陈旧/跳代 staged target、断裂 lineage 与直接 SQL 绕过均失败关闭;readiness 校验 exact trigger/function attachment,避免 schema 名义升级但约束缺失。 - 本切片没有新增 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 与 Cluster management/executor 尚未消费 transition plan,完成“审批 → 发布 binding → materialize → active CAS”的产品纵切面;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片只补产品编排,不再扩张 package 或常驻部署面。 +- 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/低配现场门。 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index 8696d2ff..d1fb45d4 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -12,7 +12,7 @@ }, { "kind": "ordered_ledger", - "maxDirectSourceFiles": 97, + "maxDirectSourceFiles": 99, "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." } diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts index f1a448f8..cd15e77d 100644 --- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts +++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts @@ -32,6 +32,8 @@ type PluginPackageMaterializedRevisionRepository = ReadyLocalStorage['pluginPackageMaterializedRevisions']; type PluginPackageSecretBindingRepository = ReadyLocalStorage['pluginPackageSecretBindings']; +type PluginPackageActivationPrerequisite = + ReadyLocalStorage['pluginPackageActivationPrerequisite']; type PluginPackageTaskReconciliationRepository = ReadyLocalStorage['pluginPackageTaskReconciliations']; type PluginPackageAutomationPublicationRepository = @@ -100,6 +102,7 @@ export type LocalAdoptedProfileBootstrapResult = readonly pluginPackageInstalls: PluginPackageInstallRepository; readonly pluginPackageMaterializedRevisions: PluginPackageMaterializedRevisionRepository; readonly pluginPackageSecretBindings: PluginPackageSecretBindingRepository; + readonly pluginPackageActivationPrerequisite: PluginPackageActivationPrerequisite; readonly pluginPackageTaskReconciliations: PluginPackageTaskReconciliationRepository; readonly pluginPackageAutomationPublications: PluginPackageAutomationPublicationRepository; readonly projectToolDefinitionSnapshots: ProjectToolDefinitionSnapshotRepository; @@ -248,6 +251,8 @@ export async function bootstrapLocalAdoptedProfileStorage( pluginPackageMaterializedRevisions: readyStorage.pluginPackageMaterializedRevisions, pluginPackageSecretBindings: readyStorage.pluginPackageSecretBindings, + pluginPackageActivationPrerequisite: + readyStorage.pluginPackageActivationPrerequisite, pluginPackageTaskReconciliations: readyStorage.pluginPackageTaskReconciliations, pluginPackageAutomationPublications: diff --git a/packages/ql3-local-admin/src/plugin-package/pluginPackageInstallation.ts b/packages/ql3-local-admin/src/plugin-package/pluginPackageInstallation.ts index e62a5942..d29d6eeb 100644 --- a/packages/ql3-local-admin/src/plugin-package/pluginPackageInstallation.ts +++ b/packages/ql3-local-admin/src/plugin-package/pluginPackageInstallation.ts @@ -1,5 +1,6 @@ import { PluginPackageInstallationCoordinator, + type PluginPackageActivationPrerequisite, type PluginPackageStageProvider, } from '@qinglong/runtime-core/plugin-package-installation'; import type { PluginPackageAdmissionRepository } from '@qinglong/runtime-core/plugin-package-admission'; @@ -64,6 +65,7 @@ export function createLocalPluginPackageFileStageProvider( export function createLocalPluginPackageInstallationCoordinator(options: { readonly repository: PluginPackageAdmissionRepository; readonly publisher: PluginPackageActivationPublisher; + readonly activationPrerequisite?: PluginPackageActivationPrerequisite; }): PluginPackageInstallationCoordinator { return new PluginPackageInstallationCoordinator(options); } diff --git a/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts b/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts index 5f257148..42c60449 100644 --- a/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts +++ b/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts @@ -3,6 +3,8 @@ export { LocalPluginPackageSecretBindingConflictError, LocalPluginPackageSecretBindingUnavailableError, type ExecuteLocalPluginPackageSecretBindingRequest, + type ExecuteLocalPluginPackageSecretBindingTransitionRequest, type LocalPluginPackageSecretBindingService, type PlanLocalPluginPackageSecretBindingRequest, + type PlanLocalPluginPackageSecretBindingTransitionRequest, } from '@qinglong/local-sqlite/plugin-package-secret-binding-administration'; diff --git a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts index ac05677d..287b27a5 100644 --- a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts +++ b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts @@ -45,6 +45,8 @@ export async function recoverLocalApplicationPluginPackages( storage: LocalApplicationReadyStorage, ): Promise { const pluginPackageInstalls = await storage.pluginPackageInstalls(); + const pluginPackageActivationPrerequisite = + await storage.pluginPackageActivationPrerequisite(); const pluginPackageActivation = new LocalPluginPackageActivationPublisher({ stagingRoot: options.pluginPackages.stagingRoot, activationRoot: options.pluginPackages.activationRoot, @@ -54,6 +56,7 @@ export async function recoverLocalApplicationPluginPackages( repository: pluginPackageInstalls, stageProvider: options.pluginPackages.stageProvider, publisher: pluginPackageActivation, + activationPrerequisite: pluginPackageActivationPrerequisite, now: options.pluginPackages.now, }).recover({ ...(options.pluginPackages.pageSize === undefined diff --git a/packages/ql3-local-application/test/activation.test.cjs b/packages/ql3-local-application/test/activation.test.cjs index b8392781..fca208c6 100644 --- a/packages/ql3-local-application/test/activation.test.cjs +++ b/packages/ql3-local-application/test/activation.test.cjs @@ -1660,6 +1660,7 @@ test('activates the concrete headless runtime in recovery and lifecycle order', retry: 0, manualRequired: 0, superseded: 0, + deferred: 0, remaining: false, safeToAdmit: true, }); diff --git a/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts b/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts index ae1595c6..beae0c2a 100644 --- a/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts +++ b/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts @@ -30,6 +30,10 @@ import { normalizePluginPackageSecretBindingPlan, type PluginPackageSecretBindingPlan, } from '@qinglong/runtime-core/plugin-package-secret-binding-plan'; +import { + normalizePluginPackageSecretBindingTransitionPlan, + type PluginPackageSecretBindingTransitionPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'; import { pluginPackageInstallRecoveryAction, type PluginPackageInstallActionInput, @@ -206,6 +210,30 @@ export interface ExecuteLocalPluginPackageSecretBindingCommand { }; } +export interface PlanLocalPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.plan'; + readonly options: LocalPluginPackageCommandOptions; + readonly request: { + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly<{ + name: string; + secretRef: string | null; + }>[]; + }; +} + +export interface ExecuteLocalPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.execute'; + readonly options: LocalPluginPackageCommandOptions; + readonly request: { + readonly plan: PluginPackageSecretBindingTransitionPlan; + readonly auditEventId: string; + }; +} + export type LocalPluginPackageCommand = | ProposeLocalPluginPackageCommand | DecideLocalPluginPackageCommand @@ -217,7 +245,9 @@ export type LocalPluginPackageCommand = | PlanLocalPluginPackageLifecycleCommand | ExecuteLocalPluginPackageLifecycleCommand | PlanLocalPluginPackageSecretBindingCommand - | ExecuteLocalPluginPackageSecretBindingCommand; + | ExecuteLocalPluginPackageSecretBindingCommand + | PlanLocalPluginPackageSecretBindingTransitionCommand + | ExecuteLocalPluginPackageSecretBindingTransitionCommand; export interface LocalPluginPackageCommandRunner { run( @@ -295,6 +325,21 @@ export type LocalPluginPackageCommandResult = status: 'created' | 'existing'; bindingDigest: string; generationDigest: string; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.plan'; + plan: Readonly; + summary: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.execute'; + status: 'created' | 'existing'; + transitionDigest: string; + receiptDigest: string; + bindingDigest: string | null; + generationDigest: string; }>; export class LocalPluginPackageCommandConfigurationError extends TypeError { @@ -603,6 +648,7 @@ function normalizeCommand(value: unknown): Readonly { } break; case 'plugin-package.secret-binding.plan': + case 'plugin-package.secret-binding.transition.plan': exactObject( value.request, ['assignments', 'packageName', 'projectId'], @@ -637,6 +683,7 @@ function normalizeCommand(value: unknown): Readonly { } break; case 'plugin-package.secret-binding.execute': + case 'plugin-package.secret-binding.transition.execute': exactObject( value.request, ['auditEventId', 'plan'], @@ -653,7 +700,11 @@ function normalizeCommand(value: unknown): Readonly { ); } try { - normalizePluginPackageSecretBindingPlan(value.request.plan); + if (value.operation === 'plugin-package.secret-binding.execute') { + normalizePluginPackageSecretBindingPlan(value.request.plan); + } else { + normalizePluginPackageSecretBindingTransitionPlan(value.request.plan); + } } catch (error) { throw new LocalPluginPackageCommandConfigurationError( 'Secret binding plan is invalid', @@ -851,6 +902,43 @@ function secretBindingPlanSummary( }); } +function secretBindingTransitionPlanSummary( + plan: Readonly, +) { + return Object.freeze({ + kind: plan.kind, + projectId: plan.nextTarget.projectId, + packageName: plan.nextTarget.packageName, + previousInstallationId: plan.previousTarget.installationId, + previousGeneration: plan.previousTarget.generation, + previousGenerationDigest: plan.previousTarget.generationDigest, + previousBindingDigest: plan.previousBinding?.bindingDigest ?? null, + previousAttemptGeneration: plan.previousAttemptGeneration, + installationId: plan.nextTarget.installationId, + generation: plan.nextTarget.generation, + generationDigest: plan.nextTarget.generationDigest, + manifestDigest: plan.nextTarget.manifestDigest, + bindingRequired: plan.nextBindingPlan !== null, + assignments: + plan.nextBindingPlan?.entries.map((entry) => + Object.freeze({ + name: entry.name, + required: entry.required, + bound: entry.secretRef !== null, + secretRef: entry.secretRef, + }), + ) ?? Object.freeze([]), + changes: plan.changes.map((change) => + Object.freeze({ + name: change.name, + requirement: change.requirement, + reference: change.reference, + }), + ), + transitionDigest: plan.transitionDigest, + }); +} + async function execute( command: Readonly, database: LocalSqlitePluginPackageManagementDatabase, @@ -873,6 +961,22 @@ async function execute( summary: secretBindingPlanSummary(plan), }); } + if (command.operation === 'plugin-package.secret-binding.transition.plan') { + const service = createLocalPluginPackageSecretBindingService( + database.authority, + ); + const plan = await service.planTransition({ + ...command.request, + principal: authenticated.principal, + plannedAtMs: Date.now(), + }); + return Object.freeze({ + schemaVersion: 1, + operation: command.operation, + plan, + summary: secretBindingTransitionPlanSummary(plan), + }); + } if (command.operation === 'plugin-package.secret-binding.execute') { const service = createLocalPluginPackageSecretBindingService( database.authority, @@ -888,6 +992,23 @@ async function execute( ...result, }); } + if ( + command.operation === 'plugin-package.secret-binding.transition.execute' + ) { + const service = createLocalPluginPackageSecretBindingService( + database.authority, + ); + const result = await service.executeTransition({ + ...command.request, + principal: authenticated.principal, + confirmAuthorization: authenticated.confirm, + }); + return Object.freeze({ + schemaVersion: 1, + operation: command.operation, + ...result, + }); + } if (command.operation === 'plugin-package.lifecycle.plan') { const service = createLocalPluginPackageLifecycleService({ authority: database.authority, diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index 4f7580d4..6a8c2fd0 100644 --- a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs +++ b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs @@ -363,9 +363,9 @@ function composeDockerHarness( '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js', ], Labels: { - 'io.qinglong.local.sqlite-contract-min': '48', - 'io.qinglong.local.sqlite-contract-max': '48', - 'io.qinglong.local.sqlite-write-contract': '48', + 'io.qinglong.local.sqlite-contract-min': '49', + 'io.qinglong.local.sqlite-contract-max': '49', + 'io.qinglong.local.sqlite-write-contract': '49', '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': '48', - 'io.qinglong.local.sqlite-contract-max': '48', - 'io.qinglong.local.sqlite-write-contract': '48', + 'io.qinglong.local.sqlite-contract-min': '49', + 'io.qinglong.local.sqlite-contract-max': '49', + 'io.qinglong.local.sqlite-write-contract': '49', '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, 48); + assert.equal(result.sqlite.contractVersion, 49); 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: 48, - writeContractVersion: 48, + contractVersion: 49, + writeContractVersion: 49, 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, 48); - assert.equal(receipt.sqlite.writeContractVersion, 48); + assert.equal(receipt.sqlite.contractVersion, 49); + assert.equal(receipt.sqlite.writeContractVersion, 49); assert.equal(receipt.sqlite.writeObservation, 'changed'); assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/); assert.equal(receipt.sqlite.backup.bytes > 0, true); diff --git a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs index bc54c42c..6bb9bceb 100644 --- a/packages/ql3-local-owner-cli/test/localReadiness.test.cjs +++ b/packages/ql3-local-owner-cli/test/localReadiness.test.cjs @@ -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, 48); - assert.equal(result.storage.migrationCount, 96); + assert.equal(result.storage.contractVersion, 49); + assert.equal(result.storage.migrationCount, 98); assert.equal(result.storage.journalMode, 'delete'); assert.equal(JSON.stringify(result).includes(state.directory), false); }); diff --git a/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs b/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs index 3929c244..2af75bf8 100644 --- a/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs +++ b/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs @@ -41,6 +41,9 @@ const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration'); const { LocalSqlitePluginPackageInstallRepository, } = require('@qinglong/local-sqlite/plugin-package-install'); +const { + LocalSqlitePluginPackageSecretBindingActivationPrerequisite, +} = require('@qinglong/local-sqlite/plugin-package-secret-binding-activation-prerequisite'); const { LocalSqlitePluginPackageAutomationPublicationRepository, } = require('@qinglong/local-sqlite/plugin-package-automation-publication'); @@ -72,10 +75,14 @@ const { } = require('@qinglong/runtime-core/plugin-package-bundle'); const { serializePluginPackageManifest, + createPluginPackageActivationReceipt, pluginPackageActivationIntentDigest, pluginPackageInstallCommit, transitionPluginPackageInstall, } = require('@qinglong/runtime-core/plugin-package-install'); +const { + PluginPackageRecoveryCoordinator, +} = require('@qinglong/runtime-core/plugin-package-recovery'); const { createInitialPluginPackageAutomationPublication, } = require('@qinglong/runtime-core/plugin-package-automation-publication'); @@ -273,6 +280,38 @@ function actionInput(secretAware = false) { }; } +function upgradeActionInput(previousInput, previousLockDigest) { + const manifest = { + ...previousInput.manifest, + metadata: { + ...previousInput.manifest.metadata, + version: '1.1.0', + description: 'One bounded upgraded package', + }, + }; + const artifact = packageArtifact(manifest); + const artifactDigest = digest(artifact); + return { + ...previousInput, + lockId: 'cli-monitor-v2', + manifest, + plan: planPluginPackageInstall( + manifest, + previousInput.environment, + previousInput.manifest, + ), + previousManifest: previousInput.manifest, + source: { + ...previousInput.source, + locator: `offline:sha256:${artifactDigest}`, + artifactDigest, + artifactBytes: artifact.byteLength, + }, + targetGeneration: 2, + previousLockDigest, + }; +} + async function fixture(t, owner = true) { const deploymentRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-package-command-')), @@ -550,6 +589,30 @@ async function activatePackageOnly(databasePath, projectId, packageName) { } } +async function stagePackageOnly(databasePath, projectId, packageName) { + const client = new DatabaseSync(databasePath); + const authority = new LocalSqliteOperationAuthority(client); + try { + const installs = new LocalSqlitePluginPackageInstallRepository(authority); + const queued = await installs.find(projectId, packageName); + const lock = await installs.findLock(queued.lockDigest); + const staged = transitionPluginPackageInstall(lock, queued, { + type: 'stage_completed', + mutationId: `secret-binding-stage-package-${queued.targetGeneration}`, + occurredAtMs: queued.updatedAtMs + 1, + stageRef: `stage:${lock.lockDigest}`, + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'e'.repeat(64), + }); + await installs.commit(pluginPackageInstallCommit(queued, staged)); + return { staged, lock }; + } finally { + await authority.close(); + } +} + function publisherTrustRunnerWithOneSnapshotFault() { let injectFault = true; return createLocalPluginPackagePublisherTrustCommandRunner({ @@ -1662,6 +1725,296 @@ test('plans and atomically binds a versioned Secret to the current Package gener } }); +test('keeps an upgrade staged until the Owner commits its Secret transition', async (t) => { + const value = await fixture(t); + const initialInput = actionInput(true); + const initialActionRef = 'proposal:transition-cli-monitor-v1'; + const initialApprovalId = 'approval-transition-cli-monitor-v1'; + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.propose', + { + actionRef: initialActionRef, + approvalRequestId: initialApprovalId, + proposalAuditEventId: '22000000-0000-4000-8000-000000000001', + approvalAuditEventId: '22000000-0000-4000-8000-000000000002', + actionInput: initialInput, + }, + 'transition-01-propose', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.decide', + { + actionRef: initialActionRef, + approvalRequestId: initialApprovalId, + expectedVersion: 1, + decisionId: 'decision-transition-cli-monitor-v1', + auditEventId: '22000000-0000-4000-8000-000000000003', + decision: 'approved', + reasonCode: 'reviewed', + }, + 'transition-02-decide', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.consume', + { + actionRef: initialActionRef, + approvalRequestId: initialApprovalId, + expectedVersion: 2, + consumptionId: 'consume-transition-cli-monitor-v1', + dispatchId: 'dispatch-transition-cli-monitor-v1', + auditEventId: '22000000-0000-4000-8000-000000000004', + }, + 'transition-03-consume', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.dispatch', + { limit: 1 }, + 'transition-04-dispatch', + ), + ); + const { active, lock: activeLock } = await activatePackageOnly( + value.databasePath, + 'default', + 'cli-monitor', + ); + const secretRef = createSecretRef({ + projectId: 'default', + name: 'transition-token', + version: 1, + }); + const database = new DatabaseSync(value.databasePath); + try { + database + .prepare( + `INSERT INTO "QingLong3LocalSecretEnvelopes" ( + "project_id", "secret_name", "version", "mutation_id", + "key_id", "algorithm", "nonce", "ciphertext", "auth_tag", + "created_at_ms" + ) VALUES (?, ?, 1, ?, ?, 'aes-256-gcm', ?, ?, ?, ?)`, + ) + .run( + 'default', + 'transition-token', + 'transition-secret-v1', + 'fixture-key-v1', + Buffer.alloc(12, 3), + Buffer.from('ciphertext'), + Buffer.alloc(16, 4), + Date.now(), + ); + } finally { + database.close(); + } + const initialPlan = await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.plan', + { + projectId: 'default', + packageName: 'cli-monitor', + assignments: [{ name: 'TOKEN', secretRef }], + }, + 'transition-05-bind-plan', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.execute', + { + plan: initialPlan.plan, + auditEventId: '22000000-0000-4000-8000-000000000005', + }, + 'transition-06-bind-execute', + ), + ); + + const upgradeInput = upgradeActionInput(initialInput, activeLock.lockDigest); + const upgradeActionRef = 'proposal:transition-cli-monitor-v2'; + const upgradeApprovalId = 'approval-transition-cli-monitor-v2'; + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.propose', + { + actionRef: upgradeActionRef, + approvalRequestId: upgradeApprovalId, + proposalAuditEventId: '22000000-0000-4000-8000-000000000006', + approvalAuditEventId: '22000000-0000-4000-8000-000000000007', + actionInput: upgradeInput, + }, + 'transition-07-upgrade-propose', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.decide', + { + actionRef: upgradeActionRef, + approvalRequestId: upgradeApprovalId, + expectedVersion: 1, + decisionId: 'decision-transition-cli-monitor-v2', + auditEventId: '22000000-0000-4000-8000-000000000008', + decision: 'approved', + reasonCode: 'reviewed', + }, + 'transition-08-upgrade-decide', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.consume', + { + actionRef: upgradeActionRef, + approvalRequestId: upgradeApprovalId, + expectedVersion: 2, + consumptionId: 'consume-transition-cli-monitor-v2', + dispatchId: 'dispatch-transition-cli-monitor-v2', + auditEventId: '22000000-0000-4000-8000-000000000009', + }, + 'transition-09-upgrade-consume', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.dispatch', + { limit: 1 }, + 'transition-10-upgrade-dispatch', + ), + ); + const { staged } = await stagePackageOnly( + value.databasePath, + 'default', + 'cli-monitor', + ); + + const recoveryClient = new DatabaseSync(value.databasePath); + const recoveryAuthority = new LocalSqliteOperationAuthority(recoveryClient); + const installs = new LocalSqlitePluginPackageInstallRepository( + recoveryAuthority, + ); + const published = []; + let publishedReceipt; + const activationPrerequisite = + new LocalSqlitePluginPackageSecretBindingActivationPrerequisite( + recoveryAuthority, + ); + const recovery = new PluginPackageRecoveryCoordinator({ + repository: installs, + stageProvider: { + async stage() { + throw new Error('staged upgrade must not be staged twice'); + }, + }, + publisher: { + async publish(intent) { + published.push(intent.intentDigest); + const receipt = createPluginPackageActivationReceipt({ + activationRef: `activation:${intent.lockDigest}`, + intentDigest: intent.intentDigest, + generation: intent.targetGeneration, + contentDigest: intent.contentDigest, + activatedAtMs: staged.updatedAtMs + 2, + }); + publishedReceipt = receipt; + return receipt; + }, + async inspect() { + return publishedReceipt + ? { status: 'published', receipt: publishedReceipt } + : { status: 'not_published' }; + }, + }, + activationPrerequisite, + now: () => staged.updatedAtMs + 1, + }); + assert.deepEqual( + await activationPrerequisite.inspect( + staged, + await installs.findLock(staged.lockDigest), + ), + { + status: 'deferred', + reason: 'secret_binding_transition_required', + }, + ); + const deferred = await recovery.recover({ pageSize: 1, maxPages: 1 }); + assert.equal(deferred.scanned, 0); + assert.equal(deferred.safeToAdmit, true); + assert.equal((await installs.find('default', 'cli-monitor')).state, 'staged'); + assert.deepEqual(published, []); + + const transitionPlan = await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.transition.plan', + { + projectId: 'default', + packageName: 'cli-monitor', + assignments: [{ name: 'TOKEN', secretRef }], + }, + 'transition-11-plan', + ), + ); + assert.equal(transitionPlan.summary.generation, 2); + assert.equal(transitionPlan.summary.kind, 'carry-forward'); + const executePath = commandFile( + value, + 'plugin-package.secret-binding.transition.execute', + { + plan: transitionPlan.plan, + auditEventId: '22000000-0000-4000-8000-00000000000a', + }, + 'transition-12-execute', + ); + const created = await runLocalPluginPackageCommandFile(executePath); + assert.equal(created.status, 'created'); + assert.match(created.receiptDigest, /^[0-9a-f]{64}$/); + assert.equal( + (await runLocalPluginPackageCommandFile(executePath)).status, + 'existing', + ); + const settled = await recovery.recoverPage({ limit: 1 }); + const recovered = await installs.find('default', 'cli-monitor'); + assert.equal(settled.items[0].status, 'settled'); + assert.equal( + recovered.state, + 'active', + JSON.stringify({ recovered, publishedReceipt }), + ); + assert.equal(published.length, 1); + const inspection = new DatabaseSync(value.databasePath, { readOnly: true }); + try { + assert.equal( + inspection + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3PluginPackageSecretBindingTransitionReceipts"`, + ) + .get().count, + 1, + ); + } finally { + inspection.close(); + await recoveryAuthority.close(); + } + assert.equal(active.state, 'active'); +}); + test('denies an authenticated non-owner before package proposal mutation', async (t) => { const value = await fixture(t, false); await assert.rejects( diff --git a/packages/ql3-local-sqlite/package.json b/packages/ql3-local-sqlite/package.json index fa71d141..37b73484 100644 --- a/packages/ql3-local-sqlite/package.json +++ b/packages/ql3-local-sqlite/package.json @@ -95,6 +95,16 @@ "require": "./dist/plugin-package/secret-binding/administration.js", "default": "./dist/plugin-package/secret-binding/administration.js" }, + "./plugin-package-secret-binding-transition-receipt": { + "types": "./dist/plugin-package/secret-binding/transitionReceiptRepository.d.ts", + "require": "./dist/plugin-package/secret-binding/transitionReceiptRepository.js", + "default": "./dist/plugin-package/secret-binding/transitionReceiptRepository.js" + }, + "./plugin-package-secret-binding-activation-prerequisite": { + "types": "./dist/plugin-package/secret-binding/activationPrerequisite.d.ts", + "require": "./dist/plugin-package/secret-binding/activationPrerequisite.js", + "default": "./dist/plugin-package/secret-binding/activationPrerequisite.js" + }, "./plugin-package-task-reconciliation": { "types": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.d.ts", "require": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.js", diff --git a/packages/ql3-local-sqlite/src/migration/migration.ts b/packages/ql3-local-sqlite/src/migration/migration.ts index 3f88f009..b782da3f 100644 --- a/packages/ql3-local-sqlite/src/migration/migration.ts +++ b/packages/ql3-local-sqlite/src/migration/migration.ts @@ -106,6 +106,8 @@ import { local0093PluginPackageSecretMaterializationGuardMigration } from '../mi import { local0094CapabilityV47Migration } from '../migrations/0094-capability-v47'; import { local0095PluginPackageSecretBindingTargetGuardMigration } from '../migrations/0095-plugin-package-secret-binding-target-guard'; import { local0096CapabilityV48Migration } from '../migrations/0096-capability-v48'; +import { local0097PluginPackageSecretBindingTransitionReceiptsMigration } from '../migrations/0097-plugin-package-secret-binding-transition-receipts'; +import { local0098CapabilityV49Migration } from '../migrations/0098-capability-v49'; import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration'; import { LOCAL_SQLITE_MIGRATION_STREAM_ID, @@ -224,6 +226,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition= 0 + ), + CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_digest_check CHECK ( + length(generation_digest) = 64 AND generation_digest NOT GLOB '*[^0-9a-f]*' AND + length(transition_digest) = 64 AND transition_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(previous_active_lock_digest) = 64 AND previous_active_lock_digest NOT GLOB '*[^0-9a-f]*' AND + length(evidence_digest) = 64 AND evidence_digest NOT GLOB '*[^0-9a-f]*' AND + (binding_digest IS NULL OR + (length(binding_digest) = 64 AND binding_digest NOT GLOB '*[^0-9a-f]*')) AND + length(receipt_digest) = 64 AND receipt_digest NOT GLOB '*[^0-9a-f]*' + ), + CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_json_check CHECK ( + length(CAST(receipt_json AS BLOB)) BETWEEN 2 AND 196608 AND + json_valid(receipt_json) AND json_type(receipt_json) = 'object' AND + json_extract(receipt_json, '$.schema') = 'qinglong/plugin-package-secret-binding-transition-receipt@v1' AND + json_extract(receipt_json, '$.transitionPlan.transitionDigest') = transition_digest AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.generationDigest') = generation_digest AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.projectId') = project_id AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.packageName') = package_name AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.installationId') = installation_id AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.lockDigest') = lock_digest AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.generation') = generation AND + json_extract(receipt_json, '$.transitionPlan.nextTarget.manifestDigest') = manifest_digest AND + json_extract(receipt_json, '$.transitionPlan.previousActiveLockDigest') = previous_active_lock_digest AND + json_extract(receipt_json, '$.authority.kind') = authority_kind AND + json_extract(receipt_json, '$.authority.evidenceDigest') = evidence_digest AND + json_extract(receipt_json, '$.bindingDigest') IS binding_digest AND + json_extract(receipt_json, '$.committedAtMs') = committed_at_ms AND + json_extract(receipt_json, '$.receiptDigest') = receipt_digest + ) +) + `, + `CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_transition_receipt_transition_uidx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (transition_digest)`, + `CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_transition_receipt_digest_uidx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (receipt_digest)`, + `CREATE INDEX ql3_plugin_package_secret_binding_transition_receipt_install_idx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (installation_id, generation_digest)`, + LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL, + ], + }); diff --git a/packages/ql3-local-sqlite/src/migrations/0098-capability-v49.ts b/packages/ql3-local-sqlite/src/migrations/0098-capability-v49.ts new file mode 100644 index 00000000..64584e2b --- /dev/null +++ b/packages/ql3-local-sqlite/src/migrations/0098-capability-v49.ts @@ -0,0 +1,14 @@ +import { CAPABILITIES_V48 } from './0096-capability-v48'; +import { defineLocalSqliteMigration } from './sqlMigration'; + +export const CAPABILITIES_V49 = CAPABILITIES_V48.replace( + '"plugin_package_secret_binding_transition":1,', + '"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,', +); + +export const local0098CapabilityV49Migration = defineLocalSqliteMigration({ + id: '0098-capability-v49', + statements: [ + `UPDATE "QingLong3SchemaCapabilities" SET contract_version = 49, migration_id = '0097-plugin-package-secret-binding-transition-receipts', capabilities = '${CAPABILITIES_V49}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 48 AND migration_id = '0095-plugin-package-secret-binding-target-guard' AND capabilities = '${CAPABILITIES_V48}'`, + ], +}); diff --git a/packages/ql3-local-sqlite/src/plugin-package/pluginPackageInstallRepository.ts b/packages/ql3-local-sqlite/src/plugin-package/pluginPackageInstallRepository.ts index d9d37c8e..18fd3f99 100644 --- a/packages/ql3-local-sqlite/src/plugin-package/pluginPackageInstallRepository.ts +++ b/packages/ql3-local-sqlite/src/plugin-package/pluginPackageInstallRepository.ts @@ -1142,6 +1142,41 @@ export class LocalSqlitePluginPackageInstallRepository JOIN "QingLong3PluginPackageInstalls" AS install ON install."installation_id" = head."installation_id" WHERE install."state" IN ('queued','staged','activating') + AND NOT ( + install."state" = 'staged' AND + install."previous_active_lock_digest" IS NOT NULL AND + ( + EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageSecretBindings" AS previous_binding + JOIN "QingLong3PluginPackageInstalls" AS previous_install + ON previous_install.project_id = install.project_id + AND previous_install.package_name = install.package_name + AND previous_install.lock_digest = + install.previous_active_lock_digest + WHERE previous_binding.installation_id = + previous_install.installation_id + ) OR + EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageAdmissionReceipts" AS admission + JOIN "QingLong3PluginPackageInstallProposals" AS proposal + ON proposal.action_ref = admission.action_ref + WHERE admission.installation_id = install.installation_id + AND json_array_length( + json_extract( + proposal.proposal_json, + '$.actionInput.manifest.spec.permissions.secrets' + ) + ) > 0 + ) + ) AND + NOT EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageSecretBindingTransitionReceipts" AS transition_receipt + WHERE transition_receipt.installation_id = install.installation_id + ) + ) AND NOT EXISTS ( SELECT 1 FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine diff --git a/packages/ql3-local-sqlite/src/plugin-package/secret-binding/activationPrerequisite.ts b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/activationPrerequisite.ts new file mode 100644 index 00000000..a5b0dc23 --- /dev/null +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/activationPrerequisite.ts @@ -0,0 +1,159 @@ +import type { DatabaseSync } from 'node:sqlite'; + +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 { LocalSqliteOperationAuthority } from '../../authority/operationAuthority'; +import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository'; + +type Row = Record; + +function text(row: Row, key: string): string { + const value = row[key]; + if (typeof value !== 'string') { + throw new Error('Secret binding activation prerequisite is unavailable'); + } + return value; +} + +export class LocalSqlitePluginPackageSecretBindingActivationPrerequisite + implements PluginPackageActivationPrerequisite +{ + readonly #authority: LocalSqliteOperationAuthority; + readonly #receipts: LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository; + + constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) { + this.#authority = + authority instanceof LocalSqliteOperationAuthority + ? authority + : new LocalSqliteOperationAuthority(authority); + this.#receipts = + new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository( + this.#authority, + ); + } + + 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 }); + } + return this.#authority.enqueue( + async () => { + const row = this.#authority.client + .prepare( + `SELECT proposal.proposal_json AS "proposalJson", + EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageInstalls" AS previous + JOIN "QingLong3PluginPackageSecretBindings" 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 "QingLong3PluginPackageInstallHeads" AS head + JOIN "QingLong3PluginPackageInstalls" AS install + ON install.installation_id = head.installation_id + JOIN "QingLong3PluginPackageAdmissionReceipts" AS admission + ON admission.installation_id = install.installation_id + JOIN "QingLong3PluginPackageInstallProposals" AS proposal + ON proposal.action_ref = admission.action_ref + WHERE head.project_id = ? AND head.package_name = ? + AND install.installation_id = ? AND install.lock_digest = ? + AND install.state = 'staged' + AND install.previous_active_lock_digest = ? + LIMIT 2`, + ) + .all( + record.projectId, + record.packageName, + record.installationId, + record.lockDigest, + record.previousActiveLockDigest, + ) as Row[]; + if (row.length !== 1) { + throw new Error( + 'Secret binding activation prerequisite is unavailable', + ); + } + const proposal = normalizePluginPackageInstallProposal( + JSON.parse( + text(row[0]!, 'proposalJson'), + ) 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 new Error( + 'Secret binding activation prerequisite provenance drift', + ); + } + const required = + proposal.actionInput.manifest.spec.permissions.secrets.length > 0 || + row[0]!.previousBindingPresent === 1; + 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 = this.#receipts.findInTransaction( + 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 }); + }, + () => new Error('Secret binding activation prerequisite is unavailable'), + ); + } +} diff --git a/packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts index 69979473..0decec94 100644 --- a/packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts @@ -11,12 +11,22 @@ import { type PluginPackageLock, } from '@qinglong/runtime-core/plugin-package-install'; import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding'; import { createPluginPackageSecretBindingFromPlan, createPluginPackageSecretBindingPlan, normalizePluginPackageSecretBindingPlan, type PluginPackageSecretBindingPlan, } from '@qinglong/runtime-core/plugin-package-secret-binding-plan'; +import { + createPluginPackageSecretBindingTransitionPlan, + normalizePluginPackageSecretBindingTransitionPlan, + type PluginPackageSecretBindingTransitionPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'; +import { + createPluginPackageSecretBindingFromTransitionPlan, + createPluginPackageSecretBindingTransitionReceipt, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'; import { parseSecretRef } from '@qinglong/runtime-core/secret-reference'; import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit'; @@ -35,6 +45,7 @@ import { sameSecurityAuditSemantic, } from '../../security/securityPersistence'; import { LocalSqlitePluginPackageSecretBindingRepository } from './repository'; +import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository'; type Row = Record; @@ -61,6 +72,24 @@ export interface ExecuteLocalPluginPackageSecretBindingRequest { readonly confirmAuthorization: () => void | Promise; } +export interface PlanLocalPluginPackageSecretBindingTransitionRequest { + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly<{ + name: string; + secretRef: string | null; + }>[]; + readonly principal: SecurityPrincipal; + readonly plannedAtMs: number; +} + +export interface ExecuteLocalPluginPackageSecretBindingTransitionRequest { + readonly plan: PluginPackageSecretBindingTransitionPlan; + readonly auditEventId: string; + readonly principal: SecurityPrincipal; + readonly confirmAuthorization: () => void | Promise; +} + export interface LocalPluginPackageSecretBindingService { plan( request: PlanLocalPluginPackageSecretBindingRequest, @@ -72,6 +101,20 @@ export interface LocalPluginPackageSecretBindingService { generationDigest: string; }> >; + planTransition( + request: PlanLocalPluginPackageSecretBindingTransitionRequest, + ): Promise>; + executeTransition( + request: ExecuteLocalPluginPackageSecretBindingTransitionRequest, + ): Promise< + Readonly<{ + status: 'created' | 'existing'; + transitionDigest: string; + receiptDigest: string; + bindingDigest: string | null; + generationDigest: string; + }> + >; } export class LocalPluginPackageSecretBindingConflictError extends Error { @@ -213,6 +256,145 @@ function generationFrom(current: ReturnType) { }); } +function loadTransition( + client: DatabaseSync, + projectId: string, + packageName: string, +): Readonly<{ + previous: ReturnType; + next: ReturnType; + previousAttemptGeneration: number; +}> { + const rows = client + .prepare( + `SELECT current.record_json AS "nextRecordJson", + current.lock_json AS "nextLockJson", + current_proposal.proposal_json AS "nextProposalJson", + previous.record_json AS "previousRecordJson", + previous.lock_json AS "previousLockJson", + previous_proposal.proposal_json AS "previousProposalJson", + ( + SELECT MAX(history.target_generation) + FROM "QingLong3PluginPackageInstalls" AS history + WHERE history.project_id = current.project_id + AND history.package_name = current.package_name + AND history.target_generation < current.target_generation + ) AS "previousAttemptGeneration" + FROM "QingLong3PluginPackageInstallHeads" AS head + JOIN "QingLong3PluginPackageInstalls" AS current + ON current.installation_id = head.installation_id + JOIN "QingLong3PluginPackageAdmissionReceipts" AS current_admission + ON current_admission.installation_id = current.installation_id + JOIN "QingLong3PluginPackageInstallProposals" AS current_proposal + ON current_proposal.action_ref = current_admission.action_ref + JOIN "QingLong3PluginPackageInstalls" AS previous + ON previous.project_id = current.project_id + AND previous.package_name = current.package_name + AND previous.lock_digest = current.previous_active_lock_digest + JOIN "QingLong3PluginPackageAdmissionReceipts" AS previous_admission + ON previous_admission.installation_id = previous.installation_id + JOIN "QingLong3PluginPackageInstallProposals" AS previous_proposal + ON previous_proposal.action_ref = previous_admission.action_ref + WHERE head.project_id = ? + AND head.package_name = ? + AND current.state = 'staged' + AND current.previous_active_lock_digest IS NOT NULL + AND current.active_lock_digest = current.previous_active_lock_digest + AND current.target_generation = ( + SELECT MAX(latest.target_generation) + FROM "QingLong3PluginPackageInstalls" AS latest + WHERE latest.project_id = current.project_id + AND latest.package_name = current.package_name + ) + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + LIMIT 2`, + ) + .all(projectId, packageName) as Row[]; + if (rows.length !== 1) { + throw new LocalPluginPackageSecretBindingConflictError( + 'reviewed staged Package generation is absent or ambiguous', + ); + } + try { + const row = rows[0]!; + const next = Object.freeze({ + record: normalizePluginPackageInstallRecord( + JSON.parse(rowText(row, 'nextRecordJson')), + ), + lock: normalizePluginPackageLock( + JSON.parse(rowText(row, 'nextLockJson')), + ), + proposal: normalizePluginPackageInstallProposal( + JSON.parse(rowText(row, 'nextProposalJson')), + ), + }); + const previous = Object.freeze({ + record: normalizePluginPackageInstallRecord( + JSON.parse(rowText(row, 'previousRecordJson')), + ), + lock: normalizePluginPackageLock( + JSON.parse(rowText(row, 'previousLockJson')), + ), + proposal: normalizePluginPackageInstallProposal( + JSON.parse(rowText(row, 'previousProposalJson')), + ), + }); + const previousAttemptGeneration = row.previousAttemptGeneration; + if ( + !Number.isSafeInteger(previousAttemptGeneration) || + previousAttemptGeneration !== next.record.targetGeneration - 1 || + next.record.projectId !== projectId || + next.record.packageName !== packageName || + next.record.lockDigest !== next.lock.lockDigest || + next.record.previousActiveLockDigest !== previous.lock.lockDigest || + next.proposal.actionDigest !== next.lock.approval.actionDigest || + next.proposal.previewDigest !== next.lock.approval.previewDigest || + next.proposal.actionInput.targetGeneration !== + next.record.targetGeneration || + next.proposal.actionInput.manifest.metadata.name !== packageName || + next.proposal.actionInput.source.contentDigest !== + next.lock.source.contentDigest || + previous.record.lockDigest !== previous.lock.lockDigest || + previous.proposal.actionDigest !== previous.lock.approval.actionDigest || + previous.proposal.previewDigest !== + previous.lock.approval.previewDigest || + previous.proposal.actionInput.targetGeneration !== + previous.record.targetGeneration || + previous.proposal.actionInput.source.contentDigest !== + previous.lock.source.contentDigest + ) { + throw new Error('Package transition provenance drift'); + } + return Object.freeze({ + previous, + next, + previousAttemptGeneration: previousAttemptGeneration as number, + }); + } catch (error) { + if (error instanceof LocalPluginPackageSecretBindingConflictError) + throw error; + throw new LocalPluginPackageSecretBindingUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } +} + +function transitionGeneration( + value: ReturnType['next'], +) { + return createPluginPackageResourceGenerationFromReferences({ + installationId: value.record.installationId, + projectId: value.record.projectId, + packageName: value.record.packageName, + lockDigest: value.record.lockDigest, + generation: value.record.targetGeneration, + previousActiveLockDigest: value.record.previousActiveLockDigest, + contentDigest: value.lock.source.contentDigest, + resources: value.lock.resources, + }); +} + function auditRecord( plan: Readonly, eventId: string, @@ -234,6 +416,27 @@ function auditRecord( }); } +function transitionAuditRecord( + plan: Readonly, + eventId: string, + principal: Readonly, + fence: Readonly, + occurredAtMs: number, +): Readonly { + return Object.freeze({ + eventId, + requestId: `package_secret_binding_transition:${plan.transitionDigest}`, + operationId: 'plugin_package.secret.transition', + projectId: plan.nextTarget.projectId, + subject: principal.subject, + authenticationId: principal.authenticationId, + outcome: 'allowed', + reasons: Object.freeze([`owner_confirmed_secret_${plan.kind}`]), + fence, + occurredAtMs, + }); +} + function verifySecretVersions( client: DatabaseSync, plan: Readonly, @@ -327,6 +530,10 @@ export function createLocalPluginPackageSecretBindingService( const bindings = new LocalSqlitePluginPackageSecretBindingRepository( authority, ); + const transitionReceipts = + new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository( + authority, + ); const authorize = async ( principalValue: SecurityPrincipal, @@ -538,5 +745,250 @@ export function createLocalPluginPackageSecretBindingService( () => new LocalPluginPackageSecretBindingUnavailableError(), ); }, + + async planTransition( + request: PlanLocalPluginPackageSecretBindingTransitionRequest, + ) { + const projectId = identity(request.projectId, 'Project ID', IDENTIFIER); + const packageName = identity( + request.packageName, + 'Package name', + PACKAGE_NAME, + ); + const plannedAtMs = timestamp(request.plannedAtMs, 'plannedAtMs'); + const authorization = await authorize( + request.principal, + projectId, + plannedAtMs, + ); + return authority.enqueue( + async () => { + verifyPolicyFence( + authority.client, + authorization.principal, + authorization.fence, + projectId, + ); + const transition = loadTransition( + authority.client, + projectId, + packageName, + ); + const previousGeneration = generationFrom(transition.previous); + const previousTarget = createPluginPackageSecretBindingTarget( + previousGeneration, + transition.previous.proposal.actionInput.manifest, + ); + const previousBinding = bindings.findInTransaction( + previousTarget.generationDigest, + ); + if ( + transitionReceipts.findInTransaction( + transitionGeneration(transition.next).generationDigest, + ) + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'staged generation transition is already committed', + ); + } + const plan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget, + previousBinding, + previousAttemptGeneration: transition.previousAttemptGeneration, + nextGeneration: transitionGeneration(transition.next), + nextManifest: transition.next.proposal.actionInput.manifest, + assignments: request.assignments, + plannedAtMs, + }); + if (plan.nextBindingPlan) { + verifySecretVersions(authority.client, plan.nextBindingPlan); + } + return plan; + }, + () => new LocalPluginPackageSecretBindingUnavailableError(), + ); + }, + + async executeTransition( + request: ExecuteLocalPluginPackageSecretBindingTransitionRequest, + ) { + const plan = normalizePluginPackageSecretBindingTransitionPlan( + request.plan, + ); + if (typeof request.confirmAuthorization !== 'function') { + throw new TypeError('confirmAuthorization is invalid'); + } + const observedAtMs = timestamp(now(), 'transition execution clock'); + const authorization = await authorize( + request.principal, + plan.nextTarget.projectId, + observedAtMs, + ); + await request.confirmAuthorization(); + return authority.enqueue( + async () => { + authority.client.exec('BEGIN IMMEDIATE'); + try { + verifyPolicyFence( + authority.client, + authorization.principal, + authorization.fence, + plan.nextTarget.projectId, + ); + const auditEventId = identity( + request.auditEventId, + 'audit event ID', + UUID, + ); + const auditsForPlan = authority.client + .prepare( + `SELECT ${LOCAL_SECURITY_AUDIT_SELECT} + FROM "QingLong3SecurityAuditEvents" + WHERE "request_id" = ? AND "operation_id" = ? + LIMIT 2`, + ) + .all( + `package_secret_binding_transition:${plan.transitionDigest}`, + 'plugin_package.secret.transition', + ) as Row[]; + if ( + auditsForPlan.length > 1 || + (auditsForPlan.length === 1 && + rowText(auditsForPlan[0]!, 'eventId') !== auditEventId) + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'transition plan already has another audit identity', + ); + } + const existingReceipt = transitionReceipts.findInTransaction( + plan.nextTarget.generationDigest, + ); + if (existingReceipt) { + if ( + existingReceipt.transitionPlan.transitionDigest !== + plan.transitionDigest || + existingReceipt.authority.kind !== 'local-owner-confirmation' || + existingReceipt.authority.evidenceDigest !== + plan.transitionDigest || + auditsForPlan.length !== 1 + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'generation is committed by another transition authority', + ); + } + authority.client.exec('COMMIT'); + return Object.freeze({ + status: 'existing' as const, + transitionDigest: plan.transitionDigest, + receiptDigest: existingReceipt.receiptDigest, + bindingDigest: existingReceipt.bindingDigest, + generationDigest: plan.nextTarget.generationDigest, + }); + } + const transition = loadTransition( + authority.client, + plan.nextTarget.projectId, + plan.nextTarget.packageName, + ); + const previousTarget = createPluginPackageSecretBindingTarget( + generationFrom(transition.previous), + transition.previous.proposal.actionInput.manifest, + ); + const expected = createPluginPackageSecretBindingTransitionPlan({ + previousTarget, + previousBinding: bindings.findInTransaction( + previousTarget.generationDigest, + ), + previousAttemptGeneration: transition.previousAttemptGeneration, + nextGeneration: transitionGeneration(transition.next), + nextManifest: transition.next.proposal.actionInput.manifest, + assignments: + plan.nextBindingPlan?.entries.map(({ name, secretRef }) => ({ + name, + secretRef, + })) ?? [], + plannedAtMs: plan.nextBindingPlan?.plannedAtMs ?? observedAtMs, + }); + if (expected.transitionDigest !== plan.transitionDigest) { + throw new LocalPluginPackageSecretBindingConflictError( + 'staged Package generation changed after transition planning', + ); + } + if (plan.nextBindingPlan) { + verifySecretVersions(authority.client, plan.nextBindingPlan); + } + const audit = transitionAuditRecord( + plan, + auditEventId, + authorization.principal, + authorization.fence, + observedAtMs, + ); + const existingAudit = authority.client + .prepare( + `SELECT ${LOCAL_SECURITY_AUDIT_SELECT} + FROM "QingLong3SecurityAuditEvents" + WHERE "event_id" = ? LIMIT 2`, + ) + .get(audit.eventId) as Row | undefined; + if (existingAudit) { + if ( + !sameSecurityAuditSemantic( + localSecurityAuditFromRow(existingAudit), + audit, + ) + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'audit identity is already used by another operation', + ); + } + } else { + insertLocalSecurityAudit(authority.client, audit); + } + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'local-owner-confirmation', + plan.transitionDigest, + observedAtMs, + ); + const bindingResult = binding + ? bindings.publishInTransaction(binding) + : null; + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: plan.transitionDigest, + }, + binding: bindingResult?.binding ?? null, + committedAtMs: observedAtMs, + }); + const receiptResult = + transitionReceipts.publishInTransaction(receipt); + authority.client.exec('COMMIT'); + return Object.freeze({ + status: receiptResult.status, + transitionDigest: plan.transitionDigest, + receiptDigest: receiptResult.receipt.receiptDigest, + bindingDigest: receiptResult.receipt.bindingDigest, + generationDigest: plan.nextTarget.generationDigest, + }); + } catch (error) { + if (authority.client.isTransaction) + authority.client.exec('ROLLBACK'); + if ( + error instanceof LocalPluginPackageSecretBindingConflictError || + error instanceof LocalPluginPackageSecretBindingUnavailableError + ) { + throw error; + } + throw new LocalPluginPackageSecretBindingUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + }, + () => new LocalPluginPackageSecretBindingUnavailableError(), + ); + }, }); } diff --git a/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptRepository.ts b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptRepository.ts new file mode 100644 index 00000000..be526e92 --- /dev/null +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptRepository.ts @@ -0,0 +1,247 @@ +import type { DatabaseSync } from 'node:sqlite'; + +import { + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES, + normalizePluginPackageSecretBindingTransitionReceipt, + type PluginPackageSecretBindingTransitionReceipt, + type PluginPackageSecretBindingTransitionReceiptRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'; +import { + PluginPackageSecretBindingConflictError, + PluginPackageSecretBindingUnavailableError, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; + +import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority'; + +type Row = Record; +const DIGEST = /^[0-9a-f]{64}$/; + +function digest(value: unknown): string { + if (typeof value !== 'string' || !DIGEST.test(value)) { + throw new TypeError( + 'Secret binding transition generation digest is invalid', + ); + } + return value; +} + +function text(row: Row, key: string): string { + const value = row[key]; + if (typeof value !== 'string') { + throw new PluginPackageSecretBindingUnavailableError(); + } + return value; +} + +function nullableText(row: Row, key: string): string | null { + const value = row[key]; + if (value !== null && 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 mapStorageError(error: unknown): Error { + if ( + error instanceof PluginPackageSecretBindingConflictError || + error instanceof PluginPackageSecretBindingUnavailableError || + error instanceof TypeError + ) { + return error; + } + if ( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('SQLITE_CONSTRAINT') + ) { + return new PluginPackageSecretBindingConflictError( + 'durable transition receipt identity is already bound', + ); + } + return new PluginPackageSecretBindingUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); +} + +export class LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository + implements PluginPackageSecretBindingTransitionReceiptRepository +{ + constructor( + readonly authority: LocalSqliteOperationAuthority | DatabaseSync, + ) { + this.authority = + authority instanceof LocalSqliteOperationAuthority + ? authority + : new LocalSqliteOperationAuthority(authority); + } + + private parse( + row: Row, + ): Readonly { + try { + const receipt = normalizePluginPackageSecretBindingTransitionReceipt( + JSON.parse(text(row, 'receiptJson')), + ); + if ( + receipt.transitionPlan.nextTarget.generationDigest !== + text(row, 'generationDigest') || + receipt.transitionPlan.transitionDigest !== + text(row, 'transitionDigest') || + receipt.transitionPlan.nextTarget.projectId !== + text(row, 'projectId') || + receipt.transitionPlan.nextTarget.packageName !== + text(row, 'packageName') || + receipt.transitionPlan.nextTarget.installationId !== + text(row, 'installationId') || + receipt.transitionPlan.nextTarget.lockDigest !== + text(row, 'lockDigest') || + receipt.transitionPlan.nextTarget.generation !== + integer(row, 'generation') || + receipt.transitionPlan.nextTarget.manifestDigest !== + text(row, 'manifestDigest') || + receipt.transitionPlan.previousActiveLockDigest !== + text(row, 'previousActiveLockDigest') || + receipt.authority.kind !== text(row, 'authorityKind') || + receipt.authority.evidenceDigest !== text(row, 'evidenceDigest') || + receipt.bindingDigest !== nullableText(row, 'bindingDigest') || + receipt.committedAtMs !== integer(row, 'committedAtMs') || + receipt.receiptDigest !== text(row, 'receiptDigest') + ) { + throw new PluginPackageSecretBindingUnavailableError(); + } + return receipt; + } catch (error) { + if (error instanceof PluginPackageSecretBindingUnavailableError) + throw error; + throw new PluginPackageSecretBindingUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + } + + private findStored( + generationDigest: string, + ): Readonly | null { + const row = (this.authority as LocalSqliteOperationAuthority).client + .prepare( + `SELECT generation_digest AS "generationDigest", + transition_digest AS "transitionDigest", + project_id AS "projectId", package_name AS "packageName", + installation_id AS "installationId", lock_digest AS "lockDigest", + generation, manifest_digest AS "manifestDigest", + previous_active_lock_digest AS "previousActiveLockDigest", + authority_kind AS "authorityKind", evidence_digest AS "evidenceDigest", + binding_digest AS "bindingDigest", committed_at_ms AS "committedAtMs", + receipt_digest AS "receiptDigest", receipt_json AS "receiptJson" + FROM "QingLong3PluginPackageSecretBindingTransitionReceipts" + WHERE generation_digest = ?`, + ) + .get(generationDigest) as Row | undefined; + return row ? this.parse(row) : null; + } + + findInTransaction( + generationDigestValue: string, + ): Readonly | null { + try { + return this.findStored(digest(generationDigestValue)); + } catch (error) { + throw mapStorageError(error); + } + } + + async find( + generationDigestValue: string, + ): Promise | null> { + const normalized = digest(generationDigestValue); + return (this.authority as LocalSqliteOperationAuthority).enqueue( + async () => this.findStored(normalized), + () => new PluginPackageSecretBindingUnavailableError(), + ); + } + + publishInTransaction( + value: Readonly, + ): Readonly<{ + status: 'created' | 'existing'; + receipt: Readonly; + }> { + const receipt = normalizePluginPackageSecretBindingTransitionReceipt(value); + const receiptJson = JSON.stringify(receipt); + if ( + Buffer.byteLength(receiptJson, 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES + ) { + throw new TypeError( + 'Secret binding transition receipt exceeds durable budget', + ); + } + try { + const existing = this.findStored( + receipt.transitionPlan.nextTarget.generationDigest, + ); + if (existing) { + if (JSON.stringify(existing) !== receiptJson) { + throw new PluginPackageSecretBindingConflictError( + 'generation is bound to another transition receipt', + ); + } + return Object.freeze({ + status: 'existing' as const, + receipt: existing, + }); + } + const target = receipt.transitionPlan.nextTarget; + const result = (this.authority as LocalSqliteOperationAuthority).client + .prepare( + `INSERT INTO "QingLong3PluginPackageSecretBindingTransitionReceipts" ( + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (generation_digest) DO NOTHING`, + ) + .run( + target.generationDigest, + receipt.transitionPlan.transitionDigest, + target.projectId, + target.packageName, + target.installationId, + target.lockDigest, + target.generation, + target.manifestDigest, + receipt.transitionPlan.previousActiveLockDigest, + receipt.authority.kind, + receipt.authority.evidenceDigest, + receipt.bindingDigest, + receipt.committedAtMs, + receipt.receiptDigest, + receiptJson, + ); + const stored = this.findStored(target.generationDigest); + if (!stored || JSON.stringify(stored) !== receiptJson) { + throw new PluginPackageSecretBindingConflictError( + 'transition receipt target is not the reviewed staged generation', + ); + } + return Object.freeze({ + status: + result.changes === 1 ? ('created' as const) : ('existing' as const), + receipt: stored, + }); + } catch (error) { + throw mapStorageError(error); + } + } +} diff --git a/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptSchemaContract.ts b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptSchemaContract.ts new file mode 100644 index 00000000..a4bf828e --- /dev/null +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/transitionReceiptSchemaContract.ts @@ -0,0 +1,62 @@ +export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME = + 'ql3_plugin_package_secret_binding_transition_receipt_guard' as const; + +export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL = + ` +CREATE TRIGGER ${LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME} +BEFORE INSERT ON "QingLong3PluginPackageSecretBindingTransitionReceipts" +FOR EACH ROW +BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageInstallHeads" AS head + JOIN "QingLong3PluginPackageInstalls" AS install + ON install.installation_id = head.installation_id + AND install.project_id = head.project_id + AND install.package_name = head.package_name + 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.state = 'staged' + AND install.previous_active_lock_digest = NEW.previous_active_lock_digest + AND install.active_lock_digest = install.previous_active_lock_digest + AND json_extract(install.lock_json, '$.manifestDigest') = NEW.manifest_digest + AND install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "QingLong3PluginPackageInstalls" AS history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) + AND EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageInstalls" AS previous + WHERE previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = NEW.previous_active_lock_digest + 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 + json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'null') + OR + (NEW.binding_digest IS NOT NULL AND + json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'object' AND + EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageSecretBindings" AS binding + WHERE binding.generation_digest = NEW.generation_digest + AND binding.binding_digest = NEW.binding_digest + AND binding.authority_kind = NEW.authority_kind + AND binding.evidence_digest = NEW.evidence_digest + AND binding.bound_at_ms = NEW.committed_at_ms + )) + ) + ) THEN RAISE(ABORT, + 'Plugin Package Secret binding transition receipt target is not reviewed staged generation') + END; +END +`.trim(); diff --git a/packages/ql3-local-sqlite/src/profile/localProfile.ts b/packages/ql3-local-sqlite/src/profile/localProfile.ts index 06705620..81a8fd45 100644 --- a/packages/ql3-local-sqlite/src/profile/localProfile.ts +++ b/packages/ql3-local-sqlite/src/profile/localProfile.ts @@ -57,6 +57,7 @@ export type LocalProfileStorageBootstrapResult = readonly pluginPackageInstalls: LocalSqliteRuntimeDatabase['pluginPackageInstalls']; readonly pluginPackageMaterializedRevisions: LocalSqliteRuntimeDatabase['pluginPackageMaterializedRevisions']; readonly pluginPackageSecretBindings: LocalSqliteRuntimeDatabase['pluginPackageSecretBindings']; + readonly pluginPackageActivationPrerequisite: LocalSqliteRuntimeDatabase['pluginPackageActivationPrerequisite']; readonly pluginPackageTaskReconciliations: LocalSqliteRuntimeDatabase['pluginPackageTaskReconciliations']; readonly pluginPackageAutomationPublications: LocalSqliteRuntimeDatabase['pluginPackageAutomationPublications']; readonly projectToolDefinitionSnapshots: LocalSqliteRuntimeDatabase['projectToolDefinitionSnapshots']; @@ -153,6 +154,8 @@ export async function bootstrapLocalProfileStorage( pluginPackageMaterializedRevisions: database.pluginPackageMaterializedRevisions, pluginPackageSecretBindings: database.pluginPackageSecretBindings, + pluginPackageActivationPrerequisite: + database.pluginPackageActivationPrerequisite, pluginPackageTaskReconciliations: database.pluginPackageTaskReconciliations, pluginPackageAutomationPublications: diff --git a/packages/ql3-local-sqlite/src/readiness/readiness.ts b/packages/ql3-local-sqlite/src/readiness/readiness.ts index a96418c6..010df510 100644 --- a/packages/ql3-local-sqlite/src/readiness/readiness.ts +++ b/packages/ql3-local-sqlite/src/readiness/readiness.ts @@ -4,13 +4,14 @@ import { localSqliteMigrationManifest } from '../migration/migrationManifest'; import { LocalSqliteMigrationStreamStore } from '../migration/migrationStreamStore'; import { LOCAL_PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGER_SQL } from '../plugin-package/pluginPackageSecretMaterializationSchemaContract'; import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGER_SQL } from '../plugin-package/secret-binding/pluginPackageSecretBindingTargetSchemaContract'; +import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL } from '../plugin-package/secret-binding/transitionReceiptSchemaContract'; import { LOCAL_STEP_RUN_REFERENCE_TRIGGERS, normalizeLocalSqliteSchemaSql, } from '../run/stepRunSchemaContract'; export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core'; -export const LOCAL_SQLITE_CONTRACT_VERSION = 48; +export const LOCAL_SQLITE_CONTRACT_VERSION = 49; const PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS = Object.freeze([ Object.freeze({ @@ -18,6 +19,11 @@ const PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS = Object.freeze([ tableName: 'QingLong3PluginPackageSecretBindings', sql: LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGER_SQL, }), + Object.freeze({ + name: 'ql3_plugin_package_secret_binding_transition_receipt_guard', + tableName: 'QingLong3PluginPackageSecretBindingTransitionReceipts', + sql: LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL, + }), ]); const PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGERS = Object.freeze([ @@ -1181,6 +1187,30 @@ const REQUIRED_SCHEMA = Object.freeze({ 'ql3_plugin_package_secret_binding_install_idx', ]), }), + QingLong3PluginPackageSecretBindingTransitionReceipts: Object.freeze({ + columns: Object.freeze([ + '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', + ]), + indexes: Object.freeze([ + 'ql3_plugin_package_secret_binding_transition_receipt_transition_uidx', + 'ql3_plugin_package_secret_binding_transition_receipt_digest_uidx', + 'ql3_plugin_package_secret_binding_transition_receipt_install_idx', + ]), + }), QingLong3ProjectToolDefinitionSnapshots: Object.freeze({ columns: Object.freeze([ 'project_id', @@ -2574,10 +2604,10 @@ export async function auditLocalSqliteReadiness( capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME || capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION || capability.migration_id !== - '0095-plugin-package-secret-binding-target-guard' || + '0097-plugin-package-secret-binding-transition-receipts' || 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_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_materialization":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_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":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 diff --git a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts index 6963cb17..b6f90892 100644 --- a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts +++ b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts @@ -30,6 +30,7 @@ import type { } from '@qinglong/runtime-core/plugin-package-automation-publication'; import type { PluginPackageMaterializedRevisionRepository } from '@qinglong/runtime-core/plugin-package-resource-materialization'; import type { PluginPackageSecretBindingRepository } from '@qinglong/runtime-core/plugin-package-secret-binding'; +import type { PluginPackageActivationPrerequisite } from '@qinglong/runtime-core/plugin-package-installation'; import type { PluginPackageTaskReconciliationRepository } from '@qinglong/runtime-core/plugin-package-task-reconciliation'; import type { PluginPackageTaskPublicationRecoverySource } from '@qinglong/runtime-core/plugin-package-task-publication'; import type { StepRunRepository } from '@qinglong/runtime-core/step-run'; @@ -111,6 +112,7 @@ export interface LocalSqliteRuntimeDatabase { pluginPackageInstalls(): Promise; pluginPackageMaterializedRevisions(): Promise; pluginPackageSecretBindings(): Promise; + pluginPackageActivationPrerequisite(): Promise; pluginPackageTaskReconciliations(): Promise< PluginPackageTaskReconciliationRepository & PluginPackageTaskPublicationRecoverySource @@ -200,6 +202,9 @@ export async function openLocalSqliteRuntimeDatabase( let pluginPackageSecretBindingsPromise: | Promise | undefined; + let pluginPackageActivationPrerequisitePromise: + | Promise + | undefined; let pluginPackageTaskReconciliationsPromise: | Promise< PluginPackageTaskReconciliationRepository & @@ -285,6 +290,17 @@ export async function openLocalSqliteRuntimeDatabase( ); return pluginPackageSecretBindingsPromise; }, + pluginPackageActivationPrerequisite() { + pluginPackageActivationPrerequisitePromise ??= import( + '../plugin-package/secret-binding/activationPrerequisite.js' + ).then( + ({ LocalSqlitePluginPackageSecretBindingActivationPrerequisite }) => + new LocalSqlitePluginPackageSecretBindingActivationPrerequisite( + authority, + ), + ); + return pluginPackageActivationPrerequisitePromise; + }, pluginPackageTaskReconciliations() { pluginPackageTaskReconciliationsPromise ??= import( '../plugin-package/pluginPackageTaskReconciliationRepository.js' diff --git a/packages/ql3-local-sqlite/src/storage/schema.ts b/packages/ql3-local-sqlite/src/storage/schema.ts index ccde7ea9..570f653a 100644 --- a/packages/ql3-local-sqlite/src/storage/schema.ts +++ b/packages/ql3-local-sqlite/src/storage/schema.ts @@ -1273,6 +1273,60 @@ export const pluginPackageSecretBindings = sqliteTable( ], ); +export const pluginPackageSecretBindingTransitionReceipts = sqliteTable( + 'QingLong3PluginPackageSecretBindingTransitionReceipts', + { + generationDigest: text('generation_digest').primaryKey(), + transitionDigest: text('transition_digest').notNull(), + projectId: text('project_id').notNull(), + 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(), + previousActiveLockDigest: text('previous_active_lock_digest').notNull(), + authorityKind: text('authority_kind').notNull(), + evidenceDigest: text('evidence_digest').notNull(), + bindingDigest: text('binding_digest').references( + () => pluginPackageSecretBindings.bindingDigest, + { onDelete: 'restrict', onUpdate: 'restrict' }, + ), + committedAtMs: integer('committed_at_ms').notNull(), + receiptDigest: text('receipt_digest').notNull(), + receiptJson: text('receipt_json', { mode: 'json' }) + .$type>() + .notNull(), + }, + (table) => [ + check( + 'ql3_plugin_package_secret_binding_transition_receipt_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 2 and 2147483647 and ${table.authorityKind} in ('approved-action-execution','local-owner-confirmation') and ${table.committedAtMs} >= 0`, + ), + check( + 'ql3_plugin_package_secret_binding_transition_receipt_digest_check', + sql`length(${table.generationDigest}) = 64 and ${table.generationDigest} not glob '*[^0-9a-f]*' and length(${table.transitionDigest}) = 64 and ${table.transitionDigest} 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.previousActiveLockDigest}) = 64 and ${table.previousActiveLockDigest} not glob '*[^0-9a-f]*' and length(${table.evidenceDigest}) = 64 and ${table.evidenceDigest} not glob '*[^0-9a-f]*' and (${table.bindingDigest} is null or (length(${table.bindingDigest}) = 64 and ${table.bindingDigest} not glob '*[^0-9a-f]*')) and length(${table.receiptDigest}) = 64 and ${table.receiptDigest} not glob '*[^0-9a-f]*'`, + ), + check( + 'ql3_plugin_package_secret_binding_transition_receipt_json_check', + sql`length(cast(${table.receiptJson} as blob)) between 2 and 196608 and json_valid(${table.receiptJson}) and json_type(${table.receiptJson}) = 'object' and json_extract(${table.receiptJson}, '$.schema') = 'qinglong/plugin-package-secret-binding-transition-receipt@v1' and json_extract(${table.receiptJson}, '$.transitionPlan.transitionDigest') = ${table.transitionDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.generationDigest') = ${table.generationDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.projectId') = ${table.projectId} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.packageName') = ${table.packageName} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.installationId') = ${table.installationId} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.lockDigest') = ${table.lockDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.generation') = ${table.generation} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.manifestDigest') = ${table.manifestDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.previousActiveLockDigest') = ${table.previousActiveLockDigest} and json_extract(${table.receiptJson}, '$.authority.kind') = ${table.authorityKind} and json_extract(${table.receiptJson}, '$.authority.evidenceDigest') = ${table.evidenceDigest} and json_extract(${table.receiptJson}, '$.bindingDigest') is ${table.bindingDigest} and json_extract(${table.receiptJson}, '$.committedAtMs') = ${table.committedAtMs} and json_extract(${table.receiptJson}, '$.receiptDigest') = ${table.receiptDigest}`, + ), + uniqueIndex( + 'ql3_plugin_package_secret_binding_transition_receipt_transition_uidx', + ).on(table.transitionDigest), + uniqueIndex( + 'ql3_plugin_package_secret_binding_transition_receipt_digest_uidx', + ).on(table.receiptDigest), + index( + 'ql3_plugin_package_secret_binding_transition_receipt_install_idx', + ).on(table.installationId, table.generationDigest), + ], +); + export const projectToolDefinitionSnapshots = sqliteTable( 'QingLong3ProjectToolDefinitionSnapshots', { @@ -4945,6 +4999,7 @@ export const localSqliteSchema = Object.freeze({ pluginPackageInstallMutations, pluginPackageMaterializedRevisions, pluginPackageSecretBindings, + pluginPackageSecretBindingTransitionReceipts, pluginPackageQuarantineEvents, pluginPackageWithdrawalReceipts, pluginPackageWithdrawalTasks, diff --git a/packages/ql3-local-sqlite/test/database.test.cjs b/packages/ql3-local-sqlite/test/database.test.cjs index 7fb195dd..fcc5c1dd 100644 --- a/packages/ql3-local-sqlite/test/database.test.cjs +++ b/packages/ql3-local-sqlite/test/database.test.cjs @@ -146,9 +146,11 @@ test('creates a reviewed edge database and opens runtime only after readiness', '0094-capability-v47', '0095-plugin-package-secret-binding-target-guard', '0096-capability-v48', + '0097-plugin-package-secret-binding-transition-receipts', + '0098-capability-v49', ]); assert.equal(migrated.readiness.contractName, 'local-control-core'); - assert.equal(migrated.readiness.contractVersion, 48); + assert.equal(migrated.readiness.contractVersion, 49); assert.equal(migrated.readiness.journalMode, 'delete'); assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600); @@ -594,8 +596,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy .get(), }, { - contract_version: 48, - migration_id: '0095-plugin-package-secret-binding-target-guard', + contract_version: 49, + migration_id: '0097-plugin-package-secret-binding-transition-receipts', }, ); } finally { @@ -782,19 +784,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, 80); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 81); client.exec( 'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)', ); client.close(); - assert.equal((await auditLocalSqlitePath(options)).tableCount, 80); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 81); const unknownClient = new DatabaseSync(databasePath); unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)'); unknownClient.close(); - assert.equal((await auditLocalSqlitePath(options)).tableCount, 81); + assert.equal((await auditLocalSqlitePath(options)).tableCount, 82); const triggerClient = new DatabaseSync(databasePath); triggerClient.exec(` diff --git a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs index ead1b8cb..47c00a4f 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs @@ -11,9 +11,18 @@ const { createPluginPackageResourceGeneration, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { + createPluginPackageSecretBindingTransitionReceipt, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'); const { LocalSqlitePluginPackageSecretBindingRepository, } = require('../dist/plugin-package/secret-binding/repository'); +const { + LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository, +} = require('../dist/plugin-package/secret-binding/transitionReceiptRepository'); const { migrateLocalSqliteDatabase } = require('../dist/migration/migration'); const LOCK_DIGEST = 'a'.repeat(64); @@ -247,7 +256,10 @@ test('rejects inactive targets and conflicting content', async (t) => { test('publishes a reviewed current staged generation but rejects post-stage states', async (t) => { const staged = await harness('staged'); t.after(() => staged.client.close()); - assert.equal((await staged.repository.publish(staged.binding)).status, 'created'); + assert.equal( + (await staged.repository.publish(staged.binding)).status, + 'created', + ); for (const state of ['queued', 'activating']) { const rejected = await harness(state); @@ -259,6 +271,102 @@ test('publishes a reviewed current staged generation but rejects post-stage stat } }); +test('persists one exact staged transition receipt and rejects durable drift', async (t) => { + const value = await harness('staged'); + t.after(() => value.client.close()); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'f'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: '9'.repeat(64), + contents: MANIFEST.spec.contents, + }); + const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: MANIFEST, + assignments: value.binding.entries.map(({ name, secretRef }) => ({ + name, + secretRef, + })), + authority: value.binding.authority, + boundAtMs: 90, + }); + value.client + .prepare( + `UPDATE "QingLong3PluginPackageInstalls" + SET lock_json = json_set(lock_json, '$.manifestDigest', ?) + WHERE installation_id = 'install-1'`, + ) + .run(previousBinding.target.manifestDigest); + value.client + .prepare( + `UPDATE "QingLong3PluginPackageInstalls" + SET lock_json = json_set(lock_json, '$.manifestDigest', ?) + WHERE installation_id = 'install-2'`, + ) + .run(value.binding.target.manifestDigest); + value.client + .prepare( + `UPDATE "QingLong3PluginPackageInstallHeads" + SET installation_id = 'install-1' + WHERE project_id = 'project-1' AND package_name = 'example-monitor'`, + ) + .run(); + await value.repository.publish(previousBinding); + value.client + .prepare( + `UPDATE "QingLong3PluginPackageInstallHeads" + SET installation_id = 'install-2' + WHERE project_id = 'project-1' AND package_name = 'example-monitor'`, + ) + .run(); + await value.repository.publish(value.binding); + const plan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: value.generation, + nextManifest: MANIFEST, + assignments: value.binding.entries.map(({ name, secretRef }) => ({ + name, + secretRef, + })), + plannedAtMs: 95, + }); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: value.binding.authority, + binding: value.binding, + committedAtMs: value.binding.boundAtMs, + }); + const receipts = + new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository( + value.client, + ); + assert.equal(receipts.publishInTransaction(receipt).status, 'created'); + assert.equal(receipts.publishInTransaction(receipt).status, 'existing'); + assert.deepEqual( + await receipts.find(value.generation.generationDigest), + receipt, + ); + + value.client.exec('PRAGMA ignore_check_constraints = ON'); + value.client + .prepare( + `UPDATE "QingLong3PluginPackageSecretBindingTransitionReceipts" + SET receipt_json = json_set(receipt_json, '$.committedAtMs', 999) + WHERE generation_digest = ?`, + ) + .run(value.generation.generationDigest); + await assert.rejects( + receipts.find(value.generation.generationDigest), + PluginPackageSecretBindingUnavailableError, + ); +}); + test('fails closed when durable binding JSON is changed in place', async (t) => { const value = await harness(); t.after(() => value.client.close()); diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs index fa009494..7adc309f 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs @@ -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, 48); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49); }); 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, 48); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49); }); test('fails closed before writing when the exact installation is not active', async (t) => { diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs index 84bdcd79..49c141bd 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskAttemptAdmissionRepository.test.cjs @@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it' stepAttemptCount: 0, }, ); - assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49); }); test('bounds candidate paging before SQL and fences cancellation', async (t) => { diff --git a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs index 1828678b..ab354ba1 100644 --- a/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs +++ b/packages/ql3-local-sqlite/test/rolloutSafety.test.cjs @@ -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, 48); - assert.equal(prepared.writeContractVersion, 48); - assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 48); + assert.equal(prepared.contractVersion, 49); + assert.equal(prepared.writeContractVersion, 49); + assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 49); assert.match(prepared.sha256, /^[0-9a-f]{64}$/); assert.equal(prepared.bytes > 0, true); assert.equal(prepared.pageCount > 0, true); diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index d3184f12..4a737900 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -92,6 +92,9 @@ "plugin-package-secret-binding-transition-plan": [ "dist/plugin-package/secret-binding/transitionPlan.d.ts" ], + "plugin-package-secret-binding-transition-receipt": [ + "dist/plugin-package/secret-binding/transitionReceipt.d.ts" + ], "plugin-package-secret-binding-approval-plan": [ "dist/plugin-package/secret-binding/approvalPlan.d.ts" ], @@ -401,6 +404,11 @@ "require": "./dist/plugin-package/secret-binding/transitionPlan.js", "default": "./dist/plugin-package/secret-binding/transitionPlan.js" }, + "./plugin-package-secret-binding-transition-receipt": { + "types": "./dist/plugin-package/secret-binding/transitionReceipt.d.ts", + "require": "./dist/plugin-package/secret-binding/transitionReceipt.js", + "default": "./dist/plugin-package/secret-binding/transitionReceipt.js" + }, "./plugin-package-secret-binding-approval-plan": { "types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts", "require": "./dist/plugin-package/secret-binding/approvalPlan.js", diff --git a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts index 05bf50ea..dbe36502 100644 --- a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts +++ b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageInstallation.ts @@ -5,6 +5,8 @@ import { import { InvalidPluginPackageInstallError, PluginPackageInstallTransitionConflictError, + PluginPackageInstallUnavailableError, + assertPluginPackageInstallMatchesLock, normalizePluginPackageLock, pluginPackageInstallCommit, transitionPluginPackageInstall, @@ -33,6 +35,20 @@ export interface PluginPackageStageProvider { ): Promise>; } +export type PluginPackageActivationPrerequisiteObservation = + | Readonly<{ status: 'ready' }> + | Readonly<{ + status: 'deferred'; + reason: 'secret_binding_transition_required'; + }>; + +export interface PluginPackageActivationPrerequisite { + inspect( + record: Readonly, + lock: Readonly, + ): Promise>; +} + export interface InstallPluginPackageOptions { readonly lock: PluginPackageLock; readonly proposalDigest: string; @@ -139,15 +155,23 @@ export function normalizePluginPackageStageEvidence( export class PluginPackageInstallationCoordinator { readonly #repository: PluginPackageAdmissionRepository; readonly #activation: PluginPackageActivationCoordinator; + readonly #activationPrerequisite?: PluginPackageActivationPrerequisite; constructor(options: { readonly repository: PluginPackageAdmissionRepository; readonly publisher: PluginPackageActivationPublisher; + readonly activationPrerequisite?: PluginPackageActivationPrerequisite; }) { const value = dataRecord(options, 'installation coordinator options'); exactKeys( value, - ['repository', 'publisher'], + [ + 'repository', + 'publisher', + ...(options.activationPrerequisite === undefined + ? [] + : ['activationPrerequisite']), + ], 'installation coordinator options', ); if ( @@ -157,7 +181,10 @@ export class PluginPackageInstallationCoordinator { typeof options.repository.create !== 'function' || typeof options.repository.commit !== 'function' || typeof options.repository.admit !== 'function' || - typeof options.repository.findAdmissionReceipt !== 'function' + typeof options.repository.findAdmissionReceipt !== 'function' || + (options.activationPrerequisite !== undefined && + (!options.activationPrerequisite || + typeof options.activationPrerequisite.inspect !== 'function')) ) { throw new InvalidPluginPackageInstallError( 'installation coordinator authority is invalid', @@ -168,6 +195,9 @@ export class PluginPackageInstallationCoordinator { repository: options.repository, publisher: options.publisher, }); + if (options.activationPrerequisite !== undefined) { + this.#activationPrerequisite = options.activationPrerequisite; + } } async #convergeActivation( @@ -186,6 +216,14 @@ export class PluginPackageInstallationCoordinator { installationId: record.installationId, }; if (record.state === 'staged') { + const lock = await this.#repository.findLock(record.lockDigest); + if (!lock) throw new PluginPackageInstallUnavailableError(); + assertPluginPackageInstallMatchesLock(lock, record); + const prerequisite = await this.#activationPrerequisite?.inspect( + record, + lock, + ); + if (prerequisite?.status === 'deferred') return record; return this.#activation.activate({ ...identity, activationStartedMutationId: options.activationStartedMutationId, diff --git a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts index 6c2d5be1..0167ebbf 100644 --- a/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts +++ b/packages/ql3-runtime-core/src/plugin-package/installation/pluginPackageRecovery.ts @@ -26,6 +26,7 @@ import { } from './pluginPackageInstall'; import { normalizePluginPackageStageEvidence, + type PluginPackageActivationPrerequisite, type PluginPackageStageProvider, } from './pluginPackageInstallation'; @@ -37,6 +38,7 @@ export const PLUGIN_PACKAGE_RECOVERY_ITEM_STATUSES = [ 'retry', 'manual_required', 'superseded', + 'deferred', ] as const; export type PluginPackageRecoveryItemStatus = @@ -64,6 +66,7 @@ export interface PluginPackageRecoveryCycleResult { readonly retry: number; readonly manualRequired: number; readonly superseded: number; + readonly deferred: number; readonly remaining: boolean; readonly safeToAdmit: boolean; } @@ -235,18 +238,20 @@ export class PluginPackageRecoveryCoordinator { readonly #stageProvider: PluginPackageStageProvider; readonly #activation: PluginPackageActivationCoordinator; readonly #now: () => number | Promise; + readonly #activationPrerequisite?: PluginPackageActivationPrerequisite; constructor(options: { readonly repository: PluginPackageInstallRepository; readonly stageProvider: PluginPackageStageProvider; readonly publisher: PluginPackageActivationPublisher; readonly now: () => number | Promise; + readonly activationPrerequisite?: PluginPackageActivationPrerequisite; }) { const value = dataRecord(options, 'recovery coordinator options'); exactKeys( value, ['repository', 'stageProvider', 'publisher', 'now'], - [], + ['activationPrerequisite'], 'recovery coordinator options', ); if ( @@ -257,7 +262,10 @@ export class PluginPackageRecoveryCoordinator { typeof options.repository.listRecoveryPage !== 'function' || !options.stageProvider || typeof options.stageProvider.stage !== 'function' || - typeof options.now !== 'function' + typeof options.now !== 'function' || + (options.activationPrerequisite !== undefined && + (!options.activationPrerequisite || + typeof options.activationPrerequisite.inspect !== 'function')) ) { throw new InvalidPluginPackageInstallError( 'recovery coordinator authority is invalid', @@ -270,6 +278,9 @@ export class PluginPackageRecoveryCoordinator { publisher: options.publisher, }); this.#now = options.now; + if (options.activationPrerequisite !== undefined) { + this.#activationPrerequisite = options.activationPrerequisite; + } } async #current( @@ -292,6 +303,14 @@ export class PluginPackageRecoveryCoordinator { installationId: record.installationId, }; if (record.state === 'staged') { + const lock = await this.#repository.findLock(record.lockDigest); + if (!lock) throw new PluginPackageInstallUnavailableError(); + assertPluginPackageInstallMatchesLock(lock, record); + const prerequisite = await this.#activationPrerequisite?.inspect( + record, + lock, + ); + if (prerequisite?.status === 'deferred') return record; return this.#activation.activate({ ...identity, activationStartedMutationId: mutationId( @@ -374,6 +393,18 @@ export class PluginPackageRecoveryCoordinator { current = committed; } current = await this.#convergeActivation(current, occurredAtMs); + if (current.state === 'staged' && this.#activationPrerequisite) { + const lock = await this.#repository.findLock(current.lockDigest); + if (!lock) throw new PluginPackageInstallUnavailableError(); + assertPluginPackageInstallMatchesLock(lock, current); + const prerequisite = await this.#activationPrerequisite.inspect( + current, + lock, + ); + if (prerequisite.status === 'deferred') { + return result(source, action, 'deferred', current); + } + } if (current.state === 'active' || current.state === 'failed') { return result(source, action, 'settled', current); } @@ -477,6 +508,7 @@ export class PluginPackageRecoveryCoordinator { retry: 0, manualRequired: 0, superseded: 0, + deferred: 0, }; let after: Readonly | undefined; let exhausted = false; @@ -501,6 +533,9 @@ export class PluginPackageRecoveryCoordinator { case 'superseded': counts.superseded += 1; break; + case 'deferred': + counts.deferred += 1; + break; } } if (!page.truncated) { @@ -517,7 +552,8 @@ export class PluginPackageRecoveryCoordinator { return Object.freeze({ ...counts, remaining, - safeToAdmit: !remaining && counts.manualRequired === 0, + safeToAdmit: + !remaining && counts.retry === 0 && counts.manualRequired === 0, }); } } diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionReceipt.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionReceipt.ts new file mode 100644 index 00000000..d81e5119 --- /dev/null +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionReceipt.ts @@ -0,0 +1,305 @@ +import { createHash } from 'node:crypto'; + +import { + createPluginPackageSecretBindingFromEntries, + normalizePluginPackageSecretBinding, + type PluginPackageSecretBinding, + type PluginPackageSecretBindingAuthorityKind, +} from './binding'; +import { + normalizePluginPackageSecretBindingTransitionPlan, + type PluginPackageSecretBindingTransitionPlan, +} from './transitionPlan'; + +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA = + 'qinglong/plugin-package-secret-binding-transition-receipt@v1' as const; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES = + 192 * 1024; + +export interface PluginPackageSecretBindingTransitionReceiptAuthority { + readonly kind: PluginPackageSecretBindingAuthorityKind; + readonly evidenceDigest: string; +} + +export interface PluginPackageSecretBindingTransitionReceipt { + readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA; + readonly transitionPlan: Readonly; + readonly authority: Readonly; + readonly bindingDigest: string | null; + readonly committedAtMs: number; + readonly receiptDigest: string; +} + +export interface CreatePluginPackageSecretBindingTransitionReceiptInput { + readonly transitionPlan: Readonly; + readonly authority: Readonly; + readonly binding: Readonly | null; + readonly committedAtMs: number; +} + +const DIGEST = /^[0-9a-f]{64}$/; +const RECEIPT_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-secret-binding-transition-receipt-digest@v1\0', + 'utf8', +); + +function invalid(message: string): never { + throw new TypeError( + `Plugin Package Secret binding transition receipt is invalid: ${message}`, + ); +} + +function dataRecord(value: unknown, label: string): Record { + 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; +} + +function exactKeys( + value: object, + expected: readonly string[], + label: string, +): void { + const actual = Reflect.ownKeys(value); + const strings = actual.filter( + (key): key is string => typeof key === 'string', + ); + const canonical = [...expected].sort(); + if ( + actual.length !== canonical.length || + strings.length !== canonical.length || + strings.sort().some((key, index) => key !== canonical[index]) + ) { + invalid(`${label} shape is invalid`); + } +} + +function timestamp(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + return invalid('committedAtMs is invalid'); + } + return value as number; +} + +function authority( + value: unknown, +): Readonly { + const candidate = dataRecord(value, 'authority'); + exactKeys(candidate, ['evidenceDigest', 'kind'], 'authority'); + if ( + candidate.kind !== 'approved-action-execution' && + candidate.kind !== 'local-owner-confirmation' + ) { + return invalid('authority kind is invalid'); + } + if ( + typeof candidate.evidenceDigest !== 'string' || + !DIGEST.test(candidate.evidenceDigest) + ) { + return invalid('authority evidence digest is invalid'); + } + return Object.freeze({ + kind: candidate.kind, + evidenceDigest: candidate.evidenceDigest, + }); +} + +function unsigned( + transitionPlan: Readonly, + normalizedAuthority: Readonly, + bindingDigest: string | null, + committedAtMs: number, +): Omit { + return Object.freeze({ + schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA, + transitionPlan, + authority: normalizedAuthority, + bindingDigest, + committedAtMs, + }); +} + +function receiptDigest( + value: Omit, +): string { + return createHash('sha256') + .update(RECEIPT_DIGEST_DOMAIN) + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function bounded( + value: Readonly, +): Readonly { + if ( + Buffer.byteLength(JSON.stringify(value), 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES + ) { + return invalid('durable JSON byte budget exceeded'); + } + return value; +} + +function assertBinding( + transitionPlan: Readonly, + bindingValue: Readonly | null, + normalizedAuthority: Readonly, + committedAtMs: number, +): Readonly | null { + if (transitionPlan.nextBindingPlan === null) { + if (bindingValue !== null) { + return invalid( + 'binding is forbidden when the transition revokes all requirements', + ); + } + return null; + } + if (bindingValue === null) { + return invalid('binding is required by the transition plan'); + } + const binding = normalizePluginPackageSecretBinding(bindingValue); + if ( + JSON.stringify(binding.target) !== + JSON.stringify(transitionPlan.nextTarget) || + JSON.stringify(binding.entries) !== + JSON.stringify(transitionPlan.nextBindingPlan.entries) || + binding.authority.kind !== normalizedAuthority.kind || + binding.authority.evidenceDigest !== normalizedAuthority.evidenceDigest || + binding.boundAtMs !== committedAtMs + ) { + return invalid( + 'binding does not match the transition, authority, and commit', + ); + } + return binding; +} + +export function createPluginPackageSecretBindingFromTransitionPlan( + planValue: Readonly, + authorityKind: PluginPackageSecretBindingAuthorityKind, + evidenceDigest: string, + boundAtMs: number, +): Readonly | null { + const plan = normalizePluginPackageSecretBindingTransitionPlan(planValue); + if (plan.nextBindingPlan === null) return null; + return createPluginPackageSecretBindingFromEntries({ + target: plan.nextTarget, + entries: plan.nextBindingPlan.entries, + authority: { kind: authorityKind, evidenceDigest }, + boundAtMs, + }); +} + +export function createPluginPackageSecretBindingTransitionReceipt( + input: CreatePluginPackageSecretBindingTransitionReceiptInput, +): Readonly { + const candidate = dataRecord(input, 'receipt input'); + exactKeys( + candidate, + ['authority', 'binding', 'committedAtMs', 'transitionPlan'], + 'receipt input', + ); + const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan( + input.transitionPlan, + ); + const normalizedAuthority = authority(input.authority); + const committedAtMs = timestamp(input.committedAtMs); + if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) { + return invalid('commit precedes the reviewed next binding plan'); + } + const binding = assertBinding( + transitionPlan, + input.binding, + normalizedAuthority, + committedAtMs, + ); + const value = unsigned( + transitionPlan, + normalizedAuthority, + binding?.bindingDigest ?? null, + committedAtMs, + ); + return bounded( + Object.freeze({ ...value, receiptDigest: receiptDigest(value) }), + ); +} + +export function normalizePluginPackageSecretBindingTransitionReceipt( + value: unknown, +): Readonly { + const candidate = dataRecord(value, 'receipt'); + exactKeys( + candidate, + [ + 'authority', + 'bindingDigest', + 'committedAtMs', + 'receiptDigest', + 'schema', + 'transitionPlan', + ], + 'receipt', + ); + if ( + candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA + ) { + return invalid('schema is unsupported'); + } + const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan( + candidate.transitionPlan, + ); + const normalizedAuthority = authority(candidate.authority); + const committedAtMs = timestamp(candidate.committedAtMs); + if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) { + return invalid('commit precedes the reviewed next binding plan'); + } + const bindingDigestValue = candidate.bindingDigest; + if ( + (bindingDigestValue !== null && + (typeof bindingDigestValue !== 'string' || + !DIGEST.test(bindingDigestValue))) || + (transitionPlan.nextBindingPlan === null) !== (bindingDigestValue === null) + ) { + return invalid('binding digest presence is inconsistent with transition'); + } + const normalized = unsigned( + transitionPlan, + normalizedAuthority, + bindingDigestValue as string | null, + committedAtMs, + ); + if ( + typeof candidate.receiptDigest !== 'string' || + !DIGEST.test(candidate.receiptDigest) || + candidate.receiptDigest !== receiptDigest(normalized) + ) { + return invalid('receipt digest does not match content'); + } + return bounded( + Object.freeze({ ...normalized, receiptDigest: candidate.receiptDigest }), + ); +} + +export interface PluginPackageSecretBindingTransitionReceiptRepository { + find( + generationDigest: string, + ): Promise | null>; +} diff --git a/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs b/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs index f8f1284e..2191ac69 100644 --- a/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageRecovery.test.cjs @@ -286,6 +286,7 @@ test('recovers queued install through stage and activation without consuming app retry: 0, manualRequired: 0, superseded: 0, + deferred: 0, remaining: false, safeToAdmit: true, }); @@ -296,6 +297,62 @@ test('recovers queued install through stage and activation without consuming app assert.deepEqual(calls, { stage: 1, publish: 1, inspect: 0 }); }); +test('stages but defers activation until the exact prerequisite is ready', async () => { + const value = fixture(); + const repository = new MemoryRepository([value]); + const calls = { stage: 0, publish: 0, inspect: 0, prerequisite: 0 }; + let ready = false; + const coordinator = new PluginPackageRecoveryCoordinator({ + repository, + stageProvider: { + async stage(lock) { + calls.stage += 1; + return stageEvidence(lock); + }, + }, + publisher: publisherFor(repository, calls), + activationPrerequisite: { + async inspect() { + calls.prerequisite += 1; + return ready + ? { status: 'ready' } + : { + status: 'deferred', + reason: 'secret_binding_transition_required', + }; + }, + }, + now: () => 250, + }); + + const deferred = await coordinator.recoverPage({ limit: 1 }); + assert.equal(deferred.items[0].status, 'deferred'); + assert.equal( + (await repository.find('default', 'example-monitor')).state, + 'staged', + ); + assert.deepEqual(calls, { + stage: 1, + publish: 0, + inspect: 0, + prerequisite: 2, + }); + + ready = true; + const settled = await coordinator.recoverPage({ limit: 1 }); + assert.equal(settled.items[0].status, 'settled'); + assert.equal( + (await repository.find('default', 'example-monitor')).state, + 'active', + ); + assert.deepEqual(calls, { + stage: 1, + publish: 1, + inspect: 0, + prerequisite: 3, + }); +}); + test('inspects an activating install without republishing it', async () => { const value = activatingFixture(); const repository = new MemoryRepository([ diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionReceipt.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionReceipt.test.cjs new file mode 100644 index 00000000..c22fc94c --- /dev/null +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionReceipt.test.cjs @@ -0,0 +1,237 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { + createPluginPackageSecretBindingFromTransitionPlan, + createPluginPackageSecretBindingTransitionReceipt, + normalizePluginPackageSecretBindingTransitionReceipt, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); + +function manifest(secrets) { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'receipt-package', + displayName: 'Receipt package', + version: '2.0.0', + description: 'Transition receipt fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['edge'], + }, + runtimes: [], + resources: { + memory: { recommended: '16Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets, + tools: secrets.length === 0 ? [] : ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +const previousManifest = manifest([{ name: 'TOKEN', required: true }]); +const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'receipt-package', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: previousManifest.spec.contents, +}); +const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'token', + version: 1, + }), + }, + ], + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: 'c'.repeat(64), + }, + boundAtMs: 10, +}); + +function transition(nextManifest, assignments) { + return createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: createPluginPackageResourceGeneration({ + installationId: 'install-2', + projectId: 'project-1', + packageName: 'receipt-package', + lockDigest: 'd'.repeat(64), + generation: 2, + previousActiveLockDigest: previousBinding.target.lockDigest, + contentDigest: 'e'.repeat(64), + contents: nextManifest.spec.contents, + }), + nextManifest, + assignments, + plannedAtMs: 20, + }); +} + +test('binds an exact next binding and approval evidence into one receipt', () => { + const plan = transition(previousManifest, [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'token', + version: 2, + }), + }, + ]); + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'approved-action-execution', + 'f'.repeat(64), + 30, + ); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'f'.repeat(64), + }, + binding, + committedAtMs: 30, + }); + assert.equal(receipt.bindingDigest, binding.bindingDigest); + assert.equal(receipt.transitionPlan.kind, 'rotate'); + assert.deepEqual( + normalizePluginPackageSecretBindingTransitionReceipt(receipt), + receipt, + ); +}); + +test('records full revocation without inventing an empty binding', () => { + const plan = transition(manifest([]), []); + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'local-owner-confirmation', + plan.transitionDigest, + 30, + ); + assert.equal(binding, null); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: plan.transitionDigest, + }, + binding, + committedAtMs: 30, + }); + assert.equal(receipt.bindingDigest, null); + assert.equal(receipt.transitionPlan.kind, 'revoke'); +}); + +test('rejects missing, surplus, or authority-detached bindings', () => { + const plan = transition(previousManifest, [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'token', + version: 2, + }), + }, + ]); + assert.throws(() => + createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: plan.transitionDigest, + }, + binding: null, + committedAtMs: 30, + }), + ); + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'local-owner-confirmation', + plan.transitionDigest, + 30, + ); + assert.throws(() => + createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'f'.repeat(64), + }, + binding, + committedAtMs: 30, + }), + ); + + const revoke = transition(manifest([]), []); + assert.throws(() => + createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: revoke, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: revoke.transitionDigest, + }, + binding, + committedAtMs: 30, + }), + ); +}); + +test('rejects receipt shape and digest tampering', () => { + const plan = transition(manifest([]), []); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: plan.transitionDigest, + }, + binding: null, + committedAtMs: 30, + }); + assert.throws(() => + normalizePluginPackageSecretBindingTransitionReceipt({ + ...receipt, + committedAtMs: 31, + }), + ); + assert.throws(() => + normalizePluginPackageSecretBindingTransitionReceipt({ + ...receipt, + unexpected: true, + }), + ); +});