diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 44cc3948..e8b6c24c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -29,7 +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-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 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 index dcb10354..97763eff 100644 --- a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -14,17 +14,17 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 ## 决策 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 的序号。 +2. 新的共享 `qinglong/plugin-package-secret-binding-transition-plan@v1` 同时绑定上一代 target、可选的上一代完整 content-free binding、上一 active lock、服务端从 durable install history 得到的 `previousAttemptGeneration`、新 generation target、可选的 B1 binding plan、逐 requirement 差异和 domain-separated transition digest。上一 active Manifest 没有 Secret requirement 时不存在历史 binding,但 target/lock/generation lineage 仍必须完整保留;若 binding 存在,它必须精确匹配上一 target。新目标必须与上一代属于同一 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 继续可用。 +5. B2 的最终产品顺序固定为:审查并批准下一代 install lock → stage public Package bytes → 从 staged lock/Manifest、上一 active target 与可选 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。 +- 共享 contract 覆盖 carry-forward、forward rotation、version rollback、换名 rebind、requirement add/remove、无历史 binding 时首次增加 Secret、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 写入。 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 index 58833e13..8f323769 100644 --- a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionPlan.ts @@ -13,6 +13,7 @@ import { type PluginPackageSecretBinding, type PluginPackageSecretBindingAssignment, type PluginPackageSecretBindingEntry, + type PluginPackageSecretBindingTarget, } from './binding'; import { createPluginPackageSecretBindingPlan, @@ -68,7 +69,8 @@ export interface PluginPackageSecretBindingTransitionChange { export interface PluginPackageSecretBindingTransitionPlan { readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA; readonly kind: PluginPackageSecretBindingTransitionKind; - readonly previousBinding: Readonly; + readonly previousTarget: Readonly; + readonly previousBinding: Readonly | null; readonly previousActiveLockDigest: string; readonly previousAttemptGeneration: number; readonly nextTarget: Readonly; @@ -78,7 +80,8 @@ export interface PluginPackageSecretBindingTransitionPlan { } export interface CreatePluginPackageSecretBindingTransitionPlanInput { - readonly previousBinding: Readonly; + readonly previousTarget: Readonly; + readonly previousBinding: Readonly | null; readonly previousAttemptGeneration: number; readonly nextGeneration: Parameters< typeof normalizePluginPackageResourceGeneration @@ -235,7 +238,7 @@ function deriveKind( } function assertLineage( - previous: Readonly, + previousTarget: Readonly, nextTarget: Readonly, previousActiveLockDigest: unknown, previousAttemptGeneration: unknown, @@ -246,23 +249,23 @@ function assertLineage( if ( typeof previousActiveLockDigest !== 'string' || !DIGEST.test(previousActiveLockDigest) || - previousActiveLockDigest !== previous.target.lockDigest + previousActiveLockDigest !== previousTarget.lockDigest ) { return invalid('previous active lock digest is invalid'); } if ( !Number.isSafeInteger(previousAttemptGeneration) || - (previousAttemptGeneration as number) < previous.target.generation || + (previousAttemptGeneration as number) < previousTarget.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.projectId !== previousTarget.projectId || + nextTarget.packageName !== previousTarget.packageName || nextTarget.generation !== (previousAttemptGeneration as number) + 1 || - nextTarget.installationId === previous.target.installationId || - nextTarget.lockDigest === previous.target.lockDigest + nextTarget.installationId === previousTarget.installationId || + nextTarget.lockDigest === previousTarget.lockDigest ) { return invalid( 'next target is not the immediate durable attempt generation', @@ -275,19 +278,21 @@ function assertLineage( } function unsignedPlan( - previousBinding: Readonly, + previousTarget: Readonly, + previousBinding: Readonly | null, previousActiveLockDigest: string, previousAttemptGeneration: number, nextTarget: Readonly, nextBindingPlan: Readonly | null, ): Omit { const changes = deriveChanges( - previousBinding.entries, + previousBinding?.entries ?? [], nextBindingPlan?.entries ?? [], ); return Object.freeze({ schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA, kind: deriveKind(changes), + previousTarget, previousBinding, previousActiveLockDigest, previousAttemptGeneration, @@ -335,19 +340,30 @@ export function createPluginPackageSecretBindingTransitionPlan( 'plannedAtMs', 'previousAttemptGeneration', 'previousBinding', + 'previousTarget', ], 'transition plan input', ); - const previousBinding = normalizePluginPackageSecretBinding( - input.previousBinding, + const previousTarget = normalizePluginPackageSecretBindingTarget( + input.previousTarget, ); + const previousBinding = + input.previousBinding === null + ? null + : normalizePluginPackageSecretBinding(input.previousBinding); + if ( + previousBinding !== null && + JSON.stringify(previousBinding.target) !== JSON.stringify(previousTarget) + ) { + return invalid('previous binding does not match the previous target'); + } const nextGeneration = normalizePluginPackageResourceGeneration( input.nextGeneration, ); const nextManifest = normalizePluginPackageManifest(input.nextManifest); if ( nextGeneration.previousActiveLockDigest !== - previousBinding.target.lockDigest + previousTarget.lockDigest ) { return invalid('next generation does not name the previous active lock'); } @@ -371,13 +387,14 @@ export function createPluginPackageSecretBindingTransitionPlan( plannedAtMs: input.plannedAtMs, }); const lineage = assertLineage( - previousBinding, + previousTarget, nextTarget, nextGeneration.previousActiveLockDigest, input.previousAttemptGeneration, ); return withDigest( unsignedPlan( + previousTarget, previousBinding, lineage.previousActiveLockDigest, lineage.previousAttemptGeneration, @@ -401,6 +418,7 @@ export function normalizePluginPackageSecretBindingTransitionPlan( 'previousActiveLockDigest', 'previousAttemptGeneration', 'previousBinding', + 'previousTarget', 'schema', 'transitionDigest', ], @@ -409,9 +427,19 @@ export function normalizePluginPackageSecretBindingTransitionPlan( if (plan.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA) { return invalid('schema is unsupported'); } - const previousBinding = normalizePluginPackageSecretBinding( - plan.previousBinding, + const previousTarget = normalizePluginPackageSecretBindingTarget( + plan.previousTarget, ); + const previousBinding = + plan.previousBinding === null + ? null + : normalizePluginPackageSecretBinding(plan.previousBinding); + if ( + previousBinding !== null && + JSON.stringify(previousBinding.target) !== JSON.stringify(previousTarget) + ) { + return invalid('previous binding does not match the previous target'); + } const nextTarget = normalizePluginPackageSecretBindingTarget(plan.nextTarget); const nextBindingPlan = plan.nextBindingPlan === null @@ -424,12 +452,13 @@ export function normalizePluginPackageSecretBindingTransitionPlan( return invalid('next binding plan does not match the next target'); } const lineage = assertLineage( - previousBinding, + previousTarget, nextTarget, plan.previousActiveLockDigest, plan.previousAttemptGeneration, ); const unsigned = unsignedPlan( + previousTarget, previousBinding, lineage.previousActiveLockDigest, lineage.previousAttemptGeneration, diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs index 40144a80..9993614c 100644 --- a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs @@ -76,6 +76,7 @@ const previousBinding = createPluginPackageSecretBinding({ }, boundAtMs: 90, }); +const previousTarget = previousBinding.target; function nextGeneration(overrides = {}) { return createPluginPackageResourceGeneration({ @@ -93,6 +94,7 @@ function nextGeneration(overrides = {}) { function transition(assignments, overrides = {}) { return createPluginPackageSecretBindingTransitionPlan({ + previousTarget, previousBinding, previousAttemptGeneration: overrides.previousAttemptGeneration ?? 1, nextGeneration: nextGeneration(overrides.generation), @@ -199,6 +201,34 @@ test('treats requirement additions and optional binding as rebind', () => { assert.equal(value.changes[0].reference, 'bound'); }); +test('supports adding the first Secret after an active generation without a binding', () => { + const value = createPluginPackageSecretBindingTransitionPlan({ + previousTarget, + previousBinding: null, + previousAttemptGeneration: 1, + nextGeneration: nextGeneration(), + nextManifest: manifest([{ name: 'TOKEN', required: true }]), + assignments: [{ name: 'TOKEN', secretRef: secret('runtime-token', 1) }], + plannedAtMs: 100, + }); + assert.equal(value.kind, 'rebind'); + assert.equal(value.previousBinding, null); + assert.deepEqual(value.previousTarget, previousTarget); + assert.deepEqual(value.changes, [ + { + name: 'TOKEN', + requirement: 'added', + reference: 'bound', + previous: null, + next: { required: true, secretRef: secret('runtime-token', 1) }, + }, + ]); + assert.deepEqual( + normalizePluginPackageSecretBindingTransitionPlan(value), + value, + ); +}); + test('uses durable attempt generations and rejects skipped or detached targets', () => { const assignments = [ { name: 'OPTIONAL_TOKEN', secretRef: null }, @@ -265,6 +295,7 @@ test('rejects extensible or accessor-bearing creation input', () => { { name: 'TOKEN', secretRef: secret('runtime-token', 2) }, ]; const input = { + previousTarget, previousBinding, previousAttemptGeneration: 1, nextGeneration: nextGeneration(),