From 8ba27dc148dd90725b03683742e7170dfa8eeb99 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 11:28:06 +0800 Subject: [PATCH] feat(ql3): add owner-confirmed package secret binding --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...confirmed-plugin-package-secret-binding.md | 42 ++ docs/adr/README.md | 1 + packages/ql3-local-admin/package.json | 5 + .../pluginPackageSecretBinding.ts | 8 + .../plugin-package/pluginPackageCommand.ts | 164 +++++- .../test/pluginPackageCommand.test.cjs | 320 ++++++++++- packages/ql3-local-sqlite/package.json | 11 +- .../secret-binding/administration.ts | 542 ++++++++++++++++++ .../repository.ts} | 26 +- .../src/runtime/runtimeDatabase.ts | 2 +- ...ginPackageSecretBindingRepository.test.cjs | 2 +- packages/ql3-runtime-core/package.json | 16 +- .../pluginPackageResourceMaterialization.ts | 2 +- .../pluginPackageTaskPublication.ts | 2 +- .../binding.ts} | 61 +- .../src/plugin-package/secret-binding/plan.ts | 168 ++++++ ...ginPackageResourceMaterialization.test.cjs | 2 +- .../test/pluginPackageSecretBinding.test.cjs | 2 +- .../pluginPackageSecretBindingPlan.test.cjs | 144 +++++ scripts/ql3-cluster-dependency-audit.cjs | 22 +- 21 files changed, 1503 insertions(+), 40 deletions(-) create mode 100644 docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md create mode 100644 packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts create mode 100644 packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts rename packages/ql3-local-sqlite/src/plugin-package/{pluginPackageSecretBindingRepository.ts => secret-binding/repository.ts} (92%) rename packages/ql3-runtime-core/src/plugin-package/{pluginPackageSecretBinding.ts => secret-binding/binding.ts} (92%) create mode 100644 packages/ql3-runtime-core/src/plugin-package/secret-binding/plan.ts create mode 100644 packages/ql3-runtime-core/test/pluginPackageSecretBindingPlan.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 195dabee..1b78b181 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -28,6 +28,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-306B1/ADR-0395(进行中):先完成当前 active、尚未绑定 Package generation 的首次 Secret binding,不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建,调用方不能注入 generation 或 digest。Local 已复用短生命周期 `ql3-package` 私有 command-file 和单 SQLite authority:强 `local-console` User 必须同时具备 `secret.manage` 与当前 Project Owner,human confirmation 后在一个 `BEGIN IMMEDIATE` transaction 内复验 Owner fence、Package provenance、精确 Secret version、content-free audit 与 immutable binding;plan 后撤权原子回滚,exact replay 收敛,不同 audit identity 或当前代重复绑定失败关闭。未新增 package、migration、表、索引、依赖、进程、连接、listener、timer、watcher 或 cache。针对包粒度复审确认 18 个 workspace package 不按 LOC 机械合并;D-306 contract/plan 与 SQLite repository/administration 分别收敛为 owning-domain 两文件目录,公开 subpath 不变、无 wrapper。Cluster separation-of-duty Approved Action、PostgreSQL durable plan/proposal 与 HA 证据仍未完成,所以 ADR 保持 Proposed;rebind/rotation/revocation 继续属于必须创建新 generation 的 D-306B2。 - 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-0395-owner-confirmed-plugin-package-secret-binding.md b/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md new file mode 100644 index 00000000..d55ba1df --- /dev/null +++ b/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md @@ -0,0 +1,42 @@ +# ADR-0395:Owner 确认的 Plugin Package Secret 首次绑定 + +- 状态:Proposed +- 日期:2026-08-13 +- 关联 RFC:QL-RFC-0001 D-306B1 +- 关联 ADR:ADR-0364、ADR-0393、ADR-0394 + +## 问题 + +D-305 已提供按 Package generation 不可变的 Secret binding ledger,D-306A 已让 materialization 消费该 binding,但部署者仍缺少受支持的产品入口。直接开放 repository 会绕过 User、Project Policy、Owner confirmation、Secret version existence 与当前 generation fence;让既有 generation 原地改绑则会使同一 materialized revision 在不同时间指向不同 Secret。 + +QingLong 3.0 同时面向低配路由设备和集群节点。本机产品入口不能引入常驻进程、第二条 SQLite 连接、watcher、timer 或 cache;Cluster 后续入口必须复用 separation-of-duty Approved Action,而不是复制 Local Owner authority。 + +## 决策 + +1. D-306B 拆成两个可独立验证的阶段:B1 只允许当前 active、尚未绑定 generation 的首次 binding;B2 才通过新 Package generation 完成 rebind、rotation 与 revocation。任何阶段都不得更新或删除历史 binding。 +2. B1 使用共享、content-free 的 `qinglong/plugin-package-secret-binding-plan@v1`。服务端从当前 installation、admission proposal、lock、Manifest 与 resource generation 重建目标和 canonical entries,调用方不能提交 generation、Manifest digest、lock digest 或 binding digest。 +3. Local 入口复用短生命周期 `ql3-package` 私有 command-file 链路。plan 与 execute 都要求强 `local-console` User、`secret.manage` 和当前 Project `owner`;execute 在 human confirmation 后进入既有单 SQLite operation authority,并在同一 `BEGIN IMMEDIATE` transaction 内重新验证 Owner fence、当前 generation、Package provenance、精确 Secret version existence、content-free audit 与 binding publish。 +4. plan 只包含 SecretRef 元数据,不包含 Secret 明文;响应只返回目标摘要、canonical entry、plan/binding digest 与 created/existing 状态。required requirement 不能为 `null`,optional requirement 可以显式为 `null`,所有非空引用必须同 Project且固定 version。 +5. plan 后撤销 Owner 必须使 execute 回滚且不留下 audit/binding。完全相同的 plan 与 audit identity 可以 exact replay;同一当前 generation 的第二次 bind/rebind、不同 audit identity 或任何 provenance drift 必须失败关闭,并提示通过新 generation 执行 B2。 +6. workspace package 继续按部署制品、authority、依赖隔离、adapter 与多消费者裁决,不按文件数合并。D-306 的 Runtime contract/plan 归入同包 `plugin-package/secret-binding/{binding,plan}`,SQLite repository/administration 归入同包 `plugin-package/secret-binding/{repository,administration}`;稳定公开 subpath 不变,不保留旧物理路径 wrapper,也不为单个 plan 新增微型 package。 +7. Local 实现不新增 package、migration、表、索引、依赖、进程、listener、连接、timer、watcher 或 cache。Cluster B1 仍须以 Approved Action、package-manager/package-executor 最小权限和 PostgreSQL HA 证据完成后,本 ADR 才能进入 Accepted。 + +## 明确不在 B1 中完成 + +- 不允许对已经绑定的 generation 原地 rebind、rotate 或 revoke。 +- 不读取、解密、输出或复制 Secret value。 +- 不让常驻 Local application、Cluster Control 或 Worker 获得管理 authority。 +- 不以 Local Owner confirmation 替代 Cluster separation-of-duty Approved Action。 + +## 验证 + +- Runtime contract:plan canonicalization、digest/shape drift、Local/Cluster authority 派生与稳定 package subpath。 +- Local 产品纵切面:真实 Package proposal/approval/activation,缺失 Secret version 拒绝,plan 后撤权原子回滚,恢复 Owner 后首次 publish、exact replay、重复 bind 与 audit identity drift 拒绝。 +- 完整 18-package clean build/test;需要本机 TLS listener 的 Worker 测试在允许回环监听的等价环境复跑。 +- package/dependency/edge/service-manager/local-image 审计与 GitNexus compare-to-`develop` 变更影响检查。 + +## 当前证据 + +- Runtime Core、Local SQLite、Local Owner CLI closure type-check 已通过;Local 产品定向纵切面 3/3。 +- 18-package clean build/test 中除受限沙箱禁止绑定 `127.0.0.1` 导致的 Worker TLS 三项外,其余已执行测试均为 0 fail;同一 Worker 完整包在允许回环监听的环境为 133/133。 +- Cluster Approved Action、PostgreSQL proposal/plan ledger、最小权限、真库与 physical HA 证据仍待 B1 后半段完成,因此本 ADR 保持 Proposed。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 4bd10ea9..93c86bf1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -398,6 +398,7 @@ | [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 | +| [ADR-0395](./ADR-0395-owner-confirmed-plugin-package-secret-binding.md) | Owner 确认的 Plugin Package Secret 首次绑定 | Proposed | ## 规则 diff --git a/packages/ql3-local-admin/package.json b/packages/ql3-local-admin/package.json index 7e2c4556..88348bfd 100644 --- a/packages/ql3-local-admin/package.json +++ b/packages/ql3-local-admin/package.json @@ -75,6 +75,11 @@ "require": "./dist/plugin-package/pluginPackageLifecycle.js", "default": "./dist/plugin-package/pluginPackageLifecycle.js" }, + "./package-secret-binding": { + "types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts", + "require": "./dist/plugin-package/pluginPackageSecretBinding.js", + "default": "./dist/plugin-package/pluginPackageSecretBinding.js" + }, "./package-recovery-catalog": { "types": "./dist/plugin-package/pluginPackageRecoveryCatalog.d.ts", "require": "./dist/plugin-package/pluginPackageRecoveryCatalog.js", diff --git a/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts b/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts new file mode 100644 index 00000000..5f257148 --- /dev/null +++ b/packages/ql3-local-admin/src/plugin-package/pluginPackageSecretBinding.ts @@ -0,0 +1,8 @@ +export { + createLocalPluginPackageSecretBindingService, + LocalPluginPackageSecretBindingConflictError, + LocalPluginPackageSecretBindingUnavailableError, + type ExecuteLocalPluginPackageSecretBindingRequest, + type LocalPluginPackageSecretBindingService, + type PlanLocalPluginPackageSecretBindingRequest, +} from '@qinglong/local-sqlite/plugin-package-secret-binding-administration'; 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 0b9a65e9..ae1595c6 100644 --- a/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts +++ b/packages/ql3-local-owner-cli/src/plugin-package/pluginPackageCommand.ts @@ -26,6 +26,10 @@ import { type PluginPackageLifecycleImpact, type PluginPackageLifecycleReceipt, } from '@qinglong/runtime-core/plugin-package-lifecycle'; +import { + normalizePluginPackageSecretBindingPlan, + type PluginPackageSecretBindingPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-plan'; import { pluginPackageInstallRecoveryAction, type PluginPackageInstallActionInput, @@ -35,6 +39,7 @@ import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin import { createLocalPluginPackageManagementService } from '@qinglong/local-admin/package-management'; import { createLocalPluginPackageLifecycleService } from '@qinglong/local-admin/package-lifecycle'; +import { createLocalPluginPackageSecretBindingService } from '@qinglong/local-admin/package-secret-binding'; const MAX_PATH_BYTES = 4096; const MAX_DISPATCH_LIMIT = 64; @@ -177,6 +182,30 @@ export interface ExecuteLocalPluginPackageLifecycleCommand { }; } +export interface PlanLocalPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.plan'; + readonly options: LocalPluginPackageCommandOptions; + readonly request: { + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly<{ + name: string; + secretRef: string | null; + }>[]; + }; +} + +export interface ExecuteLocalPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.execute'; + readonly options: LocalPluginPackageCommandOptions; + readonly request: { + readonly plan: PluginPackageSecretBindingPlan; + readonly auditEventId: string; + }; +} + export type LocalPluginPackageCommand = | ProposeLocalPluginPackageCommand | DecideLocalPluginPackageCommand @@ -186,7 +215,9 @@ export type LocalPluginPackageCommand = | ListLocalPluginPackageInstallationsCommand | DispatchLocalPluginPackageCommand | PlanLocalPluginPackageLifecycleCommand - | ExecuteLocalPluginPackageLifecycleCommand; + | ExecuteLocalPluginPackageLifecycleCommand + | PlanLocalPluginPackageSecretBindingCommand + | ExecuteLocalPluginPackageSecretBindingCommand; export interface LocalPluginPackageCommandRunner { run( @@ -251,6 +282,19 @@ export type LocalPluginPackageCommandResult = status: 'created' | 'existing'; approval: ReturnType; receipt: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.plan'; + plan: Readonly; + summary: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.execute'; + status: 'created' | 'existing'; + bindingDigest: string; + generationDigest: string; }>; export class LocalPluginPackageCommandConfigurationError extends TypeError { @@ -558,6 +602,65 @@ function normalizeCommand(value: unknown): Readonly { ); } break; + case 'plugin-package.secret-binding.plan': + exactObject( + value.request, + ['assignments', 'packageName', 'projectId'], + 'Secret binding plan request', + ); + if ( + typeof value.request.projectId !== 'string' || + !PROJECT_ID_PATTERN.test(value.request.projectId) || + typeof value.request.packageName !== 'string' || + !PACKAGE_NAME_PATTERN.test(value.request.packageName) || + !Array.isArray(value.request.assignments) || + value.request.assignments.length < 1 || + value.request.assignments.length > 64 + ) { + throw new LocalPluginPackageCommandConfigurationError( + 'Secret binding plan request is invalid', + ); + } + for (const assignment of value.request.assignments) { + exactObject(assignment, ['name', 'secretRef'], 'Secret assignment'); + if ( + typeof assignment.name !== 'string' || + !/^[A-Z_][A-Z0-9_]{0,127}$/.test(assignment.name) || + (assignment.secretRef !== null && + (typeof assignment.secretRef !== 'string' || + Buffer.byteLength(assignment.secretRef, 'utf8') > 512)) + ) { + throw new LocalPluginPackageCommandConfigurationError( + 'Secret assignment is invalid', + ); + } + } + break; + case 'plugin-package.secret-binding.execute': + exactObject( + value.request, + ['auditEventId', 'plan'], + 'Secret binding execution request', + ); + if ( + typeof value.request.auditEventId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value.request.auditEventId, + ) + ) { + throw new LocalPluginPackageCommandConfigurationError( + 'Secret binding audit event ID is invalid', + ); + } + try { + normalizePluginPackageSecretBindingPlan(value.request.plan); + } catch (error) { + throw new LocalPluginPackageCommandConfigurationError( + 'Secret binding plan is invalid', + error, + ); + } + break; default: throw new LocalPluginPackageCommandConfigurationError( 'operation is invalid', @@ -714,8 +817,7 @@ function lifecycleReceiptSummary( disposition: receipt.lifecycle.disposition, capabilityStatus: receipt.capability.status, taskTransitions: receipt.capability.taskTransitions.length, - previousActiveVectorDigest: - receipt.capability.previousActiveVectorDigest, + previousActiveVectorDigest: receipt.capability.previousActiveVectorDigest, currentActiveVectorDigest: receipt.capability.currentActiveVectorDigest, currentToolSnapshotDigest: receipt.capability.currentToolSnapshotDigest, retainedSourceCount: receipt.capability.retainedSourceCount, @@ -724,12 +826,68 @@ function lifecycleReceiptSummary( }); } +function secretBindingPlanSummary( + plan: Readonly, +) { + return Object.freeze({ + projectId: plan.target.projectId, + packageName: plan.target.packageName, + installationId: plan.target.installationId, + generation: plan.target.generation, + generationDigest: plan.target.generationDigest, + manifestDigest: plan.target.manifestDigest, + assignments: Object.freeze( + plan.entries.map((entry) => + Object.freeze({ + name: entry.name, + required: entry.required, + bound: entry.secretRef !== null, + secretRef: entry.secretRef, + }), + ), + ), + plannedAtMs: plan.plannedAtMs, + planDigest: plan.planDigest, + }); +} + async function execute( command: Readonly, database: LocalSqlitePluginPackageManagementDatabase, authenticated: Readonly, ): Promise> { await authenticated.confirm(); + if (command.operation === 'plugin-package.secret-binding.plan') { + const service = createLocalPluginPackageSecretBindingService( + database.authority, + ); + const plan = await service.plan({ + ...command.request, + principal: authenticated.principal, + plannedAtMs: Date.now(), + }); + return Object.freeze({ + schemaVersion: 1, + operation: command.operation, + plan, + summary: secretBindingPlanSummary(plan), + }); + } + if (command.operation === 'plugin-package.secret-binding.execute') { + const service = createLocalPluginPackageSecretBindingService( + database.authority, + ); + const result = await service.execute({ + ...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/pluginPackageCommand.test.cjs b/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs index eba009ed..3929c244 100644 --- a/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs +++ b/packages/ql3-local-owner-cli/test/pluginPackageCommand.test.cjs @@ -82,6 +82,7 @@ const { const { createPluginPackageResourceGeneration, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); const { materializePluginPackageResources, } = require('@qinglong/runtime-core/plugin-package-resource-materialization'); @@ -200,7 +201,7 @@ function packageArtifact(manifest) { ]); } -function actionInput() { +function actionInput(secretAware = false) { const manifest = { apiVersion: PLUGIN_PACKAGE_API_VERSION, kind: PLUGIN_PACKAGE_KIND, @@ -224,8 +225,10 @@ function actionInput() { }, permissions: { network: { allowedHosts: [] }, - secrets: [], - tools: ['system.command'], + secrets: secretAware ? [{ name: 'TOKEN', required: true }] : [], + tools: secretAware + ? ['secret.use', 'system.command'] + : ['system.command'], }, contents: { tasks: ['tasks/collect.json'], @@ -507,6 +510,46 @@ async function activatePackageAutomation(databasePath, lock, manifest) { } } +async function activatePackageOnly(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', + 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)); + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: 'secret-binding-start-package', + occurredAtMs: staged.updatedAtMs + 1, + }); + await installs.commit(pluginPackageInstallCommit(staged, activating)); + const active = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: 'secret-binding-commit-package', + occurredAtMs: activating.updatedAtMs + 1, + activationRef: `activation:${lock.lockDigest}`, + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: lock.targetGeneration, + contentDigest: lock.source.contentDigest, + }); + await installs.commit(pluginPackageInstallCommit(activating, active)); + return { active, lock }; + } finally { + await authority.close(); + } +} + function publisherTrustRunnerWithOneSnapshotFault() { let injectFault = true; return createLocalPluginPackagePublisherTrustCommandRunner({ @@ -1348,6 +1391,277 @@ test('runs the private command-file package lifecycle with replay-safe IDs', asy assertNoSensitiveMaterial(installationList); }); +test('plans and atomically binds a versioned Secret to the current Package generation', async (t) => { + const value = await fixture(t); + const input = actionInput(true); + const actionRef = 'proposal:secret-aware-cli-monitor-v1'; + const approvalRequestId = 'approval-secret-aware-cli-monitor-v1'; + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.propose', + { + actionRef, + approvalRequestId, + proposalAuditEventId: '21000000-0000-4000-8000-000000000001', + approvalAuditEventId: '21000000-0000-4000-8000-000000000002', + actionInput: input, + }, + 'secret-01-propose', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.decide', + { + actionRef, + approvalRequestId, + expectedVersion: 1, + decisionId: 'decision-secret-aware-cli-monitor-v1', + auditEventId: '21000000-0000-4000-8000-000000000003', + decision: 'approved', + reasonCode: 'reviewed', + }, + 'secret-02-decide', + ), + ); + await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.consume', + { + actionRef, + approvalRequestId, + expectedVersion: 2, + consumptionId: 'consume-secret-aware-cli-monitor-v1', + dispatchId: 'dispatch-secret-aware-cli-monitor-v1', + auditEventId: '21000000-0000-4000-8000-000000000004', + }, + 'secret-03-consume', + ), + ); + const dispatched = await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.dispatch', + { limit: 1 }, + 'secret-04-dispatch', + ), + ); + assert.equal(dispatched.summary.succeeded, 1); + const { active } = await activatePackageOnly( + value.databasePath, + 'default', + 'cli-monitor', + ); + const secretRef = createSecretRef({ + projectId: 'default', + name: 'runtime-token', + version: 1, + }); + await assert.rejects( + runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.plan', + { + projectId: 'default', + packageName: 'cli-monitor', + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'default', + name: 'runtime-token', + version: 2, + }), + }, + ], + }, + 'secret-05-missing-version', + ), + ), + /version is unavailable/, + ); + const secretDatabase = new DatabaseSync(value.databasePath); + try { + secretDatabase + .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', + 'runtime-token', + 'secret-aware-fixture-v1', + 'fixture-key-v1', + Buffer.alloc(12, 1), + Buffer.from('ciphertext'), + Buffer.alloc(16, 2), + Date.now(), + ); + } finally { + secretDatabase.close(); + } + + const planResult = await runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.plan', + { + projectId: 'default', + packageName: 'cli-monitor', + assignments: [{ name: 'TOKEN', secretRef }], + }, + 'secret-05-plan', + ), + ); + assert.equal(planResult.summary.installationId, active.installationId); + assert.equal(planResult.summary.generation, 1); + assert.deepEqual(planResult.summary.assignments, [ + { name: 'TOKEN', required: true, bound: true, secretRef }, + ]); + assertNoSensitiveMaterial(planResult); + + const revokeDatabase = new DatabaseSync(value.databasePath); + try { + revokeDatabase + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", + "role", "mutation_id", "changed_by_type", "changed_by_id", + "created_at_ms" + ) VALUES ( + 'default', 'user', 'owner-user', 2, 'revoked', NULL, + 'secret-binding-revoke-owner', 'user', 'owner-user', ? + )`, + ) + .run(Date.now()); + } finally { + revokeDatabase.close(); + } + const executePath = commandFile( + value, + 'plugin-package.secret-binding.execute', + { + plan: planResult.plan, + auditEventId: '21000000-0000-4000-8000-000000000005', + }, + 'secret-06-execute', + ); + await assert.rejects( + runLocalPluginPackageCommandFile(executePath), + /denies Secret management|Project policy changed/, + ); + const restoreDatabase = new DatabaseSync(value.databasePath); + try { + assert.equal( + restoreDatabase + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3PluginPackageSecretBindings"`, + ) + .get().count, + 0, + ); + assert.equal( + restoreDatabase + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3SecurityAuditEvents" + WHERE "operation_id" = 'plugin_package.secret.bind'`, + ) + .get().count, + 0, + ); + restoreDatabase + .prepare( + `INSERT INTO "QingLong3ProjectRoleBindings" ( + "project_id", "subject_type", "subject_id", "version", "state", + "role", "mutation_id", "changed_by_type", "changed_by_id", + "created_at_ms" + ) VALUES ( + 'default', 'user', 'owner-user', 3, 'active', 'owner', + 'secret-binding-restore-owner', 'user', 'owner-user', ? + )`, + ) + .run(Date.now()); + } finally { + restoreDatabase.close(); + } + + const created = await runLocalPluginPackageCommandFile(executePath); + assert.equal(created.status, 'created'); + assert.equal( + created.generationDigest, + planResult.plan.target.generationDigest, + ); + assert.match(created.bindingDigest, /^[0-9a-f]{64}$/); + assertNoSensitiveMaterial(created); + const replay = await runLocalPluginPackageCommandFile(executePath); + assert.equal(replay.status, 'existing'); + assert.equal(replay.bindingDigest, created.bindingDigest); + + await assert.rejects( + runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.plan', + { + projectId: 'default', + packageName: 'cli-monitor', + assignments: [{ name: 'TOKEN', secretRef }], + }, + 'secret-07-rebind-current-generation', + ), + ), + /rebind requires a new generation/, + ); + await assert.rejects( + runLocalPluginPackageCommandFile( + commandFile( + value, + 'plugin-package.secret-binding.execute', + { + plan: planResult.plan, + auditEventId: '21000000-0000-4000-8000-000000000006', + }, + 'secret-08-different-audit', + ), + ), + /another audit identity/, + ); + + const inspection = new DatabaseSync(value.databasePath, { readOnly: true }); + try { + assert.equal( + inspection + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3PluginPackageSecretBindings"`, + ) + .get().count, + 1, + ); + assert.equal( + inspection + .prepare( + `SELECT COUNT(*) AS count + FROM "QingLong3SecurityAuditEvents" + WHERE "operation_id" = 'plugin_package.secret.bind'`, + ) + .get().count, + 1, + ); + } finally { + inspection.close(); + } +}); + 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 424ffc06..fa71d141 100644 --- a/packages/ql3-local-sqlite/package.json +++ b/packages/ql3-local-sqlite/package.json @@ -86,9 +86,14 @@ "default": "./dist/plugin-package/pluginPackageMaterializedRevisionRepository.js" }, "./plugin-package-secret-binding": { - "types": "./dist/plugin-package/pluginPackageSecretBindingRepository.d.ts", - "require": "./dist/plugin-package/pluginPackageSecretBindingRepository.js", - "default": "./dist/plugin-package/pluginPackageSecretBindingRepository.js" + "types": "./dist/plugin-package/secret-binding/repository.d.ts", + "require": "./dist/plugin-package/secret-binding/repository.js", + "default": "./dist/plugin-package/secret-binding/repository.js" + }, + "./plugin-package-secret-binding-administration": { + "types": "./dist/plugin-package/secret-binding/administration.d.ts", + "require": "./dist/plugin-package/secret-binding/administration.js", + "default": "./dist/plugin-package/secret-binding/administration.js" }, "./plugin-package-task-reconciliation": { "types": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.d.ts", 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 new file mode 100644 index 00000000..69979473 --- /dev/null +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/administration.ts @@ -0,0 +1,542 @@ +import type { DatabaseSync } from 'node:sqlite'; + +import { + normalizePluginPackageInstallProposal, + type PluginPackageInstallProposal, +} from '@qinglong/runtime-core/plugin-package-proposal'; +import { + normalizePluginPackageLock, + normalizePluginPackageInstallRecord, + type PluginPackageInstallRecord, + type PluginPackageLock, +} from '@qinglong/runtime-core/plugin-package-install'; +import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import { + createPluginPackageSecretBindingFromPlan, + createPluginPackageSecretBindingPlan, + normalizePluginPackageSecretBindingPlan, + type PluginPackageSecretBindingPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-plan'; +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'; +import { + normalizeSecurityPrincipal, + type SecurityPolicyFence, +} from '@qinglong/runtime-core/security'; +import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; + +import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority'; +import { LocalSqliteProjectPolicyRepository } from '../../security/projectPolicyRepository'; +import { + insertLocalSecurityAudit, + localSecurityAuditFromRow, + LOCAL_SECURITY_AUDIT_SELECT, + sameSecurityAuditSemantic, +} from '../../security/securityPersistence'; +import { LocalSqlitePluginPackageSecretBindingRepository } from './repository'; + +type Row = Record; + +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export interface PlanLocalPluginPackageSecretBindingRequest { + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly<{ + name: string; + secretRef: string | null; + }>[]; + readonly principal: SecurityPrincipal; + readonly plannedAtMs: number; +} + +export interface ExecuteLocalPluginPackageSecretBindingRequest { + readonly plan: PluginPackageSecretBindingPlan; + readonly auditEventId: string; + readonly principal: SecurityPrincipal; + readonly confirmAuthorization: () => void | Promise; +} + +export interface LocalPluginPackageSecretBindingService { + plan( + request: PlanLocalPluginPackageSecretBindingRequest, + ): Promise>; + execute(request: ExecuteLocalPluginPackageSecretBindingRequest): Promise< + Readonly<{ + status: 'created' | 'existing'; + bindingDigest: string; + generationDigest: string; + }> + >; +} + +export class LocalPluginPackageSecretBindingConflictError extends Error { + readonly code = 'LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_CONFLICT'; + + constructor(message: string) { + super( + `Local Plugin Package Secret binding conflicts with state: ${message}`, + ); + this.name = 'LocalPluginPackageSecretBindingConflictError'; + } +} + +export class LocalPluginPackageSecretBindingUnavailableError extends Error { + readonly code = 'LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_UNAVAILABLE'; + + constructor(options?: ErrorOptions) { + super('Local Plugin Package Secret binding is unavailable', options); + this.name = 'LocalPluginPackageSecretBindingUnavailableError'; + } +} + +function rowText(row: Row, key: string): string { + const value = row[key]; + if (typeof value !== 'string') + throw new LocalPluginPackageSecretBindingUnavailableError(); + return value; +} + +function identity(value: unknown, label: string, pattern: RegExp): string { + if (typeof value !== 'string' || !pattern.test(value)) { + throw new TypeError(`${label} is invalid`); + } + return value; +} + +function timestamp(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${label} is invalid`); + } + return value as number; +} + +function loadCurrent( + client: DatabaseSync, + projectId: string, + packageName: string, +): Readonly<{ + record: PluginPackageInstallRecord; + lock: PluginPackageLock; + proposal: PluginPackageInstallProposal; +}> { + const row = client + .prepare( + `SELECT install.record_json AS "recordJson", + install.lock_json AS "lockJson", + proposal.proposal_json AS "proposalJson" + 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 + LEFT JOIN "QingLong3PluginPackageQuarantineEvents" AS quarantine + ON quarantine.project_id = install.project_id + AND quarantine.package_name = install.package_name + AND quarantine.installation_id = install.installation_id + AND quarantine.lock_digest = install.lock_digest + LEFT JOIN "QingLong3PluginPackageLifecycleHeads" AS lifecycle + ON lifecycle.project_id = install.project_id + AND lifecycle.package_name = install.package_name + AND lifecycle.installation_id = install.installation_id + AND lifecycle.lock_digest = install.lock_digest + WHERE head.project_id = ? + AND head.package_name = ? + AND install.state = 'active' + AND install.active_lock_digest = install.lock_digest + AND quarantine.event_digest IS NULL + AND COALESCE(lifecycle.disposition, 'active') = 'active' + LIMIT 2`, + ) + .all(projectId, packageName) as Row[]; + if (row.length !== 1) { + throw new LocalPluginPackageSecretBindingConflictError( + 'current active Package authority is absent or ambiguous', + ); + } + try { + const record = normalizePluginPackageInstallRecord( + JSON.parse(rowText(row[0]!, 'recordJson')), + ); + const lock = normalizePluginPackageLock( + JSON.parse(rowText(row[0]!, 'lockJson')), + ); + const proposal = normalizePluginPackageInstallProposal( + JSON.parse(rowText(row[0]!, 'proposalJson')), + ); + if ( + record.projectId !== projectId || + record.packageName !== packageName || + record.lockDigest !== lock.lockDigest || + proposal.actionDigest !== lock.approval.actionDigest || + proposal.previewDigest !== lock.approval.previewDigest || + proposal.actionInput.projectId !== projectId || + proposal.actionInput.manifest.metadata.name !== packageName || + proposal.actionInput.targetGeneration !== record.targetGeneration || + proposal.actionInput.source.contentDigest !== lock.source.contentDigest || + proposal.actionInput.manifest.metadata.version !== + record.packageVersion || + proposal.actionInput.manifest.spec.permissions.secrets.length === 0 || + !proposal.actionInput.manifest.spec.permissions.tools.includes( + 'secret.use', + ) + ) { + throw new Error('current Package provenance drift'); + } + return Object.freeze({ record, lock, proposal }); + } catch (error) { + if (error instanceof LocalPluginPackageSecretBindingConflictError) + throw error; + throw new LocalPluginPackageSecretBindingUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } +} + +function generationFrom(current: ReturnType) { + const { record, lock } = current; + return 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, + }); +} + +function auditRecord( + plan: Readonly, + eventId: string, + principal: Readonly, + fence: Readonly, + occurredAtMs: number, +): Readonly { + return Object.freeze({ + eventId, + requestId: `package_secret_binding:${plan.planDigest}`, + operationId: 'plugin_package.secret.bind', + projectId: plan.target.projectId, + subject: principal.subject, + authenticationId: principal.authenticationId, + outcome: 'allowed', + reasons: Object.freeze(['owner_confirmed_secret_binding']), + fence, + occurredAtMs, + }); +} + +function verifySecretVersions( + client: DatabaseSync, + plan: Readonly, +): void { + for (const entry of plan.entries) { + if (entry.secretRef === null) continue; + const reference = parseSecretRef(entry.secretRef); + if (reference.version === undefined) { + throw new LocalPluginPackageSecretBindingConflictError( + `Secret ${entry.name} reference is not version-pinned`, + ); + } + const row = client + .prepare( + `SELECT 1 AS present + FROM "QingLong3LocalSecretEnvelopes" + WHERE "project_id" = ? AND "secret_name" = ? AND "version" = ? + LIMIT 2`, + ) + .all(reference.projectId, reference.name, reference.version) as Row[]; + if (row.length !== 1) { + throw new LocalPluginPackageSecretBindingConflictError( + `Secret ${entry.name} version is unavailable`, + ); + } + } +} + +function verifyPolicyFence( + client: DatabaseSync, + principal: Readonly, + fence: Readonly, + projectId: string, +): void { + const row = client + .prepare( + `SELECT project."status" AS "status", + project."version" AS "projectVersion", + binding."version" AS "bindingVersion", + binding."state" AS "bindingState", + binding."role" AS "role" + FROM "QingLong3Projects" AS project + LEFT JOIN "QingLong3ProjectRoleBindings" AS binding + ON binding."project_id" = project."id" + AND binding."subject_type" = ? + AND binding."subject_id" = ? + AND binding."version" = ( + SELECT MAX(current."version") + FROM "QingLong3ProjectRoleBindings" AS current + WHERE current."project_id" = project."id" + AND current."subject_type" = ? + AND current."subject_id" = ? + ) + WHERE project."id" = ? + LIMIT 2`, + ) + .all( + principal.subject.type, + principal.subject.id, + principal.subject.type, + principal.subject.id, + projectId, + ) as Row[]; + const value = row[0]; + if ( + row.length !== 1 || + !value || + value.status !== 'active' || + value.projectVersion !== fence.projectVersion || + value.bindingVersion !== fence.bindingVersion || + value.bindingState !== 'active' || + value.role !== 'owner' + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'Project policy changed after planning', + ); + } +} + +export function createLocalPluginPackageSecretBindingService( + authorityValue: LocalSqliteOperationAuthority | DatabaseSync, + now: () => number = Date.now, +): Readonly { + const authority = + authorityValue instanceof LocalSqliteOperationAuthority + ? authorityValue + : new LocalSqliteOperationAuthority(authorityValue); + const policy = new ProjectPolicyEngine( + new LocalSqliteProjectPolicyRepository(authority), + ); + const bindings = new LocalSqlitePluginPackageSecretBindingRepository( + authority, + ); + + const authorize = async ( + principalValue: SecurityPrincipal, + projectId: string, + observedAtMs: number, + ) => { + const principal = normalizeSecurityPrincipal(principalValue, observedAtMs); + if ( + principal.subject.type !== 'user' || + principal.assurance !== 'local_console' + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'binding requires a local-console User', + ); + } + const decision = await policy.authorize( + principal, + projectId, + 'secret.manage', + ); + if (decision.effect !== 'allow' || decision.fence === null) { + throw new LocalPluginPackageSecretBindingConflictError( + 'current Project policy denies Secret management', + ); + } + return Object.freeze({ principal, fence: decision.fence }); + }; + + return Object.freeze({ + async plan(request: PlanLocalPluginPackageSecretBindingRequest) { + 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 current = loadCurrent(authority.client, projectId, packageName); + const generation = generationFrom(current); + if (bindings.findInTransaction(generation.generationDigest)) { + throw new LocalPluginPackageSecretBindingConflictError( + 'current generation is already bound; rebind requires a new generation', + ); + } + const plan = createPluginPackageSecretBindingPlan({ + generation, + manifest: current.proposal.actionInput.manifest, + assignments: request.assignments, + plannedAtMs, + }); + verifySecretVersions(authority.client, plan); + return plan; + }, + () => new LocalPluginPackageSecretBindingUnavailableError(), + ); + }, + + async execute(request: ExecuteLocalPluginPackageSecretBindingRequest) { + const plan = normalizePluginPackageSecretBindingPlan(request.plan); + if (typeof request.confirmAuthorization !== 'function') { + throw new TypeError('confirmAuthorization is invalid'); + } + const observedAtMs = timestamp(now(), 'binding execution clock'); + const authorization = await authorize( + request.principal, + plan.target.projectId, + observedAtMs, + ); + await request.confirmAuthorization(); + return authority.enqueue( + async () => { + authority.client.exec('BEGIN IMMEDIATE'); + try { + const current = loadCurrent( + authority.client, + plan.target.projectId, + plan.target.packageName, + ); + const expected = createPluginPackageSecretBindingPlan({ + generation: generationFrom(current), + manifest: current.proposal.actionInput.manifest, + assignments: plan.entries.map(({ name, secretRef }) => ({ + name, + secretRef, + })), + plannedAtMs: plan.plannedAtMs, + }); + if (expected.planDigest !== plan.planDigest) { + throw new LocalPluginPackageSecretBindingConflictError( + 'current Package generation changed after planning', + ); + } + verifyPolicyFence( + authority.client, + authorization.principal, + authorization.fence, + plan.target.projectId, + ); + verifySecretVersions(authority.client, plan); + const audit = auditRecord( + plan, + identity(request.auditEventId, 'audit event ID', UUID), + authorization.principal, + authorization.fence, + observedAtMs, + ); + const auditsForPlan = authority.client + .prepare( + `SELECT ${LOCAL_SECURITY_AUDIT_SELECT} + FROM "QingLong3SecurityAuditEvents" + WHERE "request_id" = ? AND "operation_id" = ? + LIMIT 2`, + ) + .all(audit.requestId, audit.operationId) as Row[]; + if ( + auditsForPlan.length > 1 || + (auditsForPlan.length === 1 && + rowText(auditsForPlan[0]!, 'eventId') !== audit.eventId) + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'binding plan already has another audit identity', + ); + } + 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 existingBinding = bindings.findInTransaction( + plan.target.generationDigest, + ); + const result = existingBinding + ? (() => { + if ( + existingBinding.authority.kind !== + 'local-owner-confirmation' || + existingBinding.authority.evidenceDigest !== + plan.planDigest || + JSON.stringify(existingBinding.target) !== + JSON.stringify(plan.target) || + JSON.stringify(existingBinding.entries) !== + JSON.stringify(plan.entries) + ) { + throw new LocalPluginPackageSecretBindingConflictError( + 'current generation is already bound by another plan', + ); + } + return Object.freeze({ + status: 'existing' as const, + binding: existingBinding, + }); + })() + : bindings.publishInTransaction( + createPluginPackageSecretBindingFromPlan( + plan, + 'local-owner-confirmation', + observedAtMs, + ), + ); + authority.client.exec('COMMIT'); + return Object.freeze({ + status: result.status, + bindingDigest: result.binding.bindingDigest, + generationDigest: result.binding.target.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/pluginPackageSecretBindingRepository.ts b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/repository.ts similarity index 92% rename from packages/ql3-local-sqlite/src/plugin-package/pluginPackageSecretBindingRepository.ts rename to packages/ql3-local-sqlite/src/plugin-package/secret-binding/repository.ts index 6d01e513..e9c2a4a3 100644 --- a/packages/ql3-local-sqlite/src/plugin-package/pluginPackageSecretBindingRepository.ts +++ b/packages/ql3-local-sqlite/src/plugin-package/secret-binding/repository.ts @@ -10,7 +10,7 @@ import { type PluginPackageSecretBindingRepository, } from '@qinglong/runtime-core/plugin-package-secret-binding'; -import { LocalSqliteOperationAuthority } from '../authority/operationAuthority'; +import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority'; type Row = Record; @@ -143,6 +143,16 @@ export class LocalSqlitePluginPackageSecretBindingRepository return row ? this.parse(row) : null; } + findInTransaction( + digest: string, + ): Readonly | null { + try { + return this.findStored(generationDigest(digest)); + } catch (error) { + throw mapStorageError(error); + } + } + private enqueue(work: () => T): Promise { return this.authority.enqueue( async () => { @@ -169,9 +179,17 @@ export class LocalSqlitePluginPackageSecretBindingRepository binding: Readonly; }> > { + const binding = normalizePluginPackageSecretBinding(value); + return this.enqueue(() => this.publishInTransaction(binding)); + } + + publishInTransaction(value: Readonly): Readonly<{ + status: 'created' | 'existing'; + binding: Readonly; + }> { const binding = normalizePluginPackageSecretBinding(value); const bindingJson = serialize(binding); - return this.enqueue(() => { + try { const existing = this.findStored(binding.target.generationDigest); if (existing) { if (JSON.stringify(existing) !== bindingJson) { @@ -245,6 +263,8 @@ export class LocalSqlitePluginPackageSecretBindingRepository result.changes === 1 ? ('created' as const) : ('existing' as const), binding: stored, }); - }); + } catch (error) { + throw mapStorageError(error); + } } } diff --git a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts index f394c2b1..6963cb17 100644 --- a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts +++ b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts @@ -278,7 +278,7 @@ export async function openLocalSqliteRuntimeDatabase( }, pluginPackageSecretBindings() { pluginPackageSecretBindingsPromise ??= import( - '../plugin-package/pluginPackageSecretBindingRepository.js' + '../plugin-package/secret-binding/repository.js' ).then( ({ LocalSqlitePluginPackageSecretBindingRepository }) => new LocalSqlitePluginPackageSecretBindingRepository(authority), diff --git a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs index bdbc475c..bf7f5cf2 100644 --- a/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs +++ b/packages/ql3-local-sqlite/test/pluginPackageSecretBindingRepository.test.cjs @@ -13,7 +13,7 @@ const { const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); const { LocalSqlitePluginPackageSecretBindingRepository, -} = require('../dist/plugin-package/pluginPackageSecretBindingRepository'); +} = require('../dist/plugin-package/secret-binding/repository'); const { migrateLocalSqliteDatabase } = require('../dist/migration/migration'); const LOCK_DIGEST = 'a'.repeat(64); diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index 682d9b93..72116529 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -84,7 +84,10 @@ "dist/plugin-package/pluginPackageResourceMaterialization.d.ts" ], "plugin-package-secret-binding": [ - "dist/plugin-package/pluginPackageSecretBinding.d.ts" + "dist/plugin-package/secret-binding/binding.d.ts" + ], + "plugin-package-secret-binding-plan": [ + "dist/plugin-package/secret-binding/plan.d.ts" ], "plugin-package-task-reconciliation": [ "dist/plugin-package/pluginPackageTaskReconciliation.d.ts" @@ -375,9 +378,14 @@ "default": "./dist/plugin-package/pluginPackageResourceMaterialization.js" }, "./plugin-package-secret-binding": { - "types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts", - "require": "./dist/plugin-package/pluginPackageSecretBinding.js", - "default": "./dist/plugin-package/pluginPackageSecretBinding.js" + "types": "./dist/plugin-package/secret-binding/binding.d.ts", + "require": "./dist/plugin-package/secret-binding/binding.js", + "default": "./dist/plugin-package/secret-binding/binding.js" + }, + "./plugin-package-secret-binding-plan": { + "types": "./dist/plugin-package/secret-binding/plan.d.ts", + "require": "./dist/plugin-package/secret-binding/plan.js", + "default": "./dist/plugin-package/secret-binding/plan.js" }, "./plugin-package-task-reconciliation": { "types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts", diff --git a/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts index d32328d0..1826fa49 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts +++ b/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts @@ -27,7 +27,7 @@ import { assertPluginPackageSecretBindingMatches, type PluginPackageSecretBinding, type PluginPackageSecretBindingRepository, -} from './pluginPackageSecretBinding'; +} from './secret-binding/binding'; import { normalizeTaskDefinitionLabels, normalizeTaskDefinitionSpec, diff --git a/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts b/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts index 5e0a9b95..67d0b2b6 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts +++ b/packages/ql3-runtime-core/src/plugin-package/pluginPackageTaskPublication.ts @@ -9,7 +9,7 @@ import { type PluginPackageResourceLockSource, } from './pluginPackageResourceMaterialization'; import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration'; -import type { PluginPackageSecretBindingRepository } from './pluginPackageSecretBinding'; +import type { PluginPackageSecretBindingRepository } from './secret-binding/binding'; import { InvalidPluginPackageTaskReconciliationError, PluginPackageTaskReconciliationConflictError, diff --git a/packages/ql3-runtime-core/src/plugin-package/pluginPackageSecretBinding.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts similarity index 92% rename from packages/ql3-runtime-core/src/plugin-package/pluginPackageSecretBinding.ts rename to packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts index c01c52b5..da9f6972 100644 --- a/packages/ql3-runtime-core/src/plugin-package/pluginPackageSecretBinding.ts +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts @@ -5,13 +5,13 @@ import { normalizePluginPackageManifest, type PluginPackageManifest, type PluginPackageSecretRequirement, -} from './pluginPackage'; -import { pluginPackageManifestDigest } from './installation/pluginPackageInstall'; +} from '../pluginPackage'; +import { pluginPackageManifestDigest } from '../installation/pluginPackageInstall'; import { normalizePluginPackageResourceGeneration, type PluginPackageResourceGeneration, -} from './pluginPackageResourceGeneration'; -import { parseSecretRef } from '../secret/secretReference'; +} from '../pluginPackageResourceGeneration'; +import { parseSecretRef } from '../../secret/secretReference'; export const PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA = 'qinglong/plugin-package-secret-binding@v1' as const; @@ -67,6 +67,13 @@ export interface CreatePluginPackageSecretBindingInput { readonly boundAtMs: number; } +export interface CreatePluginPackageSecretBindingFromEntriesInput { + readonly target: Readonly; + readonly entries: readonly Readonly[]; + readonly authority: Readonly; + readonly boundAtMs: number; +} + export interface PluginPackageSecretBindingRepository { find( generationDigest: string, @@ -408,19 +415,24 @@ export function createPluginPackageSecretBinding( return Object.freeze({ ...unsigned, bindingDigest: bindingDigest(unsigned) }); } -export function normalizePluginPackageSecretBinding( - value: unknown, +export function createPluginPackageSecretBindingFromEntries( + input: CreatePluginPackageSecretBindingFromEntriesInput, ): Readonly { - const binding = dataRecord(value, 'binding'); - exactKeys( - binding, - ['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'], - 'binding', - ); - if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) { - return invalid('schema is unsupported'); - } - const targetValue = dataRecord(binding.target, 'target'); + const target = normalizeTarget(input.target); + const entries = normalizeEntries(input.entries, target.projectId); + const authority = normalizeAuthority(input.authority); + const boundAtMs = timestamp(input.boundAtMs); + const unsigned = unsignedBinding(target, entries, authority, boundAtMs); + return normalizePluginPackageSecretBinding({ + ...unsigned, + bindingDigest: bindingDigest(unsigned), + }); +} + +function normalizeTarget( + value: unknown, +): Readonly { + const targetValue = dataRecord(value, 'target'); exactKeys( targetValue, [ @@ -434,7 +446,7 @@ export function normalizePluginPackageSecretBinding( ], 'target', ); - const target = Object.freeze({ + return Object.freeze({ installationId: identifier(targetValue.installationId, 'installation ID'), projectId: identifier(targetValue.projectId, 'Project ID'), packageName: packageName(targetValue.packageName), @@ -443,6 +455,21 @@ export function normalizePluginPackageSecretBinding( generationDigest: digest(targetValue.generationDigest, 'generation digest'), manifestDigest: digest(targetValue.manifestDigest, 'Manifest digest'), }); +} + +export function normalizePluginPackageSecretBinding( + value: unknown, +): Readonly { + const binding = dataRecord(value, 'binding'); + exactKeys( + binding, + ['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'], + 'binding', + ); + if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) { + return invalid('schema is unsupported'); + } + const target = normalizeTarget(binding.target); const entries = normalizeEntries(binding.entries, target.projectId); const authority = normalizeAuthority(binding.authority); const boundAtMs = timestamp(binding.boundAtMs); diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/plan.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/plan.ts new file mode 100644 index 00000000..6f14edbc --- /dev/null +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/plan.ts @@ -0,0 +1,168 @@ +import { createHash } from 'node:crypto'; + +import { + createPluginPackageSecretBinding, + createPluginPackageSecretBindingFromEntries, + type PluginPackageSecretBinding, + type PluginPackageSecretBindingAssignment, + type PluginPackageSecretBindingEntry, + type PluginPackageSecretBindingTarget, +} from './binding'; +import type { PluginPackageManifest } from '../pluginPackage'; +import type { PluginPackageResourceGeneration } from '../pluginPackageResourceGeneration'; + +export const PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA = + 'qinglong/plugin-package-secret-binding-plan@v1' as const; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES = 64 * 1024; + +export interface PluginPackageSecretBindingPlan { + readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA; + readonly target: Readonly; + readonly entries: readonly Readonly[]; + readonly plannedAtMs: number; + readonly planDigest: string; +} + +export interface CreatePluginPackageSecretBindingPlanInput { + readonly generation: Readonly; + readonly manifest: Readonly; + readonly assignments: readonly Readonly[]; + readonly plannedAtMs: number; +} + +const DIGEST = /^[0-9a-f]{64}$/; +const PLACEHOLDER_EVIDENCE_DIGEST = '0'.repeat(64); +const PLAN_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-secret-binding-plan-digest@v1\0', + 'utf8', +); + +function invalid(message: string): never { + throw new TypeError( + `Plugin Package Secret binding plan is invalid: ${message}`, + ); +} + +function exactKeys(value: object, expected: readonly string[]): void { + const actual = Object.keys(value).sort(); + const canonical = [...expected].sort(); + if ( + actual.length !== canonical.length || + actual.some((key, index) => key !== canonical[index]) + ) { + invalid('shape is invalid'); + } +} + +function unsignedPlan( + binding: Pick, + plannedAtMs: number, +): Omit { + if (!Number.isSafeInteger(plannedAtMs) || plannedAtMs < 0) { + return invalid('plannedAtMs is invalid'); + } + return Object.freeze({ + schema: PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA, + target: binding.target, + entries: binding.entries, + plannedAtMs, + }); +} + +function planDigest( + value: Omit, +): string { + return createHash('sha256') + .update(PLAN_DIGEST_DOMAIN) + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function withDigest( + value: Omit, +): Readonly { + const normalized = Object.freeze({ + ...value, + planDigest: planDigest(value), + }); + if ( + Buffer.byteLength(JSON.stringify(normalized), 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES + ) { + return invalid('durable JSON byte budget exceeded'); + } + return normalized; +} + +export function createPluginPackageSecretBindingPlan( + input: CreatePluginPackageSecretBindingPlanInput, +): Readonly { + const draft = createPluginPackageSecretBinding({ + generation: input.generation, + manifest: input.manifest, + assignments: input.assignments, + authority: Object.freeze({ + kind: 'local-owner-confirmation', + evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST, + }), + boundAtMs: 0, + }); + return withDigest(unsignedPlan(draft, input.plannedAtMs)); +} + +export function normalizePluginPackageSecretBindingPlan( + value: unknown, +): Readonly { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalid('value must be an object'); + } + exactKeys(value, [ + 'entries', + 'planDigest', + 'plannedAtMs', + 'schema', + 'target', + ]); + const candidate = value as PluginPackageSecretBindingPlan; + if (candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA) { + return invalid('schema is unsupported'); + } + if ( + typeof candidate.planDigest !== 'string' || + !DIGEST.test(candidate.planDigest) + ) { + return invalid('plan digest is invalid'); + } + const normalizedBinding = createPluginPackageSecretBindingFromEntries({ + target: candidate.target, + entries: candidate.entries, + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST, + }, + boundAtMs: 0, + }); + const unsigned = unsignedPlan(normalizedBinding, candidate.plannedAtMs); + const normalized = withDigest(unsigned); + if (normalized.planDigest !== candidate.planDigest) { + return invalid('plan digest does not match content'); + } + return normalized; +} + +export function createPluginPackageSecretBindingFromPlan( + planValue: unknown, + authorityKind: PluginPackageSecretBinding['authority']['kind'], + boundAtMs: number, +): Readonly { + const plan = normalizePluginPackageSecretBindingPlan(planValue); + return createPluginPackageSecretBindingFromEntries({ + target: plan.target, + entries: plan.entries, + authority: { + kind: authorityKind, + evidenceDigest: plan.planDigest, + }, + boundAtMs, + }); +} diff --git a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs index f6bf2e7d..105998d7 100644 --- a/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageResourceMaterialization.test.cjs @@ -25,7 +25,7 @@ const { const { createSecretRef } = require('../dist/secret/secretReference'); const { createPluginPackageSecretBinding, -} = require('../dist/plugin-package/pluginPackageSecretBinding'); +} = require('../dist/plugin-package/secret-binding/binding'); const { InvalidPluginPackageResourceMaterializationError, MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES, diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBinding.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBinding.test.cjs index f2a17a13..34362ee9 100644 --- a/packages/ql3-runtime-core/test/pluginPackageSecretBinding.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBinding.test.cjs @@ -7,7 +7,7 @@ const { assertPluginPackageSecretBindingMatches, createPluginPackageSecretBinding, normalizePluginPackageSecretBinding, -} = require('../dist/plugin-package/pluginPackageSecretBinding'); +} = require('../dist/plugin-package/secret-binding/binding'); const { createPluginPackageResourceGeneration, } = require('../dist/plugin-package/pluginPackageResourceGeneration'); diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingPlan.test.cjs new file mode 100644 index 00000000..6f96f903 --- /dev/null +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingPlan.test.cjs @@ -0,0 +1,144 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createPluginPackageSecretBindingFromPlan, + createPluginPackageSecretBindingPlan, + normalizePluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); + +const manifest = { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret binding plan fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['edge'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [ + { name: 'OPTIONAL_TOKEN', required: false }, + { name: 'TOKEN', required: true }, + ], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, +}; + +const generation = createPluginPackageResourceGeneration({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: manifest.spec.contents, +}); + +function plan() { + return createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 100, + }); +} + +test('creates one canonical content-free Secret binding plan', () => { + const value = plan(); + assert.match(value.planDigest, /^[0-9a-f]{64}$/); + assert.equal(value.plannedAtMs, 100); + assert.deepEqual(normalizePluginPackageSecretBindingPlan(value), value); + assert.equal(JSON.stringify(value).includes('secret-value'), false); + assert.equal(Object.isFrozen(value), true); +}); + +test('rejects target, entry, time and digest drift', () => { + const value = plan(); + assert.throws( + () => + normalizePluginPackageSecretBindingPlan({ ...value, plannedAtMs: -1 }), + /plannedAtMs is invalid/, + ); + assert.throws( + () => + normalizePluginPackageSecretBindingPlan({ + ...value, + target: { ...value.target, projectId: 'project-2' }, + }), + /crosses Project boundary|plan digest/, + ); + assert.throws( + () => normalizePluginPackageSecretBindingPlan({ ...value, extra: true }), + /shape is invalid/, + ); + assert.throws( + () => + normalizePluginPackageSecretBindingPlan({ + ...value, + planDigest: 'c'.repeat(64), + }), + /plan digest does not match/, + ); +}); + +test('creates Local and Cluster authority bindings from the same plan', () => { + const value = plan(); + const local = createPluginPackageSecretBindingFromPlan( + value, + 'local-owner-confirmation', + 110, + ); + const cluster = createPluginPackageSecretBindingFromPlan( + value, + 'approved-action-execution', + 120, + ); + assert.equal(local.authority.evidenceDigest, value.planDigest); + assert.equal(cluster.authority.evidenceDigest, value.planDigest); + assert.equal(local.boundAtMs, 110); + assert.equal(cluster.boundAtMs, 120); + assert.notEqual(local.bindingDigest, cluster.bindingDigest); +}); + +test('exports the plan contract only through its explicit subpath', () => { + assert.equal( + require('../dist').createPluginPackageSecretBindingPlan, + undefined, + ); + assert.equal( + require('@qinglong/runtime-core/plugin-package-secret-binding-plan') + .createPluginPackageSecretBindingPlan, + createPluginPackageSecretBindingPlan, + ); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index c10f3d03..8d53ef72 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -1844,6 +1844,11 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/local-sqlite/task-definition-administration', ].includes(specifier) ) && + !( + path.relative(packageDirectory, filePath) === + 'src/plugin-package/pluginPackageCommand.ts' && + specifier === '@qinglong/local-admin/package-secret-binding' + ) && ![ { file: 'src/plugin-package/plugin-package-workflow-command/contractAuthority.ts', @@ -2042,7 +2047,11 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/local-sqlite/operation-authority', '@qinglong/local-sqlite/plugin-package-lifecycle', '@qinglong/local-sqlite/project-policy', - ].includes(specifier)) + ].includes(specifier)) || + (path.relative(packageDirectory, filePath) === + 'src/plugin-package/pluginPackageSecretBinding.ts' && + specifier === + '@qinglong/local-sqlite/plugin-package-secret-binding-administration') ) ) { findings.push({ @@ -2831,6 +2840,17 @@ function auditSourceImports(root, packagePath, findings) { ) { continue; } + if ( + packagePath === 'packages/ql3-local-owner-cli' && + path.relative(packageDirectory, filePath) === + 'src/plugin-package/pluginPackageCommand.ts' && + [ + '@qinglong/local-admin/package-secret-binding', + '@qinglong/runtime-core/plugin-package-secret-binding-plan', + ].includes(specifier) + ) { + continue; + } if ( packagePath === 'packages/ql3-local-owner-cli' && path.relative(packageDirectory, filePath) ===