diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 680803ff..895b718a 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -451,7 +451,12 @@ become trusted by restarting every replica. Server TLS certificate rotation requires a controlled Pod rollout because TLS material is loaded only at process startup. -This deployment exposes only `propose`, `decide`, and low-sensitive `inspect`. +This deployment exposes only reviewed Package proposal/decision/inspection, +installation inventory, lifecycle review, publisher trust review, and +content-free Secret binding `plan|propose|decide|inspect`. Secret binding plans +derive generation and digests from PostgreSQL; the caller supplies only logical +requirements and fixed-version same-Project Secret references. The management +Pods never receive projected Secret values or package-executor database authority. It has no Kubernetes API token or RBAC, no Registry credential, and no Package executor/admin/runtime database identity. Process-local rate limiting is a bounded anonymous-traffic shield, not a distributed quota. Authenticated diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 975ebf41..4b341962 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 或当前代重复绑定失败关闭。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-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,缺投影失败关闭。产品入口已复用既有 `/api/v3/plugin-packages/management`、mTLS/OIDC 强 User 身份、同一个 package-manager Pool、分布式 quota 与 `ql3-cluster-admin package` 一次性 client,新增严格 exact-shape 的 `plugin-package.secret-binding.plan|propose|decide|inspect` 四命令;成功响应仅含 target、固定 version SecretRef、审批与 digest 摘要,客户端会拒绝跨 Project SecretRef、重复 requirement、错误 action/approval/digest 绑定和扩展响应。该入口不把 Secret value、executor、Kubernetes 或 binding 表直读 authority 装进 management 进程,也不增加 listener、连接或 workload。PostgreSQL 18 三角色真实纵切面已从正式 transport 完整执行 install/activate→plan/replay/propose/decide/inspect→consume/dispatch/bind,并证明 manager 不能读取最终 binding 表。完整 18-package clean build/test 退出 0,cluster-admin 315 pass/3 条件 skip,backend 1,188 pass/2 条件 skip/0 fail;package/dependency/deployment/CloudNativePG/edge/service-manager/local-image 七项审计零 finding。最新 PostgreSQL 18.4 arm64 physical HA 通过 125 gate、timeline `1→2`,报告 SHA-256 为 `6506b74721891cfd5709b1661562c204a310e99ed06131a25c464128f4ded4c6`。未新增 package、migration、表、索引、依赖、常驻进程、连接、listener、timer、watcher、cache 或 workload;18 个 workspace package仍无 single-source/shallow-source package。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 bcc498d8..756c69e4 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 @@ -40,4 +40,6 @@ 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 核心 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。 +- Cluster 产品入口已完成:复用既有 `/api/v3/plugin-packages/management` HTTPS route、mTLS/OIDC 强 User 身份、package-manager Pool、分布式 quota 与 `ql3-cluster-admin package` 一次性 client,开放 exact-shape 的 `plugin-package.secret-binding.plan|propose|decide|inspect`。响应仅含 content-free target、固定 version SecretRef、审批和 digest 摘要;client 会拒绝跨 Project、重复 requirement、action/approval/digest 漂移和扩展响应。management 进程不取得 Secret value、Kubernetes、executor 或最终 binding 表 authority,也不新增 listener、连接、进程或 workload。 +- 当前 Cluster Admin runtime 镜像的统一产品 facade 已在 UID `10001:10001`、128 MiB、0.25 CPU、32 PIDs、read-only root、无网络、全 capability drop 和 `no-new-privileges` 下完成 live contract;七个子命令、operator context preflight/readiness 与 `package` 分派均通过。这是制品/CLI 资源门,不冒充 Kubernetes Secret projection 或多 Pod live exercise。 +- 正式 transport 经 PostgreSQL 18 三角色真实纵切面 1/1,覆盖 install/activate、plan/exact replay/propose/separation-of-duty decide/authorized inspect、consumer/dispatch/bind、manager 最终表拒绝与 content-free 输出。`cluster-admin` 315 pass/3 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 条件 skip/0 fail。package boundary、cluster dependency、cluster deployment、CloudNativePG、edge import、service-manager bridge、local image 七项审计零 finding;workspace 仍为 18 包且没有 single-source/shallow-source package。PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 已通过,报告 SHA-256 为 `6506b74721891cfd5709b1661562c204a310e99ed06131a25c464128f4ded4c6`。Kubernetes live exercise 仍待完成,因此本 ADR 保持 Proposed。 diff --git a/docs/operations/ql3-plugin-package-management-client.md b/docs/operations/ql3-plugin-package-management-client.md index c0d8539a..7cc524b2 100644 --- a/docs/operations/ql3-plugin-package-management-client.md +++ b/docs/operations/ql3-plugin-package-management-client.md @@ -52,17 +52,49 @@ chmod 600 /absolute/path/client.json \ ## 执行 ```sh -ql3-plugin-package-client \ +ql3-cluster-admin package \ --config=/absolute/path/client.json \ --command=/absolute/path/command.json \ --assertion=/absolute/path/assertion.jwt ``` -可用 operation 只有以下九个: +统一产品入口会在同一 Cluster Admin 制品内委派给 +`ql3-plugin-package-client`;后者保留为直接 binary 兼容入口,两者使用同一协议和 +authority,不会启动第二个管理进程。 + +可用 operation 只有以下十八个: - `plugin-package.propose|decide|inspect` +- `plugin-package.installation.inspect|list` +- `plugin-package.lifecycle.propose|decide|inspect` - `plugin-package.publisher-revocation.propose|decide|inspect` - `plugin-package.publisher-trust-transition.propose|decide|inspect` +- `plugin-package.secret-binding.plan|propose|decide|inspect` + +首次 Secret binding 必须先提交 `plan`,由服务端从当前 active installation、lock、 +Manifest 和 generation 重建目标。调用方只提供逻辑 requirement 到同 Project、固定 +version `qlsecret:v1:` 引用的 assignment;不得提交 generation、digest、Manifest 或 +Secret value。`plan` 返回 content-free target、requirement、digest 和有效期;随后以同一 +`actionRef` 执行 `propose`,由另一名强认证 User 执行 `decide`。审批后的实际 binding 由 +短生命周期 package executor 消费,management client 不持有 executor authority。 + +```json +{ + "schemaVersion": 1, + "operation": "plugin-package.secret-binding.plan", + "request": { + "actionRef": "secret-binding:example:1", + "projectId": "project-1", + "packageName": "example", + "assignments": [ + { + "name": "TOKEN", + "secretRef": "qlsecret:v1:REPLACE_WITH_FIXED_VERSION_REFERENCE" + } + ] + } +} +``` 成功时 stdout 只有服务端审查过的低敏 result。失败时 stderr 只有稳定 code,以及可能的 HTTP status、response code、request ID 和 Retry-After;不会输出 assertion、路径、 @@ -114,7 +146,7 @@ port-forward 是 Kubernetes RBAC 控制的独立私有入口,不是 labelled-P 执行: ```sh -ql3-plugin-package-client-kubernetes \ +ql3-cluster-admin package-kubernetes \ --config=/absolute/path/client.json \ --command=/absolute/path/command.json \ --assertion=/absolute/path/assertion.jwt \ diff --git a/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts b/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts index 745fb6dd..4111aa31 100644 --- a/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts +++ b/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts @@ -3,6 +3,7 @@ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https'; import { Duplex } from 'node:stream'; import { connect as tlsConnect } from 'node:tls'; import { TextDecoder } from 'node:util'; +import { parseSecretRef } from '@qinglong/runtime-core/secret-reference'; import { ClusterPluginPackageManagementClientConfigurationError, isReviewedClusterAuthenticatedManagementClientProtocol, @@ -290,6 +291,21 @@ const LIFECYCLE_PLAN_KEYS = Object.freeze([ 'blockingReferences', 'impactDigest', ]); +const SECRET_BINDING_PLAN_KEYS = Object.freeze([ + 'actionRef', + 'projectId', + 'packageName', + 'installationId', + 'generation', + 'generationDigest', + 'lockDigest', + 'manifestDigest', + 'entries', + 'plannedAtMs', + 'expiresAtMs', + 'planDigest', + 'approvalPlanDigest', +]); function validateScalarSummary(value: unknown, keys: readonly string[]): void { const record = exactResponseObject(value, keys); @@ -463,10 +479,211 @@ function validateLifecyclePlanSummary(value: unknown): void { } } +function validateSecretBindingPlanSummary( + value: unknown, + command: Readonly< + Extract< + ClusterPluginPackageManagementCommand, + { readonly operation: `plugin-package.secret-binding.${string}` } + > + >, +): void { + const summary = exactResponseObject(value, SECRET_BINDING_PLAN_KEYS); + if ( + typeof summary.actionRef !== 'string' || + summary.actionRef.length < 1 || + summary.actionRef.length > 255 || + typeof summary.projectId !== 'string' || + summary.projectId.length < 1 || + summary.projectId.length > 128 || + typeof summary.packageName !== 'string' || + !PACKAGE_NAME_PATTERN.test(summary.packageName) || + typeof summary.installationId !== 'string' || + summary.installationId.length < 1 || + summary.installationId.length > 128 || + !Number.isSafeInteger(summary.generation) || + (summary.generation as number) < 1 || + !Number.isSafeInteger(summary.plannedAtMs) || + !Number.isSafeInteger(summary.expiresAtMs) || + (summary.expiresAtMs as number) <= (summary.plannedAtMs as number) || + !Array.isArray(summary.entries) || + summary.entries.length > 64 || + new Set( + summary.entries.map((entry) => + entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as JsonObject).name + : undefined, + ), + ).size !== summary.entries.length + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + for (const key of [ + 'generationDigest', + 'lockDigest', + 'manifestDigest', + 'planDigest', + 'approvalPlanDigest', + ]) { + if ( + typeof summary[key] !== 'string' || + !DIGEST_PATTERN.test(summary[key] as string) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + for (const entryValue of summary.entries) { + const entry = exactResponseObject(entryValue, [ + 'name', + 'required', + 'secretRef', + ]); + if ( + typeof entry.name !== 'string' || + !/^[A-Z_][A-Z0-9_]{0,127}$/.test(entry.name) || + typeof entry.required !== 'boolean' || + (entry.secretRef !== null && + (typeof entry.secretRef !== 'string' || + entry.secretRef.length > 2_048 || + CONTROL_PATTERN.test(entry.secretRef))) || + (entry.required === true && entry.secretRef === null) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + if (entry.secretRef !== null) { + try { + const reference = parseSecretRef(entry.secretRef); + if ( + reference.projectId !== summary.projectId || + typeof reference.version !== 'number' || + !Number.isSafeInteger(reference.version) || + reference.version < 1 + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } catch (error) { + if (error instanceof ClusterPluginPackageManagementClientRequestError) { + throw error; + } + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + } + if ( + summary.actionRef !== command.request.actionRef || + command.operation === 'plugin-package.secret-binding.plan' && + (summary.projectId !== command.request.projectId || + summary.packageName !== command.request.packageName || + summary.entries.length !== command.request.assignments.length || + command.request.assignments.some((assignment) => { + const responseEntry = (summary.entries as JsonObject[]).find( + (entry) => entry.name === assignment.name, + ); + return !responseEntry || responseEntry.secretRef !== assignment.secretRef; + })) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } +} + function validateResult( value: unknown, command: Readonly, ): Readonly { + if (command.operation === 'plugin-package.secret-binding.plan') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'status', + 'plan', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + !['created', 'existing'].includes(String(result.status)) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + validateSecretBindingPlanSummary(result.plan, command); + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } + if (command.operation === 'plugin-package.secret-binding.propose') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'approvalStatus', + 'plan', + 'approval', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + !['created', 'existing'].includes(String(result.approvalStatus)) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + validateSecretBindingPlanSummary(result.plan, command); + validateScalarSummary(result.approval, APPROVAL_KEYS); + const plan = result.plan as JsonObject; + const approval = result.approval as JsonObject; + if ( + approval.id !== command.request.approvalRequestId || + approval.projectId !== plan.projectId || + approval.actionDigest !== plan.approvalPlanDigest || + approval.previewDigest !== plan.planDigest + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } + if (command.operation === 'plugin-package.secret-binding.inspect') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'plan', + 'approval', + 'stale', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + typeof result.stale !== 'boolean' || + result.plan === null && result.approval === null + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + if (result.plan !== null) { + validateSecretBindingPlanSummary(result.plan, command); + } + if (result.approval !== null) { + validateScalarSummary(result.approval, APPROVAL_KEYS); + if ( + (result.approval as JsonObject).id !== + command.request.approvalRequestId + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + if (result.plan !== null && result.approval !== null) { + const plan = result.plan as JsonObject; + const approval = result.approval as JsonObject; + if ( + approval.id !== command.request.approvalRequestId || + approval.projectId !== plan.projectId || + approval.actionDigest !== plan.approvalPlanDigest || + approval.previewDigest !== plan.planDigest + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } if (command.operation === 'plugin-package.installation.inspect') { const result = exactResponseObject(value, [ 'schemaVersion', @@ -617,6 +834,12 @@ function validateResult( } if (result.approval !== null) { validateScalarSummary(result.approval, APPROVAL_KEYS); + if ( + command.operation === 'plugin-package.secret-binding.decide' && + (result.approval as JsonObject).id !== command.request.approvalRequestId + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } } else if (kind !== 'inspect') { throw new ClusterPluginPackageManagementClientRequestError(); } diff --git a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts index 6fa5c1ab..e4c199dc 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts @@ -28,6 +28,7 @@ import { } from '../../management-support/pluginPackageIdentityKeyset'; import { createClusterPluginPackageManagementService } from './pluginPackageManagement'; import { createClusterPluginPackageLifecycleManagementService } from '../lifecycle/pluginPackageLifecycleManagement'; +import { createClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement'; import { loadClusterPluginPackagePublisherTrustFileEvidence, type ClusterPluginPackagePublisherTrustFileEvidence, @@ -635,10 +636,18 @@ export async function startClusterPluginPackageManagementProcess( now, quota, }); + const secretBinding = + createClusterPluginPackageSecretBindingManagementService({ + pool: database.pool, + approvalLifetimeMs: config.approvalLifetimeMs, + now, + quota, + }); const transport = createClusterPluginPackageManagementTransport({ service, lifecycle, publisherTrust, + secretBinding, now, }); const privateKey = readTlsFile(config.privateKeyFile, true); diff --git a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts index 2ec99569..bd4a944c 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts @@ -14,6 +14,8 @@ import type { } from '@qinglong/runtime-core/plugin-package-management'; import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin-package-proposal'; import type { PluginPackageLifecyclePlan } from '@qinglong/runtime-core/plugin-package-lifecycle-plan'; +import type { PluginPackageSecretBindingAssignment } from '@qinglong/runtime-core/plugin-package-secret-binding'; +import type { PluginPackageSecretBindingApprovalPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; import { normalizeSecurityPrincipal, type SecurityPrincipal, @@ -25,6 +27,7 @@ import type { InspectClusterPluginPackagePublisherRevocationResult, InspectClusterPluginPackagePublisherTrustTransitionResult, } from '../publisher/pluginPackagePublisherTrustManagement'; +import type { ClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement'; const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']); @@ -166,6 +169,39 @@ export interface InspectClusterPluginPackagePublisherTrustTransitionCommand { readonly request: InspectClusterPluginPackageCommand['request']; } +export interface PlanClusterPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.plan'; + readonly request: { + readonly actionRef: string; + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly[]; + }; +} + +export interface ProposeClusterPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.propose'; + readonly request: { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly approvalAuditEventId: string; + }; +} + +export interface DecideClusterPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.decide'; + readonly request: DecideClusterPluginPackageCommand['request']; +} + +export interface InspectClusterPluginPackageSecretBindingCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.inspect'; + readonly request: InspectClusterPluginPackageCommand['request']; +} + export type ClusterPluginPackageManagementCommand = | ProposeClusterPluginPackageCommand | DecideClusterPluginPackageCommand @@ -180,7 +216,11 @@ export type ClusterPluginPackageManagementCommand = | InspectClusterPluginPackagePublisherRevocationCommand | ProposeClusterPluginPackagePublisherTrustTransitionCommand | DecideClusterPluginPackagePublisherTrustTransitionCommand - | InspectClusterPluginPackagePublisherTrustTransitionCommand; + | InspectClusterPluginPackagePublisherTrustTransitionCommand + | PlanClusterPluginPackageSecretBindingCommand + | ProposeClusterPluginPackageSecretBindingCommand + | DecideClusterPluginPackageSecretBindingCommand + | InspectClusterPluginPackageSecretBindingCommand; export type ClusterPluginPackageManagementTransportResult = | Readonly<{ @@ -276,6 +316,32 @@ export type ClusterPluginPackageManagementTransportResult = typeof publisherTrustTransitionProposalSummary > | null; approval: ReturnType | null; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.plan'; + status: 'created' | 'existing'; + plan: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.propose'; + approvalStatus: 'created' | 'existing'; + plan: ReturnType; + approval: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.decide'; + status: 'decided' | 'existing'; + approval: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.inspect'; + plan: ReturnType | null; + approval: ReturnType | null; + stale: boolean; }>; export interface ClusterPluginPackageManagementTransport { @@ -289,6 +355,7 @@ export interface ClusterPluginPackageManagementTransportOptions { readonly service: ClusterPluginPackageManagementService; readonly lifecycle?: ClusterPluginPackageLifecycleManagementService; readonly publisherTrust?: ClusterPluginPackagePublisherTrustManagementService; + readonly secretBinding?: ClusterPluginPackageSecretBindingManagementService; readonly now?: () => number; } @@ -528,6 +595,50 @@ export function normalizeClusterPluginPackageManagementCommand( 'publisher trust transition inspection request', ); break; + case 'plugin-package.secret-binding.plan': + exactObject( + value.request, + ['actionRef', 'assignments', 'packageName', 'projectId'], + 'Secret binding plan request', + ); + if (!Array.isArray(value.request.assignments)) { + throw new ClusterPluginPackageManagementTransportRequestError( + 'Secret binding assignments are invalid', + ); + } + for (const assignment of value.request.assignments) { + exactObject(assignment, ['name', 'secretRef'], 'Secret binding assignment'); + } + break; + case 'plugin-package.secret-binding.propose': + exactObject( + value.request, + ['actionRef', 'approvalAuditEventId', 'approvalRequestId'], + 'Secret binding proposal request', + ); + break; + case 'plugin-package.secret-binding.decide': + exactObject( + value.request, + [ + 'actionRef', + 'approvalRequestId', + 'expectedVersion', + 'decisionId', + 'auditEventId', + 'decision', + 'reasonCode', + ], + 'Secret binding decision request', + ); + break; + case 'plugin-package.secret-binding.inspect': + exactObject( + value.request, + ['actionRef', 'approvalRequestId', 'inspectionId'], + 'Secret binding inspection request', + ); + break; default: throw new ClusterPluginPackageManagementTransportRequestError( 'operation is not publicly available', @@ -640,6 +751,26 @@ function lifecyclePlanSummary( }); } +function secretBindingPlanSummary( + plan: Readonly, +) { + return Object.freeze({ + actionRef: plan.actionRef, + projectId: plan.bindingPlan.target.projectId, + packageName: plan.bindingPlan.target.packageName, + installationId: plan.bindingPlan.target.installationId, + generation: plan.bindingPlan.target.generation, + generationDigest: plan.bindingPlan.target.generationDigest, + lockDigest: plan.bindingPlan.target.lockDigest, + manifestDigest: plan.bindingPlan.target.manifestDigest, + entries: plan.bindingPlan.entries, + plannedAtMs: plan.bindingPlan.plannedAtMs, + expiresAtMs: plan.expiresAtMs, + planDigest: plan.bindingPlan.planDigest, + approvalPlanDigest: plan.approvalPlanDigest, + }); +} + function publisherRevocationProposalSummary( proposal: NonNullable< InspectClusterPluginPackagePublisherRevocationResult['proposal'] @@ -694,6 +825,7 @@ function exactDecisionReplay( | DecideClusterPluginPackageLifecycleCommand | DecideClusterPluginPackagePublisherRevocationCommand | DecideClusterPluginPackagePublisherTrustTransitionCommand + | DecideClusterPluginPackageSecretBindingCommand >, principal: Readonly, ): Readonly | null { @@ -726,6 +858,7 @@ export function createClusterPluginPackageManagementTransport( key !== 'service' && key !== 'lifecycle' && key !== 'publisherTrust' && + key !== 'secretBinding' && key !== 'now', ) || !options.service || @@ -745,6 +878,12 @@ export function createClusterPluginPackageManagementTransport( typeof options.publisherTrust.propose !== 'function' || typeof options.publisherTrust.inspect !== 'function' || typeof options.publisherTrust.inspectAuthorized !== 'function')) || + (options.secretBinding !== undefined && + (!options.secretBinding || + typeof options.secretBinding.plan !== 'function' || + typeof options.secretBinding.propose !== 'function' || + typeof options.secretBinding.decide !== 'function' || + typeof options.secretBinding.inspectAuthorized !== 'function')) || (options.now !== undefined && typeof options.now !== 'function') ) { throw new ClusterPluginPackageManagementTransportConfigurationError( @@ -941,6 +1080,78 @@ export function createClusterPluginPackageManagementTransport( stale: result.stale, }); } + case 'plugin-package.secret-binding.plan': { + if (!options.secretBinding) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret binding management is not configured', + ); + } + const result = await options.secretBinding.plan({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status: result.status, + plan: secretBindingPlanSummary(result.plan), + }); + } + case 'plugin-package.secret-binding.propose': { + if (!options.secretBinding) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret binding management is not configured', + ); + } + const result = await options.secretBinding.propose({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + approvalStatus: result.approvalStatus, + plan: secretBindingPlanSummary(result.plan), + approval: approvalSummary(result.approvalRequest), + }); + } + case 'plugin-package.secret-binding.decide': { + if (!options.secretBinding) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret binding management is not configured', + ); + } + const result = await options.secretBinding.decide({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status: result.status, + approval: approvalSummary(result.request), + }); + } + case 'plugin-package.secret-binding.inspect': { + if (!options.secretBinding) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret binding management is not configured', + ); + } + const result = await options.secretBinding.inspectAuthorized({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + plan: result.plan ? secretBindingPlanSummary(result.plan) : null, + approval: result.approvalRequest + ? approvalSummary(result.approvalRequest) + : null, + stale: result.stale, + }); + } case 'plugin-package.publisher-revocation.propose': { if (!options.publisherTrust) { throw new ClusterPluginPackageManagementTransportConfigurationError( 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 index 3b76f098..c814570c 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingManagement.ts @@ -15,8 +15,11 @@ import { import { PluginPackageManagementAuthorizationError, PluginPackageManagementConflictError, + PluginPackageManagementQuotaExceededError, PluginPackageManagementRequestError, PluginPackageManagementUnavailableError, + type PluginPackageManagementQuotaOperation, + type PluginPackageManagementQuotaPort, } 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'; @@ -111,6 +114,7 @@ export interface ClusterPluginPackageSecretBindingManagementOptions { readonly now?: () => number; readonly planLifetimeMs?: number; readonly approvalLifetimeMs?: number; + readonly quota?: PluginPackageManagementQuotaPort; } function exact(value: unknown, keys: readonly string[], label: string): void { @@ -226,12 +230,15 @@ export function createClusterPluginPackageSecretBindingManagementService( key !== 'pool' && key !== 'now' && key !== 'planLifetimeMs' && - key !== 'approvalLifetimeMs', + key !== 'approvalLifetimeMs' && + key !== 'quota', ) || !options.pool || typeof options.pool.query !== 'function' || typeof options.pool.connect !== 'function' || - (options.now !== undefined && typeof options.now !== 'function') + (options.now !== undefined && typeof options.now !== 'function') || + (options.quota !== undefined && + (!options.quota || typeof options.quota.consume !== 'function')) ) { throw new TypeError( 'cluster Plugin Package Secret binding management options are invalid', @@ -265,6 +272,28 @@ export function createClusterPluginPackageSecretBindingManagementService( new PostgresProjectPolicyRepository(options.pool), ); + const consumeQuota = async ( + projectId: string, + principal: Readonly, + operation: PluginPackageManagementQuotaOperation, + idempotencyKey: string, + ): Promise => { + if (!options.quota) return; + try { + await options.quota.consume({ + projectId, + subject: principal.subject, + operation, + idempotencyKey, + }); + } catch (error) { + if (error instanceof PluginPackageManagementQuotaExceededError) throw error; + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + }; + const authorize = async ( principalValue: SecurityPrincipal, projectId: string, @@ -346,6 +375,12 @@ export function createClusterPluginPackageSecretBindingManagementService( currentTime(now), ); const requestedActionRef = actionRef(request.actionRef); + await consumeQuota( + projectId, + authorization.principal, + 'plugin-package.propose', + requestedActionRef, + ); let existingValue; try { existingValue = await plans.findByActionRef(requestedActionRef); @@ -458,6 +493,12 @@ export function createClusterPluginPackageSecretBindingManagementService( 'secret.manage', observedAtMs, ); + await consumeQuota( + plan.bindingPlan.target.projectId, + authorization.principal, + 'plugin-package.propose', + approvalRequestId, + ); if (!sameSubject(plan.requestedBy, authorization.principal.subject)) { throw new PluginPackageManagementAuthorizationError(); } @@ -572,6 +613,12 @@ export function createClusterPluginPackageSecretBindingManagementService( 'approval.decide', observedAtMs, ); + await consumeQuota( + approval.projectId, + authorization.principal, + 'plugin-package.decide', + decisionId, + ); if ( approval.decisionId === decisionId && approval.decision === request.decision && @@ -636,19 +683,31 @@ export function createClusterPluginPackageSecretBindingManagementService( plan?.bindingPlan.target.projectId ?? approval?.projectId; if (!projectId) throw new PluginPackageManagementUnavailableError(); const observedAtMs = currentTime(now); + let authorization; try { - await authorize(request.principal, projectId, 'secret.manage', observedAtMs); + authorization = await authorize( + request.principal, + projectId, + 'secret.manage', + observedAtMs, + ); } catch (error) { if (!(error instanceof PluginPackageManagementAuthorizationError)) { throw error; } - await authorize( + authorization = await authorize( request.principal, projectId, 'approval.decide', observedAtMs, ); } + await consumeQuota( + projectId, + authorization.principal, + 'plugin-package.inspect', + request.inspectionId, + ); return Object.freeze({ plan, approvalRequest: approval, diff --git a/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs index 681a1ef5..431b28ea 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs @@ -199,6 +199,47 @@ function commands() { operation: 'plugin-package.publisher-trust-transition.inspect', request: inspection, }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.plan', + request: { + actionRef: 'secret-binding:cluster-monitor:1', + projectId: 'project-1', + packageName: 'cluster-monitor', + assignments: [{ + name: 'TOKEN', + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjJ9', + }], + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.propose', + request: { + actionRef: 'secret-binding:cluster-monitor:1', + approvalRequestId: 'approval-secret-binding-1', + approvalAuditEventId: 'audit-secret-binding-approval-1', + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.decide', + request: { + ...decision, + actionRef: 'secret-binding:cluster-monitor:1', + approvalRequestId: 'approval-secret-binding-1', + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.inspect', + request: { + actionRef: 'secret-binding:cluster-monitor:1', + approvalRequestId: 'approval-secret-binding-1', + inspectionId: 'inspection-secret-binding-1', + }, + }, ]; } @@ -327,7 +368,70 @@ function lifecyclePlanSummary() { }; } +function secretBindingPlanSummary() { + return { + actionRef: 'secret-binding:cluster-monitor:1', + projectId: 'project-1', + packageName: 'cluster-monitor', + installationId: 'install-cluster-monitor-1', + generation: 1, + generationDigest: '9'.repeat(64), + lockDigest: 'a'.repeat(64), + manifestDigest: 'b'.repeat(64), + entries: [{ + name: 'TOKEN', + required: true, + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjJ9', + }], + plannedAtMs: 1_000, + expiresAtMs: 10_000, + planDigest: 'c'.repeat(64), + approvalPlanDigest: 'd'.repeat(64), + }; +} + function successfulResult(operation) { + const secretApproval = { + ...approvalSummary(), + id: 'approval-secret-binding-1', + actionDigest: 'd'.repeat(64), + previewDigest: 'c'.repeat(64), + }; + if (operation === 'plugin-package.secret-binding.plan') { + return { + schemaVersion: 1, + operation, + status: 'created', + plan: secretBindingPlanSummary(), + }; + } + if (operation === 'plugin-package.secret-binding.propose') { + return { + schemaVersion: 1, + operation, + approvalStatus: 'created', + plan: secretBindingPlanSummary(), + approval: secretApproval, + }; + } + if (operation === 'plugin-package.secret-binding.inspect') { + return { + schemaVersion: 1, + operation, + plan: secretBindingPlanSummary(), + approval: secretApproval, + stale: false, + }; + } + if (operation === 'plugin-package.secret-binding.decide') { + return { + schemaVersion: 1, + operation, + status: 'decided', + approval: secretApproval, + }; + } if (operation === 'plugin-package.installation.inspect') { return { schemaVersion: 1, @@ -639,7 +743,7 @@ test('readiness probe rejects unreviewed status and bounded response drift', asy } }); -test('permits and validates exactly the fourteen public management operations', async () => { +test('permits and validates exactly the eighteen public management operations', async () => { const received = []; const fixture = await startServer((request, response) => { const chunks = []; @@ -684,7 +788,7 @@ test('permits and validates exactly the fourteen public management operations', return true; }, ); - assert.equal(received.length, 14); + assert.equal(received.length, 18); } finally { await fixture.close(); rmSync(files.directory, { recursive: true, force: true }); @@ -756,6 +860,137 @@ test('rejects installation inventory responses outside the requested project and } }); +test('rejects Secret binding response drift before reporting success', async () => { + const command = commands().find( + ({ operation }) => operation === 'plugin-package.secret-binding.propose', + ); + const invalidResults = [ + { + ...successfulResult(command.operation), + plan: { + ...secretBindingPlanSummary(), + actionRef: 'secret-binding:another-package:1', + }, + }, + { + ...successfulResult(command.operation), + plan: { + ...secretBindingPlanSummary(), + entries: [ + { + ...secretBindingPlanSummary().entries[0], + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJhbm90aGVyLXByb2plY3QiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjJ9', + }, + ], + }, + }, + { + ...successfulResult(command.operation), + plan: { + ...secretBindingPlanSummary(), + entries: [ + ...secretBindingPlanSummary().entries, + ...secretBindingPlanSummary().entries, + ], + }, + }, + { + ...successfulResult(command.operation), + approval: { + ...successfulResult(command.operation).approval, + actionDigest: 'e'.repeat(64), + }, + }, + ]; + const fixture = await startServer((_request, response) => { + sendJson(response, 200, { + schemaVersion: 1, + requestId: `request-invalid-secret-binding-${invalidResults.length}`, + result: invalidResults.shift(), + }); + }); + const files = createClientFiles(fixture.port, command); + try { + for (let index = 0; index < 4; index += 1) { + await assert.rejects( + executeClusterPluginPackageManagementClient(files.paths), + ClusterPluginPackageManagementClientRequestError, + ); + } + } finally { + await fixture.close(); + rmSync(files.directory, { recursive: true, force: true }); + } +}); + +test('binds Secret binding plan and plan-less inspection to the exact request', async () => { + const planCommand = commands().find( + ({ operation }) => operation === 'plugin-package.secret-binding.plan', + ); + const inspectCommand = commands().find( + ({ operation }) => operation === 'plugin-package.secret-binding.inspect', + ); + const invalidResponses = [ + { + command: planCommand, + result: { + ...successfulResult(planCommand.operation), + plan: { + ...secretBindingPlanSummary(), + entries: [{ + ...secretBindingPlanSummary().entries[0], + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoiYW5vdGhlci10b2tlbiIsInZlcnNpb24iOjJ9', + }], + }, + }, + }, + { + command: inspectCommand, + result: { + ...successfulResult(inspectCommand.operation), + plan: null, + approval: { + ...successfulResult(inspectCommand.operation).approval, + id: 'approval-secret-binding-another', + }, + }, + }, + { + command: inspectCommand, + result: { + ...successfulResult(inspectCommand.operation), + plan: null, + approval: null, + }, + }, + ]; + const fixture = await startServer((_request, response) => { + sendJson(response, 200, { + schemaVersion: 1, + requestId: `request-exact-secret-binding-${invalidResponses.length}`, + result: invalidResponses[0].result, + }); + }); + try { + for (const invalid of invalidResponses) { + invalidResponses[0] = invalid; + const files = createClientFiles(fixture.port, invalid.command); + try { + await assert.rejects( + executeClusterPluginPackageManagementClient(files.paths), + ClusterPluginPackageManagementClientRequestError, + ); + } finally { + rmSync(files.directory, { recursive: true, force: true }); + } + } + } finally { + await fixture.close(); + } +}); + test('rejects non-private, symlinked, and non-exact input files before transport', async () => { const files = createClientFiles(443); try { diff --git a/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs index d02aae74..03d33a75 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs @@ -322,6 +322,94 @@ function authentication(value = principal()) { }; } +function secretBindingPlan() { + return { + schema: 'qinglong/plugin-package-secret-binding-approval-plan@v1', + actionRef: 'secret-binding:cluster-monitor:1', + bindingPlan: { + schema: 'qinglong/plugin-package-secret-binding-plan@v1', + target: { + installationId: 'cluster-monitor-installation', + projectId: 'default', + packageName: 'cluster-monitor', + lockDigest: '1'.repeat(64), + generation: 1, + generationDigest: '2'.repeat(64), + manifestDigest: '3'.repeat(64), + }, + entries: [{ + name: 'TOKEN', + required: true, + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJkZWZhdWx0IiwibmFtZSI6InJ1bnRpbWUtdG9rZW4iLCJ2ZXJzaW9uIjoyfQ', + }], + plannedAtMs: NOW - 10, + planDigest: '4'.repeat(64), + }, + requestedBy: { type: 'user', id: 'cluster-reviewer' }, + expiresAtMs: NOW + 10_000, + approvalPlanDigest: '5'.repeat(64), + }; +} + +function fakeSecretBinding() { + const calls = { plan: [], propose: [], decide: [], inspectAuthorized: [] }; + return { + calls, + service: { + async plan(request) { + calls.plan.push(request); + return { status: 'created', plan: secretBindingPlan() }; + }, + async propose(request) { + calls.propose.push(request); + return { + plan: secretBindingPlan(), + approvalStatus: 'created', + approvalRequest: approval({ + id: request.approvalRequestId, + action: { + permission: 'secret.manage', + actionType: 'plugin_package.secret_binding.bind', + actionRef: request.actionRef, + actionDigest: '5'.repeat(64), + previewDigest: '4'.repeat(64), + }, + requestedBy: request.principal.subject, + }), + }; + }, + async decide(request) { + calls.decide.push(request); + return { + status: 'decided', + request: approval({ + id: request.approvalRequestId, + version: 2, + state: request.decision, + decisionId: request.decisionId, + decision: request.decision, + decisionReasonCode: request.reasonCode, + decidedBy: request.principal.subject, + decisionAuthenticationId: request.principal.authenticationId, + decisionAssurance: request.principal.assurance, + decidedAtMs: NOW, + decisionFence: { projectVersion: 1, bindingVersion: 1 }, + }), + }; + }, + async inspectAuthorized(request) { + calls.inspectAuthorized.push(request); + return { + plan: secretBindingPlan(), + approvalRequest: null, + stale: false, + }; + }, + }, + }; +} + function lifecyclePlan() { return { schema: 'qinglong/plugin-package-lifecycle-plan@v1', @@ -818,6 +906,82 @@ test('routes lifecycle review without exposing executor mutation authority', asy assert.deepEqual(management.calls.dispatch, []); }); +test('routes content-free Secret binding review without executor authority', async () => { + const management = fakeService(); + const secretBinding = fakeSecretBinding(); + const transport = createClusterPluginPackageManagementTransport({ + service: management.service, + secretBinding: secretBinding.service, + now: () => NOW, + }); + const planCommand = { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.plan', + request: { + actionRef: secretBindingPlan().actionRef, + projectId: 'default', + packageName: 'cluster-monitor', + assignments: secretBindingPlan().bindingPlan.entries.map( + ({ name, secretRef }) => ({ name, secretRef }), + ), + }, + }; + const planned = await transport.execute( + planCommand, + authentication().authority, + ); + assert.equal(planned.status, 'created'); + assert.deepEqual(Object.keys(planned.plan).sort(), [ + 'actionRef', 'approvalPlanDigest', 'entries', 'expiresAtMs', 'generation', + 'generationDigest', 'installationId', 'lockDigest', 'manifestDigest', + 'packageName', 'planDigest', 'plannedAtMs', 'projectId', + ]); + assert.equal(JSON.stringify(planned).includes('authenticationId'), false); + + const proposed = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.propose', + request: { + actionRef: secretBindingPlan().actionRef, + approvalRequestId: 'approval-secret-binding-1', + approvalAuditEventId: 'audit-secret-binding-approval-1', + }, + }, authentication().authority); + assert.equal(proposed.approvalStatus, 'created'); + + const decided = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.decide', + request: { + actionRef: secretBindingPlan().actionRef, + approvalRequestId: 'approval-secret-binding-1', + expectedVersion: 1, + decisionId: 'decision-secret-binding-1', + auditEventId: 'audit-secret-binding-decision-1', + decision: 'approved', + reasonCode: 'reviewed', + }, + }, authentication().authority); + assert.equal(decided.status, 'decided'); + + const inspected = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.inspect', + request: { + actionRef: secretBindingPlan().actionRef, + approvalRequestId: 'approval-secret-binding-1', + inspectionId: 'inspection-secret-binding-1', + }, + }, authentication().authority); + assert.equal(inspected.stale, false); + assert.equal(secretBinding.calls.plan.length, 1); + assert.equal(secretBinding.calls.propose.length, 1); + assert.equal(secretBinding.calls.decide.length, 1); + assert.equal(secretBinding.calls.inspectAuthorized.length, 1); + assert.deepEqual(management.calls.consume, []); + assert.deepEqual(management.calls.dispatch, []); +}); + test('routes publisher revocation proposal with derived-only low-sensitive output', async () => { const management = fakeService(); const publisherTrust = fakePublisherTrust(); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs index 33fa8853..7e120661 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingManagement.test.cjs @@ -379,3 +379,71 @@ test('rejects weak requester, self-decision and semantic actionRef replay drift' (error) => error?.name === 'ApprovalSeparationOfDutyError', ); }); + +test('consumes durable quota with stable operation identities', async () => { + const state = fixture(); + const consumed = []; + const service = createClusterPluginPackageSecretBindingManagementService({ + pool: state.pool, + now: () => 210, + planLifetimeMs: 1_000, + quota: { + async consume(command) { + consumed.push(command); + return { remaining: 9, resetAtMs: 2_000, observedAtMs: 210 }; + }, + }, + }); + const created = await service.plan(planRequest()); + const proposed = await service.propose({ + actionRef: created.plan.actionRef, + approvalRequestId: 'approval-secret-binding-quota', + approvalAuditEventId: '123e4567-e89b-42d3-a456-426614175211', + principal: REQUESTER, + }); + await service.decide({ + actionRef: created.plan.actionRef, + approvalRequestId: proposed.approvalRequest.id, + expectedVersion: 1, + decisionId: 'decision-secret-binding-quota', + auditEventId: '123e4567-e89b-42d3-a456-426614175212', + decision: 'approved', + reasonCode: 'reviewed', + principal: REVIEWER, + }); + await service.inspectAuthorized({ + actionRef: created.plan.actionRef, + approvalRequestId: proposed.approvalRequest.id, + inspectionId: 'inspection-secret-binding-quota', + principal: REVIEWER, + }); + assert.deepEqual( + consumed.map(({ operation, idempotencyKey, subject }) => ({ + operation, + idempotencyKey, + subject, + })), + [ + { + operation: 'plugin-package.propose', + idempotencyKey: created.plan.actionRef, + subject: REQUESTER.subject, + }, + { + operation: 'plugin-package.propose', + idempotencyKey: proposed.approvalRequest.id, + subject: REQUESTER.subject, + }, + { + operation: 'plugin-package.decide', + idempotencyKey: 'decision-secret-binding-quota', + subject: REVIEWER.subject, + }, + { + operation: 'plugin-package.inspect', + idempotencyKey: 'inspection-secret-binding-quota', + subject: REVIEWER.subject, + }, + ], + ); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs index a332870a..ba101fe8 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs @@ -44,6 +44,9 @@ const { const { createClusterPluginPackageManagementService, } = require('@qinglong/cluster-admin/plugin-package-management'); +const { + createClusterPluginPackageManagementTransport, +} = require('@qinglong/cluster-admin/plugin-package-management-transport'); const { createClusterPluginPackageApprovedActionDispatcher, } = require('@qinglong/cluster-admin/plugin-package-approved-action'); @@ -363,42 +366,105 @@ if (!MIGRATION_URL || !MANAGER_URL || !EXECUTOR_URL) { 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), + const secretTransport = createClusterPluginPackageManagementTransport({ + service: installManagement, + secretBinding: secretManagement, + now: () => now, }); + const requesterAuthentication = { + async authenticate() { + return principal(requesterSubject, `secret-owner-${suffix}`, now); + }, + }; + const plannedPublic = await secretTransport.execute( + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.plan', + request: { + actionRef: secretActionRef, + projectId, + packageName, + assignments: [{ name: 'TOKEN', secretRef }], + }, + }, + requesterAuthentication, + ); + assert.equal(plannedPublic.status, 'created'); + assert.equal(plannedPublic.plan.actionRef, secretActionRef); + assert.deepEqual(plannedPublic.plan.entries, [ + { name: 'TOKEN', required: false, secretRef }, + ]); + assert.equal(Object.hasOwn(plannedPublic.plan, 'authenticationId'), false); + now = Math.max(now, plannedPublic.plan.plannedAtMs); + const replay = await secretTransport.execute( + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.plan', + request: { + actionRef: secretActionRef, + projectId, + packageName, + assignments: [{ name: 'TOKEN', secretRef }], + }, + }, + requesterAuthentication, + ); 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), - }); + const secretProposed = await secretTransport.execute( + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.propose', + request: { + actionRef: secretActionRef, + approvalRequestId: secretApprovalId, + approvalAuditEventId: randomUUID(), + }, + }, + requesterAuthentication, + ); 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), - }); + const secretDecision = await secretTransport.execute( + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.decide', + request: { + actionRef: secretActionRef, + approvalRequestId: secretApprovalId, + expectedVersion: secretProposed.approval.version, + decisionId: `secret-decision-${suffix}`, + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + }, + }, + { + async authenticate() { + return principal( + reviewerSubject, + `secret-reviewer-${suffix}`, + now, + ); + }, + }, + ); assert.equal(secretDecision.status, 'decided'); + const inspected = await secretTransport.execute( + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.inspect', + request: { + actionRef: secretActionRef, + approvalRequestId: secretApprovalId, + inspectionId: `secret-inspection-${suffix}`, + }, + }, + requesterAuthentication, + ); + assert.deepEqual(inspected.plan, plannedPublic.plan); + assert.equal(inspected.approval?.state, 'approved'); + assert.equal(inspected.approval?.decision, 'approved'); + assert.equal(inspected.stale, false); now += 10; assert.deepEqual( await consumeClusterPluginPackageSecretBindingApprovals({ @@ -432,7 +498,7 @@ if (!MIGRATION_URL || !MANAGER_URL || !EXECUTOR_URL) { ).inspect(pendingSecretExecution.dispatch), { status: 'ready', - actionDigest: planned.plan.approvalPlanDigest, + actionDigest: plannedPublic.plan.approvalPlanDigest, }, ); id = 0; @@ -448,12 +514,15 @@ if (!MIGRATION_URL || !MANAGER_URL || !EXECUTOR_URL) { assert.equal(secretDispatch.succeeded, 1); const bindings = new PostgresPluginPackageSecretBindingRepository(executor.pool); const binding = await bindings.find( - planned.plan.bindingPlan.target.generationDigest, + plannedPublic.plan.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.equal( + binding.authority.evidenceDigest, + plannedPublic.plan.approvalPlanDigest, + ); + assert.deepEqual(binding.entries, plannedPublic.plan.entries); assert.doesNotMatch(JSON.stringify(binding), /secret-value/); assert.equal((await secretDispatcher.dispatchBatch({ limit: 4 })).scanned, 0); await assert.rejects( diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 8bf90901..f2579c35 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -1263,6 +1263,8 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/runtime-core/plugin-package-lifecycle-plan', '@qinglong/runtime-core/plugin-package-management', '@qinglong/runtime-core/plugin-package-proposal', + '@qinglong/runtime-core/plugin-package-secret-binding', + '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan', '@qinglong/runtime-core/security', ]); if (