From d8c4503adf540e3f9c8fe04c77b27b383bc9f2b6 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 18:12:57 +0800 Subject: [PATCH] feat(ql3): define secret binding generation transitions --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...ransition-plugin-package-secret-binding.md | 35 ++ packages/ql3-runtime-core/package.json | 8 + .../plugin-package/secret-binding/binding.ts | 21 + .../secret-binding/transitionPlan.ts | 453 ++++++++++++++++++ ...ackageSecretBindingTransitionPlan.test.cjs | 303 ++++++++++++ 6 files changed, 821 insertions(+) create mode 100644 docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md create mode 100644 packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts create mode 100644 packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 23a1fa13..44cc3948 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -29,6 +29,7 @@ - D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。 - D-306A/ADR-0394(已接受):Package Task source 以 `package-secret` placeholder 引用逻辑 requirement,materialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`;Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digest,Local/Cluster 启动发布复用既有 repository/pool,Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 不另造协议。SQLite/PostgreSQL INSERT trigger 同时防止直接写库绕过;Local 只读 readiness 继续不加载 DDL。Local contract v47、Cluster v59;不新增 package、表、索引、连接、daemon、watcher、timer、cache 或 workload。完整 18-package clean build/test 退出 0;backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 finding,workspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 source;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。D-306A 不冒充产品闭环:Local bind/rebind 命令、Cluster Approved Action/API 与新 generation rotation/revocation 编排属于 D-306B。 - D-306B1/ADR-0395(已接受):当前 active、尚未绑定 Package generation 的首次 Secret binding 已形成 Local 与 Cluster 产品闭环,且不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建;Local 使用短生命周期 `ql3-package`、Owner human confirmation 与单 SQLite transaction,Cluster 使用既有 package-management HTTPS/CLI、package-manager separation-of-duty Approval 和短生命周期 package-executor。三节点 K3s `v1.34.3+k3s1` arm64 现场门已在真实 PostgreSQL `18.4` 上完成两个 management Pod 跨节点部署、正式 client `plan→跨副本 replay→propose→双人 decide→inspect`、真实 executor Job 与只读 Kubernetes Secret projection。management/executor 均无 Secret API 读取权和 ServiceAccount token;management 不挂载 Package value,executor 只验证投影元数据;最终恰好一条 immutable binding,Approval consumed、execution succeeded,数据库敏感值扫描为 0。16/16 gate 的 owner-private、低敏报告通过独立 exact-shape 审计,SHA-256 为 `aaabb5ebea77c50bce671f91dd3051671fd20875c11a8f787fe8933f29dbfa4d`。完整 18-package clean build/test、backend 与七项边界审计,以及 PostgreSQL 18.4 physical HA 125 gate/timeline `1→2` 证据继续有效;没有新增 workspace package、migration、表、索引、依赖或常驻 workload。B2 rebind/rotation/revocation 必须通过新 Package generation 独立推进。 +- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active binding/lock、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。服务端分别推导 requirement 的 add/remove/tighten/relax 和 SecretRef 的 bind/revoke/forward-rotate/rebind;版本倒退与换名均不能冒充 rotation,删除最后一个 requirement 以 `nextBindingPlan: null` 表达而不制造空 binding。最终顺序冻结为 install approval/stage→transition approval→目标 binding publish/materialize→active CAS,失败不得移动旧 active;紧急 revoke 先复用 disable/quarantine 阻止新 admission,再做代际收敛,不声称追回运行中明文。公共 contract 归入既有 runtime-core Secret binding 聚合目录,不新增 package、依赖或常驻资源;Local/Cluster 持久化与产品纵切面仍待完成。 - D-302/ADR-0390(已接受) Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、 diff --git a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md new file mode 100644 index 00000000..dcb10354 --- /dev/null +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -0,0 +1,35 @@ +# ADR-0396:按 Package generation 切换 Plugin Package Secret binding + +- 状态:Proposed +- 日期:2026-08-13 +- 关联 RFC:QL-RFC-0001 D-306B2 +- 关联 ADR:ADR-0393、ADR-0394、ADR-0395 + +## 问题 + +D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首次 Secret binding,并永久禁止更新或删除历史 binding。现有安装状态机已经能够安全发布 `upgrade|rollback|reinstall` 的下一代并在发布失败时保留旧 active lock,但 binding 管理面只能从当前 active generation 生成计划,资源 materialization 又要求目标 generation 已有完整 binding。直接复用 B1 会在“下一代 activation 前需要 binding”和“只有 activation 后才能 bind”之间形成时序环。 + +仅增加一个 `rebind` 命令也不完整:普通 Package 升级即使沿用原 SecretRef,新 generation 仍需要独立 binding;同名 Secret 的版本前进、版本倒退、换名、optional 解绑、requirement 删除和新增具有不同风险;紧急撤权也不能假装发布新 generation 可以追回已经交给运行中进程的 Secret 明文。 + +## 决策 + +1. Secret binding 永远以 resource generation 为不可变主键。B2 不更新、不删除历史 binding,也不让 binding 脱离 Package install/lock/generation 链独立前进。 +2. 新的共享 `qinglong/plugin-package-secret-binding-transition-plan@v1` 同时绑定上一代完整 content-free binding、上一 active lock、服务端从 durable install history 得到的 `previousAttemptGeneration`、新 generation target、可选的 B1 binding plan、逐 requirement 差异和 domain-separated transition digest。目标必须与上一代属于同一 Project/Package,generation 精确等于最后一次尝试 `+1`,使用不同 installation/lock,并由 `previousActiveLockDigest` 精确指回上一 active 代。失败尝试也永久消耗 generation,后续重试不能重用已留有 binding/evidence 的序号。 +3. 服务端分别推导 requirement 的 `added|removed|tightened|relaxed|unchanged` 和 SecretRef 的 `bound|revoked|rotated|rebound|unchanged`,调用方不能自报变更类型。只有同 Project、同 Secret name、显式 version 严格增加才是 `rotated`;版本倒退、换 Secret name 或从未绑定变为绑定均为 `rebind`。移除 requirement 或从已绑定变为 `null` 为 `revoke`。 +4. transition 顶层 kind 按风险收敛:存在 revoke 即为 `revoke`;否则存在 requirement 变化、首次绑定或 rebound 即为 `rebind`;否则存在 forward version change 才为 `rotate`;完全相同才为 `carry-forward`。逐项 changes 保留完整事实,顶层 kind 不能掩盖混合变化。 +5. B2 的最终产品顺序固定为:审查并批准下一代 install lock → stage public Package bytes → 从 staged lock/Manifest 与上一 active binding 构建 transition plan → Local Owner confirmation 或 Cluster separation-of-duty Approval → 在 activation 前发布目标 generation binding → materialize/reconcile 目标资源 → CAS active pointer/head。任一步失败都不得移动 active pointer,旧 generation、旧 binding 和已 materialized revision 继续可用。 +6. `carry-forward` 仍必须生成新 binding 和新 evidence,不允许让下一代按运行时规则回退读取旧 binding。rollback 同样创建新的 generation/binding;它可以重新选择历史 SecretRef,但版本倒退按 `rebind` 审批,不能伪装为 rotate。若下一 Manifest 已删除全部 Secret requirements,transition 仍保留新 generation target 和撤销差异,但 `nextBindingPlan` 必须为 `null`,不得绕过 D-305 制造空 binding。 +7. 紧急 revoke 分成即时围栏与代际收敛两步:先复用 lifecycle disable/quarantine withdrawal 阻止新 Task/Workflow/Prompt admission,再以新 generation 删除 requirement 或置空 optional binding。B2 不声称能追回已注入运行中进程的 Secret,相关 Run 必须由既有 stop/cancel/lease convergence 处理。 +8. 共享 contract 归入现有 `@qinglong/runtime-core/plugin-package/secret-binding/`,不新增 workspace package。Local 复用单 SQLite operation authority;Cluster 复用 package-manager/package-executor 与现有 PostgreSQL Pool,不新增常驻进程、连接、timer、watcher 或 cache。 + +## 接受条件 + +- 共享 contract 覆盖 carry-forward、forward rotation、version rollback、换名 rebind、requirement add/remove、optional revoke、跳代/断链/同 installation 拒绝、shape/digest tamper 和 exact replay。 +- Local 在一个受围栏事务中证明目标 install 为 staged、上一 active binding 精确匹配、Secret versions 存在、transition 获 Owner confirmation,并在 activation 移动指针前持久化目标 binding;崩溃窗口不得切走旧代。 +- Cluster 以 package-manager 生成不可伪造 transition plan,以独立 User 决策,并由 package-executor 在 activation 前发布 binding;manager 仍不能读取 binding 表或 Secret value。 +- SQLite/PostgreSQL migration、trigger/readiness 与最小权限角色显式支持 staged-target binding,而不放宽为任意未来 generation 写入。 +- 完整 18-package build/test、backend、package/dependency/deployment/edge/import 审计与真实 PostgreSQL/Kubernetes 升级、失败回滚、rotation/revoke 现场门通过;低配设备不得增加常驻资源。 + +## 当前进度 + +- 已冻结共享 transition plan v1 的 lineage 与差异分类语义;Local/Cluster activation 前发布和持久化门仍待实现,因此 ADR 保持 Proposed。 diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index aa0e35a9..d3184f12 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -89,6 +89,9 @@ "plugin-package-secret-binding-plan": [ "dist/plugin-package/secret-binding/plan.d.ts" ], + "plugin-package-secret-binding-transition-plan": [ + "dist/plugin-package/secret-binding/transitionPlan.d.ts" + ], "plugin-package-secret-binding-approval-plan": [ "dist/plugin-package/secret-binding/approvalPlan.d.ts" ], @@ -393,6 +396,11 @@ "require": "./dist/plugin-package/secret-binding/plan.js", "default": "./dist/plugin-package/secret-binding/plan.js" }, + "./plugin-package-secret-binding-transition-plan": { + "types": "./dist/plugin-package/secret-binding/transitionPlan.d.ts", + "require": "./dist/plugin-package/secret-binding/transitionPlan.js", + "default": "./dist/plugin-package/secret-binding/transitionPlan.js" + }, "./plugin-package-secret-binding-approval-plan": { "types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts", "require": "./dist/plugin-package/secret-binding/approvalPlan.js", diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts index da9f6972..5cc0647d 100644 --- a/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/binding.ts @@ -329,6 +329,21 @@ function targetFromGeneration( }); } +export function createPluginPackageSecretBindingTarget( + generationValue: Readonly, + manifestValue: Readonly, +): Readonly { + const generation = normalizePluginPackageResourceGeneration(generationValue); + const manifest = normalizePluginPackageManifest(manifestValue); + if (manifest.metadata.name !== generation.packageName) { + return invalid('Manifest Package does not match generation'); + } + return targetFromGeneration( + generation, + pluginPackageManifestDigest(manifest), + ); +} + function unsignedBinding( target: Readonly, entries: readonly Readonly[], @@ -457,6 +472,12 @@ function normalizeTarget( }); } +export function normalizePluginPackageSecretBindingTarget( + value: unknown, +): Readonly { + return normalizeTarget(value); +} + export function normalizePluginPackageSecretBinding( value: unknown, ): Readonly { diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts new file mode 100644 index 00000000..58833e13 --- /dev/null +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts @@ -0,0 +1,453 @@ +import { createHash } from 'node:crypto'; + +import { normalizePluginPackageResourceGeneration } from '../pluginPackageResourceGeneration'; +import { + normalizePluginPackageManifest, + type PluginPackageManifest, +} from '../pluginPackage'; +import { parseSecretRef } from '../../secret/secretReference'; +import { + createPluginPackageSecretBindingTarget, + normalizePluginPackageSecretBinding, + normalizePluginPackageSecretBindingTarget, + type PluginPackageSecretBinding, + type PluginPackageSecretBindingAssignment, + type PluginPackageSecretBindingEntry, +} from './binding'; +import { + createPluginPackageSecretBindingPlan, + normalizePluginPackageSecretBindingPlan, + type PluginPackageSecretBindingPlan, +} from './plan'; + +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA = + 'qinglong/plugin-package-secret-binding-transition-plan@v1' as const; +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_KINDS = [ + 'carry-forward', + 'rotate', + 'rebind', + 'revoke', +] as const; +export const PLUGIN_PACKAGE_SECRET_REQUIREMENT_CHANGE_KINDS = [ + 'added', + 'removed', + 'tightened', + 'relaxed', + 'unchanged', +] as const; +export const PLUGIN_PACKAGE_SECRET_REFERENCE_CHANGE_KINDS = [ + 'bound', + 'revoked', + 'rotated', + 'rebound', + 'unchanged', +] as const; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_JSON_BYTES = + 160 * 1024; + +export type PluginPackageSecretBindingTransitionKind = + (typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_KINDS)[number]; +export type PluginPackageSecretRequirementChangeKind = + (typeof PLUGIN_PACKAGE_SECRET_REQUIREMENT_CHANGE_KINDS)[number]; +export type PluginPackageSecretReferenceChangeKind = + (typeof PLUGIN_PACKAGE_SECRET_REFERENCE_CHANGE_KINDS)[number]; + +export interface PluginPackageSecretBindingTransitionEntryState { + readonly required: boolean; + readonly secretRef: string | null; +} + +export interface PluginPackageSecretBindingTransitionChange { + readonly name: string; + readonly requirement: PluginPackageSecretRequirementChangeKind; + readonly reference: PluginPackageSecretReferenceChangeKind; + readonly previous: Readonly | null; + readonly next: Readonly | null; +} + +export interface PluginPackageSecretBindingTransitionPlan { + readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA; + readonly kind: PluginPackageSecretBindingTransitionKind; + readonly previousBinding: Readonly; + readonly previousActiveLockDigest: string; + readonly previousAttemptGeneration: number; + readonly nextTarget: Readonly; + readonly nextBindingPlan: Readonly | null; + readonly changes: readonly Readonly[]; + readonly transitionDigest: string; +} + +export interface CreatePluginPackageSecretBindingTransitionPlanInput { + readonly previousBinding: Readonly; + readonly previousAttemptGeneration: number; + readonly nextGeneration: Parameters< + typeof normalizePluginPackageResourceGeneration + >[0]; + readonly nextManifest: Readonly; + readonly assignments: readonly Readonly[]; + readonly plannedAtMs: number; +} + +const DIGEST = /^[0-9a-f]{64}$/; +const TRANSITION_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-secret-binding-transition-plan-digest@v1\0', + 'utf8', +); + +function invalid(message: string): never { + throw new TypeError( + `Plugin Package Secret binding transition plan is invalid: ${message}`, + ); +} + +function dataRecord(value: unknown, label: string): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return invalid(`${label} must be an object`); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Object.values(descriptors).some( + (descriptor) => + descriptor.get !== undefined || + descriptor.set !== undefined || + descriptor.enumerable !== true, + ) + ) { + return invalid(`${label} must contain enumerable data properties`); + } + return value as Record; +} + +function exactKeys( + value: object, + expected: readonly string[], + label: string, +): void { + const actual = Reflect.ownKeys(value); + const strings = actual.filter( + (key): key is string => typeof key === 'string', + ); + const canonical = [...expected].sort(); + if ( + actual.length !== canonical.length || + strings.length !== canonical.length || + strings.sort().some((key, index) => key !== canonical[index]) + ) { + invalid(`${label} shape is invalid`); + } +} + +function entryState( + entry: Readonly, +): Readonly { + return Object.freeze({ + required: entry.required, + secretRef: entry.secretRef, + }); +} + +function requirementChange( + previous: Readonly | undefined, + next: Readonly | undefined, +): PluginPackageSecretRequirementChangeKind { + if (!previous) return 'added'; + if (!next) return 'removed'; + if (previous.required === next.required) return 'unchanged'; + return next.required ? 'tightened' : 'relaxed'; +} + +function referenceChange( + previous: Readonly | undefined, + next: Readonly | undefined, +): PluginPackageSecretReferenceChangeKind { + const before = previous?.secretRef ?? null; + const after = next?.secretRef ?? null; + if (before === after) return 'unchanged'; + if (before === null) return 'bound'; + if (after === null) return 'revoked'; + const previousReference = parseSecretRef(before); + const nextReference = parseSecretRef(after); + if ( + previousReference.projectId === nextReference.projectId && + previousReference.name === nextReference.name && + previousReference.version !== undefined && + nextReference.version !== undefined && + nextReference.version > previousReference.version + ) { + return 'rotated'; + } + return 'rebound'; +} + +function deriveChanges( + previousEntries: readonly Readonly[], + nextEntries: readonly Readonly[], +): readonly Readonly[] { + const previous = new Map(previousEntries.map((entry) => [entry.name, entry])); + const next = new Map(nextEntries.map((entry) => [entry.name, entry])); + const names = [...new Set([...previous.keys(), ...next.keys()])].sort(); + return Object.freeze( + names.map((name) => { + const before = previous.get(name); + const after = next.get(name); + return Object.freeze({ + name, + requirement: requirementChange(before, after), + reference: referenceChange(before, after), + previous: before ? entryState(before) : null, + next: after ? entryState(after) : null, + }); + }), + ); +} + +function deriveKind( + changes: readonly Readonly[], +): PluginPackageSecretBindingTransitionKind { + if ( + changes.some( + (change) => + change.requirement === 'removed' || change.reference === 'revoked', + ) + ) { + return 'revoke'; + } + if ( + changes.some( + (change) => + change.requirement !== 'unchanged' || + change.reference === 'bound' || + change.reference === 'rebound', + ) + ) { + return 'rebind'; + } + if (changes.some((change) => change.reference === 'rotated')) { + return 'rotate'; + } + return 'carry-forward'; +} + +function assertLineage( + previous: Readonly, + nextTarget: Readonly, + previousActiveLockDigest: unknown, + previousAttemptGeneration: unknown, +): Readonly<{ + previousActiveLockDigest: string; + previousAttemptGeneration: number; +}> { + if ( + typeof previousActiveLockDigest !== 'string' || + !DIGEST.test(previousActiveLockDigest) || + previousActiveLockDigest !== previous.target.lockDigest + ) { + return invalid('previous active lock digest is invalid'); + } + if ( + !Number.isSafeInteger(previousAttemptGeneration) || + (previousAttemptGeneration as number) < previous.target.generation || + (previousAttemptGeneration as number) >= 2_147_483_647 + ) { + return invalid('previous attempt generation is invalid'); + } + if ( + nextTarget.projectId !== previous.target.projectId || + nextTarget.packageName !== previous.target.packageName || + nextTarget.generation !== (previousAttemptGeneration as number) + 1 || + nextTarget.installationId === previous.target.installationId || + nextTarget.lockDigest === previous.target.lockDigest + ) { + return invalid( + 'next target is not the immediate durable attempt generation', + ); + } + return Object.freeze({ + previousActiveLockDigest, + previousAttemptGeneration: previousAttemptGeneration as number, + }); +} + +function unsignedPlan( + previousBinding: Readonly, + previousActiveLockDigest: string, + previousAttemptGeneration: number, + nextTarget: Readonly, + nextBindingPlan: Readonly | null, +): Omit { + const changes = deriveChanges( + previousBinding.entries, + nextBindingPlan?.entries ?? [], + ); + return Object.freeze({ + schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA, + kind: deriveKind(changes), + previousBinding, + previousActiveLockDigest, + previousAttemptGeneration, + nextTarget, + nextBindingPlan, + changes, + }); +} + +function transitionDigest( + value: Omit, +): string { + return createHash('sha256') + .update(TRANSITION_DIGEST_DOMAIN) + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function withDigest( + value: Omit, +): Readonly { + const result = Object.freeze({ + ...value, + transitionDigest: transitionDigest(value), + }); + if ( + Buffer.byteLength(JSON.stringify(result), 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_JSON_BYTES + ) { + return invalid('durable JSON byte budget exceeded'); + } + return result; +} + +export function createPluginPackageSecretBindingTransitionPlan( + input: CreatePluginPackageSecretBindingTransitionPlanInput, +): Readonly { + const inputValue = dataRecord(input, 'transition plan input'); + exactKeys( + inputValue, + [ + 'assignments', + 'nextGeneration', + 'nextManifest', + 'plannedAtMs', + 'previousAttemptGeneration', + 'previousBinding', + ], + 'transition plan input', + ); + const previousBinding = normalizePluginPackageSecretBinding( + input.previousBinding, + ); + const nextGeneration = normalizePluginPackageResourceGeneration( + input.nextGeneration, + ); + const nextManifest = normalizePluginPackageManifest(input.nextManifest); + if ( + nextGeneration.previousActiveLockDigest !== + previousBinding.target.lockDigest + ) { + return invalid('next generation does not name the previous active lock'); + } + const nextTarget = createPluginPackageSecretBindingTarget( + nextGeneration, + nextManifest, + ); + const requirements = nextManifest.spec.permissions.secrets; + if (requirements.length === 0 && input.assignments.length !== 0) { + return invalid( + 'assignments must be empty when the next Manifest has no Secrets', + ); + } + const nextBindingPlan = + requirements.length === 0 + ? null + : createPluginPackageSecretBindingPlan({ + generation: nextGeneration, + manifest: nextManifest, + assignments: input.assignments, + plannedAtMs: input.plannedAtMs, + }); + const lineage = assertLineage( + previousBinding, + nextTarget, + nextGeneration.previousActiveLockDigest, + input.previousAttemptGeneration, + ); + return withDigest( + unsignedPlan( + previousBinding, + lineage.previousActiveLockDigest, + lineage.previousAttemptGeneration, + nextTarget, + nextBindingPlan, + ), + ); +} + +export function normalizePluginPackageSecretBindingTransitionPlan( + value: unknown, +): Readonly { + const plan = dataRecord(value, 'transition plan'); + exactKeys( + plan, + [ + 'changes', + 'kind', + 'nextBindingPlan', + 'nextTarget', + 'previousActiveLockDigest', + 'previousAttemptGeneration', + 'previousBinding', + 'schema', + 'transitionDigest', + ], + 'transition plan', + ); + if (plan.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA) { + return invalid('schema is unsupported'); + } + const previousBinding = normalizePluginPackageSecretBinding( + plan.previousBinding, + ); + const nextTarget = normalizePluginPackageSecretBindingTarget(plan.nextTarget); + const nextBindingPlan = + plan.nextBindingPlan === null + ? null + : normalizePluginPackageSecretBindingPlan(plan.nextBindingPlan); + if ( + nextBindingPlan !== null && + JSON.stringify(nextBindingPlan.target) !== JSON.stringify(nextTarget) + ) { + return invalid('next binding plan does not match the next target'); + } + const lineage = assertLineage( + previousBinding, + nextTarget, + plan.previousActiveLockDigest, + plan.previousAttemptGeneration, + ); + const unsigned = unsignedPlan( + previousBinding, + lineage.previousActiveLockDigest, + lineage.previousAttemptGeneration, + nextTarget, + nextBindingPlan, + ); + if ( + JSON.stringify(plan.changes) !== JSON.stringify(unsigned.changes) || + plan.kind !== unsigned.kind + ) { + return invalid('derived transition classification does not match content'); + } + if ( + typeof plan.transitionDigest !== 'string' || + !DIGEST.test(plan.transitionDigest) || + plan.transitionDigest !== transitionDigest(unsigned) + ) { + return invalid('transition digest does not match content'); + } + return withDigest(unsigned); +} diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs new file mode 100644 index 00000000..40144a80 --- /dev/null +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs @@ -0,0 +1,303 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionPlan, + normalizePluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); + +function manifest(secrets) { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Secret transition fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['edge'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets, + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function secret(name, version) { + return createSecretRef({ projectId: 'project-1', name, version }); +} + +const previousManifest = manifest([ + { name: 'OPTIONAL_TOKEN', required: false }, + { name: 'TOKEN', required: true }, +]); +const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: previousManifest.spec.contents, +}); +const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, + ], + authority: { + kind: 'local-owner-confirmation', + evidenceDigest: 'c'.repeat(64), + }, + boundAtMs: 90, +}); + +function nextGeneration(overrides = {}) { + return createPluginPackageResourceGeneration({ + installationId: 'install-2', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'd'.repeat(64), + generation: 2, + previousActiveLockDigest: previousBinding.target.lockDigest, + contentDigest: 'e'.repeat(64), + contents: previousManifest.spec.contents, + ...overrides, + }); +} + +function transition(assignments, overrides = {}) { + return createPluginPackageSecretBindingTransitionPlan({ + previousBinding, + previousAttemptGeneration: overrides.previousAttemptGeneration ?? 1, + nextGeneration: nextGeneration(overrides.generation), + nextManifest: overrides.manifest ?? previousManifest, + assignments, + plannedAtMs: 100, + }); +} + +test('derives an exact carry-forward for an unchanged next generation', () => { + const value = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, + ]); + assert.equal(value.kind, 'carry-forward'); + assert.equal(value.previousActiveLockDigest, 'a'.repeat(64)); + assert.deepEqual( + value.changes.map(({ name, requirement, reference }) => ({ + name, + requirement, + reference, + })), + [ + { + name: 'OPTIONAL_TOKEN', + requirement: 'unchanged', + reference: 'unchanged', + }, + { + name: 'TOKEN', + requirement: 'unchanged', + reference: 'unchanged', + }, + ], + ); + assert.deepEqual( + normalizePluginPackageSecretBindingTransitionPlan(value), + value, + ); +}); + +test('distinguishes forward rotation from rebind and version rollback', () => { + const rotated = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 3) }, + ]); + assert.equal(rotated.kind, 'rotate'); + assert.equal(rotated.changes[1].reference, 'rotated'); + + const rebound = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('replacement-token', 1) }, + ]); + assert.equal(rebound.kind, 'rebind'); + assert.equal(rebound.changes[1].reference, 'rebound'); + + const rolledBack = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 1) }, + ]); + assert.equal(rolledBack.kind, 'rebind'); + assert.equal(rolledBack.changes[1].reference, 'rebound'); +}); + +test('classifies removal or unbinding as revocation', () => { + const nextManifest = manifest([{ name: 'OPTIONAL_TOKEN', required: false }]); + const value = transition([{ name: 'OPTIONAL_TOKEN', secretRef: null }], { + manifest: nextManifest, + }); + assert.equal(value.kind, 'revoke'); + assert.deepEqual(value.changes[1], { + name: 'TOKEN', + requirement: 'removed', + reference: 'revoked', + previous: { required: true, secretRef: secret('runtime-token', 2) }, + next: null, + }); +}); + +test('represents final requirement removal without inventing an empty binding', () => { + const value = transition([], { manifest: manifest([]) }); + assert.equal(value.kind, 'revoke'); + assert.equal(value.nextBindingPlan, null); + assert.equal(value.nextTarget.generation, 2); + assert.equal(value.changes.length, 2); +}); + +test('treats requirement additions and optional binding as rebind', () => { + const nextManifest = manifest([ + { name: 'EXTRA_TOKEN', required: false }, + { name: 'OPTIONAL_TOKEN', required: false }, + { name: 'TOKEN', required: true }, + ]); + const value = transition( + [ + { name: 'EXTRA_TOKEN', secretRef: secret('extra-token', 1) }, + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, + ], + { manifest: nextManifest }, + ); + assert.equal(value.kind, 'rebind'); + assert.equal(value.changes[0].requirement, 'added'); + assert.equal(value.changes[0].reference, 'bound'); +}); + +test('uses durable attempt generations and rejects skipped or detached targets', () => { + const assignments = [ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, + ]; + assert.throws( + () => transition(assignments, { generation: { generation: 3 } }), + /immediate durable attempt generation/, + ); + const retried = transition(assignments, { + generation: { generation: 3 }, + previousAttemptGeneration: 2, + }); + assert.equal(retried.nextTarget.generation, 3); + assert.equal(retried.previousAttemptGeneration, 2); + assert.throws( + () => + transition(assignments, { + generation: { previousActiveLockDigest: 'f'.repeat(64) }, + }), + /does not name the previous active lock/, + ); + assert.throws( + () => + transition(assignments, { generation: { installationId: 'install-1' } }), + /immediate durable attempt generation/, + ); +}); + +test('fails closed when classification, content or digest is rewritten', () => { + const value = transition([ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 3) }, + ]); + assert.throws( + () => + normalizePluginPackageSecretBindingTransitionPlan({ + ...value, + kind: 'carry-forward', + }), + /classification/, + ); + assert.throws( + () => + normalizePluginPackageSecretBindingTransitionPlan({ + ...value, + changes: value.changes.slice(1), + }), + /classification/, + ); + assert.throws( + () => + normalizePluginPackageSecretBindingTransitionPlan({ + ...value, + transitionDigest: 'f'.repeat(64), + }), + /digest/, + ); +}); + +test('rejects extensible or accessor-bearing creation input', () => { + const assignments = [ + { name: 'OPTIONAL_TOKEN', secretRef: null }, + { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, + ]; + const input = { + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: nextGeneration(), + nextManifest: previousManifest, + assignments, + plannedAtMs: 100, + }; + assert.throws( + () => + createPluginPackageSecretBindingTransitionPlan({ ...input, extra: true }), + /shape is invalid/, + ); + const accessor = { ...input }; + Object.defineProperty(accessor, 'plannedAtMs', { + enumerable: true, + get() { + throw new Error('must not execute'); + }, + }); + assert.throws( + () => createPluginPackageSecretBindingTransitionPlan(accessor), + /data properties/, + ); +}); + +test('exports the transition contract only through its explicit subpath', () => { + assert.equal( + require('../dist').createPluginPackageSecretBindingTransitionPlan, + undefined, + ); + assert.equal( + require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan') + .createPluginPackageSecretBindingTransitionPlan, + createPluginPackageSecretBindingTransitionPlan, + ); +});