feat(ql3): define secret binding generation transitions

This commit is contained in:
whyour
2026-08-13 18:12:57 +08:00
parent 22eea3ac71
commit d8c4503adf
6 changed files with 821 additions and 0 deletions
+1
View File
@@ -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 v46PostgreSQL 追加 `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 退出 0backend 1,188 pass/2 skip,五项边界审计零 findingworkspace 仍无 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 引用逻辑 requirementmaterialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digestLocal/Cluster 启动发布复用既有 repository/poolTask 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 退出 0backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 findingworkspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 sourcePostgreSQL 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 transactionCluster 使用既有 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 tokenmanagement 不挂载 Package valueexecutor 只验证投影元数据;最终恰好一条 immutable bindingApproval 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、
@@ -0,0 +1,35 @@
# ADR-0396:按 Package generation 切换 Plugin Package Secret binding
- 状态:Proposed
- 日期:2026-08-13
- 关联 RFCQL-RFC-0001 D-306B2
- 关联 ADRADR-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/Packagegeneration 精确等于最后一次尝试 `+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 requirementstransition 仍保留新 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 authorityCluster 复用 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 前发布 bindingmanager 仍不能读取 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。
+8
View File
@@ -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",
@@ -329,6 +329,21 @@ function targetFromGeneration(
});
}
export function createPluginPackageSecretBindingTarget(
generationValue: Readonly<PluginPackageResourceGeneration>,
manifestValue: Readonly<PluginPackageManifest>,
): Readonly<PluginPackageSecretBindingTarget> {
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<PluginPackageSecretBindingTarget>,
entries: readonly Readonly<PluginPackageSecretBindingEntry>[],
@@ -457,6 +472,12 @@ function normalizeTarget(
});
}
export function normalizePluginPackageSecretBindingTarget(
value: unknown,
): Readonly<PluginPackageSecretBindingTarget> {
return normalizeTarget(value);
}
export function normalizePluginPackageSecretBinding(
value: unknown,
): Readonly<PluginPackageSecretBinding> {
@@ -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<PluginPackageSecretBindingTransitionEntryState> | null;
readonly next: Readonly<PluginPackageSecretBindingTransitionEntryState> | null;
}
export interface PluginPackageSecretBindingTransitionPlan {
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PLAN_SCHEMA;
readonly kind: PluginPackageSecretBindingTransitionKind;
readonly previousBinding: Readonly<PluginPackageSecretBinding>;
readonly previousActiveLockDigest: string;
readonly previousAttemptGeneration: number;
readonly nextTarget: Readonly<PluginPackageSecretBinding['target']>;
readonly nextBindingPlan: Readonly<PluginPackageSecretBindingPlan> | null;
readonly changes: readonly Readonly<PluginPackageSecretBindingTransitionChange>[];
readonly transitionDigest: string;
}
export interface CreatePluginPackageSecretBindingTransitionPlanInput {
readonly previousBinding: Readonly<PluginPackageSecretBinding>;
readonly previousAttemptGeneration: number;
readonly nextGeneration: Parameters<
typeof normalizePluginPackageResourceGeneration
>[0];
readonly nextManifest: Readonly<PluginPackageManifest>;
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
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<string, unknown> {
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<string, unknown>;
}
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<PluginPackageSecretBindingEntry>,
): Readonly<PluginPackageSecretBindingTransitionEntryState> {
return Object.freeze({
required: entry.required,
secretRef: entry.secretRef,
});
}
function requirementChange(
previous: Readonly<PluginPackageSecretBindingEntry> | undefined,
next: Readonly<PluginPackageSecretBindingEntry> | 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<PluginPackageSecretBindingEntry> | undefined,
next: Readonly<PluginPackageSecretBindingEntry> | 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<PluginPackageSecretBindingEntry>[],
nextEntries: readonly Readonly<PluginPackageSecretBindingEntry>[],
): readonly Readonly<PluginPackageSecretBindingTransitionChange>[] {
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<PluginPackageSecretBindingTransitionChange>[],
): 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<PluginPackageSecretBinding>,
nextTarget: Readonly<PluginPackageSecretBinding['target']>,
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<PluginPackageSecretBinding>,
previousActiveLockDigest: string,
previousAttemptGeneration: number,
nextTarget: Readonly<PluginPackageSecretBinding['target']>,
nextBindingPlan: Readonly<PluginPackageSecretBindingPlan> | null,
): Omit<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'> {
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<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'>,
): string {
return createHash('sha256')
.update(TRANSITION_DIGEST_DOMAIN)
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function withDigest(
value: Omit<PluginPackageSecretBindingTransitionPlan, 'transitionDigest'>,
): Readonly<PluginPackageSecretBindingTransitionPlan> {
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<PluginPackageSecretBindingTransitionPlan> {
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<PluginPackageSecretBindingTransitionPlan> {
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);
}
@@ -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,
);
});