From 7016903fba834236509edc024cb06310c63de253 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 14:23:41 +0800 Subject: [PATCH] feat(ql3): complete cluster secret binding authority --- .../base/cron-job.yaml | 10 + .../cloudnative-pg/cron-job-patch.yaml | 2 + .../config.example.yaml | 10 + docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- ...confirmed-plugin-package-secret-binding.md | 5 +- packages/ql3-cluster-admin/package.json | 15 + .../executor/pluginPackageApprovedAction.ts | 17 +- .../executor/pluginPackageExecutorProcess.ts | 59 ++ ...ginPackageSecretBindingApprovalConsumer.ts | 177 +++++ ...luginPackageSecretBindingApprovedAction.ts | 172 +++++ .../pluginPackageSecretBindingManagement.ts | 663 ++++++++++++++++++ .../ql3-cluster-admin/test/bootstrap.test.cjs | 2 + .../test/pluginPackageApprovedAction.test.cjs | 3 + .../pluginPackageExecutorProcess.test.cjs | 7 + .../test/pluginPackageRecovery.test.cjs | 3 + ...kageSecretBindingApprovalConsumer.test.cjs | 237 +++++++ ...ackageSecretBindingApprovedAction.test.cjs | 254 +++++++ ...ginPackageSecretBindingManagement.test.cjs | 381 ++++++++++ ...SecretBindingPostgres.integration.test.cjs | 471 +++++++++++++ .../test/application.test.cjs | 1 + .../test/bootstrap.test.cjs | 1 + .../src/entrypoints/packageExecutor.ts | 2 + .../src/entrypoints/packageManager.ts | 2 + .../pluginPackageSecretBindingRepository.ts | 5 +- ...kageSecretBindingApprovalPlanRepository.ts | 55 ++ ...cretBindingApprovalPlanRepository.test.cjs | 65 ++ ...ginPackageSecretBindingRepository.test.cjs | 18 + scripts/ql3-cluster-dependency-audit.cjs | 6 + scripts/ql3-cluster-deployment-audit.cjs | 20 +- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 30 files changed, 2660 insertions(+), 9 deletions(-) create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.ts create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovalConsumer.test.cjs create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovedAction.test.cjs create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml index a4e69c42..1676b7e3 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml @@ -65,6 +65,8 @@ spec: value: '16' - name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES value: '16' + - name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT + value: /var/run/secrets/qinglong3/plugin-package-values - name: QL3_POSTGRES_TLS_MODE value: verify-full - name: QL3_POSTGRES_TLS_CA_FILE @@ -96,6 +98,9 @@ spec: - name: postgres-package-executor-ca mountPath: /var/run/secrets/qinglong3/postgres-package-executor readOnly: true + - name: plugin-package-values + mountPath: /var/run/secrets/qinglong3/plugin-package-values + readOnly: true volumes: - name: tmp emptyDir: @@ -108,3 +113,8 @@ spec: items: - key: postgres-ca.crt path: ca.crt + - name: plugin-package-values + secret: + secretName: ql3-cluster-plugin-package-values + optional: true + defaultMode: 288 diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml index a4d6bf52..b2610bd0 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml @@ -17,6 +17,8 @@ value: '16' - name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES value: '16' + - name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT + value: /var/run/secrets/qinglong3/plugin-package-values - name: QL3_POSTGRES_TLS_MODE value: verify-full - name: QL3_POSTGRES_TLS_CA_FILE diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml index 2eac4f97..044c9369 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml @@ -10,3 +10,13 @@ stringData: postgres-package-executor-url: REPLACE_WITH_PACKAGE_EXECUTOR_DSN postgres-tls-servername: REPLACE_WITH_POSTGRES_DNS_NAME postgres-ca.crt: REPLACE_WITH_POSTGRES_CA_CERTIFICATE +--- +# Optional SecretRef projection. Each key is the lowercase SHA-256 of the +# canonical qlsecret:v1 reference; the executor checks metadata only. +apiVersion: v1 +kind: Secret +metadata: + name: ql3-cluster-plugin-package-values + namespace: qinglong3-system +type: Opaque +data: {} diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 1b78b181..975ebf41 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -28,7 +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-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 或当前代重复绑定失败关闭。Cluster 核心 authority 已补齐 package-manager `plan→propose→separation-of-duty decide`、package-executor 有界 approved queue consumer、Approved Action handler 与 immutable PostgreSQL binding publish;executor 只对可选 read-only Kubernetes Secret 投影执行 metadata-only existence check,不读取 Secret value,缺投影失败关闭。PostgreSQL 18 三角色真实纵切面完整执行 install/activate→plan/propose/decide→consume/dispatch/bind 并发现、修复了 binding INSERT placeholder typmod 缺失;cluster-admin/cluster-postgres 最终全包 638 pass/2 条件 skip,完整 18-package 串行门禁与 backend 1,188 pass/2 条件 skip/0 fail 均通过,package/dependency/deployment/CloudNativePG 审计零 finding。最新 PostgreSQL 18.4 arm64 physical HA 通过 125 gate、timeline `1→2`,报告 SHA-256 为 `7d54b2194420f9f14919aec7b67d744ba51ef5f3e3b681bbda8b44cc00e4f3f6`。未新增 package、migration、表、索引、依赖、常驻进程、连接、listener、timer、watcher 或 cache;18 个 workspace package 仍无 single-source/shallow-source package。Cluster Secret binding 的产品 HTTP/CLI transport、Kubernetes live exercise 与 B2 rebind/rotation/revocation 仍待完成,所以 ADR 保持 Proposed。 - 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 index d55ba1df..bcc498d8 100644 --- a/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md @@ -19,7 +19,7 @@ QingLong 3.0 同时面向低配路由设备和集群节点。本机产品入口 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。 +7. Local 实现不新增 package、migration、表、索引、依赖、进程、listener、连接、timer、watcher 或 cache。Cluster B1 复用 package-manager/package-executor 最小权限:manager 从数据库快照重建 plan 并提交 separation-of-duty Approval,短生命周期 executor 有界消费 approved queue、复验 requester `secret.manage` fence、执行 Approved Action,再以 metadata-only projected Secret existence proof 发布不可变 binding。产品 HTTP/CLI transport 与 Kubernetes live exercise 完成后,本 ADR 才能进入 Accepted。 ## 明确不在 B1 中完成 @@ -39,4 +39,5 @@ QingLong 3.0 同时面向低配路由设备和集群节点。本机产品入口 - 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。 +- Cluster 核心 authority 已完成:package-manager durable plan/proposal/decision、package-executor 有界 consumer/dispatch/handler、只读可选 Secret 投影与 immutable binding publish。`cluster-admin` 与 `cluster-postgres` 最终全包合计 638 pass/2 条件 skip;PostgreSQL 18 三角色真实纵切面 1/1,覆盖真实 install/activate、plan/propose/decide、consume/dispatch/bind、exact replay、manager 对 binding 表拒绝和 content-free 断言,并发现、修复 INSERT placeholder typmod 缺失。 +- package boundary、cluster dependency、cluster deployment、CloudNativePG 审计零 finding;workspace 仍为 18 包且没有 single-source/shallow-source package。完整 18-package 串行测试与 backend 1,188 pass/2 条件 skip/0 fail 均通过。PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 已通过,报告 SHA-256 为 `7d54b2194420f9f14919aec7b67d744ba51ef5f3e3b681bbda8b44cc00e4f3f6`。Cluster Secret binding 产品 HTTP/CLI transport 与 Kubernetes live exercise 仍待完成,因此本 ADR 保持 Proposed。 diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index 05da5488..4137fbcf 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -255,6 +255,21 @@ "require": "./dist/plugin-package/secret-binding/projectedSecretExistenceInspector.js", "default": "./dist/plugin-package/secret-binding/projectedSecretExistenceInspector.js" }, + "./plugin-package-secret-binding-management": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingManagement.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingManagement.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingManagement.js" + }, + "./plugin-package-secret-binding-approval-consumer": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.js" + }, + "./plugin-package-secret-binding-approved-action": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.js" + }, "./plugin-package-lifecycle-management": { "types": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.d.ts", "require": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.js", diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts index 347bed27..a3f06fcb 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts @@ -7,6 +7,10 @@ import { import { PluginPackageApprovedActionHandler } from '@qinglong/runtime-core/plugin-package-approved-action'; import { PostgresApprovedActionExecutionRepository } from '@qinglong/cluster-postgres/approved-action-execution'; import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgres/plugin-package-install'; +import { + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingRepository, +} from '@qinglong/cluster-postgres/package-executor'; import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal'; import { PostgresPluginPackagePublisherRevocationProposalRepository, @@ -22,6 +26,8 @@ import { ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler, type ClusterPluginPackagePublisherTrustTransitionExecutionPort, } from '../publisher/pluginPackagePublisherTrustTransitionApprovedAction'; +import { ClusterPluginPackageSecretBindingApprovedActionHandler } from '../secret-binding/pluginPackageSecretBindingApprovedAction'; +import type { PluginPackageSecretExistenceInspector } from '../secret-binding/projectedSecretExistenceInspector'; export const CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT = 16; @@ -31,6 +37,7 @@ export interface ClusterPluginPackageApprovedActionDispatcherOptions readonly defaultBatchSize?: number; readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort; readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort; + readonly secretExistenceInspector: PluginPackageSecretExistenceInspector; } export function createClusterPluginPackageApprovedActionDispatcher( @@ -44,15 +51,21 @@ export function createClusterPluginPackageApprovedActionDispatcher( defaultBatchSize, publisherRevocations, publisherTrustTransitions, + secretExistenceInspector, ...dispatcherOptions } = options; const executions = new PostgresApprovedActionExecutionRepository(pool); - const handler = new PluginPackageApprovedActionHandler( + const installHandler = new PluginPackageApprovedActionHandler( new PostgresPluginPackageInstallProposalRepository(pool), new PostgresPluginPackageInstallRepository(pool), ); const handlers = [ - handler, + installHandler, + new ClusterPluginPackageSecretBindingApprovedActionHandler( + new PostgresPluginPackageSecretBindingApprovalPlanReader(pool), + new PostgresPluginPackageSecretBindingRepository(pool), + secretExistenceInspector, + ), ...(['overlap_add', 'safe_retire'] as const).map( (mode) => new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler( diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts index bad72df3..633b301d 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts @@ -8,6 +8,7 @@ import type { ApprovedActionDispatchBatchSummary, ApprovedActionDispatcher, } from '@qinglong/runtime-core/approved-action-dispatcher'; +import { isAbsolute, normalize, parse } from 'node:path'; import { assertPostgresPackageExecutorSchemaReady, createPostgresDatabaseOpener, @@ -33,6 +34,12 @@ import { type ClusterPluginPackagePublisherTrustTransitionApprovalSummary, type ConsumeClusterPluginPackagePublisherTrustTransitionApprovalsOptions, } from '../publisher/pluginPackagePublisherTrustTransitionApprovalConsumer'; +import { + consumeClusterPluginPackageSecretBindingApprovals, + type ClusterPluginPackageSecretBindingApprovalSummary, + type ConsumeClusterPluginPackageSecretBindingApprovalsOptions, +} from '../secret-binding/pluginPackageSecretBindingApprovalConsumer'; +import { ProjectedPluginPackageSecretExistenceInspector } from '../secret-binding/projectedSecretExistenceInspector'; import { runClusterPluginPackagePublisherRevocation, } from '../publisher/pluginPackagePublisherRevocation'; @@ -52,6 +59,7 @@ export type ClusterPluginPackageExecutorProcessConfig = leaseDurationMs: number; revocationPageSize: number; revocationMaxPages: number; + secretProjectionRoot: string | null; database: Readonly<{ connection: PostgresConnectionOptions; pool: PostgresPoolOptions; @@ -61,6 +69,7 @@ export type ClusterPluginPackageExecutorProcessConfig = export interface ClusterPluginPackageExecutorBatchResult { readonly approvals: Readonly; readonly trustTransitionApprovals: Readonly; + readonly secretBindingApprovals: Readonly; readonly dispatch: Readonly; } @@ -85,6 +94,9 @@ export interface RunClusterPluginPackageExecutorProcessOptions { ) => Promise< Readonly >; + readonly consumeSecretBindingApprovals?: ( + options: ConsumeClusterPluginPackageSecretBindingApprovalsOptions, + ) => Promise>; readonly createDispatcher?: ( options: ClusterPluginPackageApprovedActionDispatcherOptions, ) => ApprovedActionDispatcher; @@ -258,6 +270,27 @@ function databaseConfig( }); } +function secretProjectionRoot( + environment: ClusterPluginPackageExecutorProcessEnvironment, +): string | null { + const value = boundedValue( + environment, + 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT', + 4096, + ); + if (value === undefined) return null; + if ( + !isAbsolute(value) || + parse(value).root === value || + normalize(value) !== value + ) { + throw new ClusterPluginPackageExecutorProcessConfigError( + 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT must be an exact absolute directory', + ); + } + return value; +} + export function loadClusterPluginPackageExecutorProcessConfig( environment: ClusterPluginPackageExecutorProcessEnvironment, ): ClusterPluginPackageExecutorProcessConfig { @@ -320,6 +353,7 @@ export function loadClusterPluginPackageExecutorProcessConfig( 1, 64, ), + secretProjectionRoot: secretProjectionRoot(environment), database: databaseConfig(environment), }); } @@ -340,6 +374,7 @@ function isIdleBatch( return ( batch.approvals.scanned === 0 && batch.trustTransitionApprovals.scanned === 0 && + batch.secretBindingApprovals.scanned === 0 && batch.dispatch.scanned === 0 ); } @@ -358,6 +393,8 @@ export async function runClusterPluginPackageExecutorProcess( typeof options.consumeApprovals !== 'function') || (options.consumeTrustTransitionApprovals !== undefined && typeof options.consumeTrustTransitionApprovals !== 'function') || + (options.consumeSecretBindingApprovals !== undefined && + typeof options.consumeSecretBindingApprovals !== 'function') || (options.createDispatcher !== undefined && typeof options.createDispatcher !== 'function') || (options.now !== undefined && typeof options.now !== 'function') @@ -391,11 +428,26 @@ export async function runClusterPluginPackageExecutorProcess( const consumeTrustTransitionApprovals = options.consumeTrustTransitionApprovals ?? consumeClusterPluginPackagePublisherTrustTransitionApprovals; + const consumeSecretBindingApprovals = + options.consumeSecretBindingApprovals ?? + consumeClusterPluginPackageSecretBindingApprovals; const dispatcher = dispatcherFactory({ pool: database.pool, owner: config.owner, leaseDurationMs: config.leaseDurationMs, defaultBatchSize: config.dispatchBatchSize, + secretExistenceInspector: + config.secretProjectionRoot === null + ? Object.freeze({ + async assertExists(): Promise { + throw new Error( + 'Plugin Package Secret projection is not configured', + ); + }, + }) + : new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: config.secretProjectionRoot, + }), ...(options.now ? { clock: options.now } : {}), publisherRevocations: { async run(receipt) { @@ -429,12 +481,19 @@ export async function runClusterPluginPackageExecutorProcess( limit: config.approvalBatchSize, ...(options.now ? { now: options.now } : {}), }); + const secretBindingApprovals = + await consumeSecretBindingApprovals({ + pool: database.pool, + limit: config.approvalBatchSize, + ...(options.now ? { now: options.now } : {}), + }); const dispatch = await dispatcher.dispatchBatch({ limit: config.dispatchBatchSize, }); const batch = Object.freeze({ approvals, trustTransitionApprovals, + secretBindingApprovals, dispatch, }); batches.push(batch); diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts new file mode 100644 index 00000000..6c95a2bf --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts @@ -0,0 +1,177 @@ +// Cluster Plugin Package Secret binding approval consumption boundary. +import { createHash } from 'node:crypto'; + +import { + PostgresApprovalRequestRepository, + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresProjectPolicyRepository, +} from '@qinglong/cluster-postgres/package-executor'; +import type { PostgresPool } from '@qinglong/runtime-core'; +import { pluginPackageSecretBindingApprovedAction } from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; +import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; + +export const CLUSTER_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_BATCH_LIMIT = 16; + +export interface ConsumeClusterPluginPackageSecretBindingApprovalsOptions { + readonly pool: PostgresPool; + readonly now?: () => number; + readonly limit?: number; +} + +export interface ClusterPluginPackageSecretBindingApprovalSummary { + readonly scanned: number; + readonly consumed: number; + readonly existing: number; + readonly expired: number; + readonly blocked: number; +} + +const CONSUMER = Object.freeze({ + type: 'system' as const, + id: 'cluster_package_executor', +}); + +function stableDigest(domain: string, value: string): string { + return createHash('sha256') + .update(domain) + .update('\0') + .update(value) + .digest('hex'); +} + +function stableId(prefix: string, domain: string, value: string): string { + return `${prefix}-${stableDigest(domain, value)}`; +} + +function stableAuditEventId(requestId: string): string { + const bytes = Buffer.from( + stableDigest( + 'qinglong/plugin-package-secret-binding-consume-audit@v1', + requestId, + ), + 'hex', + ); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16, + )}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +export async function consumeClusterPluginPackageSecretBindingApprovals( + options: ConsumeClusterPluginPackageSecretBindingApprovalsOptions, +): Promise> { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + Object.keys(options).some((key) => !['pool', 'now', 'limit'].includes(key)) || + !options.pool || + typeof options.pool.query !== 'function' || + typeof options.pool.connect !== 'function' || + (options.now !== undefined && typeof options.now !== 'function') + ) { + throw new TypeError( + 'Secret binding approval consumer options are invalid', + ); + } + const limit = + options.limit ?? CLUSTER_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_BATCH_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) { + throw new TypeError('Secret binding approval consumer limit is invalid'); + } + const observedAtMs = (options.now ?? Date.now)(); + if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) { + throw new TypeError('Secret binding approval consumer clock is invalid'); + } + const plans = new PostgresPluginPackageSecretBindingApprovalPlanReader( + options.pool, + ); + const approvals = new PostgresApprovalRequestRepository(options.pool); + const policy = new ProjectPolicyEngine( + new PostgresProjectPolicyRepository(options.pool), + ); + const requests = await plans.listApprovedRequests(limit); + let consumed = 0; + let existing = 0; + let expired = 0; + let blocked = 0; + for (const request of requests) { + const plan = await plans.findByActionRef(request.action.actionRef); + if ( + !plan || + request.projectId !== plan.bindingPlan.target.projectId || + request.decisionMode !== 'separation_of_duty' || + JSON.stringify(request.action) !== + JSON.stringify(pluginPackageSecretBindingApprovedAction(plan)) || + request.requestedBy.type !== plan.requestedBy.type || + request.requestedBy.id !== plan.requestedBy.id + ) { + blocked += 1; + continue; + } + if ( + observedAtMs >= request.expiresAtMs || + observedAtMs > plan.expiresAtMs + ) { + expired += 1; + continue; + } + const decision = await policy.decide({ + subject: request.requestedBy, + projectId: request.projectId, + permission: 'secret.manage', + }); + if ( + decision.fence === null || + (decision.effect !== 'allow' && decision.effect !== 'require_approval') + ) { + blocked += 1; + continue; + } + const consumptionId = stableId( + 'psbc', + 'qinglong/plugin-package-secret-binding-consumption@v1', + request.id, + ); + const dispatchId = stableId( + 'psbd', + 'qinglong/plugin-package-secret-binding-dispatch@v1', + request.id, + ); + const result = await approvals.consume({ + requestId: request.id, + expectedVersion: request.version, + consumptionId, + dispatchId, + action: request.action, + requestedBy: request.requestedBy, + consumedBy: CONSUMER, + consumedAtMs: observedAtMs, + authorizationFence: decision.fence, + audit: { + eventId: stableAuditEventId(request.id), + requestId: request.id, + operationId: 'approval.consume', + projectId: request.projectId, + subject: CONSUMER, + authenticationId: 'cluster-package-executor', + outcome: 'allowed', + reasons: ['package_secret_binding_execution'], + fence: decision.fence, + occurredAtMs: observedAtMs, + }, + }); + if (result.status === 'consumed') consumed += 1; + else existing += 1; + } + return Object.freeze({ + scanned: requests.length, + consumed, + existing, + expired, + blocked, + }); +} diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.ts new file mode 100644 index 00000000..2c67a39b --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.ts @@ -0,0 +1,172 @@ +// Cluster Plugin Package Secret binding Approved Action boundary. +import type { + ApprovedActionHandler, + ApprovedActionHandlerExecutionContext, + ApprovedActionHandlerInspection, + ApprovedActionHandlerResult, +} from '@qinglong/runtime-core/approved-action-dispatcher'; +import { + InvalidPluginPackageSecretBindingApprovalPlanError, + PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE, + createPluginPackageSecretBindingFromApprovalPlan, + normalizePluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, + type PluginPackageSecretBindingApprovalPlanRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; +import { + PluginPackageSecretBindingConflictError, + type PluginPackageSecretBindingRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; + +import type { PluginPackageSecretExistenceInspector } from './projectedSecretExistenceInspector'; + +export class ClusterPluginPackageSecretBindingApprovedActionHandler + implements ApprovedActionHandler +{ + readonly actionType = PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE; + + constructor( + readonly plans: Pick< + PluginPackageSecretBindingApprovalPlanRepository, + 'findByActionRef' + >, + readonly bindings: PluginPackageSecretBindingRepository, + readonly secrets: PluginPackageSecretExistenceInspector, + ) { + if ( + !plans || + typeof plans.findByActionRef !== 'function' || + !bindings || + typeof bindings.find !== 'function' || + typeof bindings.publish !== 'function' || + !secrets || + typeof secrets.assertExists !== 'function' + ) { + throw new TypeError( + 'Secret binding Approved Action authority is invalid', + ); + } + } + + async inspect( + dispatch: ApprovedActionHandlerExecutionContext['dispatch'], + ): Promise { + let plan; + try { + plan = await this.plans.findByActionRef(dispatch.action.actionRef); + } catch { + return Object.freeze({ + status: 'retry', + resultCode: 'package_secret_binding_plan_unavailable', + }); + } + if (!plan) { + return Object.freeze({ + status: 'blocked', + resultCode: 'package_secret_binding_plan_missing', + }); + } + try { + const normalized = normalizePluginPackageSecretBindingApprovalPlan(plan); + if ( + JSON.stringify(dispatch.action) !== + JSON.stringify(pluginPackageSecretBindingApprovedAction(normalized)) || + dispatch.projectId !== normalized.bindingPlan.target.projectId || + dispatch.requestedBy.type !== normalized.requestedBy.type || + dispatch.requestedBy.id !== normalized.requestedBy.id || + dispatch.createdAtMs > normalized.expiresAtMs + ) { + throw new Error('dispatch does not match Secret binding plan'); + } + const secretRefs = normalized.bindingPlan.entries.flatMap((entry) => + entry.secretRef === null ? [] : [entry.secretRef], + ); + if (secretRefs.length > 0) { + await this.secrets.assertExists(secretRefs); + } + return Object.freeze({ + status: 'ready', + actionDigest: normalized.approvalPlanDigest, + }); + } catch { + return Object.freeze({ + status: 'blocked', + resultCode: 'package_secret_binding_plan_rejected', + }); + } + } + + async execute( + context: Readonly, + ): Promise> { + const startedAtMs = context.execution.startedAtMs; + if ( + context.execution.status !== 'executing' || + startedAtMs === null || + context.execution.leaseOwner !== context.fence.owner || + context.execution.leaseToken !== context.fence.leaseToken || + context.execution.version !== context.fence.version + ) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_binding_execution_rejected', + }); + } + const plan = await this.plans.findByActionRef( + context.dispatch.action.actionRef, + ); + if (!plan) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_binding_plan_missing', + }); + } + const inspection = await this.inspect(context.dispatch); + if (inspection.status !== 'ready') { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_binding_plan_rejected', + }); + } + let binding; + try { + binding = createPluginPackageSecretBindingFromApprovalPlan( + plan, + startedAtMs, + ); + const result = await this.bindings.publish(binding); + return Object.freeze({ + outcome: 'succeeded', + resultCode: + result.status === 'created' + ? 'package_secret_binding_published' + : 'package_secret_binding_existing', + resultDigest: result.binding.bindingDigest, + }); + } catch (error) { + if (error instanceof PluginPackageSecretBindingConflictError) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_binding_conflict', + }); + } + if (error instanceof InvalidPluginPackageSecretBindingApprovalPlanError) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_binding_plan_rejected', + }); + } + const existing = await this.bindings.find( + plan.bindingPlan.target.generationDigest, + ); + if (!existing || !binding || existing.bindingDigest !== binding.bindingDigest) { + throw error; + } + return Object.freeze({ + outcome: 'succeeded', + resultCode: 'package_secret_binding_existing', + resultDigest: existing.bindingDigest, + }); + } + } +} diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts new file mode 100644 index 00000000..3b76f098 --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts @@ -0,0 +1,663 @@ +// Cluster Plugin Package Secret binding management boundary. +import { + PostgresApprovalRequestRepository, + PostgresPluginPackageSecretBindingApprovalPlanRepository, + PostgresProjectPolicyRepository, +} from '@qinglong/cluster-postgres/package-manager'; +import type { PostgresPool } from '@qinglong/runtime-core'; +import { + createApprovalRequest, + normalizeApprovalRequestRecord, + type ApprovalRequestRecord, + type CreateApprovalRequestResult, + type DecideApprovalRequestResult, +} from '@qinglong/runtime-core/approved-action'; +import { + PluginPackageManagementAuthorizationError, + PluginPackageManagementConflictError, + PluginPackageManagementRequestError, + PluginPackageManagementUnavailableError, +} from '@qinglong/runtime-core/plugin-package-management'; +import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import type { PluginPackageSecretBindingAssignment } from '@qinglong/runtime-core/plugin-package-secret-binding'; +import { + MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_LIFETIME_MS, + PluginPackageSecretBindingApprovalPlanConflictError, + PluginPackageSecretBindingApprovalPlanUnavailableError, + createPluginPackageSecretBindingApprovalPlan, + normalizePluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, + type CreatePluginPackageSecretBindingApprovalPlanResult, + type PluginPackageSecretBindingApprovalPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; +import { createPluginPackageSecretBindingPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-plan'; +import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; +import { + normalizeSecurityPrincipal, + type SecurityPolicyFence, + type SecurityPrincipal, + type SecuritySubject, +} from '@qinglong/runtime-core/security'; +import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit'; + +const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000; +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + +export interface PlanClusterPluginPackageSecretBindingRequest { + readonly actionRef: string; + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly[]; + readonly principal: SecurityPrincipal; +} + +export interface ProposeClusterPluginPackageSecretBindingRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly approvalAuditEventId: string; + readonly principal: SecurityPrincipal; +} + +export interface ProposeClusterPluginPackageSecretBindingResult { + readonly plan: Readonly; + readonly approvalStatus: CreateApprovalRequestResult['status']; + readonly approvalRequest: Readonly; +} + +export interface DecideClusterPluginPackageSecretBindingRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly expectedVersion: number; + readonly decisionId: string; + readonly auditEventId: string; + readonly decision: 'approved' | 'rejected'; + readonly reasonCode: string; + readonly principal: SecurityPrincipal; +} + +export interface InspectClusterPluginPackageSecretBindingRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly inspectionId: string; + readonly principal: SecurityPrincipal; +} + +export interface InspectClusterPluginPackageSecretBindingResult { + readonly plan: Readonly | null; + readonly approvalRequest: Readonly | null; + readonly stale: boolean; +} + +export interface ClusterPluginPackageSecretBindingManagementService { + plan( + request: PlanClusterPluginPackageSecretBindingRequest, + ): Promise>; + propose( + request: ProposeClusterPluginPackageSecretBindingRequest, + ): Promise>; + decide( + request: DecideClusterPluginPackageSecretBindingRequest, + ): Promise>; + inspectAuthorized( + request: InspectClusterPluginPackageSecretBindingRequest, + ): Promise>; +} + +export interface ClusterPluginPackageSecretBindingManagementOptions { + readonly pool: PostgresPool; + readonly now?: () => number; + readonly planLifetimeMs?: number; + readonly approvalLifetimeMs?: number; +} + +function exact(value: unknown, keys: readonly string[], label: string): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new PluginPackageManagementRequestError(`${label} is invalid`); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new PluginPackageManagementRequestError(`${label} shape is invalid`); + } +} + +function identifier(value: unknown, label: string): string { + if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { + throw new PluginPackageManagementRequestError(`${label} is invalid`); + } + return value; +} + +function actionRef(value: unknown): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + throw new PluginPackageManagementRequestError('actionRef is invalid'); + } + return value; +} + +function currentTime(now: () => number): number { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new PluginPackageManagementUnavailableError(); + } + return value; +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function sameSubject( + left: Readonly, + right: Readonly, +): boolean { + return left.type === right.type && left.id === right.id; +} + +function assignmentsMatch( + assignments: readonly Readonly[], + plan: Readonly, +): boolean { + if ( + !Array.isArray(assignments) || + assignments.length !== plan.bindingPlan.entries.length + ) { + return false; + } + const mapped = new Map(); + for (const value of assignments) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join('\0') !== 'name\0secretRef' || + typeof value.name !== 'string' || + (value.secretRef !== null && typeof value.secretRef !== 'string') || + mapped.has(value.name) + ) { + return false; + } + mapped.set(value.name, value.secretRef); + } + return plan.bindingPlan.entries.every( + (entry) => mapped.get(entry.name) === entry.secretRef, + ); +} + +function audit( + eventId: string, + requestId: string, + operationId: 'approval.request' | 'approval.decide', + projectId: string, + principal: Readonly, + outcome: 'allowed' | 'approval_required', + fence: Readonly, + occurredAtMs: number, +): Readonly { + return Object.freeze({ + eventId, + requestId, + operationId, + projectId, + subject: principal.subject, + authenticationId: principal.authenticationId, + outcome, + reasons: Object.freeze(['package_secret_binding_review']), + fence, + occurredAtMs, + }); +} + +export function createClusterPluginPackageSecretBindingManagementService( + options: ClusterPluginPackageSecretBindingManagementOptions, +): Readonly { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + Object.keys(options).some( + (key) => + key !== 'pool' && + key !== 'now' && + key !== 'planLifetimeMs' && + key !== 'approvalLifetimeMs', + ) || + !options.pool || + typeof options.pool.query !== 'function' || + typeof options.pool.connect !== 'function' || + (options.now !== undefined && typeof options.now !== 'function') + ) { + throw new TypeError( + 'cluster Plugin Package Secret binding management options are invalid', + ); + } + const planLifetimeMs = + options.planLifetimeMs ?? + MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_LIFETIME_MS; + const approvalLifetimeMs = + options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS; + for (const [value, label] of [ + [planLifetimeMs, 'plan'], + [approvalLifetimeMs, 'approval'], + ] as const) { + if ( + !Number.isSafeInteger(value) || + value < 1_000 || + value > MAX_PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_LIFETIME_MS + ) { + throw new TypeError( + `cluster Plugin Package Secret binding ${label} lifetime is invalid`, + ); + } + } + const now = options.now ?? Date.now; + const plans = new PostgresPluginPackageSecretBindingApprovalPlanRepository( + options.pool, + ); + const approvals = new PostgresApprovalRequestRepository(options.pool); + const policy = new ProjectPolicyEngine( + new PostgresProjectPolicyRepository(options.pool), + ); + + const authorize = async ( + principalValue: SecurityPrincipal, + projectId: string, + permission: 'secret.manage' | 'approval.decide', + observedAtMs: number, + ): Promise< + Readonly<{ + principal: Readonly; + fence: Readonly; + }> + > => { + let principal; + try { + principal = normalizeSecurityPrincipal(principalValue, observedAtMs); + } catch { + throw new PluginPackageManagementAuthorizationError(); + } + if ( + principal.subject.type !== 'user' || + (principal.assurance !== 'multi_factor' && + principal.assurance !== 'hardware') + ) { + throw new PluginPackageManagementAuthorizationError(); + } + let decision; + try { + decision = await policy.authorize(principal, projectId, permission); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (decision.effect !== 'allow' || decision.fence === null) { + throw new PluginPackageManagementAuthorizationError(); + } + return Object.freeze({ principal, fence: decision.fence }); + }; + + const loadPlan = async ( + requestedActionRef: string, + ): Promise> => { + let value; + try { + value = await plans.findByActionRef(actionRef(requestedActionRef)); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (!value) { + throw new PluginPackageManagementConflictError( + 'Secret binding plan does not exist', + ); + } + return normalizePluginPackageSecretBindingApprovalPlan(value); + }; + + return Object.freeze({ + async plan(request: PlanClusterPluginPackageSecretBindingRequest) { + exact( + request, + ['actionRef', 'assignments', 'packageName', 'principal', 'projectId'], + 'Secret binding plan request', + ); + const projectId = identifier(request.projectId, 'projectId'); + if ( + typeof request.packageName !== 'string' || + !PACKAGE_NAME_PATTERN.test(request.packageName) + ) { + throw new PluginPackageManagementRequestError('packageName is invalid'); + } + if (!Array.isArray(request.assignments)) { + throw new PluginPackageManagementRequestError('assignments are invalid'); + } + const authorization = await authorize( + request.principal, + projectId, + 'secret.manage', + currentTime(now), + ); + const requestedActionRef = actionRef(request.actionRef); + let existingValue; + try { + existingValue = await plans.findByActionRef(requestedActionRef); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (existingValue) { + const existing = normalizePluginPackageSecretBindingApprovalPlan( + existingValue, + ); + if ( + existing.bindingPlan.target.projectId !== projectId || + existing.bindingPlan.target.packageName !== request.packageName || + !sameSubject(existing.requestedBy, authorization.principal.subject) || + existing.expiresAtMs - existing.bindingPlan.plannedAtMs !== + planLifetimeMs || + !assignmentsMatch(request.assignments, existing) + ) { + throw new PluginPackageManagementConflictError( + 'Secret binding actionRef is bound to another request', + ); + } + return Object.freeze({ status: 'existing' as const, plan: existing }); + } + let snapshot; + try { + snapshot = await plans.loadPlanningSnapshot( + projectId, + request.packageName, + ); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (!snapshot) { + throw new PluginPackageManagementConflictError( + 'current active unbound Package generation does not exist', + ); + } + const generation = createPluginPackageResourceGenerationFromReferences({ + installationId: snapshot.record.installationId, + projectId: snapshot.record.projectId, + packageName: snapshot.record.packageName, + lockDigest: snapshot.record.lockDigest, + generation: snapshot.record.targetGeneration, + previousActiveLockDigest: snapshot.record.previousActiveLockDigest, + contentDigest: snapshot.lock.source.contentDigest, + resources: snapshot.lock.resources, + }); + let plan; + try { + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest: snapshot.proposal.actionInput.manifest, + assignments: request.assignments, + plannedAtMs: snapshot.observedAtMs, + }); + plan = createPluginPackageSecretBindingApprovalPlan({ + actionRef: requestedActionRef, + bindingPlan, + requestedBy: authorization.principal.subject, + expiresAtMs: snapshot.observedAtMs + planLifetimeMs, + }); + return await plans.create(plan); + } catch (error) { + if (error instanceof PluginPackageSecretBindingApprovalPlanConflictError) { + throw new PluginPackageManagementConflictError( + 'Secret binding actionRef or generation is already bound', + ); + } + if (error instanceof PluginPackageSecretBindingApprovalPlanUnavailableError) { + throw new PluginPackageManagementUnavailableError({ cause: error }); + } + if (error instanceof TypeError) { + throw new PluginPackageManagementRequestError( + 'Secret binding assignments are invalid', + ); + } + throw error; + } + }, + + async propose(request: ProposeClusterPluginPackageSecretBindingRequest) { + exact( + request, + ['actionRef', 'approvalAuditEventId', 'approvalRequestId', 'principal'], + 'Secret binding proposal request', + ); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const approvalAuditEventId = identifier( + request.approvalAuditEventId, + 'approvalAuditEventId', + ); + const plan = await loadPlan(request.actionRef); + const observedAtMs = currentTime(now); + if (observedAtMs > plan.expiresAtMs) { + throw new PluginPackageManagementConflictError( + 'Secret binding plan expired', + ); + } + const authorization = await authorize( + request.principal, + plan.bindingPlan.target.projectId, + 'secret.manage', + observedAtMs, + ); + if (!sameSubject(plan.requestedBy, authorization.principal.subject)) { + throw new PluginPackageManagementAuthorizationError(); + } + const binding = pluginPackageSecretBindingApprovedAction(plan); + const existing = await approvals.findById(approvalRequestId); + if (existing) { + const normalized = normalizeApprovalRequestRecord(existing); + if ( + normalized.projectId !== plan.bindingPlan.target.projectId || + normalized.decisionMode !== 'separation_of_duty' || + !sameSubject(normalized.requestedBy, plan.requestedBy) || + !same(normalized.action, binding) + ) { + throw new PluginPackageManagementConflictError( + 'Approval request is bound to another Secret binding plan', + ); + } + return Object.freeze({ + plan, + approvalStatus: 'existing' as const, + approvalRequest: normalized, + }); + } + const expiresAtMs = Math.min( + observedAtMs + approvalLifetimeMs, + plan.expiresAtMs, + ); + if (expiresAtMs <= observedAtMs) { + throw new PluginPackageManagementConflictError( + 'Secret binding plan has no approval lifetime', + ); + } + const result = await approvals.create({ + request: createApprovalRequest({ + id: approvalRequestId, + projectId: plan.bindingPlan.target.projectId, + action: binding, + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: authorization.principal.subject, + requestedAtMs: observedAtMs, + expiresAtMs, + requestFence: authorization.fence, + }), + audit: audit( + approvalAuditEventId, + approvalRequestId, + 'approval.request', + plan.bindingPlan.target.projectId, + authorization.principal, + 'approval_required', + authorization.fence, + observedAtMs, + ), + }); + return Object.freeze({ + plan, + approvalStatus: result.status, + approvalRequest: result.request, + }); + }, + + async decide(request: DecideClusterPluginPackageSecretBindingRequest) { + exact( + request, + [ + 'actionRef', + 'approvalRequestId', + 'auditEventId', + 'decision', + 'decisionId', + 'expectedVersion', + 'principal', + 'reasonCode', + ], + 'Secret binding decision request', + ); + if ( + (request.decision !== 'approved' && request.decision !== 'rejected') || + typeof request.reasonCode !== 'string' || + !REASON_PATTERN.test(request.reasonCode) || + !Number.isSafeInteger(request.expectedVersion) || + request.expectedVersion < 1 + ) { + throw new PluginPackageManagementRequestError( + 'Secret binding decision tuple is invalid', + ); + } + const plan = await loadPlan(request.actionRef); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const decisionId = identifier(request.decisionId, 'decisionId'); + const auditEventId = identifier(request.auditEventId, 'auditEventId'); + const current = await approvals.findById(approvalRequestId); + if (!current) { + throw new PluginPackageManagementConflictError( + 'Approval request does not exist', + ); + } + const approval = normalizeApprovalRequestRecord(current); + if (!same(approval.action, pluginPackageSecretBindingApprovedAction(plan))) { + throw new PluginPackageManagementConflictError( + 'Approval request does not match Secret binding plan', + ); + } + const observedAtMs = currentTime(now); + const authorization = await authorize( + request.principal, + approval.projectId, + 'approval.decide', + observedAtMs, + ); + if ( + approval.decisionId === decisionId && + approval.decision === request.decision && + approval.decisionReasonCode === request.reasonCode && + approval.decidedBy && + sameSubject(approval.decidedBy, authorization.principal.subject) + ) { + return Object.freeze({ status: 'existing' as const, request: approval }); + } + return approvals.decide({ + requestId: approvalRequestId, + expectedVersion: request.expectedVersion, + decisionId, + decision: request.decision, + reasonCode: request.reasonCode, + principal: authorization.principal, + decidedAtMs: observedAtMs, + authorizationFence: authorization.fence, + audit: audit( + auditEventId, + approvalRequestId, + 'approval.decide', + approval.projectId, + authorization.principal, + 'allowed', + authorization.fence, + observedAtMs, + ), + }); + }, + + async inspectAuthorized( + request: InspectClusterPluginPackageSecretBindingRequest, + ) { + exact( + request, + ['actionRef', 'approvalRequestId', 'inspectionId', 'principal'], + 'Secret binding inspection request', + ); + identifier(request.inspectionId, 'inspectionId'); + const requestedActionRef = actionRef(request.actionRef); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const [planValue, approvalValue] = await Promise.all([ + plans.findByActionRef(requestedActionRef), + approvals.findById(approvalRequestId), + ]); + if (!planValue && !approvalValue) { + throw new PluginPackageManagementConflictError( + 'Secret binding review state does not exist', + ); + } + const plan = planValue + ? normalizePluginPackageSecretBindingApprovalPlan(planValue) + : null; + const approval = approvalValue + ? normalizeApprovalRequestRecord(approvalValue) + : null; + const projectId = + plan?.bindingPlan.target.projectId ?? approval?.projectId; + if (!projectId) throw new PluginPackageManagementUnavailableError(); + const observedAtMs = currentTime(now); + try { + await authorize(request.principal, projectId, 'secret.manage', observedAtMs); + } catch (error) { + if (!(error instanceof PluginPackageManagementAuthorizationError)) { + throw error; + } + await authorize( + request.principal, + projectId, + 'approval.decide', + observedAtMs, + ); + } + return Object.freeze({ + plan, + approvalRequest: approval, + stale: + plan === null || + approval === null || + !same(approval.action, pluginPackageSecretBindingApprovedAction(plan)) || + observedAtMs > plan.expiresAtMs, + }); + }, + }); +} diff --git a/packages/ql3-cluster-admin/test/bootstrap.test.cjs b/packages/ql3-cluster-admin/test/bootstrap.test.cjs index 434134f0..a2759b54 100644 --- a/packages/ql3-cluster-admin/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-admin/test/bootstrap.test.cjs @@ -166,12 +166,14 @@ function database(serverVersionNum = '160014') { 'lock_approval_policy_fence', 'lock_run_management_policy_fence', 'plugin_package_lifecycle_blocking_runs', + 'plugin_package_secret_binding_planning_snapshot', 'plugin_package_automation_start_allowed', 'plugin_package_run_start_allowed', 'plugin_package_tool_start_allowed', 'plugin_package_workflow_admission_snapshot', 'plugin_package_workflow_task_attempt_snapshot', 'register_plugin_package_automation_disposition_event', + 'create_plugin_package_secret_binding_approval_plan', ].includes(functionName), isOwner: false, })), diff --git a/packages/ql3-cluster-admin/test/pluginPackageApprovedAction.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageApprovedAction.test.cjs index 6129ffa2..0e02be43 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageApprovedAction.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageApprovedAction.test.cjs @@ -20,6 +20,9 @@ test('composes one bounded caller-driven cluster Package dispatcher', async () = owner: 'cluster_package_dispatcher_1', clock: () => 100, createId: () => 'dispatcher-id-1', + secretExistenceInspector: { + async assertExists() {}, + }, }); let observedLimit = null; dispatcher.repository.listDueExecutions = async (query) => { diff --git a/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs index 79fddc37..2da8fe02 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs @@ -17,6 +17,8 @@ function environment(overrides = {}) { QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS: '600000', QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE: '8', QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES: '4', + QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: + '/var/run/secrets/qinglong3/plugin-package-values', QL3_POSTGRES_PACKAGE_EXECUTOR_URL: 'postgresql://ql3_package_executor:secret@postgres/qinglong', QL3_POSTGRES_TLS_MODE: 'disable', @@ -47,6 +49,10 @@ test('loads bounded low-footprint Package-executor configuration', () => { assert.equal(config.maxBatches, 2); assert.equal(config.revocationPageSize, 8); assert.equal(config.revocationMaxPages, 4); + assert.equal( + config.secretProjectionRoot, + '/var/run/secrets/qinglong3/plugin-package-values', + ); assert.equal(config.database.pool.maxConnections, 2); assert.equal(config.database.connection.tls.mode, 'disable'); }); @@ -57,6 +63,7 @@ test('rejects implicit insecure PostgreSQL and unbounded work', () => { environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_MAX_BATCHES: '65' }), environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE: '129' }), environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER: 'not safe' }), + environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: 'relative/path' }), ]) { assert.throws( () => loadClusterPluginPackageExecutorProcessConfig(invalid), diff --git a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs index fc3c1a61..64f65856 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs @@ -52,6 +52,7 @@ function executorPrivileges() { 'project_role_bindings', 'approval_requests', 'plugin_package_install_proposals', + 'plugin_package_secret_binding_approval_plans', 'plugin_package_task_ownerships', 'plugin_package_task_reconciliations', 'plugin_package_task_reconciliation_items', @@ -201,6 +202,8 @@ function database(serverVersionNum = '160014') { 'plugin_package_workflow_task_attempt_snapshot', 'lock_run_management_policy_fence', 'register_plugin_package_automation_disposition_event', + 'create_plugin_package_secret_binding_approval_plan', + 'plugin_package_secret_binding_planning_snapshot', ].includes(functionName), isOwner: false, })), diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovalConsumer.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovalConsumer.test.cjs new file mode 100644 index 00000000..35999904 --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovalConsumer.test.cjs @@ -0,0 +1,237 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createApprovalRequest, + decideApprovalRequest, + approvalRequestDigest, +} = require('@qinglong/runtime-core/approved-action'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + consumeClusterPluginPackageSecretBindingApprovals, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-approval-consumer'); + +const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' }); +const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' }); +const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 }); + +function plan() { + const manifest = { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret binding consumer fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; + const generation = createPluginPackageResourceGeneration({ + installationId: 'install-secret-binding-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: manifest.spec.contents, + }); + return createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:example-monitor-v1', + bindingPlan: createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 100, + }), + requestedBy: REQUESTER, + expiresAtMs: 1_000, + }); +} + +function approvedRequest(candidate) { + return decideApprovalRequest( + createApprovalRequest({ + id: 'approval-secret-binding-1', + projectId: 'project-1', + action: pluginPackageSecretBindingApprovedAction(candidate), + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: REQUESTER, + requestedAtMs: 110, + expiresAtMs: 900, + requestFence: FENCE, + }), + { + expectedVersion: 1, + decisionId: 'decision-secret-binding-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: REVIEWER, + authenticationId: 'auth-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 800, + assurance: 'multi_factor', + }, + decidedAtMs: 120, + authorizationFence: FENCE, + }, + ); +} + +function pool(candidate, request, policyEffect = 'allow') { + const dispatches = new Map(); + return { + dispatches, + async query(text, values) { + if (text.includes('JOIN "ql3"."plugin_package_secret_binding_approval_plans"')) { + return { + rows: [{ + requestJson: request, + requestDigest: approvalRequestDigest(request), + }], + }; + } + if (text.includes('FROM "ql3"."plugin_package_secret_binding_approval_plans"')) { + return { rows: [{ planJson: candidate }] }; + } + if (text.includes('FROM "ql3"."projects" AS project')) { + return policyEffect === 'deny' + ? { rows: [] } + : { + rows: [{ + projectId: 'project-1', + projectName: 'Project 1', + projectSlug: 'project-1', + projectStatus: 'active', + projectVersion: 3, + projectCreatedAtMs: 1, + projectUpdatedAtMs: 2, + bindingProjectId: 'project-1', + bindingSubjectType: 'user', + bindingSubjectId: 'cluster-owner', + bindingVersion: 4, + bindingState: 'active', + bindingRole: 'admin', + bindingMutationId: 'binding-owner-v4', + bindingChangedByType: 'user', + bindingChangedById: 'root-owner', + bindingCreatedAtMs: 2, + }], + }; + } + if ( + text === 'BEGIN ISOLATION LEVEL SERIALIZABLE' || + text === 'COMMIT' || + text === 'ROLLBACK' + ) { + return { rows: [] }; + } + if (text.includes("SELECT set_config(")) return { rows: [{}] }; + if (text.includes('lock_approval_policy_fence')) { + return { rows: [{ matches: true }] }; + } + if (text.includes('FROM "ql3"."approval_requests"')) { + return { rows: [{ requestJson: request, requestDigest: approvalRequestDigest(request) }] }; + } + if (text.includes('INSERT INTO "ql3"."approved_action_dispatches"')) { + dispatches.set(values[0], values); + return { rows: [], rowCount: 1 }; + } + if (text.includes('INSERT INTO "ql3"."approved_action_executions"')) { + return { rows: [], rowCount: 1 }; + } + if (text.includes('UPDATE "ql3"."approval_requests"')) { + return { rows: [], rowCount: 1 }; + } + if (text.includes('INSERT INTO "ql3"."security_audit_events"')) { + return { rows: [], rowCount: 1 }; + } + throw new Error(`unexpected query: ${text}`); + }, + async connect() { + return { query: this.query.bind(this), release() {} }; + }, + }; +} + +test('consumes one exact approved Secret binding request under a current requester fence', async () => { + const candidate = plan(); + const request = approvedRequest(candidate); + const database = pool(candidate, request); + const summary = await consumeClusterPluginPackageSecretBindingApprovals({ + pool: database, + now: () => 130, + limit: 4, + }); + assert.deepEqual(summary, { + scanned: 1, + consumed: 1, + existing: 0, + expired: 0, + blocked: 0, + }); + assert.equal(database.dispatches.size, 1); +}); + +test('does not consume expired or no-longer-authorized approvals', async () => { + const candidate = plan(); + const request = approvedRequest(candidate); + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingApprovals({ + pool: pool(candidate, request), + now: () => 901, + limit: 4, + }), + { scanned: 1, consumed: 0, existing: 0, expired: 1, blocked: 0 }, + ); + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingApprovals({ + pool: pool(candidate, request, 'deny'), + now: () => 130, + limit: 4, + }), + { scanned: 1, consumed: 0, existing: 0, expired: 0, blocked: 1 }, + ); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovedAction.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovedAction.test.cjs new file mode 100644 index 00000000..4cece99b --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingApprovedAction.test.cjs @@ -0,0 +1,254 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + consumeApprovalRequest, + createApprovalRequest, + decideApprovalRequest, +} = require('@qinglong/runtime-core/approved-action'); +const { + claimApprovedActionExecution, + createApprovedActionExecution, + startApprovedActionExecution, +} = require('@qinglong/runtime-core/approved-action-execution'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + ClusterPluginPackageSecretBindingApprovedActionHandler, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-approved-action'); + +const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' }); +const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' }); +const CONSUMER = Object.freeze({ + type: 'system', + id: 'cluster_package_executor', +}); +const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 }); + +function approvalPlan() { + const manifest = { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret binding handler fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; + const generation = createPluginPackageResourceGeneration({ + installationId: 'install-secret-binding-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: manifest.spec.contents, + }); + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 100, + }); + return createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:example-monitor-v1', + bindingPlan, + requestedBy: REQUESTER, + expiresAtMs: 1_000, + }); +} + +function dispatch(plan) { + const action = pluginPackageSecretBindingApprovedAction(plan); + const pending = createApprovalRequest({ + id: 'approval-secret-binding-1', + projectId: plan.bindingPlan.target.projectId, + action, + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: REQUESTER, + requestedAtMs: 110, + expiresAtMs: 900, + requestFence: FENCE, + }); + const approved = decideApprovalRequest(pending, { + expectedVersion: 1, + decisionId: 'decision-secret-binding-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: REVIEWER, + authenticationId: 'auth-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 800, + assurance: 'multi_factor', + }, + decidedAtMs: 120, + authorizationFence: FENCE, + }); + return consumeApprovalRequest(approved, { + expectedVersion: 2, + consumptionId: 'consume-secret-binding-1', + dispatchId: 'dispatch-secret-binding-1', + action, + requestedBy: REQUESTER, + consumedBy: CONSUMER, + consumedAtMs: 130, + authorizationFence: FENCE, + }).dispatch; +} + +function execution(approvedDispatch) { + const claimed = claimApprovedActionExecution( + createApprovedActionExecution(approvedDispatch, 5), + { + owner: 'secret-binding-executor', + leaseToken: 'lease-secret-binding-1', + nowMs: 131, + leaseDurationMs: 500, + }, + ); + assert.equal(claimed.status, 'leased'); + return startApprovedActionExecution( + { dispatch: approvedDispatch, execution: claimed }, + { + dispatchId: approvedDispatch.id, + approvalRequestId: approvedDispatch.approvalRequestId, + actionDigest: approvedDispatch.action.actionDigest, + owner: claimed.leaseOwner, + leaseToken: claimed.leaseToken, + expectedVersion: claimed.version, + startedAtMs: 140, + }, + ); +} + +function handler(plan, stored = new Map()) { + return new ClusterPluginPackageSecretBindingApprovedActionHandler( + { + async findByActionRef(actionRef) { + return actionRef === plan?.actionRef ? plan : null; + }, + }, + { + async find(generationDigest) { + return stored.get(generationDigest) ?? null; + }, + async publish(binding) { + const key = binding.target.generationDigest; + const existing = stored.get(key); + if (existing) return { status: 'existing', binding: existing }; + stored.set(key, binding); + return { status: 'created', binding }; + }, + }, + { + async assertExists() {}, + }, + ); +} + +test('publishes exactly the approved content-free binding and replays it', async () => { + const plan = approvalPlan(); + const approvedDispatch = dispatch(plan); + const started = execution(approvedDispatch); + const stored = new Map(); + const subject = handler(plan, stored); + assert.deepEqual(await subject.inspect(approvedDispatch), { + status: 'ready', + actionDigest: plan.approvalPlanDigest, + }); + const context = { + dispatch: approvedDispatch, + execution: started, + idempotencyKey: approvedDispatch.id, + fence: { + owner: started.leaseOwner, + leaseToken: started.leaseToken, + version: started.version, + }, + }; + const created = await subject.execute(context); + const replay = await subject.execute(context); + assert.equal(created.outcome, 'succeeded'); + assert.equal(created.resultCode, 'package_secret_binding_published'); + assert.equal(replay.resultCode, 'package_secret_binding_existing'); + assert.equal(replay.resultDigest, created.resultDigest); + const binding = stored.get(plan.bindingPlan.target.generationDigest); + assert.equal(binding.authority.kind, 'approved-action-execution'); + assert.equal(binding.authority.evidenceDigest, plan.approvalPlanDigest); + assert.deepEqual(binding.entries, plan.bindingPlan.entries); + assert.doesNotMatch(JSON.stringify(binding), /secret-value/); +}); + +test('blocks missing/drifted plans and rejects a stale execution fence', async () => { + const plan = approvalPlan(); + const approvedDispatch = dispatch(plan); + assert.deepEqual(await handler(null).inspect(approvedDispatch), { + status: 'blocked', + resultCode: 'package_secret_binding_plan_missing', + }); + const drifted = { ...plan, approvalPlanDigest: 'f'.repeat(64) }; + assert.deepEqual(await handler(drifted).inspect(approvedDispatch), { + status: 'blocked', + resultCode: 'package_secret_binding_plan_rejected', + }); + const started = execution(approvedDispatch); + assert.deepEqual( + await handler(plan).execute({ + dispatch: approvedDispatch, + execution: started, + idempotencyKey: approvedDispatch.id, + fence: { + owner: started.leaseOwner, + leaseToken: started.leaseToken, + version: started.version + 1, + }, + }), + { + outcome: 'failed', + resultCode: 'package_secret_binding_execution_rejected', + }, + ); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs new file mode 100644 index 00000000..33fa8853 --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs @@ -0,0 +1,381 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + PLUGIN_PACKAGE_API_VERSION, + PLUGIN_PACKAGE_KIND, + planPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package'); +const { + createPluginPackageInstall, + createPluginPackageLock, + pluginPackageActivationIntentDigest, + pluginPackageInstallActionDigest, + pluginPackageInstallPlanDigest, + transitionPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package-install'); +const { + createPluginPackageInstallProposal, +} = require('@qinglong/runtime-core/plugin-package-proposal'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + PluginPackageManagementAuthorizationError, + PluginPackageManagementConflictError, +} = require('@qinglong/runtime-core/plugin-package-management'); +const { + createClusterPluginPackageSecretBindingManagementService, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-management'); + +const REQUESTER = Object.freeze({ + subject: Object.freeze({ type: 'user', id: 'cluster-owner' }), + authenticationId: 'auth-cluster-owner', + authenticatedAtMs: 100, + expiresAtMs: 10_000, + assurance: 'multi_factor', +}); +const REVIEWER = Object.freeze({ + subject: Object.freeze({ type: 'user', id: 'security-reviewer' }), + authenticationId: 'auth-security-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 10_000, + assurance: 'hardware', +}); + +function installFixture() { + const manifest = { + apiVersion: PLUGIN_PACKAGE_API_VERSION, + kind: PLUGIN_PACKAGE_KIND, + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret binding management fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; + const environment = { + qinglongVersion: '3.0.0-alpha.0', + architecture: 'arm64', + deploymentProfile: 'cluster-control', + runtimes: [], + availableMemoryBytes: 128 * 1024 * 1024, + availableDiskBytes: 256 * 1024 * 1024, + }; + const installPlan = planPluginPackageInstall(manifest, environment); + const actionInput = { + lockId: 'lock-secret-binding-1', + projectId: 'project-1', + manifest, + plan: installPlan, + environment, + source: { + kind: 'offline', + locator: `offline:sha256:${'a'.repeat(64)}`, + artifactDigest: 'a'.repeat(64), + artifactBytes: 2048, + contentDigest: 'b'.repeat(64), + }, + architecture: 'arm64', + deploymentProfile: 'cluster-control', + targetGeneration: 1, + }; + const proposal = createPluginPackageInstallProposal({ + actionRef: 'proposal:secret-binding-install-v1', + actionInput, + proposedBy: REQUESTER.subject, + proposalFence: { projectVersion: 3, bindingVersion: 4 }, + createdAtMs: 90, + }); + const lock = createPluginPackageLock({ + ...actionInput, + approval: { + requestId: 'approval-install-v1', + requestVersion: 1, + dispatchId: 'dispatch-install-v1', + actionDigest: pluginPackageInstallActionDigest(actionInput), + previewDigest: pluginPackageInstallPlanDigest(installPlan), + approvedBy: { type: 'user', id: 'install-reviewer' }, + approvedAtMs: 100, + expiresAtMs: 2_000, + fence: { projectVersion: 3, bindingVersion: 4 }, + }, + createdAtMs: 101, + }); + const queued = createPluginPackageInstall(lock, { + installationId: 'install-secret-binding-1', + mutationId: 'mutation-create', + occurredAtMs: 102, + }); + const staged = transitionPluginPackageInstall(lock, queued, { + type: 'stage_completed', + mutationId: 'mutation-stage', + occurredAtMs: 103, + stageRef: 'stage-secret-binding-1', + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'c'.repeat(64), + }); + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: 'mutation-activate', + occurredAtMs: 104, + }); + const record = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: 'mutation-commit', + occurredAtMs: 105, + activationRef: 'activation-secret-binding-1', + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: 1, + contentDigest: lock.source.contentDigest, + }); + return { lock, manifest, proposal, record }; +} + +function policyRow(subjectId, role) { + return { + projectId: 'project-1', + projectName: 'Project 1', + projectSlug: 'project-1', + projectStatus: 'active', + projectVersion: 3, + projectCreatedAtMs: 1, + projectUpdatedAtMs: 2, + bindingProjectId: 'project-1', + bindingSubjectType: 'user', + bindingSubjectId: subjectId, + bindingVersion: 4, + bindingState: 'active', + bindingRole: role, + bindingMutationId: `binding-${subjectId}-v4`, + bindingChangedByType: 'user', + bindingChangedById: 'root-owner', + bindingCreatedAtMs: 2, + }; +} + +function fixture() { + const install = installFixture(); + const plans = new Map(); + const approvals = new Map(); + const audits = new Map(); + const pool = { + async query(text, values) { + if (text.includes('FROM "ql3"."projects" AS project')) { + const role = values[2] === 'cluster-owner' ? 'owner' : 'admin'; + return { rows: [policyRow(values[2], role)] }; + } + if (text.includes('plugin_package_secret_binding_planning_snapshot')) { + return { + rows: [{ + recordJson: install.record, + lockJson: install.lock, + proposalJson: install.proposal, + observedAtMs: 200, + }], + }; + } + if (text.includes('create_plugin_package_secret_binding_approval_plan')) { + const plan = JSON.parse(values[0]); + if (plans.has(plan.actionRef)) return { rows: [{ status: 'existing' }] }; + plans.set(plan.actionRef, plan); + return { rows: [{ status: 'created' }] }; + } + if (text.includes('FROM "ql3"."plugin_package_secret_binding_approval_plans"')) { + const plan = plans.get(values[0]); + return { rows: plan ? [{ planJson: plan }] : [] }; + } + if (text.includes('FROM "ql3"."approval_requests"')) { + const request = approvals.get(values[0]); + return { + rows: request + ? [{ requestJson: request, requestDigest: require('@qinglong/runtime-core/approved-action').approvalRequestDigest(request) }] + : [], + }; + } + throw new Error(`unexpected pool query: ${text}`); + }, + async connect() { + const client = { + async query(text, values) { + if ( + text === 'BEGIN ISOLATION LEVEL SERIALIZABLE' || + text === 'COMMIT' || + text === 'ROLLBACK' || + text.includes('set_config') + ) return { rows: [] }; + if (text.includes('lock_approval_policy_fence')) { + return { rows: [{ matches: true }] }; + } + if (text.includes('FROM "ql3"."approval_requests"')) { + const request = approvals.get(values[0]); + return { rows: request ? [{ requestJson: request, requestDigest: require('@qinglong/runtime-core/approved-action').approvalRequestDigest(request) }] : [] }; + } + if (text.includes('FROM "ql3"."security_audit_events"')) { + const audit = audits.get(values[0]); + return { rows: audit ? [audit] : [] }; + } + if (text.includes('INSERT INTO "ql3"."approval_requests"')) { + approvals.set(values[0], JSON.parse(values[14])); + return { rows: [], rowCount: 1 }; + } + if (text.includes('UPDATE "ql3"."approval_requests"')) { + approvals.set(values[8], JSON.parse(values[5])); + return { rows: [], rowCount: 1 }; + } + if (text.includes('INSERT INTO "ql3"."security_audit_events"')) { + audits.set(values[0], { + eventId: values[0], requestId: values[1], operationId: values[2], + projectId: values[3], subjectType: values[4], subjectId: values[5], + authenticationId: values[6], outcome: values[7], + reasonsJson: JSON.parse(values[8]), fenceProjectVersion: values[9], + fenceBindingVersion: values[10], occurredAtMs: values[11], + }); + return { rows: [], rowCount: 1 }; + } + throw new Error(`unexpected transaction query: ${text}`); + }, + release() {}, + }; + return client; + }, + }; + return { approvals, plans, pool }; +} + +function planRequest(overrides = {}) { + return { + actionRef: 'secret-binding:example-monitor-v1', + projectId: 'project-1', + packageName: 'example-monitor', + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }], + principal: REQUESTER, + ...overrides, + }; +} + +test('plans, proposes and independently decides one exact Secret binding', async () => { + const state = fixture(); + let clock = 210; + const service = createClusterPluginPackageSecretBindingManagementService({ + pool: state.pool, + now: () => clock, + planLifetimeMs: 1_000, + approvalLifetimeMs: 1_000, + }); + const created = await service.plan(planRequest()); + clock = 300; + const replay = await service.plan(planRequest()); + assert.equal(created.status, 'created'); + assert.equal(replay.status, 'existing'); + assert.deepEqual(replay.plan, created.plan); + assert.equal(created.plan.bindingPlan.plannedAtMs, 200); + assert.equal(created.plan.expiresAtMs, 1_200); + assert.deepEqual(created.plan.bindingPlan.entries, [{ + name: 'TOKEN', + required: true, + secretRef: planRequest().assignments[0].secretRef, + }]); + + const proposed = await service.propose({ + actionRef: created.plan.actionRef, + approvalRequestId: 'approval-secret-binding-1', + approvalAuditEventId: '123e4567-e89b-42d3-a456-426614175201', + principal: REQUESTER, + }); + assert.equal(proposed.approvalStatus, 'created'); + assert.equal(proposed.approvalRequest.decisionMode, 'separation_of_duty'); + assert.equal(proposed.approvalRequest.risk, 'high'); + assert.equal(proposed.approvalRequest.action.permission, 'secret.manage'); + + clock = 350; + const decided = await service.decide({ + actionRef: created.plan.actionRef, + approvalRequestId: proposed.approvalRequest.id, + expectedVersion: 1, + decisionId: 'decision-secret-binding-1', + auditEventId: '123e4567-e89b-42d3-a456-426614175202', + decision: 'approved', + reasonCode: 'reviewed', + principal: REVIEWER, + }); + assert.equal(decided.status, 'decided'); + assert.equal(decided.request.state, 'approved'); + assert.deepEqual(decided.request.decidedBy, REVIEWER.subject); +}); + +test('rejects weak requester, self-decision and semantic actionRef replay drift', async () => { + const state = fixture(); + const service = createClusterPluginPackageSecretBindingManagementService({ + pool: state.pool, + now: () => 210, + planLifetimeMs: 1_000, + }); + await assert.rejects( + service.plan(planRequest({ + principal: { ...REQUESTER, assurance: 'single_factor' }, + })), + PluginPackageManagementAuthorizationError, + ); + const created = await service.plan(planRequest()); + await assert.rejects( + service.plan(planRequest({ + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'another-token', + version: 2, + }), + }], + })), + PluginPackageManagementConflictError, + ); + const proposed = await service.propose({ + actionRef: created.plan.actionRef, + approvalRequestId: 'approval-secret-binding-1', + approvalAuditEventId: '123e4567-e89b-42d3-a456-426614175201', + principal: REQUESTER, + }); + await assert.rejects( + service.decide({ + actionRef: created.plan.actionRef, + approvalRequestId: proposed.approvalRequest.id, + expectedVersion: 1, + decisionId: 'decision-secret-binding-self', + auditEventId: '123e4567-e89b-42d3-a456-426614175203', + decision: 'approved', + reasonCode: 'reviewed', + principal: REQUESTER, + }), + (error) => error?.name === 'ApprovalSeparationOfDutyError', + ); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs new file mode 100644 index 00000000..a332870a --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs @@ -0,0 +1,471 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { randomBytes, randomUUID } = require('node:crypto'); +const { mkdirSync, rmSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { test } = require('node:test'); + +const { + createPostgresDatabaseOpener, + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingRepository, +} = require('@qinglong/cluster-postgres/package-executor'); +const { + PostgresApprovedActionExecutionRepository, +} = require('@qinglong/cluster-postgres/approved-action-execution'); +const { + runPostgresMigrations, +} = require('@qinglong/cluster-postgres/migration'); +const { + PostgresApprovalRequestRepository, +} = require('@qinglong/cluster-postgres/approved-action'); +const { + PostgresPluginPackageInstallRepository, +} = require('@qinglong/cluster-postgres/plugin-package-install'); +const { + PLUGIN_PACKAGE_API_VERSION, + PLUGIN_PACKAGE_KIND, + planPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package'); +const { + pluginPackageInstallCommit, + pluginPackageActivationIntentDigest, + transitionPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package-install'); +const { + createPluginPackagePublisherProvenance, +} = require('@qinglong/runtime-core/plugin-package-publisher-provenance'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { + createClusterPluginPackageManagementService, +} = require('@qinglong/cluster-admin/plugin-package-management'); +const { + createClusterPluginPackageApprovedActionDispatcher, +} = require('@qinglong/cluster-admin/plugin-package-approved-action'); +const { + createClusterPluginPackageSecretBindingManagementService, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-management'); +const { + consumeClusterPluginPackageSecretBindingApprovals, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-approval-consumer'); +const { + ProjectedPluginPackageSecretExistenceInspector, +} = require('@qinglong/cluster-admin/plugin-package-secret-existence-inspector'); +const { + ClusterPluginPackageSecretBindingApprovedActionHandler, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-approved-action'); + +const MIGRATION_URL = + process.env.QL3_TEST_POSTGRES_MIGRATION_URL ?? + process.env.QL3_TEST_POSTGRES_URL; +const MANAGER_URL = process.env.QL3_TEST_POSTGRES_PACKAGE_MANAGER_URL; +const EXECUTOR_URL = process.env.QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL; + +function opener(role, connectionString, applicationName) { + return createPostgresDatabaseOpener({ + role, + connection: { connectionString, tls: { mode: 'disable' } }, + pool: { maxConnections: 2, applicationName }, + onPoolError(error) { + throw error; + }, + }); +} + +function principal(subject, authenticationId, now) { + return Object.freeze({ + subject, + authenticationId, + authenticatedAtMs: now - 1, + expiresAtMs: now + 120_000, + assurance: 'multi_factor', + }); +} + +function audit(eventId, requestId, operationId, projectId, subject, now, fence) { + return Object.freeze({ + eventId, + requestId, + operationId, + projectId, + subject, + authenticationId: 'cluster-secret-binding-integration', + outcome: 'allowed', + reasons: Object.freeze(['package_review']), + fence, + occurredAtMs: now, + }); +} + +if (!MIGRATION_URL || !MANAGER_URL || !EXECUTOR_URL) { + test('Cluster Secret binding PostgreSQL integration requires three role URLs', { + skip: true, + }); +} else { + test('plans, approves, consumes and publishes one Secret binding through real PostgreSQL roles', async () => { + const suffix = randomBytes(4).toString('hex'); + const projectId = `secret-binding-${suffix}`; + const packageName = `secret-binding-${suffix}`; + const requesterSubject = Object.freeze({ type: 'user', id: `owner-${suffix}` }); + const reviewerSubject = Object.freeze({ type: 'user', id: `reviewer-${suffix}` }); + const fence = Object.freeze({ projectVersion: 1, bindingVersion: 1 }); + let now = Date.now(); + const migration = await opener('migration', MIGRATION_URL, `ql3-secret-migrate-${suffix}`)(); + const manager = await opener('package-manager', MANAGER_URL, `ql3-secret-manager-${suffix}`)(); + const executor = await opener('package-executor', EXECUTOR_URL, `ql3-secret-executor-${suffix}`)(); + const projectionRoot = join(tmpdir(), `ql3-secret-projection-${suffix}`); + mkdirSync(projectionRoot, { mode: 0o700 }); + try { + await runPostgresMigrations({ pool: migration.pool }); + await migration.pool.query( + `INSERT INTO "ql3"."projects" ( + id, name, slug, status, version, created_at_ms, updated_at_ms + ) VALUES ($1, $1, $1, 'active', 1, $2, $2)`, + [projectId, now], + ); + await migration.pool.query( + `INSERT INTO "ql3"."project_role_bindings" ( + project_id, subject_type, subject_id, version, state, role, + mutation_id, changed_by_type, changed_by_id, created_at_ms + ) VALUES + ($1, 'user', $2, 1, 'active', 'owner', $4, 'system', 'integration', $5), + ($1, 'user', $3, 1, 'active', 'admin', $6, 'system', 'integration', $5)`, + [ + projectId, + requesterSubject.id, + reviewerSubject.id, + `grant-owner-${suffix}`, + now, + `grant-reviewer-${suffix}`, + ], + ); + + const manifest = Object.freeze({ + apiVersion: PLUGIN_PACKAGE_API_VERSION, + kind: PLUGIN_PACKAGE_KIND, + metadata: { + name: packageName, + displayName: 'Secret binding PostgreSQL integration', + version: '1.0.0', + description: 'One bounded content-free Secret binding fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '16Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: false }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }); + const environment = Object.freeze({ + qinglongVersion: '3.0.0-alpha.0', + architecture: 'arm64', + deploymentProfile: 'cluster-control', + runtimes: [], + availableMemoryBytes: 128 * 1024 * 1024, + availableDiskBytes: 256 * 1024 * 1024, + }); + const installPlan = planPluginPackageInstall(manifest, environment); + const actionInput = Object.freeze({ + lockId: `lock-${suffix}`, + projectId, + manifest, + plan: installPlan, + environment, + source: { + kind: 'offline', + locator: `offline:sha256:${'a'.repeat(64)}`, + artifactDigest: 'a'.repeat(64), + artifactBytes: 2048, + contentDigest: 'b'.repeat(64), + }, + architecture: 'arm64', + deploymentProfile: 'cluster-control', + targetGeneration: 1, + }); + const installActionRef = `install:${packageName}:v1`; + const installApprovalId = `install-approval-${suffix}`; + const installManagement = createClusterPluginPackageManagementService({ + pool: manager.pool, + now: () => now, + approvalLifetimeMs: 60_000, + }); + const proposed = await installManagement.propose({ + actionRef: installActionRef, + approvalRequestId: installApprovalId, + proposalAuditEventId: randomUUID(), + approvalAuditEventId: randomUUID(), + requestedAtMs: now, + actionInput, + principal: principal(requesterSubject, `install-owner-${suffix}`, now), + }); + now += 10; + const installDecision = await installManagement.decide({ + approvalRequestId: installApprovalId, + expectedVersion: proposed.approvalRequest.version, + decisionId: `install-decision-${suffix}`, + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + decidedAtMs: now, + principal: principal(reviewerSubject, `install-reviewer-${suffix}`, now), + }); + assert.equal(installDecision.status, 'decided'); + now += 10; + const installConsumed = await new PostgresApprovalRequestRepository( + executor.pool, + ).consume({ + requestId: installApprovalId, + expectedVersion: installDecision.request.version, + consumptionId: `install-consume-${suffix}`, + dispatchId: `install-dispatch-${suffix}`, + action: installDecision.request.action, + requestedBy: requesterSubject, + consumedBy: { type: 'system', id: 'cluster_package_executor' }, + consumedAtMs: now, + authorizationFence: fence, + audit: audit( + randomUUID(), + installApprovalId, + 'approval.consume', + projectId, + { type: 'system', id: 'cluster_package_executor' }, + now, + fence, + ), + }); + assert.equal(installConsumed.status, 'consumed'); + let id = 0; + now += 10; + const installDispatch = await createClusterPluginPackageApprovedActionDispatcher({ + pool: executor.pool, + owner: `install-executor-${suffix}`, + clock: () => now, + createId: () => `install-executor-id-${suffix}-${++id}`, + secretExistenceInspector: { async assertExists() {} }, + }).dispatchBatch({ limit: 4 }); + assert.equal(installDispatch.succeeded, 1); + + const installs = new PostgresPluginPackageInstallRepository(executor.pool); + const queued = await installs.find(projectId, packageName); + assert.ok(queued); + const lock = await installs.findLock(queued.lockDigest); + assert.ok(lock); + now += 10; + const staged = transitionPluginPackageInstall(lock, queued, { + type: 'stage_completed', + mutationId: `stage-${suffix}`, + occurredAtMs: now, + stageRef: `stage:${lock.lockDigest}`, + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'c'.repeat(64), + }); + const provenance = createPluginPackagePublisherProvenance({ + projectId, + packageName, + installationId: queued.installationId, + lockDigest: lock.lockDigest, + artifactDigest: staged.stageReceipt.artifactDigest, + manifestDigest: staged.stageReceipt.manifestDigest, + contentDigest: staged.stageReceipt.contentDigest, + stageEvidenceDigest: staged.stageReceipt.evidenceDigest, + signature: { + publisher: 'integration.qinglong.dev', + keyId: 'integration-key-1', + signatureDigest: 'd'.repeat(64), + keyNotBeforeMs: now - 1, + keyNotAfterMs: now + 60_000, + verifiedAtMs: now, + }, + }); + await executor.pool.query( + `INSERT INTO "ql3"."plugin_package_publisher_provenance" ( + installation_id, project_id, package_name, lock_digest, + artifact_digest, manifest_digest, content_digest, + stage_evidence_digest, publisher, key_id, signature_digest, + key_not_before_ms, key_not_after_ms, verified_at_ms, + provenance_digest, provenance_json + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16::jsonb + )`, + [ + provenance.installationId, + provenance.projectId, + provenance.packageName, + provenance.lockDigest, + provenance.artifactDigest, + provenance.manifestDigest, + provenance.contentDigest, + provenance.stageEvidenceDigest, + provenance.publisher, + provenance.keyId, + provenance.signatureDigest, + provenance.keyNotBeforeMs, + provenance.keyNotAfterMs, + provenance.verifiedAtMs, + provenance.provenanceDigest, + JSON.stringify(provenance), + ], + ); + await installs.commit(pluginPackageInstallCommit(queued, staged)); + now += 10; + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: `activate-${suffix}`, + occurredAtMs: now, + }); + await installs.commit(pluginPackageInstallCommit(staged, activating)); + now += 10; + const active = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: `commit-${suffix}`, + occurredAtMs: now, + activationRef: `active:${lock.lockDigest}`, + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: 1, + contentDigest: lock.source.contentDigest, + }); + await installs.commit(pluginPackageInstallCommit(activating, active)); + + const secretRef = createSecretRef({ + projectId, + name: 'runtime-token', + version: 1, + }); + writeFileSync(join(projectionRoot, secretProjectionFileName(secretRef)), '', { + mode: 0o440, + }); + const secretActionRef = `secret-binding:${packageName}:v1`; + const secretApprovalId = `secret-approval-${suffix}`; + const secretManagement = createClusterPluginPackageSecretBindingManagementService({ + pool: manager.pool, + now: () => now, + planLifetimeMs: 60_000, + approvalLifetimeMs: 60_000, + }); + const planned = await secretManagement.plan({ + actionRef: secretActionRef, + projectId, + packageName, + assignments: [{ name: 'TOKEN', secretRef }], + principal: principal(requesterSubject, `secret-owner-${suffix}`, now), + }); + assert.equal(planned.status, 'created'); + now = Math.max(now, planned.plan.bindingPlan.plannedAtMs); + const replay = await secretManagement.plan({ + actionRef: secretActionRef, + projectId, + packageName, + assignments: [{ name: 'TOKEN', secretRef }], + principal: principal(requesterSubject, `secret-owner-${suffix}`, now), + }); + assert.equal(replay.status, 'existing'); + now += 10; + const secretProposed = await secretManagement.propose({ + actionRef: secretActionRef, + approvalRequestId: secretApprovalId, + approvalAuditEventId: randomUUID(), + principal: principal(requesterSubject, `secret-owner-${suffix}`, now), + }); + now += 10; + const secretDecision = await secretManagement.decide({ + actionRef: secretActionRef, + approvalRequestId: secretApprovalId, + expectedVersion: secretProposed.approvalRequest.version, + decisionId: `secret-decision-${suffix}`, + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + principal: principal(reviewerSubject, `secret-reviewer-${suffix}`, now), + }); + assert.equal(secretDecision.status, 'decided'); + now += 10; + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingApprovals({ + pool: executor.pool, + now: () => now, + limit: 4, + }), + { scanned: 1, consumed: 1, existing: 0, expired: 0, blocked: 0 }, + ); + const inspector = new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: projectionRoot, + }); + await inspector.assertExists([secretRef]); + const consumedSecretApproval = + await new PostgresApprovalRequestRepository(executor.pool).findById( + secretApprovalId, + ); + assert.ok(consumedSecretApproval?.dispatchId); + const pendingSecretExecution = + await new PostgresApprovedActionExecutionRepository( + executor.pool, + ).findExecutionByDispatchId(consumedSecretApproval.dispatchId); + assert.ok(pendingSecretExecution); + assert.deepEqual( + await new ClusterPluginPackageSecretBindingApprovedActionHandler( + new PostgresPluginPackageSecretBindingApprovalPlanReader( + executor.pool, + ), + new PostgresPluginPackageSecretBindingRepository(executor.pool), + inspector, + ).inspect(pendingSecretExecution.dispatch), + { + status: 'ready', + actionDigest: planned.plan.approvalPlanDigest, + }, + ); + id = 0; + now += 10; + const secretDispatcher = createClusterPluginPackageApprovedActionDispatcher({ + pool: executor.pool, + owner: `secret-executor-${suffix}`, + clock: () => now, + createId: () => `secret-executor-id-${suffix}-${++id}`, + secretExistenceInspector: inspector, + }); + const secretDispatch = await secretDispatcher.dispatchBatch({ limit: 4 }); + assert.equal(secretDispatch.succeeded, 1); + const bindings = new PostgresPluginPackageSecretBindingRepository(executor.pool); + const binding = await bindings.find( + planned.plan.bindingPlan.target.generationDigest, + ); + assert.ok(binding); + assert.equal(binding.authority.kind, 'approved-action-execution'); + assert.equal(binding.authority.evidenceDigest, planned.plan.approvalPlanDigest); + assert.deepEqual(binding.entries, planned.plan.bindingPlan.entries); + assert.doesNotMatch(JSON.stringify(binding), /secret-value/); + assert.equal((await secretDispatcher.dispatchBatch({ limit: 4 })).scanned, 0); + await assert.rejects( + manager.pool.query( + `SELECT * FROM "ql3"."plugin_package_secret_bindings" WHERE generation_digest = $1`, + [binding.target.generationDigest], + ), + (error) => error?.code === '42501', + ); + } finally { + await Promise.all([migration.close(), manager.close(), executor.close()]); + rmSync(projectionRoot, { recursive: true, force: true }); + } + }); +} diff --git a/packages/ql3-cluster-control/test/application.test.cjs b/packages/ql3-cluster-control/test/application.test.cjs index ee3ad16c..5af9831b 100644 --- a/packages/ql3-cluster-control/test/application.test.cjs +++ b/packages/ql3-cluster-control/test/application.test.cjs @@ -199,6 +199,7 @@ function runtimePrivileges() { plugin_package_automation_publications: [true, false, false, false], plugin_package_automation_disposition_events: [false, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false], + plugin_package_secret_binding_approval_plans: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_workflow_admissions: [true, true, false, false], plugin_package_workflow_admission_steps: [true, true, false, false], diff --git a/packages/ql3-cluster-control/test/bootstrap.test.cjs b/packages/ql3-cluster-control/test/bootstrap.test.cjs index 26f980ea..5963dcd6 100644 --- a/packages/ql3-cluster-control/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-control/test/bootstrap.test.cjs @@ -113,6 +113,7 @@ function runtimePrivileges() { plugin_package_automation_publications: [true, false, false, false], plugin_package_automation_disposition_events: [false, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false], + plugin_package_secret_binding_approval_plans: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_workflow_admissions: [true, true, false, false], plugin_package_workflow_admission_steps: [true, true, false, false], diff --git a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts index 5c4cb0fe..42ba7151 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts @@ -40,6 +40,8 @@ export { } from '../schema/schemaReadiness'; export { PostgresPluginPackageMaterializedRevisionRepository } from '../plugin-package/installation/pluginPackageMaterializedRevisionRepository'; +export { PostgresApprovalRequestRepository } from '../approved-action/approvalRequestRepository'; +export { PostgresProjectPolicyRepository } from '../security/projectPolicyRepository'; export { PostgresPluginPackageSecretBindingRepository } from '../plugin-package/installation/pluginPackageSecretBindingRepository'; export { PostgresPluginPackageSecretBindingApprovalPlanReader } from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; export { PostgresPluginPackageAutomationPublicationRepository } from '../plugin-package/publication/pluginPackageAutomationPublicationRepository'; diff --git a/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts b/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts index 216a0fbe..cb83ace5 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts @@ -55,6 +55,8 @@ export { } from '../management/pluginPackageIdentityKeysetLedgerRepository'; export { PostgresPluginPackagePublisherTrustAuthorityRepository } from '../plugin-package/publisher/pluginPackagePublisherTrustAuthorityRepository'; +export { PostgresApprovalRequestRepository } from '../approved-action/approvalRequestRepository'; +export { PostgresProjectPolicyRepository } from '../security/projectPolicyRepository'; export { PostgresPluginPackageLifecyclePlanReader } from '../plugin-package/lifecycle/pluginPackageLifecyclePlanRepository'; export { PostgresPluginPackageSecretBindingApprovalPlanReader, diff --git a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts index 45bfa8b4..0800b3c1 100644 --- a/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts +++ b/packages/ql3-cluster-postgres/src/plugin-package/installation/pluginPackageSecretBindingRepository.ts @@ -180,7 +180,10 @@ export class PostgresPluginPackageSecretBindingRepository lock_digest, generation, manifest_digest, authority_kind, evidence_digest, bound_at_ms, binding_digest, binding_json ) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb + SELECT $1::char(64), $2::varchar(128), $3::varchar(63), + $4::varchar(128), $5::char(64), $6::integer, $7::char(64), + $8::varchar(32), $9::char(64), $10::bigint, $11::char(64), + $12::jsonb FROM "ql3"."plugin_package_installs" AS install INNER JOIN "ql3"."plugin_package_install_heads" AS head ON head.installation_id = install.installation_id diff --git a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts index d5d48adf..abb776b7 100644 --- a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts +++ b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts @@ -1,4 +1,9 @@ import type { PostgresPool } from '@qinglong/runtime-core'; +import { + approvalRequestDigest, + normalizeApprovalRequestRecord, + type ApprovalRequestRecord, +} from '@qinglong/runtime-core/approved-action'; import { normalizePluginPackageInstallProposal, type PluginPackageInstallProposal, @@ -86,6 +91,26 @@ function normalizeRow( } } +function normalizeApprovalRow(row: Row): Readonly { + try { + const request = normalizeApprovalRequestRecord( + postgresRequiredJsonObject( + row.requestJson, + unavailable, + ) as unknown as ApprovalRequestRecord, + ); + if ( + approvalRequestDigest(request) !== + postgresRequiredString(row.requestDigest, unavailable) + ) { + throw unavailable(); + } + return request; + } catch (error) { + throw unavailable(error); + } +} + function same(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -194,6 +219,36 @@ export class PostgresPluginPackageSecretBindingApprovalPlanReader { throw mapStorageError(error); } } + + async listApprovedRequests( + limitValue: number, + ): Promise[]> { + if ( + !Number.isSafeInteger(limitValue) || + limitValue < 1 || + limitValue > 64 + ) { + throw new TypeError('Secret binding approval page limit is invalid'); + } + try { + const result = await this.pool.query( + `SELECT request.request_json AS "requestJson", + request.request_digest AS "requestDigest" + FROM "ql3"."approval_requests" AS request + JOIN "ql3"."plugin_package_secret_binding_approval_plans" AS plan + ON plan.action_ref = request.action_ref + WHERE request.state = 'approved' + AND request.action_type = 'plugin_package.secret_binding.bind' + ORDER BY request.updated_at_ms, request.request_id + LIMIT $1`, + [limitValue], + ); + if (result.rows.length > limitValue) throw unavailable(); + return Object.freeze(result.rows.map(normalizeApprovalRow)); + } catch (error) { + throw mapStorageError(error); + } + } } export class PostgresPluginPackageSecretBindingApprovalPlanRepository diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs index eb33c5bf..32ab6cd8 100644 --- a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs @@ -3,6 +3,11 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); +const { + approvalRequestDigest, + createApprovalRequest, + decideApprovalRequest, +} = require('@qinglong/runtime-core/approved-action'); const { PLUGIN_PACKAGE_API_VERSION, PLUGIN_PACKAGE_KIND, @@ -292,6 +297,66 @@ test('returns absence and fails closed on replay drift or storage conflict', asy ); }); +test('lists only a bounded digest-verified approved Secret binding queue', async () => { + const { approvalPlan } = fixture(); + const pending = createApprovalRequest({ + id: 'approval-secret-binding-list-1', + projectId: approvalPlan.bindingPlan.target.projectId, + action: require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan') + .pluginPackageSecretBindingApprovedAction(approvalPlan), + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: approvalPlan.requestedBy, + requestedAtMs: 301, + expiresAtMs: 800, + requestFence: { projectVersion: 1, bindingVersion: 1 }, + }); + const approved = decideApprovalRequest(pending, { + expectedVersion: 1, + decisionId: 'decision-secret-binding-list-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: { type: 'user', id: 'security-reviewer' }, + authenticationId: 'auth-security-reviewer', + authenticatedAtMs: 302, + expiresAtMs: 700, + assurance: 'multi_factor', + }, + decidedAtMs: 303, + authorizationFence: { projectVersion: 1, bindingVersion: 1 }, + }); + const calls = []; + const reader = new PostgresPluginPackageSecretBindingApprovalPlanReader({ + async query(text, parameters) { + calls.push({ text, parameters }); + return { + rows: [{ + requestJson: approved, + requestDigest: approvalRequestDigest(approved), + }], + }; + }, + }); + assert.deepEqual(await reader.listApprovedRequests(4), [approved]); + assert.match(calls[0].text, /request\.state = 'approved'/); + assert.match(calls[0].text, /plugin_package\.secret_binding\.bind/); + assert.deepEqual(calls[0].parameters, [4]); + await assert.rejects(reader.listApprovedRequests(65), TypeError); + + const corrupt = new PostgresPluginPackageSecretBindingApprovalPlanReader({ + async query() { + return { + rows: [{ requestJson: approved, requestDigest: 'f'.repeat(64) }], + }; + }, + }); + await assert.rejects( + corrupt.listApprovedRequests(4), + PluginPackageSecretBindingApprovalPlanUnavailableError, + ); +}); + test('exports plan creation only to the Package manager and readback to the executor', () => { const manager = require('@qinglong/cluster-postgres/package-manager'); const executor = require('@qinglong/cluster-postgres/package-executor'); diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs index cc728a76..cf122b5c 100644 --- a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingRepository.test.cjs @@ -187,3 +187,21 @@ test('publishes storage through package-executor and explicit subpath', () => { PostgresPluginPackageSecretBindingRepository, ); }); + +test('casts reused INSERT parameters to their durable PostgreSQL column types', async () => { + let statement = ''; + const repository = new PostgresPluginPackageSecretBindingRepository({ + async query(text) { + if (text.includes('INSERT INTO')) { + statement = text; + return { rows: [{ generation_digest: 'a'.repeat(64) }], rowCount: 1 }; + } + return { rows: [] }; + }, + }); + await assert.rejects(repository.publish(fixture())); + assert.match(statement, /\$2::varchar\(128\)/); + assert.match(statement, /\$3::varchar\(63\)/); + assert.match(statement, /\$6::integer/); + assert.match(statement, /\$10::bigint/); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 8d53ef72..8bf90901 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -2342,6 +2342,12 @@ function auditSourceImports(root, packagePath, findings) { (path.relative(packageDirectory, filePath) === 'src/plugin-package/management/pluginPackageManagementProcess.ts' && specifier === '@qinglong/cluster-postgres/package-manager') || + (path.relative(packageDirectory, filePath) === + 'src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts' && + specifier === '@qinglong/cluster-postgres/package-manager') || + (path.relative(packageDirectory, filePath) === + 'src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts' && + specifier === '@qinglong/cluster-postgres/package-executor') || ([ 'src/worker-credential/management-server/workerCredentialManagement.ts', 'src/worker-credential/management-server/workerCredentialManagementProcess.ts', diff --git a/scripts/ql3-cluster-deployment-audit.cjs b/scripts/ql3-cluster-deployment-audit.cjs index 9d7449e6..c89f7524 100644 --- a/scripts/ql3-cluster-deployment-audit.cjs +++ b/scripts/ql3-cluster-deployment-audit.cjs @@ -3254,6 +3254,10 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { ['QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', '600000'], ['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE', '16'], ['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES', '16'], + [ + 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT', + '/var/run/secrets/qinglong3/plugin-package-values', + ], ['QL3_POSTGRES_TLS_MODE', 'verify-full'], [ 'QL3_POSTGRES_TLS_CA_FILE', @@ -3294,6 +3298,11 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { 'postgres-package-executor-ca', ); const caVolume = namedEntry(pod?.volumes, 'postgres-package-executor-ca'); + const secretMount = namedEntry( + container?.volumeMounts, + 'plugin-package-values', + ); + const secretVolume = namedEntry(pod?.volumes, 'plugin-package-values'); if ( caMount?.readOnly !== true || caMount?.mountPath !== @@ -3302,6 +3311,13 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { caVolume?.secret?.defaultMode !== 0o444 || JSON.stringify(caVolume?.secret?.items) !== JSON.stringify([{ key: 'postgres-ca.crt', path: 'ca.crt' }]) || + secretMount?.readOnly !== true || + secretMount?.mountPath !== + '/var/run/secrets/qinglong3/plugin-package-values' || + secretVolume?.secret?.secretName !== + 'ql3-cluster-plugin-package-values' || + secretVolume?.secret?.optional !== true || + secretVolume?.secret?.defaultMode !== 0o440 || JSON.stringify(networkPolicy?.spec?.policyTypes) !== JSON.stringify(['Ingress', 'Egress']) || JSON.stringify(networkPolicy?.spec?.ingress) !== JSON.stringify([]) || @@ -3310,7 +3326,7 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { findings.push( finding( 'QL3_CLUSTER_PLUGIN_EXECUTOR_BOUNDARY', - 'Plugin Package executor must have one private CA projection, no ingress and DNS-only base egress', + 'Plugin Package executor must have private read-only CA and optional SecretRef projections, no ingress and DNS-only base egress', ), ); } @@ -3366,6 +3382,8 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { }, ]) || cloudNativeEnv.has('QL3_POSTGRES_PACKAGE_EXECUTOR_URL') || + cloudNativeEnv.get('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT')?.value !== + '/var/run/secrets/qinglong3/plugin-package-values' || cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_HOST')?.value !== 'ql3-postgres-rw.qinglong3-system.svc' || cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_USER')?.valueFrom diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index c8b27e0c..c341e5df 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 100, + sourceFiles: 103, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 99, + nestedSourceFiles: 102, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },