From d72631a2b0542cf3451d28b04a5a5921c705abb3 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 10:31:06 +0800 Subject: [PATCH] feat(ql3): materialize package secret bindings --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...d-plugin-package-secret-materialization.md | 43 +++++ docs/adr/README.md | 1 + docs/ql3-package-boundaries.json | 4 +- .../recovery/pluginPackageRecovery.ts | 14 +- .../ql3-cluster-admin/test/bootstrap.test.cjs | 1 + .../test/pluginPackageRecovery.test.cjs | 1 + .../src/migration/migrationManifest.ts | 5 + .../src/migrations/index.ts | 2 + ...in-package-secret-materialization-guard.ts | 101 +++++++++++ .../src/schema/schemaContract.ts | 18 +- .../src/schema/schemaReadiness.ts | 3 + .../postgresqlMigrationDefinitions.test.cjs | 42 +++++ .../test/postgresqlSchemaReadiness.test.cjs | 23 +-- .../adopted-profile/localAdoptedProfile.ts | 4 + .../pluginPackageStartup.ts | 3 + .../test/localDeployment.test.cjs | 22 +-- .../test/localReadiness.test.cjs | 4 +- .../src/migration/migration.ts | 4 + .../src/migration/migrationManifest.ts | 10 + ...in-package-secret-materialization-guard.ts | 8 + .../src/migrations/0094-capability-v47.ts | 14 ++ ...kageSecretMaterializationSchemaContract.ts | 67 +++++++ .../src/profile/localProfile.ts | 2 + .../src/readiness/readiness.ts | 16 +- .../src/runtime/runtimeDatabase.ts | 14 ++ .../ql3-local-sqlite/test/database.test.cjs | 91 +++++++++- ...ackageWorkflowAdmissionRepository.test.cjs | 4 +- ...lowTaskAttemptAdmissionRepository.test.cjs | 2 +- .../test/rolloutSafety.test.cjs | 6 +- .../pluginPackageResourceMaterialization.ts | 171 ++++++++++++++++-- .../pluginPackageTaskPublication.ts | 82 +++++++-- ...ginPackageResourceMaterialization.test.cjs | 159 +++++++++++++--- test/back/ql3PackageBoundaryAudit.test.cjs | 12 +- 34 files changed, 839 insertions(+), 115 deletions(-) create mode 100644 docs/adr/ADR-0394-generation-bound-plugin-package-secret-materialization.md create mode 100644 packages/ql3-cluster-postgres/src/migrations/pg-0060-plugin-package-secret-materialization-guard.ts create mode 100644 packages/ql3-local-sqlite/src/migrations/0093-plugin-package-secret-materialization-guard.ts create mode 100644 packages/ql3-local-sqlite/src/migrations/0094-capability-v47.ts create mode 100644 packages/ql3-local-sqlite/src/plugin-package/pluginPackageSecretMaterializationSchemaContract.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 5aea3ef3..195dabee 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -27,6 +27,7 @@ - D-304/ADR-0392(已接受):Plugin Package 进入安全 quarantine 时,Workflow/Prompt automation publication 不再仅依赖运行时 start guard 间接拒绝,而是与 quarantine event、Package-owned Task disabled revisions、Project Tool snapshot 和 withdrawal receipt 在同一 SQLite/PostgreSQL 事务中收敛为 `withdrawn`。原先仅能引用普通 lifecycle event 的外键升级为 append-only disposition-event 联合引用,历史 migration 与 publication digest schema 保持不变;SQLite edge/standalone 崩溃矩阵覆盖 automation publication insert 后、event/task/receipt/COMMIT 前后,PostgreSQL 通过触发器在既有 `SECURITY DEFINER` quarantine commit 内登记 disposition,再由同一外层 SERIALIZABLE transaction CAS publication head。能力位为 `plugin_package_automation_security_withdrawal@1`;不新增 package、daemon、timer、连接或常驻缓存,适用于低配路由设备和集群节点。SQLite 全量 228/228;PostgreSQL package 311 pass/1 条外部 URL 条件 skip;完整 18-package build/test 退出 0,backend 1,188 pass/2 skip,package/dependency boundary 零 finding;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`。 - D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。 +- D-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-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-0394-generation-bound-plugin-package-secret-materialization.md b/docs/adr/ADR-0394-generation-bound-plugin-package-secret-materialization.md new file mode 100644 index 00000000..0263a3c2 --- /dev/null +++ b/docs/adr/ADR-0394-generation-bound-plugin-package-secret-materialization.md @@ -0,0 +1,43 @@ +# ADR-0394:按 Generation 固定的 Plugin Package Secret Materialization + +- 状态:Accepted +- 日期:2026-08-13 +- 关联 RFC:QL-RFC-0001 D-306A +- 关联 ADR:ADR-0151、ADR-0153、ADR-0393 + +## 问题 + +D-305 已把 Package 的逻辑 Secret requirement 固定为不可变、按 generation 绑定的版本化 `qlsecret://` 引用,但 Task source 仍不能消费该绑定。若在 dispatch 时才按 requirement 名称查找,会使 materialized revision 与实际 Secret 版本脱节;若允许 Package source 直接携带部署方 SecretRef,又会绕过安装方授权。 + +Local/Edge 还要求只读 readiness 不加载 migration 执行模块,且不能因 Secret-aware Package 引入 watcher、缓存、连接或常驻进程。Cluster 必须在直接数据库写入时也拒绝未绑定或越权的 SecretRef。 + +## 决策 + +1. Package Task source 以 `{kind:"package-secret", requirement:"NAME"}` 引用 Manifest 的逻辑 requirement,不允许直接写 `kind:"secret"` 或 `qlsecret://`。 +2. materialization 读取当前 generation 的 D-305 binding,把逻辑 requirement 编译为已有、固定版本的 Task `SecretRef`;optional 且显式绑定为 `null` 的环境项被省略。普通 Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 继续复用现有执行链,不增加新的 Secret 传输协议。 +3. materialized revision 嵌入完整 binding 快照但不含明文,binding 与最终资源共同进入 revision digest。任何 Task SecretRef 必须属于该快照;Manifest 声明 Secret 时缺失 binding 或 `secret.use` capability 均失败关闭。 +4. SQLite 与 PostgreSQL 在 materialized revision INSERT 边界增加触发器,拒绝畸形 Secret 声明、未解析 placeholder、缺失/不匹配 binding 和 binding 外 SecretRef,防止绕过 core repository 直接写库。 +5. SQLite 的触发器 SQL 真源位于非执行型 schema contract;migration 与 readiness 共同引用,保持只读 readiness 和 rollout-safety 不加载 DDL 模块。Local contract 升至 v47,Cluster 升至 v59。 +6. 实现复用既有 SQLite authority、PostgreSQL pool、Task Secret resolution 与 Worker delivery,不增加 package、表、索引、连接、timer、watcher、cache、Pod 或 daemon。Local 仅增加一个 INSERT trigger,Cluster 增加一个 trigger function 与一个 INSERT trigger。 + +## 生命周期与阶段边界 + +- binding 与 materialized revision 均按 generation 不可变;Secret 新版本不会静默改变既有 generation。 +- Package disable/quarantine/uninstall 继续由现有 generation/lifecycle start guard 阻止新执行;已开始执行仍遵循既有 Run/Worker 清理语义。 +- D-306A 完成 runtime consumption 和数据库防绕过,不宣称产品授权闭环完成。面向用户的 Local bind/rebind 命令、Cluster Approved Action/API,以及通过新 generation 执行 rotation/rebinding/revocation 的编排属于 D-306B。 + +## 资源与部署影响 + +| Profile | 增量 | 常驻开销 | +| --- | --- | --- | +| Edge / Standalone | 一个 INSERT trigger;启动发布按需点查一个既有 binding row | 无新增进程、连接、缓存、timer 或 watcher | +| Cluster | 一个非 `SECURITY DEFINER` trigger function 和一个 INSERT trigger;复用 executor pool | 无新增角色、连接、workload、sidecar 或 worker | + +## 验证 + +- core:placeholder 编译、optional-null、省略、直接 SecretRef/缺失 binding/越权引用拒绝、revision digest 与 normalize round-trip。 +- SQLite:真实 migration、畸形声明与未绑定 SecretRef 直接 INSERT 拒绝、readiness DDL lazy boundary、rollout safety、edge/standalone contract。 +- PostgreSQL:migration checksum、trigger/function、固定 search path、权限撤回、schema/readiness 与 physical-streaming HA gate。 +- 完整 18-package clean build/test 退出 0;backend 共 1,190 项,1,188 pass、2 条件 skip、0 fail。 +- package boundary、cluster dependency、edge import、service-manager bridge import、local image 五项审计零 finding;workspace 保持 18 package、1,090 source、1,072 nested source,`singleSourcePackages=[]`、`shallowSourcePackages=[]`。两个经审查的 `ordered_ledger` 目录精确为 PostgreSQL 61 与 SQLite 95 个 migration source,通用密集目录门禁未放宽。 +- PostgreSQL 18.4 arm64 HA Docker gate 共 125 项全绿,timeline `1→2`;私有报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。 diff --git a/docs/adr/README.md b/docs/adr/README.md index cc93fe15..4bd10ea9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -397,6 +397,7 @@ | [ADR-0391](./ADR-0391-read-only-cluster-operator-context-readiness.md) | Read-only Cluster Operator Context Readiness | Accepted | | [ADR-0392](./ADR-0392-atomic-plugin-package-automation-security-withdrawal.md) | Plugin Package Automation 安全隔离原子撤回 | Accepted | | [ADR-0393](./ADR-0393-generation-bound-plugin-package-secret-binding-ledger.md) | 按 Generation 固定的 Plugin Package Secret 绑定账本 | Accepted | +| [ADR-0394](./ADR-0394-generation-bound-plugin-package-secret-materialization.md) | 按 Generation 固定的 Plugin Package Secret Materialization | Accepted | ## 规则 diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index b4c91765..9e7caffd 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -6,13 +6,13 @@ "reviewedDenseDirectories": [ { "kind": "ordered_ledger", - "maxDirectSourceFiles": 60, + "maxDirectSourceFiles": 61, "path": "packages/ql3-cluster-postgres/src/migrations", "rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." }, { "kind": "ordered_ledger", - "maxDirectSourceFiles": 93, + "maxDirectSourceFiles": 95, "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-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts index b8c9a0dd..ffed7a02 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts @@ -39,6 +39,7 @@ import { assertPostgresPackageExecutorSchemaReady, PostgresPluginPackageAutomationPublicationRepository, PostgresPluginPackageMaterializedRevisionRepository, + PostgresPluginPackageSecretBindingRepository, PostgresPluginPackagePublisherProvenanceRepository, PostgresPluginPackageTaskReconciliationRepository, PostgresProjectToolDefinitionSnapshotRepository, @@ -102,8 +103,7 @@ export class ClusterPluginPackagePublisherProvenanceRecoveryRequiredError extend readonly recovery: Readonly, ) { super('Cluster has unresolved Plugin Package publisher provenance work'); - this.name = - 'ClusterPluginPackagePublisherProvenanceRecoveryRequiredError'; + this.name = 'ClusterPluginPackagePublisherProvenanceRecoveryRequiredError'; } } @@ -166,9 +166,7 @@ function assertOptions(options: ClusterPluginPackageRecoveryOptions): void { (!options.resourceByteSource || typeof options.resourceByteSource.open !== 'function')) || typeof options.trustAuthorityId !== 'string' || - !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test( - options.trustAuthorityId, - ) || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(options.trustAuthorityId) || typeof options.now !== 'function' || (options.pageSize !== undefined && (!Number.isSafeInteger(options.pageSize) || @@ -313,8 +311,7 @@ export async function recoverClusterPluginPackages( const resourceByteSource = options.resourceByteSource ?? new ClusterPluginPackageOciResourceByteSource({ - authority: - stageAuthority as ClusterPluginPackageOciStageAuthority, + authority: stageAuthority as ClusterPluginPackageOciStageAuthority, lockSource: repository, }); const recovery = await new PluginPackageRecoveryCoordinator({ @@ -340,6 +337,8 @@ export async function recoverClusterPluginPackages( database.pool, taskSpecSemanticRegistry, ); + const secretBindingRepository = + new PostgresPluginPackageSecretBindingRepository(database.pool); const taskPublicationRecovery = await new PluginPackageTaskPublicationRecoveryCoordinator({ source: taskReconciliationRepository, @@ -348,6 +347,7 @@ export async function recoverClusterPluginPackages( lockSource: repository, byteSource: resourceByteSource, materializedRepository, + secretBindingSource: secretBindingRepository, reconciliationRepository: taskReconciliationRepository, taskSpecSemanticRegistry, }), diff --git a/packages/ql3-cluster-admin/test/bootstrap.test.cjs b/packages/ql3-cluster-admin/test/bootstrap.test.cjs index 4fcb1df7..434134f0 100644 --- a/packages/ql3-cluster-admin/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-admin/test/bootstrap.test.cjs @@ -160,6 +160,7 @@ function database(serverVersionNum = '160014') { 'commit_plugin_package_lifecycle', 'commit_plugin_package_task_reconciliation', 'commit_plugin_package_quarantine', + 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'lock_active_plugin_package_project', 'lock_approval_policy_fence', diff --git a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs index f686c57d..fc3c1a61 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs @@ -192,6 +192,7 @@ function database(serverVersionNum = '160014') { rows: contract.functions.map(({ name: functionName }) => ({ functionName, executeAllowed: ![ + 'enforce_plugin_package_secret_materialization', 'enforce_plugin_package_stage_provenance', 'plugin_package_automation_start_allowed', 'plugin_package_run_start_allowed', diff --git a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts index db482705..3703b5f2 100644 --- a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts +++ b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts @@ -303,5 +303,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest = checksum: '87582d256c868bd7f5af352c4b052fdab9f3714e1e7179e35d33bfa5d62957be', }), + Object.freeze({ + id: 'pg-0060-plugin-package-secret-materialization-guard', + checksum: + '28284ca860b39ff9de5b2aa1a2a60ef2c463fd6a72798d237040174272b64b1e', + }), ]), }); diff --git a/packages/ql3-cluster-postgres/src/migrations/index.ts b/packages/ql3-cluster-postgres/src/migrations/index.ts index 1bf9afc4..f364e3b1 100644 --- a/packages/ql3-cluster-postgres/src/migrations/index.ts +++ b/packages/ql3-cluster-postgres/src/migrations/index.ts @@ -62,6 +62,7 @@ import { pg0056RunManagementBoundaryMigration } from '../run-management/pg-0056- import { pg0057RunManagementStopBoundaryMigration } from '../run-management/pg-0057-run-management-stop-boundary'; import { pg0058PluginPackageAutomationDispositionEventsMigration } from './pg-0058-plugin-package-automation-disposition-events'; import { pg0059PluginPackageSecretBindingsMigration } from './pg-0059-plugin-package-secret-bindings'; +import { pg0060PluginPackageSecretMaterializationGuardMigration } from './pg-0060-plugin-package-secret-materialization-guard'; export const postgresqlMainMigrationStream: MigrationStreamDefinition = Object.freeze({ @@ -129,5 +130,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition '{manifest,spec,permissions,secrets}' + ) IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'Package Secret permission declarations are malformed' + USING ERRCODE = 'check_violation'; + END IF; + + secret_count := jsonb_array_length( + NEW.revision_json #> '{manifest,spec,permissions,secrets}' + ); + embedded_binding := NEW.revision_json -> 'secretBinding'; + + IF secret_count = 0 THEN + IF embedded_binding IS NOT NULL THEN + RAISE EXCEPTION 'unexpected Package Secret binding' + USING ERRCODE = 'check_violation'; + END IF; + ELSE + SELECT binding.binding_json + INTO stored_binding + FROM "ql3"."plugin_package_secret_bindings" AS binding + WHERE binding.generation_digest = NEW.generation_digest; + IF stored_binding IS NULL OR stored_binding <> embedded_binding THEN + RAISE EXCEPTION 'Package Secret binding is absent or mismatched' + USING ERRCODE = 'check_violation'; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(NEW.revision_json -> 'resources') AS resource + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE( + resource #> '{value,spec,config,environment}', + '[]'::jsonb + ) + ) AS environment + WHERE resource ->> 'kind' = 'task' + AND environment ->> 'kind' = 'package-secret' + ) THEN + RAISE EXCEPTION 'unresolved Package Secret placeholder' + USING ERRCODE = 'check_violation'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM jsonb_array_elements(NEW.revision_json -> 'resources') AS resource + CROSS JOIN LATERAL jsonb_array_elements( + COALESCE( + resource #> '{value,spec,config,environment}', + '[]'::jsonb + ) + ) AS environment + WHERE resource ->> 'kind' = 'task' + AND environment ->> 'kind' = 'secret' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + COALESCE(embedded_binding -> 'entries', '[]'::jsonb) + ) AS binding_entry + WHERE binding_entry ->> 'secretRef' = + environment ->> 'secretRef' + ) + ) THEN + RAISE EXCEPTION 'Task SecretRef is outside Package binding' + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END +$ql3$ + `.trim(), + `REVOKE ALL ON FUNCTION "ql3"."enforce_plugin_package_secret_materialization"() FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `CREATE TRIGGER ql3_plugin_package_secret_materialization_guard BEFORE INSERT ON "ql3"."plugin_package_materialized_revisions" FOR EACH ROW EXECUTE FUNCTION "ql3"."enforce_plugin_package_secret_materialization"()`, + `DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 59, migration_id = 'pg-0060-plugin-package-secret-materialization-guard', capabilities = '${CAPABILITIES_V59}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 58 AND migration_id = 'pg-0059-plugin-package-secret-bindings' AND capabilities = '${CAPABILITIES_V58}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 58' USING ERRCODE = 'check_violation'; END IF; END $ql3$`, + ], + }); diff --git a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts index c8b21c97..8a2bd8f8 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts @@ -15,8 +15,8 @@ export interface PostgresSchemaContractFunction { export interface PostgresSchemaContract { readonly schema: 'ql3'; readonly contractName: 'control-core'; - readonly contractVersion: 58; - readonly migrationId: 'pg-0059-plugin-package-secret-bindings'; + readonly contractVersion: 59; + readonly migrationId: 'pg-0060-plugin-package-secret-materialization-guard'; readonly minimumServerMajor: 16; readonly maximumServerMajor: 18; readonly capabilities: Readonly<{ @@ -56,6 +56,7 @@ export interface PostgresSchemaContract { plugin_package_management_quota: 1; plugin_package_materialized_revision: 1; plugin_package_secret_binding: 1; + plugin_package_secret_materialization: 1; plugin_package_proposal: 1; plugin_package_publisher_provenance: 1; plugin_package_publisher_trust_authority: 1; @@ -105,8 +106,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = Object.freeze({ schema: 'ql3', contractName: 'control-core', - contractVersion: 58, - migrationId: 'pg-0059-plugin-package-secret-bindings', + contractVersion: 59, + migrationId: 'pg-0060-plugin-package-secret-materialization-guard', minimumServerMajor: 16, maximumServerMajor: 18, capabilities: Object.freeze({ @@ -139,6 +140,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = plugin_package_management_quota: 1, plugin_package_materialized_revision: 1, plugin_package_secret_binding: 1, + plugin_package_secret_materialization: 1, plugin_package_proposal: 1, plugin_package_publisher_provenance: 1, plugin_package_publisher_trust_authority: 1, @@ -2336,6 +2338,14 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_run_retry_policies_run_fk', ]), functions: Object.freeze([ + Object.freeze({ + name: 'enforce_plugin_package_secret_materialization', + identityArguments: '', + owner: 'ql3_migration', + securityDefiner: false, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), Object.freeze({ name: 'register_plugin_package_automation_disposition_event', identityArguments: '', diff --git a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts index 867a96f1..12602d0a 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts @@ -1507,6 +1507,7 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, + enforce_plugin_package_secret_materialization: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: false, @@ -1525,6 +1526,7 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, + enforce_plugin_package_secret_materialization: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: false, lock_approval_policy_fence: true, @@ -1543,6 +1545,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges commit_plugin_package_lifecycle: true, commit_plugin_package_quarantine: true, commit_plugin_package_task_reconciliation: true, + enforce_plugin_package_secret_materialization: false, enforce_plugin_package_stage_provenance: false, lock_active_plugin_package_project: true, lock_approval_policy_fence: true, diff --git a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs index 4d940ab2..a855bbf8 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs @@ -110,6 +110,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async () 'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events', 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ], ); for (const migration of postgresqlMainMigrationStream.migrations) { @@ -541,6 +542,11 @@ test('freezes every published PostgreSQL migration checksum', () => { checksum: '87582d256c868bd7f5af352c4b052fdab9f3714e1e7179e35d33bfa5d62957be', }, + { + id: 'pg-0060-plugin-package-secret-materialization-guard', + checksum: + '28284ca860b39ff9de5b2aa1a2a60ef2c463fd6a72798d237040174272b64b1e', + }, ]; assert.deepEqual( postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ @@ -1988,3 +1994,39 @@ test('advances capability v58 with immutable generation-bound Package Secret bin /migration_id = 'pg-0058-plugin-package-automation-disposition-events'/, ); }); + +test('advances capability v59 with fail-closed Package Secret materialization', async () => { + const migration = migrationById( + 'pg-0060-plugin-package-secret-materialization-guard', + ); + const statements = []; + await migration.up({ + async query(statement) { + statements.push(statement); + return { rows: [] }; + }, + }); + const sql = statements.join('\n'); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."enforce_plugin_package_secret_materialization"\(\)/, + ); + assert.match( + sql, + /jsonb_typeof\([\s\S]+IS DISTINCT FROM 'array'[\s\S]+permission declarations are malformed/, + ); + assert.match(sql, /unresolved Package Secret placeholder/); + assert.match(sql, /Task SecretRef is outside Package binding/); + assert.match( + sql, + /CREATE TRIGGER ql3_plugin_package_secret_materialization_guard BEFORE INSERT/, + ); + assert.match( + sql, + /REVOKE ALL ON FUNCTION [^;]+ FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress/, + ); + assert.match(sql, /contract_version = 59/); + assert.match(sql, /"plugin_package_secret_materialization":1/); + assert.match(sql, /contract_version = 58/); + assert.match(sql, /migration_id = 'pg-0059-plugin-package-secret-bindings'/); +}); diff --git a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs index 84eb2d48..99bec3b6 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs @@ -754,7 +754,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro serverMajor: 16, currentUser: 'ql3_runtime', contractName: 'control-core', - contractVersion: 58, + contractVersion: 59, migrationIds: [ 'pg-0001-schema-capability', 'pg-0002-run-core', @@ -815,6 +815,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro 'pg-0057-run-management-stop-boundary', 'pg-0058-plugin-package-automation-disposition-events', 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ], }); }); @@ -845,10 +846,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async ( }), ); assert.equal(report.currentUser, 'ql3_admin'); - assert.equal(report.contractVersion, 58); + assert.equal(report.contractVersion, 59); assert.equal( report.migrationIds.at(-1), - 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ); }); @@ -861,10 +862,10 @@ test('accepts the isolated least-privilege automation manager role', async () => }), ); assert.equal(report.currentUser, 'ql3_automation_manager'); - assert.equal(report.contractVersion, 58); + assert.equal(report.contractVersion, 59); assert.equal( report.migrationIds.at(-1), - 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ); const widened = automationManagerPrivileges(); @@ -893,10 +894,10 @@ test('accepts the isolated least-privilege human Approval manager role', async ( }), ); assert.equal(report.currentUser, 'ql3_approval_manager'); - assert.equal(report.contractVersion, 58); + assert.equal(report.contractVersion, 59); assert.equal( report.migrationIds.at(-1), - 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ); const widened = approvalManagerPrivileges(); @@ -927,10 +928,10 @@ test('accepts the isolated least-privilege Run manager role', async () => { }), ); assert.equal(report.currentUser, 'ql3_run_manager'); - assert.equal(report.contractVersion, 58); + assert.equal(report.contractVersion, 59); assert.equal( report.migrationIds.at(-1), - 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ); const widened = runManagerPrivileges(); @@ -1062,10 +1063,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => { }), ); assert.equal(report.currentUser, 'ql3_worker_ingress'); - assert.equal(report.contractVersion, 58); + assert.equal(report.contractVersion, 59); assert.equal( report.migrationIds.at(-1), - 'pg-0059-plugin-package-secret-bindings', + 'pg-0060-plugin-package-secret-materialization-guard', ); }); diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts index a5f902f9..f1a448f8 100644 --- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts +++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts @@ -30,6 +30,8 @@ type PluginPackageInstallRepository = ReadyLocalStorage['pluginPackageInstalls']; type PluginPackageMaterializedRevisionRepository = ReadyLocalStorage['pluginPackageMaterializedRevisions']; +type PluginPackageSecretBindingRepository = + ReadyLocalStorage['pluginPackageSecretBindings']; type PluginPackageTaskReconciliationRepository = ReadyLocalStorage['pluginPackageTaskReconciliations']; type PluginPackageAutomationPublicationRepository = @@ -97,6 +99,7 @@ export type LocalAdoptedProfileBootstrapResult = readonly ownerPepper: LocalOwnerPepperRepository; readonly pluginPackageInstalls: PluginPackageInstallRepository; readonly pluginPackageMaterializedRevisions: PluginPackageMaterializedRevisionRepository; + readonly pluginPackageSecretBindings: PluginPackageSecretBindingRepository; readonly pluginPackageTaskReconciliations: PluginPackageTaskReconciliationRepository; readonly pluginPackageAutomationPublications: PluginPackageAutomationPublicationRepository; readonly projectToolDefinitionSnapshots: ProjectToolDefinitionSnapshotRepository; @@ -244,6 +247,7 @@ export async function bootstrapLocalAdoptedProfileStorage( pluginPackageInstalls: readyStorage.pluginPackageInstalls, pluginPackageMaterializedRevisions: readyStorage.pluginPackageMaterializedRevisions, + pluginPackageSecretBindings: readyStorage.pluginPackageSecretBindings, pluginPackageTaskReconciliations: readyStorage.pluginPackageTaskReconciliations, pluginPackageAutomationPublications: diff --git a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts index 74ab975d..ac05677d 100644 --- a/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts +++ b/packages/ql3-local-application/src/application-runtime/pluginPackageStartup.ts @@ -79,6 +79,8 @@ export async function recoverLocalApplicationPluginPackages( await storage.pluginPackageTaskReconciliations(); const pluginPackageMaterializedRevisions = await storage.pluginPackageMaterializedRevisions(); + const pluginPackageSecretBindings = + await storage.pluginPackageSecretBindings(); const pluginPackageTaskPublicationRecovery = await new PluginPackageTaskPublicationRecoveryCoordinator({ source: pluginPackageTaskReconciliations, @@ -89,6 +91,7 @@ export async function recoverLocalApplicationPluginPackages( stagingRoot: options.pluginPackages.stagingRoot, }), materializedRepository: pluginPackageMaterializedRevisions, + secretBindingSource: pluginPackageSecretBindings, reconciliationRepository: pluginPackageTaskReconciliations, taskSpecSemanticRegistry, }), diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index 7343a0fa..df23f98f 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': '46', - 'io.qinglong.local.sqlite-contract-max': '46', - 'io.qinglong.local.sqlite-write-contract': '46', + 'io.qinglong.local.sqlite-contract-min': '47', + 'io.qinglong.local.sqlite-contract-max': '47', + 'io.qinglong.local.sqlite-write-contract': '47', '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': '46', - 'io.qinglong.local.sqlite-contract-max': '46', - 'io.qinglong.local.sqlite-write-contract': '46', + 'io.qinglong.local.sqlite-contract-min': '47', + 'io.qinglong.local.sqlite-contract-max': '47', + 'io.qinglong.local.sqlite-write-contract': '47', '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, 46); + assert.equal(result.sqlite.contractVersion, 47); 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: 46, - writeContractVersion: 46, + contractVersion: 47, + writeContractVersion: 47, 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, 46); - assert.equal(receipt.sqlite.writeContractVersion, 46); + assert.equal(receipt.sqlite.contractVersion, 47); + assert.equal(receipt.sqlite.writeContractVersion, 47); 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 83779ab4..1dd4281d 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, 46); - assert.equal(result.storage.migrationCount, 92); + assert.equal(result.storage.contractVersion, 47); + assert.equal(result.storage.migrationCount, 94); assert.equal(result.storage.journalMode, 'delete'); assert.equal(JSON.stringify(result).includes(state.directory), false); }); diff --git a/packages/ql3-local-sqlite/src/migration/migration.ts b/packages/ql3-local-sqlite/src/migration/migration.ts index 1d2804c0..22735cd2 100644 --- a/packages/ql3-local-sqlite/src/migration/migration.ts +++ b/packages/ql3-local-sqlite/src/migration/migration.ts @@ -102,6 +102,8 @@ import { local0089PluginPackageAutomationDispositionEventsMigration } from '../m import { local0090CapabilityV45Migration } from '../migrations/0090-capability-v45'; import { local0091PluginPackageSecretBindingsMigration } from '../migrations/0091-plugin-package-secret-bindings'; import { local0092CapabilityV46Migration } from '../migrations/0092-capability-v46'; +import { local0093PluginPackageSecretMaterializationGuardMigration } from '../migrations/0093-plugin-package-secret-materialization-guard'; +import { local0094CapabilityV47Migration } from '../migrations/0094-capability-v47'; import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration'; import { LOCAL_SQLITE_MIGRATION_STREAM_ID, @@ -216,6 +218,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition 0 AND NOT EXISTS ( + SELECT 1 + FROM "QingLong3PluginPackageSecretBindings" AS binding + WHERE binding.generation_digest = NEW.generation_digest + AND json(binding.binding_json) = json_extract( + NEW.revision_json, + '$.secretBinding' + ) + ) + THEN RAISE(ABORT, 'Package Secret binding is absent or mismatched') + WHEN EXISTS ( + SELECT 1 + FROM json_each(NEW.revision_json, '$.resources') AS resource + JOIN json_each( + resource.value, + '$.value.spec.config.environment' + ) AS environment + WHERE json_extract(resource.value, '$.kind') = 'task' + AND json_extract(environment.value, '$.kind') = 'package-secret' + ) + THEN RAISE(ABORT, 'unresolved Package Secret placeholder') + WHEN EXISTS ( + SELECT 1 + FROM json_each(NEW.revision_json, '$.resources') AS resource + JOIN json_each( + resource.value, + '$.value.spec.config.environment' + ) AS environment + WHERE json_extract(resource.value, '$.kind') = 'task' + AND json_extract(environment.value, '$.kind') = 'secret' + AND NOT EXISTS ( + SELECT 1 + FROM json_each( + NEW.revision_json, + '$.secretBinding.entries' + ) AS binding_entry + WHERE json_extract(binding_entry.value, '$.secretRef') = + json_extract(environment.value, '$.secretRef') + ) + ) + THEN RAISE(ABORT, 'Task SecretRef is outside Package binding') + END; +END +`.trim(); diff --git a/packages/ql3-local-sqlite/src/profile/localProfile.ts b/packages/ql3-local-sqlite/src/profile/localProfile.ts index 95cfa88a..06705620 100644 --- a/packages/ql3-local-sqlite/src/profile/localProfile.ts +++ b/packages/ql3-local-sqlite/src/profile/localProfile.ts @@ -56,6 +56,7 @@ export type LocalProfileStorageBootstrapResult = readonly ownerPepper: LocalSqliteRuntimeDatabase['ownerPepper']; readonly pluginPackageInstalls: LocalSqliteRuntimeDatabase['pluginPackageInstalls']; readonly pluginPackageMaterializedRevisions: LocalSqliteRuntimeDatabase['pluginPackageMaterializedRevisions']; + readonly pluginPackageSecretBindings: LocalSqliteRuntimeDatabase['pluginPackageSecretBindings']; readonly pluginPackageTaskReconciliations: LocalSqliteRuntimeDatabase['pluginPackageTaskReconciliations']; readonly pluginPackageAutomationPublications: LocalSqliteRuntimeDatabase['pluginPackageAutomationPublications']; readonly projectToolDefinitionSnapshots: LocalSqliteRuntimeDatabase['projectToolDefinitionSnapshots']; @@ -151,6 +152,7 @@ export async function bootstrapLocalProfileStorage( pluginPackageInstalls: database.pluginPackageInstalls, pluginPackageMaterializedRevisions: database.pluginPackageMaterializedRevisions, + pluginPackageSecretBindings: database.pluginPackageSecretBindings, 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 5c947750..cf2ab232 100644 --- a/packages/ql3-local-sqlite/src/readiness/readiness.ts +++ b/packages/ql3-local-sqlite/src/readiness/readiness.ts @@ -2,13 +2,22 @@ import { auditMigrationStreamHistory } from '@qinglong/runtime-core/migration-st import type { DatabaseSync } from 'node:sqlite'; import { localSqliteMigrationManifest } from '../migration/migrationManifest'; import { LocalSqliteMigrationStreamStore } from '../migration/migrationStreamStore'; +import { LOCAL_PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGER_SQL } from '../plugin-package/pluginPackageSecretMaterializationSchemaContract'; 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 = 46; +export const LOCAL_SQLITE_CONTRACT_VERSION = 47; + +const PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGERS = Object.freeze([ + Object.freeze({ + name: 'ql3_plugin_package_secret_materialization_guard', + tableName: 'QingLong3PluginPackageMaterializedRevisions', + sql: LOCAL_PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGER_SQL, + }), +]); const PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS = Object.freeze([ Object.freeze({ @@ -1743,6 +1752,7 @@ function assertRequiredSchema(client: DatabaseSync): number { const expectedTriggers = [ ...LOCAL_STEP_RUN_REFERENCE_TRIGGERS, ...PLUGIN_PACKAGE_AUTOMATION_DISPOSITION_TRIGGERS, + ...PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGERS, ].sort((left, right) => left.name.localeCompare(right.name)); if ( triggerRows.length !== expectedTriggers.length || @@ -2554,10 +2564,10 @@ export async function auditLocalSqliteReadiness( capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME || capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION || capability.migration_id !== - '0091-plugin-package-secret-bindings' || + '0093-plugin-package-secret-materialization-guard' || 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_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_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 561c34b9..f394c2b1 100644 --- a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts +++ b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts @@ -29,6 +29,7 @@ import type { PluginPackageAutomationPublicationRecoverySource, } 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 { 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'; @@ -109,6 +110,7 @@ export interface LocalSqliteRuntimeDatabase { readonly ownerPepper: LocalOwnerPepperRepository; pluginPackageInstalls(): Promise; pluginPackageMaterializedRevisions(): Promise; + pluginPackageSecretBindings(): Promise; pluginPackageTaskReconciliations(): Promise< PluginPackageTaskReconciliationRepository & PluginPackageTaskPublicationRecoverySource @@ -195,6 +197,9 @@ export async function openLocalSqliteRuntimeDatabase( let pluginPackageMaterializedRevisionsPromise: | Promise | undefined; + let pluginPackageSecretBindingsPromise: + | Promise + | undefined; let pluginPackageTaskReconciliationsPromise: | Promise< PluginPackageTaskReconciliationRepository & @@ -271,6 +276,15 @@ export async function openLocalSqliteRuntimeDatabase( ); return pluginPackageMaterializedRevisionsPromise; }, + pluginPackageSecretBindings() { + pluginPackageSecretBindingsPromise ??= import( + '../plugin-package/pluginPackageSecretBindingRepository.js' + ).then( + ({ LocalSqlitePluginPackageSecretBindingRepository }) => + new LocalSqlitePluginPackageSecretBindingRepository(authority), + ); + return pluginPackageSecretBindingsPromise; + }, pluginPackageTaskReconciliations() { pluginPackageTaskReconciliationsPromise ??= import( '../plugin-package/pluginPackageTaskReconciliationRepository.js' diff --git a/packages/ql3-local-sqlite/test/database.test.cjs b/packages/ql3-local-sqlite/test/database.test.cjs index fdb86aa4..269f1bb1 100644 --- a/packages/ql3-local-sqlite/test/database.test.cjs +++ b/packages/ql3-local-sqlite/test/database.test.cjs @@ -142,9 +142,11 @@ test('creates a reviewed edge database and opens runtime only after readiness', '0090-capability-v45', '0091-plugin-package-secret-bindings', '0092-capability-v46', + '0093-plugin-package-secret-materialization-guard', + '0094-capability-v47', ]); assert.equal(migrated.readiness.contractName, 'local-control-core'); - assert.equal(migrated.readiness.contractVersion, 46); + assert.equal(migrated.readiness.contractVersion, 47); assert.equal(migrated.readiness.journalMode, 'delete'); assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600); @@ -228,6 +230,89 @@ test('creates a reviewed edge database and opens runtime only after readiness', } }); +test('rejects malformed and unbound Package Secret materialized revisions at the database boundary', async () => { + const client = new DatabaseSync(':memory:'); + try { + client.exec('PRAGMA foreign_keys = ON'); + await runMigrationStream({ + stream: localSqliteMigrationDefinition, + store: new LocalSqliteMigrationStreamStore(client), + }); + const insert = client.prepare( + `INSERT INTO "QingLong3PluginPackageMaterializedRevisions" ( + generation_digest, project_id, package_name, generation, + lock_digest, manifest_digest, revision_digest, revision_json, + created_at_ms + ) VALUES (?, 'default', 'secret-guard', 1, ?, ?, ?, ?, 1)`, + ); + const digest = 'a'.repeat(64); + const revision = ({ manifest, resources = [] }) => ({ + schema: 'qinglong/plugin-package-materialized-revision@v1', + generation: { + installationId: 'install-secret-guard', + projectId: 'default', + packageName: 'secret-guard', + lockDigest: digest, + generation: 1, + generationDigest: digest, + }, + manifestDigest: digest, + manifest, + resources, + revisionDigest: digest, + createdAtMs: 1, + }); + + assert.throws( + () => + insert.run( + digest, + digest, + digest, + digest, + JSON.stringify(revision({ manifest: {} })), + ), + /permission declarations are malformed/, + ); + assert.throws( + () => + insert.run( + digest, + digest, + digest, + digest, + JSON.stringify( + revision({ + manifest: { spec: { permissions: { secrets: [] } } }, + resources: [ + { + kind: 'task', + value: { + spec: { + config: { + environment: [ + { + name: 'TOKEN', + kind: 'secret', + secretRef: + 'qlsecret://default/runtime-token?version=1', + }, + ], + }, + }, + }, + }, + ], + }), + ), + ), + /outside Package binding/, + ); + } finally { + client.close(); + } +}); + test('standalone opts into bounded WAL while edge keeps rollback journal', async (t) => { const { databasePath } = fixture(t); const options = { databasePath, profile: 'standalone' }; @@ -507,8 +592,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy .get(), }, { - contract_version: 46, - migration_id: '0091-plugin-package-secret-bindings', + contract_version: 47, + migration_id: '0093-plugin-package-secret-materialization-guard', }, ); } finally { diff --git a/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionRepository.test.cjs index 34dc256b..1e15ba94 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, 46); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); }); 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, 46); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); }); 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 ca700f0b..107aef2e 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, 46); + assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 47); }); 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 7beb2f6b..a2b55acc 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, 46); - assert.equal(prepared.writeContractVersion, 46); - assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 46); + assert.equal(prepared.contractVersion, 47); + assert.equal(prepared.writeContractVersion, 47); + assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 47); 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/src/plugin-package/pluginPackageResourceMaterialization.ts b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts index 5523343f..d32328d0 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts +++ b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts @@ -23,6 +23,11 @@ import { type PluginPackageResourceKind, type PluginPackageResourceReference, } from './pluginPackageResourceGeneration'; +import { + assertPluginPackageSecretBindingMatches, + type PluginPackageSecretBinding, + type PluginPackageSecretBindingRepository, +} from './pluginPackageSecretBinding'; import { normalizeTaskDefinitionLabels, normalizeTaskDefinitionSpec, @@ -125,6 +130,7 @@ export interface PluginPackageMaterializedRevision { readonly lock: Readonly; readonly manifest: Readonly; readonly manifestDigest: string; + readonly secretBinding?: Readonly; readonly resources: readonly Readonly[]; readonly revisionDigest: string; } @@ -138,6 +144,7 @@ export interface MaterializePluginPackageResourcesInput { readonly generation: Readonly; readonly lock: Readonly; readonly manifestBytes: Uint8Array; + readonly secretBinding?: Readonly; readonly resources: readonly Readonly[]; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; } @@ -171,6 +178,10 @@ export interface MaterializeActivePluginPackageResourcesOptions { readonly generationSource: PluginPackageResourceGenerationSource; readonly lockSource: PluginPackageResourceLockSource; readonly byteSource: PluginPackageResourceByteSource; + readonly secretBindingSource?: Pick< + PluginPackageSecretBindingRepository, + 'find' + >; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; } @@ -498,6 +509,8 @@ function normalizeTaskResource( projectId: string, manifest: Readonly, registry: TaskSpecSemanticRegistry, + secretBinding: Readonly | undefined, + source: boolean, ): Readonly { const task = dataRecord(value, 'Task resource'); exactKeys( @@ -512,7 +525,7 @@ function normalizeTaskResource( const id = resourceId(task.id, 'Task id'); const taskId = taskIdentity(packageName, id); const kind = task.kind; - const spec = normalizeTaskDefinitionSpec(task.spec as TaskDefinitionSpec); + let spec = normalizeTaskDefinitionSpec(task.spec as TaskDefinitionSpec); if (kind !== 'command' || spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA) { return invalid( 'Task resource v1 only supports qinglong/command@v1 command tasks', @@ -526,6 +539,9 @@ function normalizeTaskResource( if (!(registry instanceof TaskSpecSemanticRegistry)) { return invalid('TaskSpec semantic registry is invalid'); } + if (source) { + spec = compilePackageSecretEnvironment(spec, secretBinding); + } let normalizedSpec: TaskDefinitionSpec; try { normalizedSpec = registry.normalize({ @@ -537,22 +553,7 @@ function normalizeTaskResource( } catch { return invalid('Task resource spec semantics are invalid or unsupported'); } - const environment = normalizedSpec.config.environment; - if ( - Array.isArray(environment) && - environment.some( - (entry) => - entry !== null && - typeof entry === 'object' && - !Array.isArray(entry) && - (entry as Readonly>).kind === - 'secret', - ) - ) { - return invalid( - 'Task resource v1 does not support unresolved package Secret bindings', - ); - } + assertResolvedPackageSecretEnvironment(normalizedSpec, secretBinding); const description = optionalDescription(task.description); if (typeof task.enabled !== 'boolean') { return invalid('Task enabled is invalid'); @@ -571,6 +572,78 @@ function normalizeTaskResource( }); } +function compilePackageSecretEnvironment( + spec: Readonly, + secretBinding: Readonly | undefined, +): Readonly { + const environment = spec.config.environment; + if (!Array.isArray(environment)) return spec; + const entries = new Map( + (secretBinding?.entries ?? []).map((entry) => [entry.name, entry]), + ); + const compiled = environment.flatMap((value) => { + const entry = dataRecord(value, 'Task environment'); + if (entry.kind === 'secret') { + return invalid('Package Task source cannot contain a direct SecretRef'); + } + if (entry.kind !== 'package-secret') return [value]; + exactKeys( + entry, + ['kind', 'name', 'requirement'], + [], + 'Package Secret environment', + ); + const requirement = boundedText( + entry.requirement, + 'Package Secret requirement', + 128, + ); + const binding = entries.get(requirement); + if (!binding) { + return invalid( + 'Package Task references an undeclared Secret requirement', + ); + } + if (binding.secretRef === null) return []; + return [ + Object.freeze({ + name: entry.name as TaskDefinitionJson, + kind: 'secret' as const, + secretRef: binding.secretRef, + }), + ]; + }); + return normalizeTaskDefinitionSpec( + Object.freeze({ + schema: spec.schema, + config: Object.freeze({ + ...spec.config, + environment: Object.freeze(compiled), + }), + }), + ); +} + +function assertResolvedPackageSecretEnvironment( + spec: Readonly, + secretBinding: Readonly | undefined, +): void { + const allowed = new Set( + (secretBinding?.entries ?? []).flatMap((entry) => + entry.secretRef === null ? [] : [entry.secretRef], + ), + ); + const environment = spec.config.environment; + if (!Array.isArray(environment)) return; + for (const value of environment) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const entry = value as Readonly>; + if (entry.kind === 'secret' && !allowed.has(entry.secretRef as string)) { + invalid('Task SecretRef is not authorized by the Package binding'); + } + } +} + function normalizeWorkflowResource( value: unknown, ): Readonly { @@ -784,6 +857,8 @@ function normalizeMaterializedValue( projectId: string, manifest: Readonly, registry: TaskSpecSemanticRegistry, + secretBinding: Readonly | undefined, + source: boolean, ): PluginPackageMaterializedResourceValue { switch (kind) { case 'task': @@ -793,6 +868,8 @@ function normalizeMaterializedValue( projectId, manifest, registry, + secretBinding, + source, ); case 'workflow': return normalizeWorkflowResource(value); @@ -877,6 +954,7 @@ function normalizeRevisionResources( generation: Readonly, manifest: Readonly, registry: TaskSpecSemanticRegistry, + secretBinding: Readonly | undefined, ): readonly Readonly[] { const entries = boundedDenseArray( values, @@ -918,6 +996,8 @@ function normalizeRevisionResources( generation.projectId, manifest, registry, + secretBinding, + false, ), }); }); @@ -972,7 +1052,7 @@ export function normalizePluginPackageMaterializedRevision( 'resources', 'revisionDigest', ], - [], + ['secretBinding'], 'materialized revision', ); if (revision.schema !== PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA) { @@ -994,11 +1074,17 @@ export function normalizePluginPackageMaterializedRevision( return invalid('materialized manifest identity or digest is invalid'); } assertManifestMatchesGeneration(manifest, generation); + const secretBinding = normalizeMaterializationSecretBinding( + revision.secretBinding as Readonly | undefined, + generation, + manifest, + ); const resources = normalizeRevisionResources( revision.resources, generation, manifest, taskSpecSemanticRegistry, + secretBinding, ); const descriptors: PluginPackageContentEntryDescriptor[] = resources .map((resource) => @@ -1020,6 +1106,7 @@ export function normalizePluginPackageMaterializedRevision( lock, manifest, manifestDigest, + ...(secretBinding === undefined ? {} : { secretBinding }), resources, }); const revisionDigest = digest(revision.revisionDigest, 'revision digest'); @@ -1042,7 +1129,7 @@ export function materializePluginPackageResources( 'resources', 'taskSpecSemanticRegistry', ], - [], + ['secretBinding'], 'materialization input', ); const generation = normalizePluginPackageResourceGeneration(value.generation); @@ -1050,6 +1137,11 @@ export function materializePluginPackageResources( assertGenerationMatchesLock(generation, lock); const manifest = normalizeManifestBytes(value.manifestBytes, lock); assertManifestMatchesGeneration(manifest, generation); + const secretBinding = normalizeMaterializationSecretBinding( + value.secretBinding, + generation, + manifest, + ); const entries = boundedDenseArray( value.resources, generation.resources.length, @@ -1095,6 +1187,8 @@ export function materializePluginPackageResources( generation.projectId, manifest, value.taskSpecSemanticRegistry, + secretBinding, + true, ), }); }); @@ -1119,6 +1213,7 @@ export function materializePluginPackageResources( lock, manifest, manifestDigest: lock.manifestDigest, + ...(secretBinding === undefined ? {} : { secretBinding }), resources: Object.freeze(resources), }); return normalizePluginPackageMaterializedRevision( @@ -1130,6 +1225,32 @@ export function materializePluginPackageResources( ); } +function normalizeMaterializationSecretBinding( + value: Readonly | undefined, + generation: Readonly, + manifest: Readonly, +): Readonly | undefined { + if (manifest.spec.permissions.secrets.length === 0) { + if (value !== undefined) { + invalid('Secret binding is forbidden when Manifest declares no Secrets'); + } + return undefined; + } + if (!manifest.spec.permissions.tools.includes('secret.use')) { + return invalid( + 'Secret-aware Package requires the approved secret.use permission', + ); + } + if (value === undefined) { + return invalid('Secret-aware Package requires an approved binding'); + } + try { + return assertPluginPackageSecretBindingMatches(value, generation, manifest); + } catch { + return invalid('Secret binding does not match generation and Manifest'); + } +} + function materializationSources( value: MaterializeActivePluginPackageResourcesOptions, ): void { @@ -1143,6 +1264,9 @@ function materializationSources( typeof value.lockSource.findLock !== 'function' || !value.byteSource || typeof value.byteSource.open !== 'function' || + (value.secretBindingSource !== undefined && + (!value.secretBindingSource || + typeof value.secretBindingSource.find !== 'function')) || !(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry) ) { invalid('active materialization sources are invalid'); @@ -1226,10 +1350,19 @@ export async function materializeActivePluginPackageResources( }); } } + const activeManifest = normalizeManifestBytes(manifestBytes, lock); + const activeSecretBindingValue = + activeManifest.spec.permissions.secrets.length === 0 + ? undefined + : await value.secretBindingSource?.find(generation.generationDigest); + const activeSecretBinding = activeSecretBindingValue ?? undefined; const revision = materializePluginPackageResources({ generation, lock, manifestBytes, + ...(activeSecretBinding === undefined + ? {} + : { secretBinding: activeSecretBinding }), resources: Object.freeze(resources), taskSpecSemanticRegistry: value.taskSpecSemanticRegistry, }); diff --git a/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts b/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts index a67b2bc6..5e0a9b95 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts +++ b/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts @@ -9,6 +9,7 @@ import { type PluginPackageResourceLockSource, } from './pluginPackageResourceMaterialization'; import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration'; +import type { PluginPackageSecretBindingRepository } from './pluginPackageSecretBinding'; import { InvalidPluginPackageTaskReconciliationError, PluginPackageTaskReconciliationConflictError, @@ -103,7 +104,10 @@ export class PluginPackageTaskPublicationConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_TASK_PUBLICATION_CONFLICT'; constructor(message: string, options?: ErrorOptions) { - super(`Plugin Package Task publication conflicts with state: ${message}`, options); + super( + `Plugin Package Task publication conflicts with state: ${message}`, + options, + ); this.name = 'PluginPackageTaskPublicationConflictError'; } } @@ -172,7 +176,11 @@ function packageName(value: unknown): string { return value; } -function positiveInteger(value: unknown, maximum: number, label: string): number { +function positiveInteger( + value: unknown, + maximum: number, + label: string, +): number { if ( !Number.isSafeInteger(value) || (value as number) < 1 || @@ -242,8 +250,7 @@ function normalizePage( if ( candidates.some( (candidate, index) => - index > 0 && - compareCandidates(candidates[index - 1]!, candidate) >= 0, + index > 0 && compareCandidates(candidates[index - 1]!, candidate) >= 0, ) ) { return invalid('recovery candidates must be uniquely sorted'); @@ -274,6 +281,10 @@ function publicationAuthorities(options: { readonly lockSource: PluginPackageResourceLockSource; readonly byteSource: PluginPackageResourceByteSource; readonly materializedRepository: PluginPackageMaterializedRevisionRepository; + readonly secretBindingSource?: Pick< + PluginPackageSecretBindingRepository, + 'find' + >; readonly reconciliationRepository: PluginPackageTaskReconciliationRepository; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; }): void { @@ -288,12 +299,13 @@ function publicationAuthorities(options: { 'reconciliationRepository', 'taskSpecSemanticRegistry', ], - [], + ['secretBindingSource'], 'publication coordinator options', ); if ( !options.generationSource || - typeof options.generationSource.findActiveResourceGeneration !== 'function' || + typeof options.generationSource.findActiveResourceGeneration !== + 'function' || !options.lockSource || typeof options.lockSource.findLock !== 'function' || !options.byteSource || @@ -301,6 +313,9 @@ function publicationAuthorities(options: { !options.materializedRepository || typeof options.materializedRepository.find !== 'function' || typeof options.materializedRepository.publish !== 'function' || + (options.secretBindingSource !== undefined && + (!options.secretBindingSource || + typeof options.secretBindingSource.find !== 'function')) || !options.reconciliationRepository || typeof options.reconciliationRepository.find !== 'function' || typeof options.reconciliationRepository.reconcile !== 'function' || @@ -316,6 +331,9 @@ export class PluginPackageTaskPublicationCoordinator { readonly #byteSource: PluginPackageResourceByteSource; readonly #materializedRepository: PluginPackageMaterializedRevisionRepository; readonly #reconciliationRepository: PluginPackageTaskReconciliationRepository; + readonly #secretBindingSource: + | Pick + | undefined; readonly #registry: TaskSpecSemanticRegistry; constructor(options: { @@ -323,6 +341,10 @@ export class PluginPackageTaskPublicationCoordinator { readonly lockSource: PluginPackageResourceLockSource; readonly byteSource: PluginPackageResourceByteSource; readonly materializedRepository: PluginPackageMaterializedRevisionRepository; + readonly secretBindingSource?: Pick< + PluginPackageSecretBindingRepository, + 'find' + >; readonly reconciliationRepository: PluginPackageTaskReconciliationRepository; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; }) { @@ -331,6 +353,7 @@ export class PluginPackageTaskPublicationCoordinator { this.#lockSource = options.lockSource; this.#byteSource = options.byteSource; this.#materializedRepository = options.materializedRepository; + this.#secretBindingSource = options.secretBindingSource; this.#reconciliationRepository = options.reconciliationRepository; this.#registry = options.taskSpecSemanticRegistry; } @@ -362,20 +385,28 @@ export class PluginPackageTaskPublicationCoordinator { ); let materialized: 'created' | 'existing' = 'existing'; if (revision === null) { - const materializedValue = await materializeActivePluginPackageResources({ - ...identity, - generationSource: this.#generationSource, - lockSource: this.#lockSource, - byteSource: this.#byteSource, - taskSpecSemanticRegistry: this.#registry, - }); + const materializedValue = await materializeActivePluginPackageResources( + { + ...identity, + generationSource: this.#generationSource, + lockSource: this.#lockSource, + byteSource: this.#byteSource, + ...(this.#secretBindingSource === undefined + ? {} + : { secretBindingSource: this.#secretBindingSource }), + taskSpecSemanticRegistry: this.#registry, + }, + ); if (materializedValue === null) { return Object.freeze({ status: 'superseded', generationDigest: first.generationDigest, }); } - if (materializedValue.generation.generationDigest !== first.generationDigest) { + if ( + materializedValue.generation.generationDigest !== + first.generationDigest + ) { return Object.freeze({ status: 'superseded', generationDigest: first.generationDigest, @@ -454,7 +485,9 @@ export class PluginPackageTaskPublicationCoordinator { error instanceof PluginPackageResourceMaterializationUnavailableError || error instanceof PluginPackageTaskReconciliationUnavailableError ) { - throw new PluginPackageTaskPublicationUnavailableError({ cause: error }); + throw new PluginPackageTaskPublicationUnavailableError({ + cause: error, + }); } throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, @@ -472,7 +505,12 @@ export class PluginPackageTaskPublicationRecoveryCoordinator { readonly publisher: PluginPackageTaskPublicationCoordinator; }) { const value = record(options, 'recovery coordinator options'); - exactKeys(value, ['publisher', 'source'], [], 'recovery coordinator options'); + exactKeys( + value, + ['publisher', 'source'], + [], + 'recovery coordinator options', + ); if ( !options.source || typeof options.source.listPendingPage !== 'function' || @@ -511,7 +549,8 @@ export class PluginPackageTaskPublicationRecoveryCoordinator { options.limit, ); } catch (error) { - if (error instanceof InvalidPluginPackageTaskPublicationError) throw error; + if (error instanceof InvalidPluginPackageTaskPublicationError) + throw error; throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); @@ -528,8 +567,8 @@ export class PluginPackageTaskPublicationRecoveryCoordinator { published.status === 'current' ? 'settled' : published.status === 'superseded' - ? 'superseded' - : 'manual_required'; + ? 'superseded' + : 'manual_required'; } catch (error) { status = error instanceof InvalidPluginPackageTaskPublicationError || @@ -595,7 +634,10 @@ export class PluginPackageTaskPublicationRecoveryCoordinator { } let probe: Readonly; try { - probe = normalizePage(await this.#source.listPendingPage({ limit: 1 }), 1); + probe = normalizePage( + await this.#source.listPendingPage({ limit: 1 }), + 1, + ); } catch (error) { throw new PluginPackageTaskPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, diff --git a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs index fe48a195..f6bf2e7d 100644 --- a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs @@ -23,6 +23,9 @@ const { createBuiltInTaskSpecSemanticRegistry, } = require('../dist/task-definition/taskSpecSemantic'); const { createSecretRef } = require('../dist/secret/secretReference'); +const { + createPluginPackageSecretBinding, +} = require('../dist/plugin-package/pluginPackageSecretBinding'); const { InvalidPluginPackageResourceMaterializationError, MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES, @@ -287,10 +290,7 @@ test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision, ); - const drafts = pluginPackageTaskDefinitionDrafts( - revision, - value.registry, - ); + const drafts = pluginPackageTaskDefinitionDrafts(revision, value.registry); assert.deepEqual(JSON.parse(JSON.stringify(drafts)), [ { projectId: 'project-001', @@ -366,9 +366,7 @@ test('fails closed on unapproved capabilities, unresolved references and source const drift = fixture(); const changed = drift.entries.map((entry, index) => - index === 0 - ? { ...entry, bytes: Buffer.from('{}') } - : entry, + index === 0 ? { ...entry, bytes: Buffer.from('{}') } : entry, ); assert.throws( () => @@ -383,8 +381,8 @@ test('fails closed on unapproved capabilities, unresolved references and source ); }); -test('rejects secret-bearing package Tasks until an approved binding format exists', () => { - const value = fixture({ +test('compiles approved Package Secret requirements into pinned Task SecretRefs', () => { + const direct = fixture({ manifest: { spec: { permissions: { @@ -427,13 +425,128 @@ test('rejects secret-bearing package Tasks until an approved binding format exis assert.throws( () => materializePluginPackageResources({ - generation: value.generation, - lock: value.lock, - manifestBytes: value.manifestBytes, - resources: value.entries, - taskSpecSemanticRegistry: value.registry, + generation: direct.generation, + lock: direct.lock, + manifestBytes: direct.manifestBytes, + resources: direct.entries, + taskSpecSemanticRegistry: direct.registry, }), - /does not support unresolved package Secret bindings/, + /requires an approved binding/, + ); + + const directBinding = createPluginPackageSecretBinding({ + generation: direct.generation, + manifest: direct.manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-001', + name: 'TOKEN', + version: 1, + }), + }, + ], + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: 'e'.repeat(64), + }, + boundAtMs: 200, + }); + assert.throws( + () => + materializePluginPackageResources({ + generation: direct.generation, + lock: direct.lock, + manifestBytes: direct.manifestBytes, + secretBinding: directBinding, + resources: direct.entries, + taskSpecSemanticRegistry: direct.registry, + }), + /cannot contain a direct SecretRef/, + ); + + const value = fixture({ + manifest: { + spec: { + permissions: { + secrets: [ + { name: 'OPTIONAL_TOKEN', required: false }, + { name: 'TOKEN', required: true }, + ], + tools: ['run.read', 'secret.use', 'system.command'], + }, + }, + }, + resourceValues: { + 'tasks/collect.json': { + schema: 'qinglong/plugin-package-task-resource@v1', + id: 'collect', + name: 'Collect', + labels: {}, + enabled: true, + kind: 'command', + spec: { + schema: 'qinglong/command@v1', + config: { + command: { + kind: 'argv', + file: '/usr/bin/printf', + args: ['ok'], + }, + environment: [ + { + name: 'OPTIONAL_TOKEN', + kind: 'package-secret', + requirement: 'OPTIONAL_TOKEN', + }, + { + name: 'API_TOKEN', + kind: 'package-secret', + requirement: 'TOKEN', + }, + ], + }, + }, + }, + }, + }); + const secretRef = createSecretRef({ + projectId: 'project-001', + name: 'runtime-token', + version: 3, + }); + const binding = createPluginPackageSecretBinding({ + generation: value.generation, + manifest: value.manifest, + assignments: [ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef }, + ], + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'e'.repeat(64), + }, + boundAtMs: 200, + }); + const revision = materializePluginPackageResources({ + generation: value.generation, + lock: value.lock, + manifestBytes: value.manifestBytes, + secretBinding: binding, + resources: value.entries, + taskSpecSemanticRegistry: value.registry, + }); + assert.equal(revision.secretBinding.bindingDigest, binding.bindingDigest); + assert.deepEqual( + JSON.parse( + JSON.stringify(revision.resources[1].value.spec.config.environment), + ), + [{ kind: 'secret', name: 'API_TOKEN', secretRef }], + ); + assert.deepEqual( + normalizePluginPackageMaterializedRevision(revision, value.registry), + revision, ); }); @@ -523,7 +636,10 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat }, byteSource: { async open(generation) { - assert.equal(generation.generationDigest, value.generation.generationDigest); + assert.equal( + generation.generationDigest, + value.generation.generationDigest, + ); return { async read(path, maximumBytes) { reads.push({ path, maximumBytes }); @@ -568,7 +684,11 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat return this.calls === 1 ? value.generation : changed; }, }, - lockSource: { async findLock() { return value.lock; } }, + lockSource: { + async findLock() { + return value.lock; + }, + }, byteSource: { async open() { return { @@ -588,10 +708,7 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat }); test('publishes materialization only through the explicit runtime-core subpath', () => { - assert.equal( - require('../dist').materializePluginPackageResources, - undefined, - ); + assert.equal(require('../dist').materializePluginPackageResources, undefined); assert.equal( require('@qinglong/runtime-core/plugin-package-resource-materialization') .materializePluginPackageResources, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index c4a15653..66d66864 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -74,12 +74,12 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( [ { directory: 'packages/ql3-cluster-postgres/src/migrations', - directSourceFiles: 60, + directSourceFiles: 61, reviewKind: 'ordered_ledger', }, { directory: 'packages/ql3-local-sqlite/src/migrations', - directSourceFiles: 93, + directSourceFiles: 95, reviewKind: 'ordered_ledger', }, ], @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 156, + sourceFiles: 157, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 155, + nestedSourceFiles: 156, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 184, + sourceFiles: 187, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 183, + nestedSourceFiles: 186, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );