mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate local secret transitions before activation
This commit is contained in:
@@ -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 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。SQLite capability v48(0095/0096)与 PostgreSQL capability v61(pg0062)现已在 repository 与数据库 trigger 两层只允许当前 active 或具完整上一 active lineage、且为 durable install history 最大 generation 的 staged target;queued/activating/failed、陈旧/跳代、断链和直接 SQL 绕过均失败关闭,readiness 校验 exact trigger/function。完整 18-package 串行门、backend 1194 项(1192 pass/2 条件 skip)及真实 PostgreSQL 18.4 B2 role/trigger 门通过。最终顺序仍为 install approval/stage→transition approval→目标 binding publish/materialize→active CAS,失败不得移动旧 active;紧急 revoke 先复用 disable/quarantine 阻止新 admission,再做代际收敛,不声称追回运行中明文。公共 contract 归入既有 runtime-core Secret binding 聚合目录;本切片未新增 package、第三方依赖、表、连接或常驻资源。Local Owner 与 Cluster management/executor 产品编排、真实 Kubernetes rotation/revoke 和低配物理证据仍待完成。
|
||||
- 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` 指回旧代。SQLite capability v49(0097/0098)现新增 immutable transition receipt ledger、typed schema、readiness 与数据库 trigger;Local Owner 私有短命令已完成 staged generation 的 server-derived `plan→execute`,在一个 `BEGIN IMMEDIATE` 中原子提交可选 binding、allowed audit 与 receipt,并支持跨时钟 exact replay。安装恢复缺 receipt 时保留 staged 与旧 active,直接协调器和应用启动都复用 activation prerequisite 再次失败关闭;receipt 到位后才允许 active CAS。Runtime Core 533/533、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 root-only skip、Local Application 47 pass/4 平台 skip,合计 1071 pass/9 条件 skip/0 fail;没有新增 workspace package、第三方依赖、连接、timer、watcher 或常驻资源。PostgreSQL v61 staged-target 门继续有效,但 Cluster receipt ledger、management/executor separation-of-duty 产品编排、真实 Kubernetes rotation/revoke、升级失败回滚和低配物理证据仍待完成。
|
||||
- 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、
|
||||
|
||||
@@ -34,4 +34,7 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首
|
||||
|
||||
- 已冻结共享 transition plan v1 的 lineage 与差异分类语义,并完成 Local/Cluster 的 activation 前持久化门:SQLite capability v48 通过 migration 0095/0096、PostgreSQL capability v61 通过 migration pg0062,把 repository 写入和数据库 trigger 同时约束为“当前 active target”或“由完整上一 active lineage 支撑、且为 durable install history 最大 generation 的 staged target”。`queued|activating|failed`、陈旧/跳代 staged target、断裂 lineage 与直接 SQL 绕过均失败关闭;readiness 校验 exact trigger/function attachment,避免 schema 名义升级但约束缺失。
|
||||
- 本切片没有新增 workspace package、第三方依赖、表、连接、daemon、timer、watcher 或常驻资源;Edge/Standalone 只增加 SQLite schema guard,Cluster 复用既有 PostgreSQL Pool/role。完整 18-package 串行测试与 backend 门已通过;真实 PostgreSQL 18.4 的 migration/runtime role 门证明 active、合规 staged 与 exact replay 可写,activating、陈旧/非最大 staged 以及直接 SQL 可被拒绝。
|
||||
- Local Owner 与 Cluster management/executor 尚未消费 transition plan,完成“审批 → 发布 binding → materialize → active CAS”的产品纵切面;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片只补产品编排,不再扩张 package 或常驻部署面。
|
||||
- Local Owner 产品纵切面已完成:新增私有短命令 `plugin-package.secret-binding.transition.plan|execute`,从当前 staged head、上一 active install/proposal/lock、历史 binding 与 durable 最大尝试 generation 重建计划;Owner 重新鉴权后在一个 `BEGIN IMMEDIATE` 中原子写入可选目标 binding、allowed audit 和 immutable transition receipt。相同 plan/audit 可跨时钟 exact replay,revoke 通过 `bindingDigest: null` receipt 表达,不制造空 binding。
|
||||
- SQLite capability v49(0097/0098)新增 transition receipt ledger、exact schema/readiness/typed schema 和插入 trigger。安装恢复在缺 receipt 时不扫描该 staged generation,旧 active 继续服务;共享 activation prerequisite 在直接安装/恢复路径再次 fail-closed 检查 receipt,receipt 到位后才允许 active CAS。没有新增连接、timer、watcher、常驻进程、workspace package 或第三方依赖,Edge/Standalone/Adopted 共用同一 SQLite authority。
|
||||
- Runtime Core 533/533、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1071 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。
|
||||
- Cluster management/executor 仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片实现 PostgreSQL receipt ledger、separation-of-duty Approval 与 package-executor,再进入 Kubernetes/低配现场门。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ordered_ledger",
|
||||
"maxDirectSourceFiles": 97,
|
||||
"maxDirectSourceFiles": 99,
|
||||
"path": "packages/ql3-local-sqlite/src/migrations",
|
||||
"rationale": "SQLite migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory."
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ type PluginPackageMaterializedRevisionRepository =
|
||||
ReadyLocalStorage['pluginPackageMaterializedRevisions'];
|
||||
type PluginPackageSecretBindingRepository =
|
||||
ReadyLocalStorage['pluginPackageSecretBindings'];
|
||||
type PluginPackageActivationPrerequisite =
|
||||
ReadyLocalStorage['pluginPackageActivationPrerequisite'];
|
||||
type PluginPackageTaskReconciliationRepository =
|
||||
ReadyLocalStorage['pluginPackageTaskReconciliations'];
|
||||
type PluginPackageAutomationPublicationRepository =
|
||||
@@ -100,6 +102,7 @@ export type LocalAdoptedProfileBootstrapResult =
|
||||
readonly pluginPackageInstalls: PluginPackageInstallRepository;
|
||||
readonly pluginPackageMaterializedRevisions: PluginPackageMaterializedRevisionRepository;
|
||||
readonly pluginPackageSecretBindings: PluginPackageSecretBindingRepository;
|
||||
readonly pluginPackageActivationPrerequisite: PluginPackageActivationPrerequisite;
|
||||
readonly pluginPackageTaskReconciliations: PluginPackageTaskReconciliationRepository;
|
||||
readonly pluginPackageAutomationPublications: PluginPackageAutomationPublicationRepository;
|
||||
readonly projectToolDefinitionSnapshots: ProjectToolDefinitionSnapshotRepository;
|
||||
@@ -248,6 +251,8 @@ export async function bootstrapLocalAdoptedProfileStorage(
|
||||
pluginPackageMaterializedRevisions:
|
||||
readyStorage.pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings: readyStorage.pluginPackageSecretBindings,
|
||||
pluginPackageActivationPrerequisite:
|
||||
readyStorage.pluginPackageActivationPrerequisite,
|
||||
pluginPackageTaskReconciliations:
|
||||
readyStorage.pluginPackageTaskReconciliations,
|
||||
pluginPackageAutomationPublications:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
PluginPackageInstallationCoordinator,
|
||||
type PluginPackageActivationPrerequisite,
|
||||
type PluginPackageStageProvider,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import type { PluginPackageAdmissionRepository } from '@qinglong/runtime-core/plugin-package-admission';
|
||||
@@ -64,6 +65,7 @@ export function createLocalPluginPackageFileStageProvider(
|
||||
export function createLocalPluginPackageInstallationCoordinator(options: {
|
||||
readonly repository: PluginPackageAdmissionRepository;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
}): PluginPackageInstallationCoordinator {
|
||||
return new PluginPackageInstallationCoordinator(options);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export {
|
||||
LocalPluginPackageSecretBindingConflictError,
|
||||
LocalPluginPackageSecretBindingUnavailableError,
|
||||
type ExecuteLocalPluginPackageSecretBindingRequest,
|
||||
type ExecuteLocalPluginPackageSecretBindingTransitionRequest,
|
||||
type LocalPluginPackageSecretBindingService,
|
||||
type PlanLocalPluginPackageSecretBindingRequest,
|
||||
type PlanLocalPluginPackageSecretBindingTransitionRequest,
|
||||
} from '@qinglong/local-sqlite/plugin-package-secret-binding-administration';
|
||||
|
||||
@@ -45,6 +45,8 @@ export async function recoverLocalApplicationPluginPackages(
|
||||
storage: LocalApplicationReadyStorage,
|
||||
): Promise<LocalApplicationPluginPackageStartup> {
|
||||
const pluginPackageInstalls = await storage.pluginPackageInstalls();
|
||||
const pluginPackageActivationPrerequisite =
|
||||
await storage.pluginPackageActivationPrerequisite();
|
||||
const pluginPackageActivation = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
activationRoot: options.pluginPackages.activationRoot,
|
||||
@@ -54,6 +56,7 @@ export async function recoverLocalApplicationPluginPackages(
|
||||
repository: pluginPackageInstalls,
|
||||
stageProvider: options.pluginPackages.stageProvider,
|
||||
publisher: pluginPackageActivation,
|
||||
activationPrerequisite: pluginPackageActivationPrerequisite,
|
||||
now: options.pluginPackages.now,
|
||||
}).recover({
|
||||
...(options.pluginPackages.pageSize === undefined
|
||||
|
||||
@@ -1660,6 +1660,7 @@ test('activates the concrete headless runtime in recovery and lifecycle order',
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
deferred: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
import {
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
type PluginPackageSecretBindingTransitionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan';
|
||||
import {
|
||||
pluginPackageInstallRecoveryAction,
|
||||
type PluginPackageInstallActionInput,
|
||||
@@ -206,6 +210,30 @@ export interface ExecuteLocalPluginPackageSecretBindingCommand {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingTransitionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.transition.plan';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingTransitionCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.transition.execute';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly plan: PluginPackageSecretBindingTransitionPlan;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCommand =
|
||||
| ProposeLocalPluginPackageCommand
|
||||
| DecideLocalPluginPackageCommand
|
||||
@@ -217,7 +245,9 @@ export type LocalPluginPackageCommand =
|
||||
| PlanLocalPluginPackageLifecycleCommand
|
||||
| ExecuteLocalPluginPackageLifecycleCommand
|
||||
| PlanLocalPluginPackageSecretBindingCommand
|
||||
| ExecuteLocalPluginPackageSecretBindingCommand;
|
||||
| ExecuteLocalPluginPackageSecretBindingCommand
|
||||
| PlanLocalPluginPackageSecretBindingTransitionCommand
|
||||
| ExecuteLocalPluginPackageSecretBindingTransitionCommand;
|
||||
|
||||
export interface LocalPluginPackageCommandRunner {
|
||||
run(
|
||||
@@ -295,6 +325,21 @@ export type LocalPluginPackageCommandResult =
|
||||
status: 'created' | 'existing';
|
||||
bindingDigest: string;
|
||||
generationDigest: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.transition.plan';
|
||||
plan: Readonly<PluginPackageSecretBindingTransitionPlan>;
|
||||
summary: ReturnType<typeof secretBindingTransitionPlanSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.transition.execute';
|
||||
status: 'created' | 'existing';
|
||||
transitionDigest: string;
|
||||
receiptDigest: string;
|
||||
bindingDigest: string | null;
|
||||
generationDigest: string;
|
||||
}>;
|
||||
|
||||
export class LocalPluginPackageCommandConfigurationError extends TypeError {
|
||||
@@ -603,6 +648,7 @@ function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.plan':
|
||||
case 'plugin-package.secret-binding.transition.plan':
|
||||
exactObject(
|
||||
value.request,
|
||||
['assignments', 'packageName', 'projectId'],
|
||||
@@ -637,6 +683,7 @@ function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.execute':
|
||||
case 'plugin-package.secret-binding.transition.execute':
|
||||
exactObject(
|
||||
value.request,
|
||||
['auditEventId', 'plan'],
|
||||
@@ -653,7 +700,11 @@ function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
);
|
||||
}
|
||||
try {
|
||||
normalizePluginPackageSecretBindingPlan(value.request.plan);
|
||||
if (value.operation === 'plugin-package.secret-binding.execute') {
|
||||
normalizePluginPackageSecretBindingPlan(value.request.plan);
|
||||
} else {
|
||||
normalizePluginPackageSecretBindingTransitionPlan(value.request.plan);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding plan is invalid',
|
||||
@@ -851,6 +902,43 @@ function secretBindingPlanSummary(
|
||||
});
|
||||
}
|
||||
|
||||
function secretBindingTransitionPlanSummary(
|
||||
plan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: plan.kind,
|
||||
projectId: plan.nextTarget.projectId,
|
||||
packageName: plan.nextTarget.packageName,
|
||||
previousInstallationId: plan.previousTarget.installationId,
|
||||
previousGeneration: plan.previousTarget.generation,
|
||||
previousGenerationDigest: plan.previousTarget.generationDigest,
|
||||
previousBindingDigest: plan.previousBinding?.bindingDigest ?? null,
|
||||
previousAttemptGeneration: plan.previousAttemptGeneration,
|
||||
installationId: plan.nextTarget.installationId,
|
||||
generation: plan.nextTarget.generation,
|
||||
generationDigest: plan.nextTarget.generationDigest,
|
||||
manifestDigest: plan.nextTarget.manifestDigest,
|
||||
bindingRequired: plan.nextBindingPlan !== null,
|
||||
assignments:
|
||||
plan.nextBindingPlan?.entries.map((entry) =>
|
||||
Object.freeze({
|
||||
name: entry.name,
|
||||
required: entry.required,
|
||||
bound: entry.secretRef !== null,
|
||||
secretRef: entry.secretRef,
|
||||
}),
|
||||
) ?? Object.freeze([]),
|
||||
changes: plan.changes.map((change) =>
|
||||
Object.freeze({
|
||||
name: change.name,
|
||||
requirement: change.requirement,
|
||||
reference: change.reference,
|
||||
}),
|
||||
),
|
||||
transitionDigest: plan.transitionDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalPluginPackageCommand>,
|
||||
database: LocalSqlitePluginPackageManagementDatabase,
|
||||
@@ -873,6 +961,22 @@ async function execute(
|
||||
summary: secretBindingPlanSummary(plan),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.secret-binding.transition.plan') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const plan = await service.planTransition({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
plannedAtMs: Date.now(),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
plan,
|
||||
summary: secretBindingTransitionPlanSummary(plan),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.secret-binding.execute') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
@@ -888,6 +992,23 @@ async function execute(
|
||||
...result,
|
||||
});
|
||||
}
|
||||
if (
|
||||
command.operation === 'plugin-package.secret-binding.transition.execute'
|
||||
) {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const result = await service.executeTransition({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
confirmAuthorization: authenticated.confirm,
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
...result,
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.lifecycle.plan') {
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority: database.authority,
|
||||
|
||||
@@ -363,9 +363,9 @@ function composeDockerHarness(
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '48',
|
||||
'io.qinglong.local.sqlite-contract-max': '48',
|
||||
'io.qinglong.local.sqlite-write-contract': '48',
|
||||
'io.qinglong.local.sqlite-contract-min': '49',
|
||||
'io.qinglong.local.sqlite-contract-max': '49',
|
||||
'io.qinglong.local.sqlite-write-contract': '49',
|
||||
'io.qinglong.local.application-config': '2',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -975,9 +975,9 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
'/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js',
|
||||
],
|
||||
Labels: {
|
||||
'io.qinglong.local.sqlite-contract-min': '48',
|
||||
'io.qinglong.local.sqlite-contract-max': '48',
|
||||
'io.qinglong.local.sqlite-write-contract': '48',
|
||||
'io.qinglong.local.sqlite-contract-min': '49',
|
||||
'io.qinglong.local.sqlite-contract-max': '49',
|
||||
'io.qinglong.local.sqlite-write-contract': '49',
|
||||
'io.qinglong.local.application-config': '2',
|
||||
'io.qinglong.local.compose-selection': '1',
|
||||
'io.qinglong.ai': 'excluded',
|
||||
@@ -1029,7 +1029,7 @@ test('preflights exact local image, Compose merge and SQLite capability', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.generation, 1);
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.sqlite.contractVersion, 48);
|
||||
assert.equal(result.sqlite.contractVersion, 49);
|
||||
assert.equal(result.image.architecture, 'arm64');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0].slice(0, 2), ['image', 'inspect']);
|
||||
@@ -1129,8 +1129,8 @@ test('applies one Compose generation and exactly replays its health receipt', as
|
||||
assert.equal(mode(receiptPath), 0o600);
|
||||
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
||||
assert.deepEqual(receipt.sqlite, {
|
||||
contractVersion: 48,
|
||||
writeContractVersion: 48,
|
||||
contractVersion: 49,
|
||||
writeContractVersion: 49,
|
||||
writeObservation: 'unchanged',
|
||||
backup: null,
|
||||
});
|
||||
@@ -1427,8 +1427,8 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
|
||||
`${command.request.rolloutId}.sqlite`,
|
||||
);
|
||||
assert.equal(mode(backupPath), 0o600);
|
||||
assert.equal(receipt.sqlite.contractVersion, 48);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 48);
|
||||
assert.equal(receipt.sqlite.contractVersion, 49);
|
||||
assert.equal(receipt.sqlite.writeContractVersion, 49);
|
||||
assert.equal(receipt.sqlite.writeObservation, 'changed');
|
||||
assert.match(receipt.sqlite.backup.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(receipt.sqlite.backup.bytes > 0, true);
|
||||
|
||||
@@ -34,8 +34,8 @@ test('inspects the exact fresh Profile schema without exposing its path', async
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.storage.contractName, 'local-control-core');
|
||||
assert.equal(result.storage.contractVersion, 48);
|
||||
assert.equal(result.storage.migrationCount, 96);
|
||||
assert.equal(result.storage.contractVersion, 49);
|
||||
assert.equal(result.storage.migrationCount, 98);
|
||||
assert.equal(result.storage.journalMode, 'delete');
|
||||
assert.equal(JSON.stringify(result).includes(state.directory), false);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqlitePluginPackageSecretBindingActivationPrerequisite,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-secret-binding-activation-prerequisite');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-automation-publication');
|
||||
@@ -72,10 +75,14 @@ const {
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
serializePluginPackageManifest,
|
||||
createPluginPackageActivationReceipt,
|
||||
pluginPackageActivationIntentDigest,
|
||||
pluginPackageInstallCommit,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
@@ -273,6 +280,38 @@ function actionInput(secretAware = false) {
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeActionInput(previousInput, previousLockDigest) {
|
||||
const manifest = {
|
||||
...previousInput.manifest,
|
||||
metadata: {
|
||||
...previousInput.manifest.metadata,
|
||||
version: '1.1.0',
|
||||
description: 'One bounded upgraded package',
|
||||
},
|
||||
};
|
||||
const artifact = packageArtifact(manifest);
|
||||
const artifactDigest = digest(artifact);
|
||||
return {
|
||||
...previousInput,
|
||||
lockId: 'cli-monitor-v2',
|
||||
manifest,
|
||||
plan: planPluginPackageInstall(
|
||||
manifest,
|
||||
previousInput.environment,
|
||||
previousInput.manifest,
|
||||
),
|
||||
previousManifest: previousInput.manifest,
|
||||
source: {
|
||||
...previousInput.source,
|
||||
locator: `offline:sha256:${artifactDigest}`,
|
||||
artifactDigest,
|
||||
artifactBytes: artifact.byteLength,
|
||||
},
|
||||
targetGeneration: 2,
|
||||
previousLockDigest,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t, owner = true) {
|
||||
const deploymentRoot = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-package-command-')),
|
||||
@@ -550,6 +589,30 @@ async function activatePackageOnly(databasePath, projectId, packageName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function stagePackageOnly(databasePath, projectId, packageName) {
|
||||
const client = new DatabaseSync(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
try {
|
||||
const installs = new LocalSqlitePluginPackageInstallRepository(authority);
|
||||
const queued = await installs.find(projectId, packageName);
|
||||
const lock = await installs.findLock(queued.lockDigest);
|
||||
const staged = transitionPluginPackageInstall(lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `secret-binding-stage-package-${queued.targetGeneration}`,
|
||||
occurredAtMs: queued.updatedAtMs + 1,
|
||||
stageRef: `stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(queued, staged));
|
||||
return { staged, lock };
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function publisherTrustRunnerWithOneSnapshotFault() {
|
||||
let injectFault = true;
|
||||
return createLocalPluginPackagePublisherTrustCommandRunner({
|
||||
@@ -1662,6 +1725,296 @@ test('plans and atomically binds a versioned Secret to the current Package gener
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps an upgrade staged until the Owner commits its Secret transition', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const initialInput = actionInput(true);
|
||||
const initialActionRef = 'proposal:transition-cli-monitor-v1';
|
||||
const initialApprovalId = 'approval-transition-cli-monitor-v1';
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.propose',
|
||||
{
|
||||
actionRef: initialActionRef,
|
||||
approvalRequestId: initialApprovalId,
|
||||
proposalAuditEventId: '22000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '22000000-0000-4000-8000-000000000002',
|
||||
actionInput: initialInput,
|
||||
},
|
||||
'transition-01-propose',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.decide',
|
||||
{
|
||||
actionRef: initialActionRef,
|
||||
approvalRequestId: initialApprovalId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-transition-cli-monitor-v1',
|
||||
auditEventId: '22000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
},
|
||||
'transition-02-decide',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.consume',
|
||||
{
|
||||
actionRef: initialActionRef,
|
||||
approvalRequestId: initialApprovalId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-transition-cli-monitor-v1',
|
||||
dispatchId: 'dispatch-transition-cli-monitor-v1',
|
||||
auditEventId: '22000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
'transition-03-consume',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.dispatch',
|
||||
{ limit: 1 },
|
||||
'transition-04-dispatch',
|
||||
),
|
||||
);
|
||||
const { active, lock: activeLock } = await activatePackageOnly(
|
||||
value.databasePath,
|
||||
'default',
|
||||
'cli-monitor',
|
||||
);
|
||||
const secretRef = createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'transition-token',
|
||||
version: 1,
|
||||
});
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes" (
|
||||
"project_id", "secret_name", "version", "mutation_id",
|
||||
"key_id", "algorithm", "nonce", "ciphertext", "auth_tag",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, 1, ?, ?, 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'default',
|
||||
'transition-token',
|
||||
'transition-secret-v1',
|
||||
'fixture-key-v1',
|
||||
Buffer.alloc(12, 3),
|
||||
Buffer.from('ciphertext'),
|
||||
Buffer.alloc(16, 4),
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
const initialPlan = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'transition-05-bind-plan',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.execute',
|
||||
{
|
||||
plan: initialPlan.plan,
|
||||
auditEventId: '22000000-0000-4000-8000-000000000005',
|
||||
},
|
||||
'transition-06-bind-execute',
|
||||
),
|
||||
);
|
||||
|
||||
const upgradeInput = upgradeActionInput(initialInput, activeLock.lockDigest);
|
||||
const upgradeActionRef = 'proposal:transition-cli-monitor-v2';
|
||||
const upgradeApprovalId = 'approval-transition-cli-monitor-v2';
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.propose',
|
||||
{
|
||||
actionRef: upgradeActionRef,
|
||||
approvalRequestId: upgradeApprovalId,
|
||||
proposalAuditEventId: '22000000-0000-4000-8000-000000000006',
|
||||
approvalAuditEventId: '22000000-0000-4000-8000-000000000007',
|
||||
actionInput: upgradeInput,
|
||||
},
|
||||
'transition-07-upgrade-propose',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.decide',
|
||||
{
|
||||
actionRef: upgradeActionRef,
|
||||
approvalRequestId: upgradeApprovalId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-transition-cli-monitor-v2',
|
||||
auditEventId: '22000000-0000-4000-8000-000000000008',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
},
|
||||
'transition-08-upgrade-decide',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.consume',
|
||||
{
|
||||
actionRef: upgradeActionRef,
|
||||
approvalRequestId: upgradeApprovalId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-transition-cli-monitor-v2',
|
||||
dispatchId: 'dispatch-transition-cli-monitor-v2',
|
||||
auditEventId: '22000000-0000-4000-8000-000000000009',
|
||||
},
|
||||
'transition-09-upgrade-consume',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.dispatch',
|
||||
{ limit: 1 },
|
||||
'transition-10-upgrade-dispatch',
|
||||
),
|
||||
);
|
||||
const { staged } = await stagePackageOnly(
|
||||
value.databasePath,
|
||||
'default',
|
||||
'cli-monitor',
|
||||
);
|
||||
|
||||
const recoveryClient = new DatabaseSync(value.databasePath);
|
||||
const recoveryAuthority = new LocalSqliteOperationAuthority(recoveryClient);
|
||||
const installs = new LocalSqlitePluginPackageInstallRepository(
|
||||
recoveryAuthority,
|
||||
);
|
||||
const published = [];
|
||||
let publishedReceipt;
|
||||
const activationPrerequisite =
|
||||
new LocalSqlitePluginPackageSecretBindingActivationPrerequisite(
|
||||
recoveryAuthority,
|
||||
);
|
||||
const recovery = new PluginPackageRecoveryCoordinator({
|
||||
repository: installs,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
throw new Error('staged upgrade must not be staged twice');
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
async publish(intent) {
|
||||
published.push(intent.intentDigest);
|
||||
const receipt = createPluginPackageActivationReceipt({
|
||||
activationRef: `activation:${intent.lockDigest}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: intent.targetGeneration,
|
||||
contentDigest: intent.contentDigest,
|
||||
activatedAtMs: staged.updatedAtMs + 2,
|
||||
});
|
||||
publishedReceipt = receipt;
|
||||
return receipt;
|
||||
},
|
||||
async inspect() {
|
||||
return publishedReceipt
|
||||
? { status: 'published', receipt: publishedReceipt }
|
||||
: { status: 'not_published' };
|
||||
},
|
||||
},
|
||||
activationPrerequisite,
|
||||
now: () => staged.updatedAtMs + 1,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await activationPrerequisite.inspect(
|
||||
staged,
|
||||
await installs.findLock(staged.lockDigest),
|
||||
),
|
||||
{
|
||||
status: 'deferred',
|
||||
reason: 'secret_binding_transition_required',
|
||||
},
|
||||
);
|
||||
const deferred = await recovery.recover({ pageSize: 1, maxPages: 1 });
|
||||
assert.equal(deferred.scanned, 0);
|
||||
assert.equal(deferred.safeToAdmit, true);
|
||||
assert.equal((await installs.find('default', 'cli-monitor')).state, 'staged');
|
||||
assert.deepEqual(published, []);
|
||||
|
||||
const transitionPlan = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.transition.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'transition-11-plan',
|
||||
),
|
||||
);
|
||||
assert.equal(transitionPlan.summary.generation, 2);
|
||||
assert.equal(transitionPlan.summary.kind, 'carry-forward');
|
||||
const executePath = commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.transition.execute',
|
||||
{
|
||||
plan: transitionPlan.plan,
|
||||
auditEventId: '22000000-0000-4000-8000-00000000000a',
|
||||
},
|
||||
'transition-12-execute',
|
||||
);
|
||||
const created = await runLocalPluginPackageCommandFile(executePath);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.match(created.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(
|
||||
(await runLocalPluginPackageCommandFile(executePath)).status,
|
||||
'existing',
|
||||
);
|
||||
const settled = await recovery.recoverPage({ limit: 1 });
|
||||
const recovered = await installs.find('default', 'cli-monitor');
|
||||
assert.equal(settled.items[0].status, 'settled');
|
||||
assert.equal(
|
||||
recovered.state,
|
||||
'active',
|
||||
JSON.stringify({ recovered, publishedReceipt }),
|
||||
);
|
||||
assert.equal(published.length, 1);
|
||||
const inspection = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
inspection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageSecretBindingTransitionReceipts"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
inspection.close();
|
||||
await recoveryAuthority.close();
|
||||
}
|
||||
assert.equal(active.state, 'active');
|
||||
});
|
||||
|
||||
test('denies an authenticated non-owner before package proposal mutation', async (t) => {
|
||||
const value = await fixture(t, false);
|
||||
await assert.rejects(
|
||||
|
||||
@@ -95,6 +95,16 @@
|
||||
"require": "./dist/plugin-package/secret-binding/administration.js",
|
||||
"default": "./dist/plugin-package/secret-binding/administration.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-transition-receipt": {
|
||||
"types": "./dist/plugin-package/secret-binding/transitionReceiptRepository.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/transitionReceiptRepository.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionReceiptRepository.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-activation-prerequisite": {
|
||||
"types": "./dist/plugin-package/secret-binding/activationPrerequisite.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/activationPrerequisite.js",
|
||||
"default": "./dist/plugin-package/secret-binding/activationPrerequisite.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.js",
|
||||
|
||||
@@ -106,6 +106,8 @@ import { local0093PluginPackageSecretMaterializationGuardMigration } from '../mi
|
||||
import { local0094CapabilityV47Migration } from '../migrations/0094-capability-v47';
|
||||
import { local0095PluginPackageSecretBindingTargetGuardMigration } from '../migrations/0095-plugin-package-secret-binding-target-guard';
|
||||
import { local0096CapabilityV48Migration } from '../migrations/0096-capability-v48';
|
||||
import { local0097PluginPackageSecretBindingTransitionReceiptsMigration } from '../migrations/0097-plugin-package-secret-binding-transition-receipts';
|
||||
import { local0098CapabilityV49Migration } from '../migrations/0098-capability-v49';
|
||||
import type { LocalSqliteMigrationContext } from '../migrations/sqlMigration';
|
||||
import {
|
||||
LOCAL_SQLITE_MIGRATION_STREAM_ID,
|
||||
@@ -224,6 +226,8 @@ export const localSqliteMigrationDefinition: MigrationStreamDefinition<LocalSqli
|
||||
local0094CapabilityV47Migration,
|
||||
local0095PluginPackageSecretBindingTargetGuardMigration,
|
||||
local0096CapabilityV48Migration,
|
||||
local0097PluginPackageSecretBindingTransitionReceiptsMigration,
|
||||
local0098CapabilityV49Migration,
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
@@ -492,5 +492,15 @@ export const localSqliteMigrationManifest: MigrationStreamManifest =
|
||||
checksum:
|
||||
'07118f8e2f1e4f4aa7b9bb95ba9b70276f62de0e63d63cf5dcceacd3532853d9',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0097-plugin-package-secret-binding-transition-receipts',
|
||||
checksum:
|
||||
'8d4e1bd0cf40e6c3c8d8b628cc04d95209a99b38344b7997966050f90db567c0',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: '0098-capability-v49',
|
||||
checksum:
|
||||
'133bdb78900256971bb6e13de8a129024d09f9d4c9d9290137eca3ff6e8b30eb',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL } from '../plugin-package/secret-binding/transitionReceiptSchemaContract';
|
||||
|
||||
export const local0097PluginPackageSecretBindingTransitionReceiptsMigration =
|
||||
defineLocalSqliteMigration({
|
||||
id: '0097-plugin-package-secret-binding-transition-receipts',
|
||||
statements: [
|
||||
`
|
||||
CREATE TABLE "QingLong3PluginPackageSecretBindingTransitionReceipts" (
|
||||
generation_digest TEXT PRIMARY KEY NOT NULL,
|
||||
transition_digest TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
package_name TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
lock_digest TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
previous_active_lock_digest TEXT NOT NULL,
|
||||
authority_kind TEXT NOT NULL,
|
||||
evidence_digest TEXT NOT NULL,
|
||||
binding_digest TEXT,
|
||||
committed_at_ms INTEGER NOT NULL,
|
||||
receipt_digest TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_install_fk
|
||||
FOREIGN KEY (installation_id)
|
||||
REFERENCES "QingLong3PluginPackageInstalls" (installation_id)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_binding_fk
|
||||
FOREIGN KEY (binding_digest)
|
||||
REFERENCES "QingLong3PluginPackageSecretBindings" (binding_digest)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_identity_check CHECK (
|
||||
length(project_id) BETWEEN 1 AND 128 AND
|
||||
length(package_name) BETWEEN 1 AND 63 AND
|
||||
length(installation_id) BETWEEN 1 AND 128 AND
|
||||
generation BETWEEN 2 AND 2147483647 AND
|
||||
authority_kind IN ('approved-action-execution','local-owner-confirmation') AND
|
||||
committed_at_ms >= 0
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_digest_check CHECK (
|
||||
length(generation_digest) = 64 AND generation_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(transition_digest) = 64 AND transition_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(lock_digest) = 64 AND lock_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(manifest_digest) = 64 AND manifest_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(previous_active_lock_digest) = 64 AND previous_active_lock_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
length(evidence_digest) = 64 AND evidence_digest NOT GLOB '*[^0-9a-f]*' AND
|
||||
(binding_digest IS NULL OR
|
||||
(length(binding_digest) = 64 AND binding_digest NOT GLOB '*[^0-9a-f]*')) AND
|
||||
length(receipt_digest) = 64 AND receipt_digest NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
CONSTRAINT ql3_plugin_package_secret_binding_transition_receipt_json_check CHECK (
|
||||
length(CAST(receipt_json AS BLOB)) BETWEEN 2 AND 196608 AND
|
||||
json_valid(receipt_json) AND json_type(receipt_json) = 'object' AND
|
||||
json_extract(receipt_json, '$.schema') = 'qinglong/plugin-package-secret-binding-transition-receipt@v1' AND
|
||||
json_extract(receipt_json, '$.transitionPlan.transitionDigest') = transition_digest AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.generationDigest') = generation_digest AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.projectId') = project_id AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.packageName') = package_name AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.installationId') = installation_id AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.lockDigest') = lock_digest AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.generation') = generation AND
|
||||
json_extract(receipt_json, '$.transitionPlan.nextTarget.manifestDigest') = manifest_digest AND
|
||||
json_extract(receipt_json, '$.transitionPlan.previousActiveLockDigest') = previous_active_lock_digest AND
|
||||
json_extract(receipt_json, '$.authority.kind') = authority_kind AND
|
||||
json_extract(receipt_json, '$.authority.evidenceDigest') = evidence_digest AND
|
||||
json_extract(receipt_json, '$.bindingDigest') IS binding_digest AND
|
||||
json_extract(receipt_json, '$.committedAtMs') = committed_at_ms AND
|
||||
json_extract(receipt_json, '$.receiptDigest') = receipt_digest
|
||||
)
|
||||
)
|
||||
`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_transition_receipt_transition_uidx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (transition_digest)`,
|
||||
`CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_transition_receipt_digest_uidx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (receipt_digest)`,
|
||||
`CREATE INDEX ql3_plugin_package_secret_binding_transition_receipt_install_idx ON "QingLong3PluginPackageSecretBindingTransitionReceipts" (installation_id, generation_digest)`,
|
||||
LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CAPABILITIES_V48 } from './0096-capability-v48';
|
||||
import { defineLocalSqliteMigration } from './sqlMigration';
|
||||
|
||||
export const CAPABILITIES_V49 = CAPABILITIES_V48.replace(
|
||||
'"plugin_package_secret_binding_transition":1,',
|
||||
'"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,',
|
||||
);
|
||||
|
||||
export const local0098CapabilityV49Migration = defineLocalSqliteMigration({
|
||||
id: '0098-capability-v49',
|
||||
statements: [
|
||||
`UPDATE "QingLong3SchemaCapabilities" SET contract_version = 49, migration_id = '0097-plugin-package-secret-binding-transition-receipts', capabilities = '${CAPABILITIES_V49}', updated_at_ms = CAST(unixepoch('subsec') * 1000 AS INTEGER) WHERE contract_name = 'local-control-core' AND contract_version = 48 AND migration_id = '0095-plugin-package-secret-binding-target-guard' AND capabilities = '${CAPABILITIES_V48}'`,
|
||||
],
|
||||
});
|
||||
@@ -1142,6 +1142,41 @@ export class LocalSqlitePluginPackageInstallRepository
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install."installation_id" = head."installation_id"
|
||||
WHERE install."state" IN ('queued','staged','activating')
|
||||
AND NOT (
|
||||
install."state" = 'staged' AND
|
||||
install."previous_active_lock_digest" IS NOT NULL AND
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindings" AS previous_binding
|
||||
JOIN "QingLong3PluginPackageInstalls" AS previous_install
|
||||
ON previous_install.project_id = install.project_id
|
||||
AND previous_install.package_name = install.package_name
|
||||
AND previous_install.lock_digest =
|
||||
install.previous_active_lock_digest
|
||||
WHERE previous_binding.installation_id =
|
||||
previous_install.installation_id
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageAdmissionReceipts" AS admission
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS proposal
|
||||
ON proposal.action_ref = admission.action_ref
|
||||
WHERE admission.installation_id = install.installation_id
|
||||
AND json_array_length(
|
||||
json_extract(
|
||||
proposal.proposal_json,
|
||||
'$.actionInput.manifest.spec.permissions.secrets'
|
||||
)
|
||||
) > 0
|
||||
)
|
||||
) AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindingTransitionReceipts" AS transition_receipt
|
||||
WHERE transition_receipt.installation_id = install.installation_id
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizePluginPackageInstallProposal,
|
||||
type PluginPackageInstallProposal,
|
||||
} from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
import {
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
normalizePluginPackageLock,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import type {
|
||||
PluginPackageActivationPrerequisite,
|
||||
PluginPackageActivationPrerequisiteObservation,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Secret binding activation prerequisite is unavailable');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageSecretBindingActivationPrerequisite
|
||||
implements PluginPackageActivationPrerequisite
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #receipts: LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#receipts =
|
||||
new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository(
|
||||
this.#authority,
|
||||
);
|
||||
}
|
||||
|
||||
async inspect(
|
||||
recordValue: Readonly<PluginPackageInstallRecord>,
|
||||
lockValue: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
const record = normalizePluginPackageInstallRecord(recordValue);
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
if (record.state !== 'staged') {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite requires staged install',
|
||||
);
|
||||
}
|
||||
if (record.previousActiveLockDigest === null) {
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
}
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
const row = this.#authority.client
|
||||
.prepare(
|
||||
`SELECT proposal.proposal_json AS "proposalJson",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstalls" AS previous
|
||||
JOIN "QingLong3PluginPackageSecretBindings" AS binding
|
||||
ON binding.installation_id = previous.installation_id
|
||||
WHERE previous.project_id = install.project_id
|
||||
AND previous.package_name = install.package_name
|
||||
AND previous.lock_digest = install.previous_active_lock_digest
|
||||
) AS "previousBindingPresent"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS admission
|
||||
ON admission.installation_id = install.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS proposal
|
||||
ON proposal.action_ref = admission.action_ref
|
||||
WHERE head.project_id = ? AND head.package_name = ?
|
||||
AND install.installation_id = ? AND install.lock_digest = ?
|
||||
AND install.state = 'staged'
|
||||
AND install.previous_active_lock_digest = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(
|
||||
record.projectId,
|
||||
record.packageName,
|
||||
record.installationId,
|
||||
record.lockDigest,
|
||||
record.previousActiveLockDigest,
|
||||
) as Row[];
|
||||
if (row.length !== 1) {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite is unavailable',
|
||||
);
|
||||
}
|
||||
const proposal = normalizePluginPackageInstallProposal(
|
||||
JSON.parse(
|
||||
text(row[0]!, 'proposalJson'),
|
||||
) as PluginPackageInstallProposal,
|
||||
);
|
||||
if (
|
||||
proposal.actionDigest !== lock.approval.actionDigest ||
|
||||
proposal.previewDigest !== lock.approval.previewDigest ||
|
||||
proposal.actionInput.targetGeneration !== record.targetGeneration ||
|
||||
proposal.actionInput.source.contentDigest !==
|
||||
lock.source.contentDigest
|
||||
) {
|
||||
throw new Error(
|
||||
'Secret binding activation prerequisite provenance drift',
|
||||
);
|
||||
}
|
||||
const required =
|
||||
proposal.actionInput.manifest.spec.permissions.secrets.length > 0 ||
|
||||
row[0]!.previousBindingPresent === 1;
|
||||
if (!required) return Object.freeze({ status: 'ready' as const });
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: record.installationId,
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
lockDigest: record.lockDigest,
|
||||
generation: record.targetGeneration,
|
||||
previousActiveLockDigest: record.previousActiveLockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
const target = createPluginPackageSecretBindingTarget(
|
||||
generation,
|
||||
proposal.actionInput.manifest,
|
||||
);
|
||||
const receipt = this.#receipts.findInTransaction(
|
||||
target.generationDigest,
|
||||
);
|
||||
if (
|
||||
!receipt ||
|
||||
JSON.stringify(receipt.transitionPlan.nextTarget) !==
|
||||
JSON.stringify(target) ||
|
||||
receipt.transitionPlan.previousActiveLockDigest !==
|
||||
record.previousActiveLockDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'deferred' as const,
|
||||
reason: 'secret_binding_transition_required' as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
},
|
||||
() => new Error('Secret binding activation prerequisite is unavailable'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,22 @@ import {
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import { createPluginPackageSecretBindingTarget } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import {
|
||||
createPluginPackageSecretBindingFromPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
import {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
type PluginPackageSecretBindingTransitionPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan';
|
||||
import {
|
||||
createPluginPackageSecretBindingFromTransitionPlan,
|
||||
createPluginPackageSecretBindingTransitionReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
import { parseSecretRef } from '@qinglong/runtime-core/secret-reference';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
@@ -35,6 +45,7 @@ import {
|
||||
sameSecurityAuditSemantic,
|
||||
} from '../../security/securityPersistence';
|
||||
import { LocalSqlitePluginPackageSecretBindingRepository } from './repository';
|
||||
import { LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository } from './transitionReceiptRepository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
@@ -61,6 +72,24 @@ export interface ExecuteLocalPluginPackageSecretBindingRequest {
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingTransitionRequest {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingTransitionRequest {
|
||||
readonly plan: PluginPackageSecretBindingTransitionPlan;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageSecretBindingService {
|
||||
plan(
|
||||
request: PlanLocalPluginPackageSecretBindingRequest,
|
||||
@@ -72,6 +101,20 @@ export interface LocalPluginPackageSecretBindingService {
|
||||
generationDigest: string;
|
||||
}>
|
||||
>;
|
||||
planTransition(
|
||||
request: PlanLocalPluginPackageSecretBindingTransitionRequest,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionPlan>>;
|
||||
executeTransition(
|
||||
request: ExecuteLocalPluginPackageSecretBindingTransitionRequest,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
transitionDigest: string;
|
||||
receiptDigest: string;
|
||||
bindingDigest: string | null;
|
||||
generationDigest: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class LocalPluginPackageSecretBindingConflictError extends Error {
|
||||
@@ -213,6 +256,145 @@ function generationFrom(current: ReturnType<typeof loadCurrent>) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadTransition(
|
||||
client: DatabaseSync,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Readonly<{
|
||||
previous: ReturnType<typeof loadCurrent>;
|
||||
next: ReturnType<typeof loadCurrent>;
|
||||
previousAttemptGeneration: number;
|
||||
}> {
|
||||
const rows = client
|
||||
.prepare(
|
||||
`SELECT current.record_json AS "nextRecordJson",
|
||||
current.lock_json AS "nextLockJson",
|
||||
current_proposal.proposal_json AS "nextProposalJson",
|
||||
previous.record_json AS "previousRecordJson",
|
||||
previous.lock_json AS "previousLockJson",
|
||||
previous_proposal.proposal_json AS "previousProposalJson",
|
||||
(
|
||||
SELECT MAX(history.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS history
|
||||
WHERE history.project_id = current.project_id
|
||||
AND history.package_name = current.package_name
|
||||
AND history.target_generation < current.target_generation
|
||||
) AS "previousAttemptGeneration"
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS current
|
||||
ON current.installation_id = head.installation_id
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS current_admission
|
||||
ON current_admission.installation_id = current.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS current_proposal
|
||||
ON current_proposal.action_ref = current_admission.action_ref
|
||||
JOIN "QingLong3PluginPackageInstalls" AS previous
|
||||
ON previous.project_id = current.project_id
|
||||
AND previous.package_name = current.package_name
|
||||
AND previous.lock_digest = current.previous_active_lock_digest
|
||||
JOIN "QingLong3PluginPackageAdmissionReceipts" AS previous_admission
|
||||
ON previous_admission.installation_id = previous.installation_id
|
||||
JOIN "QingLong3PluginPackageInstallProposals" AS previous_proposal
|
||||
ON previous_proposal.action_ref = previous_admission.action_ref
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND current.state = 'staged'
|
||||
AND current.previous_active_lock_digest IS NOT NULL
|
||||
AND current.active_lock_digest = current.previous_active_lock_digest
|
||||
AND current.target_generation = (
|
||||
SELECT MAX(latest.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS latest
|
||||
WHERE latest.project_id = current.project_id
|
||||
AND latest.package_name = current.package_name
|
||||
)
|
||||
AND previous.state = 'active'
|
||||
AND previous.active_lock_digest = previous.lock_digest
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(projectId, packageName) as Row[];
|
||||
if (rows.length !== 1) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'reviewed staged Package generation is absent or ambiguous',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const row = rows[0]!;
|
||||
const next = Object.freeze({
|
||||
record: normalizePluginPackageInstallRecord(
|
||||
JSON.parse(rowText(row, 'nextRecordJson')),
|
||||
),
|
||||
lock: normalizePluginPackageLock(
|
||||
JSON.parse(rowText(row, 'nextLockJson')),
|
||||
),
|
||||
proposal: normalizePluginPackageInstallProposal(
|
||||
JSON.parse(rowText(row, 'nextProposalJson')),
|
||||
),
|
||||
});
|
||||
const previous = Object.freeze({
|
||||
record: normalizePluginPackageInstallRecord(
|
||||
JSON.parse(rowText(row, 'previousRecordJson')),
|
||||
),
|
||||
lock: normalizePluginPackageLock(
|
||||
JSON.parse(rowText(row, 'previousLockJson')),
|
||||
),
|
||||
proposal: normalizePluginPackageInstallProposal(
|
||||
JSON.parse(rowText(row, 'previousProposalJson')),
|
||||
),
|
||||
});
|
||||
const previousAttemptGeneration = row.previousAttemptGeneration;
|
||||
if (
|
||||
!Number.isSafeInteger(previousAttemptGeneration) ||
|
||||
previousAttemptGeneration !== next.record.targetGeneration - 1 ||
|
||||
next.record.projectId !== projectId ||
|
||||
next.record.packageName !== packageName ||
|
||||
next.record.lockDigest !== next.lock.lockDigest ||
|
||||
next.record.previousActiveLockDigest !== previous.lock.lockDigest ||
|
||||
next.proposal.actionDigest !== next.lock.approval.actionDigest ||
|
||||
next.proposal.previewDigest !== next.lock.approval.previewDigest ||
|
||||
next.proposal.actionInput.targetGeneration !==
|
||||
next.record.targetGeneration ||
|
||||
next.proposal.actionInput.manifest.metadata.name !== packageName ||
|
||||
next.proposal.actionInput.source.contentDigest !==
|
||||
next.lock.source.contentDigest ||
|
||||
previous.record.lockDigest !== previous.lock.lockDigest ||
|
||||
previous.proposal.actionDigest !== previous.lock.approval.actionDigest ||
|
||||
previous.proposal.previewDigest !==
|
||||
previous.lock.approval.previewDigest ||
|
||||
previous.proposal.actionInput.targetGeneration !==
|
||||
previous.record.targetGeneration ||
|
||||
previous.proposal.actionInput.source.contentDigest !==
|
||||
previous.lock.source.contentDigest
|
||||
) {
|
||||
throw new Error('Package transition provenance drift');
|
||||
}
|
||||
return Object.freeze({
|
||||
previous,
|
||||
next,
|
||||
previousAttemptGeneration: previousAttemptGeneration as number,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageSecretBindingConflictError)
|
||||
throw error;
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function transitionGeneration(
|
||||
value: ReturnType<typeof loadTransition>['next'],
|
||||
) {
|
||||
return createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: value.record.installationId,
|
||||
projectId: value.record.projectId,
|
||||
packageName: value.record.packageName,
|
||||
lockDigest: value.record.lockDigest,
|
||||
generation: value.record.targetGeneration,
|
||||
previousActiveLockDigest: value.record.previousActiveLockDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
resources: value.lock.resources,
|
||||
});
|
||||
}
|
||||
|
||||
function auditRecord(
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
eventId: string,
|
||||
@@ -234,6 +416,27 @@ function auditRecord(
|
||||
});
|
||||
}
|
||||
|
||||
function transitionAuditRecord(
|
||||
plan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
eventId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: `package_secret_binding_transition:${plan.transitionDigest}`,
|
||||
operationId: 'plugin_package.secret.transition',
|
||||
projectId: plan.nextTarget.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze([`owner_confirmed_secret_${plan.kind}`]),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function verifySecretVersions(
|
||||
client: DatabaseSync,
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
@@ -327,6 +530,10 @@ export function createLocalPluginPackageSecretBindingService(
|
||||
const bindings = new LocalSqlitePluginPackageSecretBindingRepository(
|
||||
authority,
|
||||
);
|
||||
const transitionReceipts =
|
||||
new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository(
|
||||
authority,
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
@@ -538,5 +745,250 @@ export function createLocalPluginPackageSecretBindingService(
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
|
||||
async planTransition(
|
||||
request: PlanLocalPluginPackageSecretBindingTransitionRequest,
|
||||
) {
|
||||
const projectId = identity(request.projectId, 'Project ID', IDENTIFIER);
|
||||
const packageName = identity(
|
||||
request.packageName,
|
||||
'Package name',
|
||||
PACKAGE_NAME,
|
||||
);
|
||||
const plannedAtMs = timestamp(request.plannedAtMs, 'plannedAtMs');
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
projectId,
|
||||
plannedAtMs,
|
||||
);
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
verifyPolicyFence(
|
||||
authority.client,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
projectId,
|
||||
);
|
||||
const transition = loadTransition(
|
||||
authority.client,
|
||||
projectId,
|
||||
packageName,
|
||||
);
|
||||
const previousGeneration = generationFrom(transition.previous);
|
||||
const previousTarget = createPluginPackageSecretBindingTarget(
|
||||
previousGeneration,
|
||||
transition.previous.proposal.actionInput.manifest,
|
||||
);
|
||||
const previousBinding = bindings.findInTransaction(
|
||||
previousTarget.generationDigest,
|
||||
);
|
||||
if (
|
||||
transitionReceipts.findInTransaction(
|
||||
transitionGeneration(transition.next).generationDigest,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'staged generation transition is already committed',
|
||||
);
|
||||
}
|
||||
const plan = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: transition.previousAttemptGeneration,
|
||||
nextGeneration: transitionGeneration(transition.next),
|
||||
nextManifest: transition.next.proposal.actionInput.manifest,
|
||||
assignments: request.assignments,
|
||||
plannedAtMs,
|
||||
});
|
||||
if (plan.nextBindingPlan) {
|
||||
verifySecretVersions(authority.client, plan.nextBindingPlan);
|
||||
}
|
||||
return plan;
|
||||
},
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
|
||||
async executeTransition(
|
||||
request: ExecuteLocalPluginPackageSecretBindingTransitionRequest,
|
||||
) {
|
||||
const plan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
request.plan,
|
||||
);
|
||||
if (typeof request.confirmAuthorization !== 'function') {
|
||||
throw new TypeError('confirmAuthorization is invalid');
|
||||
}
|
||||
const observedAtMs = timestamp(now(), 'transition execution clock');
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.nextTarget.projectId,
|
||||
observedAtMs,
|
||||
);
|
||||
await request.confirmAuthorization();
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
authority.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
verifyPolicyFence(
|
||||
authority.client,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
plan.nextTarget.projectId,
|
||||
);
|
||||
const auditEventId = identity(
|
||||
request.auditEventId,
|
||||
'audit event ID',
|
||||
UUID,
|
||||
);
|
||||
const auditsForPlan = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "request_id" = ? AND "operation_id" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(
|
||||
`package_secret_binding_transition:${plan.transitionDigest}`,
|
||||
'plugin_package.secret.transition',
|
||||
) as Row[];
|
||||
if (
|
||||
auditsForPlan.length > 1 ||
|
||||
(auditsForPlan.length === 1 &&
|
||||
rowText(auditsForPlan[0]!, 'eventId') !== auditEventId)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'transition plan already has another audit identity',
|
||||
);
|
||||
}
|
||||
const existingReceipt = transitionReceipts.findInTransaction(
|
||||
plan.nextTarget.generationDigest,
|
||||
);
|
||||
if (existingReceipt) {
|
||||
if (
|
||||
existingReceipt.transitionPlan.transitionDigest !==
|
||||
plan.transitionDigest ||
|
||||
existingReceipt.authority.kind !== 'local-owner-confirmation' ||
|
||||
existingReceipt.authority.evidenceDigest !==
|
||||
plan.transitionDigest ||
|
||||
auditsForPlan.length !== 1
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'generation is committed by another transition authority',
|
||||
);
|
||||
}
|
||||
authority.client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
transitionDigest: plan.transitionDigest,
|
||||
receiptDigest: existingReceipt.receiptDigest,
|
||||
bindingDigest: existingReceipt.bindingDigest,
|
||||
generationDigest: plan.nextTarget.generationDigest,
|
||||
});
|
||||
}
|
||||
const transition = loadTransition(
|
||||
authority.client,
|
||||
plan.nextTarget.projectId,
|
||||
plan.nextTarget.packageName,
|
||||
);
|
||||
const previousTarget = createPluginPackageSecretBindingTarget(
|
||||
generationFrom(transition.previous),
|
||||
transition.previous.proposal.actionInput.manifest,
|
||||
);
|
||||
const expected = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget,
|
||||
previousBinding: bindings.findInTransaction(
|
||||
previousTarget.generationDigest,
|
||||
),
|
||||
previousAttemptGeneration: transition.previousAttemptGeneration,
|
||||
nextGeneration: transitionGeneration(transition.next),
|
||||
nextManifest: transition.next.proposal.actionInput.manifest,
|
||||
assignments:
|
||||
plan.nextBindingPlan?.entries.map(({ name, secretRef }) => ({
|
||||
name,
|
||||
secretRef,
|
||||
})) ?? [],
|
||||
plannedAtMs: plan.nextBindingPlan?.plannedAtMs ?? observedAtMs,
|
||||
});
|
||||
if (expected.transitionDigest !== plan.transitionDigest) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'staged Package generation changed after transition planning',
|
||||
);
|
||||
}
|
||||
if (plan.nextBindingPlan) {
|
||||
verifySecretVersions(authority.client, plan.nextBindingPlan);
|
||||
}
|
||||
const audit = transitionAuditRecord(
|
||||
plan,
|
||||
auditEventId,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
);
|
||||
const existingAudit = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ? LIMIT 2`,
|
||||
)
|
||||
.get(audit.eventId) as Row | undefined;
|
||||
if (existingAudit) {
|
||||
if (
|
||||
!sameSecurityAuditSemantic(
|
||||
localSecurityAuditFromRow(existingAudit),
|
||||
audit,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'audit identity is already used by another operation',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
insertLocalSecurityAudit(authority.client, audit);
|
||||
}
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
observedAtMs,
|
||||
);
|
||||
const bindingResult = binding
|
||||
? bindings.publishInTransaction(binding)
|
||||
: null;
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: bindingResult?.binding ?? null,
|
||||
committedAtMs: observedAtMs,
|
||||
});
|
||||
const receiptResult =
|
||||
transitionReceipts.publishInTransaction(receipt);
|
||||
authority.client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: receiptResult.status,
|
||||
transitionDigest: plan.transitionDigest,
|
||||
receiptDigest: receiptResult.receipt.receiptDigest,
|
||||
bindingDigest: receiptResult.receipt.bindingDigest,
|
||||
generationDigest: plan.nextTarget.generationDigest,
|
||||
});
|
||||
} catch (error) {
|
||||
if (authority.client.isTransaction)
|
||||
authority.client.exec('ROLLBACK');
|
||||
if (
|
||||
error instanceof LocalPluginPackageSecretBindingConflictError ||
|
||||
error instanceof LocalPluginPackageSecretBindingUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES,
|
||||
normalizePluginPackageSecretBindingTransitionReceipt,
|
||||
type PluginPackageSecretBindingTransitionReceipt,
|
||||
type PluginPackageSecretBindingTransitionReceiptRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||
import {
|
||||
PluginPackageSecretBindingConflictError,
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) {
|
||||
throw new TypeError(
|
||||
'Secret binding transition generation digest is invalid',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function mapStorageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof PluginPackageSecretBindingConflictError ||
|
||||
error instanceof PluginPackageSecretBindingUnavailableError ||
|
||||
error instanceof TypeError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new PluginPackageSecretBindingConflictError(
|
||||
'durable transition receipt identity is already bound',
|
||||
);
|
||||
}
|
||||
return new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository
|
||||
implements PluginPackageSecretBindingTransitionReceiptRepository
|
||||
{
|
||||
constructor(
|
||||
readonly authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
) {
|
||||
this.authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
}
|
||||
|
||||
private parse(
|
||||
row: Row,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
try {
|
||||
const receipt = normalizePluginPackageSecretBindingTransitionReceipt(
|
||||
JSON.parse(text(row, 'receiptJson')),
|
||||
);
|
||||
if (
|
||||
receipt.transitionPlan.nextTarget.generationDigest !==
|
||||
text(row, 'generationDigest') ||
|
||||
receipt.transitionPlan.transitionDigest !==
|
||||
text(row, 'transitionDigest') ||
|
||||
receipt.transitionPlan.nextTarget.projectId !==
|
||||
text(row, 'projectId') ||
|
||||
receipt.transitionPlan.nextTarget.packageName !==
|
||||
text(row, 'packageName') ||
|
||||
receipt.transitionPlan.nextTarget.installationId !==
|
||||
text(row, 'installationId') ||
|
||||
receipt.transitionPlan.nextTarget.lockDigest !==
|
||||
text(row, 'lockDigest') ||
|
||||
receipt.transitionPlan.nextTarget.generation !==
|
||||
integer(row, 'generation') ||
|
||||
receipt.transitionPlan.nextTarget.manifestDigest !==
|
||||
text(row, 'manifestDigest') ||
|
||||
receipt.transitionPlan.previousActiveLockDigest !==
|
||||
text(row, 'previousActiveLockDigest') ||
|
||||
receipt.authority.kind !== text(row, 'authorityKind') ||
|
||||
receipt.authority.evidenceDigest !== text(row, 'evidenceDigest') ||
|
||||
receipt.bindingDigest !== nullableText(row, 'bindingDigest') ||
|
||||
receipt.committedAtMs !== integer(row, 'committedAtMs') ||
|
||||
receipt.receiptDigest !== text(row, 'receiptDigest')
|
||||
) {
|
||||
throw new PluginPackageSecretBindingUnavailableError();
|
||||
}
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPackageSecretBindingUnavailableError)
|
||||
throw error;
|
||||
throw new PluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private findStored(
|
||||
generationDigest: string,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> | null {
|
||||
const row = (this.authority as LocalSqliteOperationAuthority).client
|
||||
.prepare(
|
||||
`SELECT generation_digest AS "generationDigest",
|
||||
transition_digest AS "transitionDigest",
|
||||
project_id AS "projectId", package_name AS "packageName",
|
||||
installation_id AS "installationId", lock_digest AS "lockDigest",
|
||||
generation, manifest_digest AS "manifestDigest",
|
||||
previous_active_lock_digest AS "previousActiveLockDigest",
|
||||
authority_kind AS "authorityKind", evidence_digest AS "evidenceDigest",
|
||||
binding_digest AS "bindingDigest", committed_at_ms AS "committedAtMs",
|
||||
receipt_digest AS "receiptDigest", receipt_json AS "receiptJson"
|
||||
FROM "QingLong3PluginPackageSecretBindingTransitionReceipts"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(generationDigest) as Row | undefined;
|
||||
return row ? this.parse(row) : null;
|
||||
}
|
||||
|
||||
findInTransaction(
|
||||
generationDigestValue: string,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> | null {
|
||||
try {
|
||||
return this.findStored(digest(generationDigestValue));
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async find(
|
||||
generationDigestValue: string,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionReceipt> | null> {
|
||||
const normalized = digest(generationDigestValue);
|
||||
return (this.authority as LocalSqliteOperationAuthority).enqueue(
|
||||
async () => this.findStored(normalized),
|
||||
() => new PluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
publishInTransaction(
|
||||
value: Readonly<PluginPackageSecretBindingTransitionReceipt>,
|
||||
): Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
receipt: Readonly<PluginPackageSecretBindingTransitionReceipt>;
|
||||
}> {
|
||||
const receipt = normalizePluginPackageSecretBindingTransitionReceipt(value);
|
||||
const receiptJson = JSON.stringify(receipt);
|
||||
if (
|
||||
Buffer.byteLength(receiptJson, 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Secret binding transition receipt exceeds durable budget',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const existing = this.findStored(
|
||||
receipt.transitionPlan.nextTarget.generationDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== receiptJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'generation is bound to another transition receipt',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
receipt: existing,
|
||||
});
|
||||
}
|
||||
const target = receipt.transitionPlan.nextTarget;
|
||||
const result = (this.authority as LocalSqliteOperationAuthority).client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageSecretBindingTransitionReceipts" (
|
||||
generation_digest, transition_digest, project_id, package_name,
|
||||
installation_id, lock_digest, generation, manifest_digest,
|
||||
previous_active_lock_digest, authority_kind, evidence_digest,
|
||||
binding_digest, committed_at_ms, receipt_digest, receipt_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (generation_digest) DO NOTHING`,
|
||||
)
|
||||
.run(
|
||||
target.generationDigest,
|
||||
receipt.transitionPlan.transitionDigest,
|
||||
target.projectId,
|
||||
target.packageName,
|
||||
target.installationId,
|
||||
target.lockDigest,
|
||||
target.generation,
|
||||
target.manifestDigest,
|
||||
receipt.transitionPlan.previousActiveLockDigest,
|
||||
receipt.authority.kind,
|
||||
receipt.authority.evidenceDigest,
|
||||
receipt.bindingDigest,
|
||||
receipt.committedAtMs,
|
||||
receipt.receiptDigest,
|
||||
receiptJson,
|
||||
);
|
||||
const stored = this.findStored(target.generationDigest);
|
||||
if (!stored || JSON.stringify(stored) !== receiptJson) {
|
||||
throw new PluginPackageSecretBindingConflictError(
|
||||
'transition receipt target is not the reviewed staged generation',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status:
|
||||
result.changes === 1 ? ('created' as const) : ('existing' as const),
|
||||
receipt: stored,
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME =
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_guard' as const;
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL =
|
||||
`
|
||||
CREATE TRIGGER ${LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_NAME}
|
||||
BEFORE INSERT ON "QingLong3PluginPackageSecretBindingTransitionReceipts"
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstallHeads" AS head
|
||||
JOIN "QingLong3PluginPackageInstalls" AS install
|
||||
ON install.installation_id = head.installation_id
|
||||
AND install.project_id = head.project_id
|
||||
AND install.package_name = head.package_name
|
||||
WHERE head.project_id = NEW.project_id
|
||||
AND head.package_name = NEW.package_name
|
||||
AND install.installation_id = NEW.installation_id
|
||||
AND install.lock_digest = NEW.lock_digest
|
||||
AND install.target_generation = NEW.generation
|
||||
AND install.state = 'staged'
|
||||
AND install.previous_active_lock_digest = NEW.previous_active_lock_digest
|
||||
AND install.active_lock_digest = install.previous_active_lock_digest
|
||||
AND json_extract(install.lock_json, '$.manifestDigest') = NEW.manifest_digest
|
||||
AND install.target_generation = (
|
||||
SELECT MAX(history.target_generation)
|
||||
FROM "QingLong3PluginPackageInstalls" AS history
|
||||
WHERE history.project_id = install.project_id
|
||||
AND history.package_name = install.package_name
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageInstalls" AS previous
|
||||
WHERE previous.project_id = install.project_id
|
||||
AND previous.package_name = install.package_name
|
||||
AND previous.lock_digest = NEW.previous_active_lock_digest
|
||||
AND previous.state = 'active'
|
||||
AND previous.active_lock_digest = previous.lock_digest
|
||||
AND previous.target_generation < install.target_generation
|
||||
)
|
||||
AND (
|
||||
(NEW.binding_digest IS NULL AND
|
||||
json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'null')
|
||||
OR
|
||||
(NEW.binding_digest IS NOT NULL AND
|
||||
json_type(NEW.receipt_json, '$.transitionPlan.nextBindingPlan') = 'object' AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM "QingLong3PluginPackageSecretBindings" AS binding
|
||||
WHERE binding.generation_digest = NEW.generation_digest
|
||||
AND binding.binding_digest = NEW.binding_digest
|
||||
AND binding.authority_kind = NEW.authority_kind
|
||||
AND binding.evidence_digest = NEW.evidence_digest
|
||||
AND binding.bound_at_ms = NEW.committed_at_ms
|
||||
))
|
||||
)
|
||||
) THEN RAISE(ABORT,
|
||||
'Plugin Package Secret binding transition receipt target is not reviewed staged generation')
|
||||
END;
|
||||
END
|
||||
`.trim();
|
||||
@@ -57,6 +57,7 @@ export type LocalProfileStorageBootstrapResult =
|
||||
readonly pluginPackageInstalls: LocalSqliteRuntimeDatabase['pluginPackageInstalls'];
|
||||
readonly pluginPackageMaterializedRevisions: LocalSqliteRuntimeDatabase['pluginPackageMaterializedRevisions'];
|
||||
readonly pluginPackageSecretBindings: LocalSqliteRuntimeDatabase['pluginPackageSecretBindings'];
|
||||
readonly pluginPackageActivationPrerequisite: LocalSqliteRuntimeDatabase['pluginPackageActivationPrerequisite'];
|
||||
readonly pluginPackageTaskReconciliations: LocalSqliteRuntimeDatabase['pluginPackageTaskReconciliations'];
|
||||
readonly pluginPackageAutomationPublications: LocalSqliteRuntimeDatabase['pluginPackageAutomationPublications'];
|
||||
readonly projectToolDefinitionSnapshots: LocalSqliteRuntimeDatabase['projectToolDefinitionSnapshots'];
|
||||
@@ -153,6 +154,8 @@ export async function bootstrapLocalProfileStorage(
|
||||
pluginPackageMaterializedRevisions:
|
||||
database.pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings: database.pluginPackageSecretBindings,
|
||||
pluginPackageActivationPrerequisite:
|
||||
database.pluginPackageActivationPrerequisite,
|
||||
pluginPackageTaskReconciliations:
|
||||
database.pluginPackageTaskReconciliations,
|
||||
pluginPackageAutomationPublications:
|
||||
|
||||
@@ -4,13 +4,14 @@ import { localSqliteMigrationManifest } from '../migration/migrationManifest';
|
||||
import { LocalSqliteMigrationStreamStore } from '../migration/migrationStreamStore';
|
||||
import { LOCAL_PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGER_SQL } from '../plugin-package/pluginPackageSecretMaterializationSchemaContract';
|
||||
import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGER_SQL } from '../plugin-package/secret-binding/pluginPackageSecretBindingTargetSchemaContract';
|
||||
import { LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL } from '../plugin-package/secret-binding/transitionReceiptSchemaContract';
|
||||
import {
|
||||
LOCAL_STEP_RUN_REFERENCE_TRIGGERS,
|
||||
normalizeLocalSqliteSchemaSql,
|
||||
} from '../run/stepRunSchemaContract';
|
||||
|
||||
export const LOCAL_SQLITE_CONTRACT_NAME = 'local-control-core';
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 48;
|
||||
export const LOCAL_SQLITE_CONTRACT_VERSION = 49;
|
||||
|
||||
const PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS = Object.freeze([
|
||||
Object.freeze({
|
||||
@@ -18,6 +19,11 @@ const PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGERS = Object.freeze([
|
||||
tableName: 'QingLong3PluginPackageSecretBindings',
|
||||
sql: LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TARGET_TRIGGER_SQL,
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'ql3_plugin_package_secret_binding_transition_receipt_guard',
|
||||
tableName: 'QingLong3PluginPackageSecretBindingTransitionReceipts',
|
||||
sql: LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_TRIGGER_SQL,
|
||||
}),
|
||||
]);
|
||||
|
||||
const PLUGIN_PACKAGE_SECRET_MATERIALIZATION_TRIGGERS = Object.freeze([
|
||||
@@ -1181,6 +1187,30 @@ const REQUIRED_SCHEMA = Object.freeze({
|
||||
'ql3_plugin_package_secret_binding_install_idx',
|
||||
]),
|
||||
}),
|
||||
QingLong3PluginPackageSecretBindingTransitionReceipts: Object.freeze({
|
||||
columns: Object.freeze([
|
||||
'generation_digest',
|
||||
'transition_digest',
|
||||
'project_id',
|
||||
'package_name',
|
||||
'installation_id',
|
||||
'lock_digest',
|
||||
'generation',
|
||||
'manifest_digest',
|
||||
'previous_active_lock_digest',
|
||||
'authority_kind',
|
||||
'evidence_digest',
|
||||
'binding_digest',
|
||||
'committed_at_ms',
|
||||
'receipt_digest',
|
||||
'receipt_json',
|
||||
]),
|
||||
indexes: Object.freeze([
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_transition_uidx',
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_digest_uidx',
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_install_idx',
|
||||
]),
|
||||
}),
|
||||
QingLong3ProjectToolDefinitionSnapshots: Object.freeze({
|
||||
columns: Object.freeze([
|
||||
'project_id',
|
||||
@@ -2574,10 +2604,10 @@ export async function auditLocalSqliteReadiness(
|
||||
capability.contract_name !== LOCAL_SQLITE_CONTRACT_NAME ||
|
||||
capability.contract_version !== LOCAL_SQLITE_CONTRACT_VERSION ||
|
||||
capability.migration_id !==
|
||||
'0095-plugin-package-secret-binding-target-guard' ||
|
||||
'0097-plugin-package-secret-binding-transition-receipts' ||
|
||||
typeof capability.capabilities !== 'string' ||
|
||||
capability.capabilities !==
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
'{"run_core":1,"run_retry_policy":1,"completion_receipt_journal":1,"local_dispatch_plan":1,"local_secret_envelope":1,"local_project_policy":1,"local_project_administration":1,"local_security_audit":1,"local_security_audit_compaction":1,"local_secret_authorized_mutation":1,"local_identity":1,"local_api_credential":1,"local_identity_provisioning":1,"local_identity_credential_administration":1,"local_owner_bootstrap":1,"local_owner_delivery_acknowledgement":1,"api_credential_pepper_binding":1,"local_owner_pepper_catalog":1,"local_owner_credential_recovery":1,"local_owner_pepper_reference_inspection":1,"local_owner_pepper_material_gc":1,"local_owner_delivery_acknowledgement_gc":1,"task_definition":1,"local_execution_revision_digest":1,"trigger_definition":1,"legacy_adoption_ledger":1,"local_scheduler_admission":1,"plugin_package_install":1,"approved_action":1,"plugin_package_admission":1,"approved_action_execution":1,"plugin_package_proposal":1,"plugin_package_materialized_revision":1,"plugin_package_secret_binding":1,"plugin_package_secret_binding_transition":1,"plugin_package_secret_binding_transition_receipt":1,"plugin_package_secret_materialization":1,"plugin_package_task_reconciliation":1,"project_tool_definition_snapshot":1,"step_run":1,"tool_execution_evidence":1,"tool_execution_start_barrier":1,"tool_invocation_artifact":1,"tool_execution_artifact_binding":1,"tool_execution_completion":1,"tool_execution_failure_completion":1,"tool_result_key_catalog":1,"tool_result_rekey":1,"plugin_package_quarantine":1,"plugin_package_lifecycle":1,"plugin_package_automation_publication":1,"plugin_package_automation_security_withdrawal":1,"plugin_package_workflow_admission":1,"plugin_package_workflow_run_list":1,"run_attempt_log_retention":1,"plugin_package_workflow_task_attempt_admission":1}' ||
|
||||
typeof capability.updated_at_ms !== 'number' ||
|
||||
!Number.isSafeInteger(capability.updated_at_ms) ||
|
||||
capability.updated_at_ms < 0
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageMaterializedRevisionRepository } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import type { PluginPackageSecretBindingRepository } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import type { PluginPackageActivationPrerequisite } from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import type { PluginPackageTaskReconciliationRepository } from '@qinglong/runtime-core/plugin-package-task-reconciliation';
|
||||
import type { PluginPackageTaskPublicationRecoverySource } from '@qinglong/runtime-core/plugin-package-task-publication';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
@@ -111,6 +112,7 @@ export interface LocalSqliteRuntimeDatabase {
|
||||
pluginPackageInstalls(): Promise<PluginPackageInstallRepository>;
|
||||
pluginPackageMaterializedRevisions(): Promise<PluginPackageMaterializedRevisionRepository>;
|
||||
pluginPackageSecretBindings(): Promise<PluginPackageSecretBindingRepository>;
|
||||
pluginPackageActivationPrerequisite(): Promise<PluginPackageActivationPrerequisite>;
|
||||
pluginPackageTaskReconciliations(): Promise<
|
||||
PluginPackageTaskReconciliationRepository &
|
||||
PluginPackageTaskPublicationRecoverySource
|
||||
@@ -200,6 +202,9 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
let pluginPackageSecretBindingsPromise:
|
||||
| Promise<PluginPackageSecretBindingRepository>
|
||||
| undefined;
|
||||
let pluginPackageActivationPrerequisitePromise:
|
||||
| Promise<PluginPackageActivationPrerequisite>
|
||||
| undefined;
|
||||
let pluginPackageTaskReconciliationsPromise:
|
||||
| Promise<
|
||||
PluginPackageTaskReconciliationRepository &
|
||||
@@ -285,6 +290,17 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
);
|
||||
return pluginPackageSecretBindingsPromise;
|
||||
},
|
||||
pluginPackageActivationPrerequisite() {
|
||||
pluginPackageActivationPrerequisitePromise ??= import(
|
||||
'../plugin-package/secret-binding/activationPrerequisite.js'
|
||||
).then(
|
||||
({ LocalSqlitePluginPackageSecretBindingActivationPrerequisite }) =>
|
||||
new LocalSqlitePluginPackageSecretBindingActivationPrerequisite(
|
||||
authority,
|
||||
),
|
||||
);
|
||||
return pluginPackageActivationPrerequisitePromise;
|
||||
},
|
||||
pluginPackageTaskReconciliations() {
|
||||
pluginPackageTaskReconciliationsPromise ??= import(
|
||||
'../plugin-package/pluginPackageTaskReconciliationRepository.js'
|
||||
|
||||
@@ -1273,6 +1273,60 @@ export const pluginPackageSecretBindings = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const pluginPackageSecretBindingTransitionReceipts = sqliteTable(
|
||||
'QingLong3PluginPackageSecretBindingTransitionReceipts',
|
||||
{
|
||||
generationDigest: text('generation_digest').primaryKey(),
|
||||
transitionDigest: text('transition_digest').notNull(),
|
||||
projectId: text('project_id').notNull(),
|
||||
packageName: text('package_name').notNull(),
|
||||
installationId: text('installation_id')
|
||||
.notNull()
|
||||
.references(() => pluginPackageInstalls.installationId, {
|
||||
onDelete: 'restrict',
|
||||
onUpdate: 'restrict',
|
||||
}),
|
||||
lockDigest: text('lock_digest').notNull(),
|
||||
generation: integer('generation').notNull(),
|
||||
manifestDigest: text('manifest_digest').notNull(),
|
||||
previousActiveLockDigest: text('previous_active_lock_digest').notNull(),
|
||||
authorityKind: text('authority_kind').notNull(),
|
||||
evidenceDigest: text('evidence_digest').notNull(),
|
||||
bindingDigest: text('binding_digest').references(
|
||||
() => pluginPackageSecretBindings.bindingDigest,
|
||||
{ onDelete: 'restrict', onUpdate: 'restrict' },
|
||||
),
|
||||
committedAtMs: integer('committed_at_ms').notNull(),
|
||||
receiptDigest: text('receipt_digest').notNull(),
|
||||
receiptJson: text('receipt_json', { mode: 'json' })
|
||||
.$type<Record<string, unknown>>()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_identity_check',
|
||||
sql`length(${table.projectId}) between 1 and 128 and length(${table.packageName}) between 1 and 63 and length(${table.installationId}) between 1 and 128 and ${table.generation} between 2 and 2147483647 and ${table.authorityKind} in ('approved-action-execution','local-owner-confirmation') and ${table.committedAtMs} >= 0`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_digest_check',
|
||||
sql`length(${table.generationDigest}) = 64 and ${table.generationDigest} not glob '*[^0-9a-f]*' and length(${table.transitionDigest}) = 64 and ${table.transitionDigest} not glob '*[^0-9a-f]*' and length(${table.lockDigest}) = 64 and ${table.lockDigest} not glob '*[^0-9a-f]*' and length(${table.manifestDigest}) = 64 and ${table.manifestDigest} not glob '*[^0-9a-f]*' and length(${table.previousActiveLockDigest}) = 64 and ${table.previousActiveLockDigest} not glob '*[^0-9a-f]*' and length(${table.evidenceDigest}) = 64 and ${table.evidenceDigest} not glob '*[^0-9a-f]*' and (${table.bindingDigest} is null or (length(${table.bindingDigest}) = 64 and ${table.bindingDigest} not glob '*[^0-9a-f]*')) and length(${table.receiptDigest}) = 64 and ${table.receiptDigest} not glob '*[^0-9a-f]*'`,
|
||||
),
|
||||
check(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_json_check',
|
||||
sql`length(cast(${table.receiptJson} as blob)) between 2 and 196608 and json_valid(${table.receiptJson}) and json_type(${table.receiptJson}) = 'object' and json_extract(${table.receiptJson}, '$.schema') = 'qinglong/plugin-package-secret-binding-transition-receipt@v1' and json_extract(${table.receiptJson}, '$.transitionPlan.transitionDigest') = ${table.transitionDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.generationDigest') = ${table.generationDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.projectId') = ${table.projectId} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.packageName') = ${table.packageName} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.installationId') = ${table.installationId} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.lockDigest') = ${table.lockDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.generation') = ${table.generation} and json_extract(${table.receiptJson}, '$.transitionPlan.nextTarget.manifestDigest') = ${table.manifestDigest} and json_extract(${table.receiptJson}, '$.transitionPlan.previousActiveLockDigest') = ${table.previousActiveLockDigest} and json_extract(${table.receiptJson}, '$.authority.kind') = ${table.authorityKind} and json_extract(${table.receiptJson}, '$.authority.evidenceDigest') = ${table.evidenceDigest} and json_extract(${table.receiptJson}, '$.bindingDigest') is ${table.bindingDigest} and json_extract(${table.receiptJson}, '$.committedAtMs') = ${table.committedAtMs} and json_extract(${table.receiptJson}, '$.receiptDigest') = ${table.receiptDigest}`,
|
||||
),
|
||||
uniqueIndex(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_transition_uidx',
|
||||
).on(table.transitionDigest),
|
||||
uniqueIndex(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_digest_uidx',
|
||||
).on(table.receiptDigest),
|
||||
index(
|
||||
'ql3_plugin_package_secret_binding_transition_receipt_install_idx',
|
||||
).on(table.installationId, table.generationDigest),
|
||||
],
|
||||
);
|
||||
|
||||
export const projectToolDefinitionSnapshots = sqliteTable(
|
||||
'QingLong3ProjectToolDefinitionSnapshots',
|
||||
{
|
||||
@@ -4945,6 +4999,7 @@ export const localSqliteSchema = Object.freeze({
|
||||
pluginPackageInstallMutations,
|
||||
pluginPackageMaterializedRevisions,
|
||||
pluginPackageSecretBindings,
|
||||
pluginPackageSecretBindingTransitionReceipts,
|
||||
pluginPackageQuarantineEvents,
|
||||
pluginPackageWithdrawalReceipts,
|
||||
pluginPackageWithdrawalTasks,
|
||||
|
||||
@@ -146,9 +146,11 @@ test('creates a reviewed edge database and opens runtime only after readiness',
|
||||
'0094-capability-v47',
|
||||
'0095-plugin-package-secret-binding-target-guard',
|
||||
'0096-capability-v48',
|
||||
'0097-plugin-package-secret-binding-transition-receipts',
|
||||
'0098-capability-v49',
|
||||
]);
|
||||
assert.equal(migrated.readiness.contractName, 'local-control-core');
|
||||
assert.equal(migrated.readiness.contractVersion, 48);
|
||||
assert.equal(migrated.readiness.contractVersion, 49);
|
||||
assert.equal(migrated.readiness.journalMode, 'delete');
|
||||
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
|
||||
|
||||
@@ -594,8 +596,8 @@ test('backfills v14 execution revisions with a verified independent digest', asy
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
contract_version: 48,
|
||||
migration_id: '0095-plugin-package-secret-binding-target-guard',
|
||||
contract_version: 49,
|
||||
migration_id: '0097-plugin-package-secret-binding-transition-receipts',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
@@ -782,19 +784,19 @@ test('excludes reviewed optional feature tables while preserving unknown table d
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
|
||||
client.exec(
|
||||
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
|
||||
);
|
||||
client.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 80);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
|
||||
|
||||
const unknownClient = new DatabaseSync(databasePath);
|
||||
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
|
||||
unknownClient.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 81);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 82);
|
||||
|
||||
const triggerClient = new DatabaseSync(databasePath);
|
||||
triggerClient.exec(`
|
||||
|
||||
@@ -11,9 +11,18 @@ const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
|
||||
const {
|
||||
createPluginPackageSecretBindingTransitionReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
|
||||
const {
|
||||
LocalSqlitePluginPackageSecretBindingRepository,
|
||||
} = require('../dist/plugin-package/secret-binding/repository');
|
||||
const {
|
||||
LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository,
|
||||
} = require('../dist/plugin-package/secret-binding/transitionReceiptRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
@@ -247,7 +256,10 @@ test('rejects inactive targets and conflicting content', async (t) => {
|
||||
test('publishes a reviewed current staged generation but rejects post-stage states', async (t) => {
|
||||
const staged = await harness('staged');
|
||||
t.after(() => staged.client.close());
|
||||
assert.equal((await staged.repository.publish(staged.binding)).status, 'created');
|
||||
assert.equal(
|
||||
(await staged.repository.publish(staged.binding)).status,
|
||||
'created',
|
||||
);
|
||||
|
||||
for (const state of ['queued', 'activating']) {
|
||||
const rejected = await harness(state);
|
||||
@@ -259,6 +271,102 @@ test('publishes a reviewed current staged generation but rejects post-stage stat
|
||||
}
|
||||
});
|
||||
|
||||
test('persists one exact staged transition receipt and rejects durable drift', async (t) => {
|
||||
const value = await harness('staged');
|
||||
t.after(() => value.client.close());
|
||||
const previousGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: 'f'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: '9'.repeat(64),
|
||||
contents: MANIFEST.spec.contents,
|
||||
});
|
||||
const previousBinding = createPluginPackageSecretBinding({
|
||||
generation: previousGeneration,
|
||||
manifest: MANIFEST,
|
||||
assignments: value.binding.entries.map(({ name, secretRef }) => ({
|
||||
name,
|
||||
secretRef,
|
||||
})),
|
||||
authority: value.binding.authority,
|
||||
boundAtMs: 90,
|
||||
});
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstalls"
|
||||
SET lock_json = json_set(lock_json, '$.manifestDigest', ?)
|
||||
WHERE installation_id = 'install-1'`,
|
||||
)
|
||||
.run(previousBinding.target.manifestDigest);
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstalls"
|
||||
SET lock_json = json_set(lock_json, '$.manifestDigest', ?)
|
||||
WHERE installation_id = 'install-2'`,
|
||||
)
|
||||
.run(value.binding.target.manifestDigest);
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstallHeads"
|
||||
SET installation_id = 'install-1'
|
||||
WHERE project_id = 'project-1' AND package_name = 'example-monitor'`,
|
||||
)
|
||||
.run();
|
||||
await value.repository.publish(previousBinding);
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstallHeads"
|
||||
SET installation_id = 'install-2'
|
||||
WHERE project_id = 'project-1' AND package_name = 'example-monitor'`,
|
||||
)
|
||||
.run();
|
||||
await value.repository.publish(value.binding);
|
||||
const plan = createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget: previousBinding.target,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: 1,
|
||||
nextGeneration: value.generation,
|
||||
nextManifest: MANIFEST,
|
||||
assignments: value.binding.entries.map(({ name, secretRef }) => ({
|
||||
name,
|
||||
secretRef,
|
||||
})),
|
||||
plannedAtMs: 95,
|
||||
});
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: value.binding.authority,
|
||||
binding: value.binding,
|
||||
committedAtMs: value.binding.boundAtMs,
|
||||
});
|
||||
const receipts =
|
||||
new LocalSqlitePluginPackageSecretBindingTransitionReceiptRepository(
|
||||
value.client,
|
||||
);
|
||||
assert.equal(receipts.publishInTransaction(receipt).status, 'created');
|
||||
assert.equal(receipts.publishInTransaction(receipt).status, 'existing');
|
||||
assert.deepEqual(
|
||||
await receipts.find(value.generation.generationDigest),
|
||||
receipt,
|
||||
);
|
||||
|
||||
value.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageSecretBindingTransitionReceipts"
|
||||
SET receipt_json = json_set(receipt_json, '$.committedAtMs', 999)
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.run(value.generation.generationDigest);
|
||||
await assert.rejects(
|
||||
receipts.find(value.generation.generationDigest),
|
||||
PluginPackageSecretBindingUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when durable binding JSON is changed in place', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.client.close());
|
||||
|
||||
@@ -156,7 +156,7 @@ test('atomically admits one generation-bound Workflow Run and exactly replays it
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
|
||||
});
|
||||
|
||||
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
|
||||
@@ -288,7 +288,7 @@ test('exactly replays immutable admission after the Workflow StepRun advances',
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
|
||||
});
|
||||
|
||||
test('fails closed before writing when the exact installation is not active', async (t) => {
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ test('atomically admits the exact reconciled local Task revision and replays it'
|
||||
stepAttemptCount: 0,
|
||||
},
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 48);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 49);
|
||||
});
|
||||
|
||||
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
|
||||
|
||||
@@ -40,9 +40,9 @@ test('creates and exactly replays a reviewed rollout backup', async (t) => {
|
||||
await migrateLocalSqlitePath(state);
|
||||
const prepared = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.contractVersion, 48);
|
||||
assert.equal(prepared.writeContractVersion, 48);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 48);
|
||||
assert.equal(prepared.contractVersion, 49);
|
||||
assert.equal(prepared.writeContractVersion, 49);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 49);
|
||||
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(prepared.bytes > 0, true);
|
||||
assert.equal(prepared.pageCount > 0, true);
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
"plugin-package-secret-binding-transition-plan": [
|
||||
"dist/plugin-package/secret-binding/transitionPlan.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-transition-receipt": [
|
||||
"dist/plugin-package/secret-binding/transitionReceipt.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-approval-plan": [
|
||||
"dist/plugin-package/secret-binding/approvalPlan.d.ts"
|
||||
],
|
||||
@@ -401,6 +404,11 @@
|
||||
"require": "./dist/plugin-package/secret-binding/transitionPlan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionPlan.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-transition-receipt": {
|
||||
"types": "./dist/plugin-package/secret-binding/transitionReceipt.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/transitionReceipt.js",
|
||||
"default": "./dist/plugin-package/secret-binding/transitionReceipt.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-approval-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/approvalPlan.js",
|
||||
|
||||
+40
-2
@@ -5,6 +5,8 @@ import {
|
||||
import {
|
||||
InvalidPluginPackageInstallError,
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
PluginPackageInstallUnavailableError,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageLock,
|
||||
pluginPackageInstallCommit,
|
||||
transitionPluginPackageInstall,
|
||||
@@ -33,6 +35,20 @@ export interface PluginPackageStageProvider {
|
||||
): Promise<Readonly<PluginPackageStageEvidence>>;
|
||||
}
|
||||
|
||||
export type PluginPackageActivationPrerequisiteObservation =
|
||||
| Readonly<{ status: 'ready' }>
|
||||
| Readonly<{
|
||||
status: 'deferred';
|
||||
reason: 'secret_binding_transition_required';
|
||||
}>;
|
||||
|
||||
export interface PluginPackageActivationPrerequisite {
|
||||
inspect(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
|
||||
}
|
||||
|
||||
export interface InstallPluginPackageOptions {
|
||||
readonly lock: PluginPackageLock;
|
||||
readonly proposalDigest: string;
|
||||
@@ -139,15 +155,23 @@ export function normalizePluginPackageStageEvidence(
|
||||
export class PluginPackageInstallationCoordinator {
|
||||
readonly #repository: PluginPackageAdmissionRepository;
|
||||
readonly #activation: PluginPackageActivationCoordinator;
|
||||
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
|
||||
constructor(options: {
|
||||
readonly repository: PluginPackageAdmissionRepository;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
}) {
|
||||
const value = dataRecord(options, 'installation coordinator options');
|
||||
exactKeys(
|
||||
value,
|
||||
['repository', 'publisher'],
|
||||
[
|
||||
'repository',
|
||||
'publisher',
|
||||
...(options.activationPrerequisite === undefined
|
||||
? []
|
||||
: ['activationPrerequisite']),
|
||||
],
|
||||
'installation coordinator options',
|
||||
);
|
||||
if (
|
||||
@@ -157,7 +181,10 @@ export class PluginPackageInstallationCoordinator {
|
||||
typeof options.repository.create !== 'function' ||
|
||||
typeof options.repository.commit !== 'function' ||
|
||||
typeof options.repository.admit !== 'function' ||
|
||||
typeof options.repository.findAdmissionReceipt !== 'function'
|
||||
typeof options.repository.findAdmissionReceipt !== 'function' ||
|
||||
(options.activationPrerequisite !== undefined &&
|
||||
(!options.activationPrerequisite ||
|
||||
typeof options.activationPrerequisite.inspect !== 'function'))
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'installation coordinator authority is invalid',
|
||||
@@ -168,6 +195,9 @@ export class PluginPackageInstallationCoordinator {
|
||||
repository: options.repository,
|
||||
publisher: options.publisher,
|
||||
});
|
||||
if (options.activationPrerequisite !== undefined) {
|
||||
this.#activationPrerequisite = options.activationPrerequisite;
|
||||
}
|
||||
}
|
||||
|
||||
async #convergeActivation(
|
||||
@@ -186,6 +216,14 @@ export class PluginPackageInstallationCoordinator {
|
||||
installationId: record.installationId,
|
||||
};
|
||||
if (record.state === 'staged') {
|
||||
const lock = await this.#repository.findLock(record.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const prerequisite = await this.#activationPrerequisite?.inspect(
|
||||
record,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: options.activationStartedMutationId,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from './pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageStageEvidence,
|
||||
type PluginPackageActivationPrerequisite,
|
||||
type PluginPackageStageProvider,
|
||||
} from './pluginPackageInstallation';
|
||||
|
||||
@@ -37,6 +38,7 @@ export const PLUGIN_PACKAGE_RECOVERY_ITEM_STATUSES = [
|
||||
'retry',
|
||||
'manual_required',
|
||||
'superseded',
|
||||
'deferred',
|
||||
] as const;
|
||||
|
||||
export type PluginPackageRecoveryItemStatus =
|
||||
@@ -64,6 +66,7 @@ export interface PluginPackageRecoveryCycleResult {
|
||||
readonly retry: number;
|
||||
readonly manualRequired: number;
|
||||
readonly superseded: number;
|
||||
readonly deferred: number;
|
||||
readonly remaining: boolean;
|
||||
readonly safeToAdmit: boolean;
|
||||
}
|
||||
@@ -235,18 +238,20 @@ export class PluginPackageRecoveryCoordinator {
|
||||
readonly #stageProvider: PluginPackageStageProvider;
|
||||
readonly #activation: PluginPackageActivationCoordinator;
|
||||
readonly #now: () => number | Promise<number>;
|
||||
readonly #activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
|
||||
constructor(options: {
|
||||
readonly repository: PluginPackageInstallRepository;
|
||||
readonly stageProvider: PluginPackageStageProvider;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
readonly now: () => number | Promise<number>;
|
||||
readonly activationPrerequisite?: PluginPackageActivationPrerequisite;
|
||||
}) {
|
||||
const value = dataRecord(options, 'recovery coordinator options');
|
||||
exactKeys(
|
||||
value,
|
||||
['repository', 'stageProvider', 'publisher', 'now'],
|
||||
[],
|
||||
['activationPrerequisite'],
|
||||
'recovery coordinator options',
|
||||
);
|
||||
if (
|
||||
@@ -257,7 +262,10 @@ export class PluginPackageRecoveryCoordinator {
|
||||
typeof options.repository.listRecoveryPage !== 'function' ||
|
||||
!options.stageProvider ||
|
||||
typeof options.stageProvider.stage !== 'function' ||
|
||||
typeof options.now !== 'function'
|
||||
typeof options.now !== 'function' ||
|
||||
(options.activationPrerequisite !== undefined &&
|
||||
(!options.activationPrerequisite ||
|
||||
typeof options.activationPrerequisite.inspect !== 'function'))
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'recovery coordinator authority is invalid',
|
||||
@@ -270,6 +278,9 @@ export class PluginPackageRecoveryCoordinator {
|
||||
publisher: options.publisher,
|
||||
});
|
||||
this.#now = options.now;
|
||||
if (options.activationPrerequisite !== undefined) {
|
||||
this.#activationPrerequisite = options.activationPrerequisite;
|
||||
}
|
||||
}
|
||||
|
||||
async #current(
|
||||
@@ -292,6 +303,14 @@ export class PluginPackageRecoveryCoordinator {
|
||||
installationId: record.installationId,
|
||||
};
|
||||
if (record.state === 'staged') {
|
||||
const lock = await this.#repository.findLock(record.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
const prerequisite = await this.#activationPrerequisite?.inspect(
|
||||
record,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: mutationId(
|
||||
@@ -374,6 +393,18 @@ export class PluginPackageRecoveryCoordinator {
|
||||
current = committed;
|
||||
}
|
||||
current = await this.#convergeActivation(current, occurredAtMs);
|
||||
if (current.state === 'staged' && this.#activationPrerequisite) {
|
||||
const lock = await this.#repository.findLock(current.lockDigest);
|
||||
if (!lock) throw new PluginPackageInstallUnavailableError();
|
||||
assertPluginPackageInstallMatchesLock(lock, current);
|
||||
const prerequisite = await this.#activationPrerequisite.inspect(
|
||||
current,
|
||||
lock,
|
||||
);
|
||||
if (prerequisite.status === 'deferred') {
|
||||
return result(source, action, 'deferred', current);
|
||||
}
|
||||
}
|
||||
if (current.state === 'active' || current.state === 'failed') {
|
||||
return result(source, action, 'settled', current);
|
||||
}
|
||||
@@ -477,6 +508,7 @@ export class PluginPackageRecoveryCoordinator {
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
deferred: 0,
|
||||
};
|
||||
let after: Readonly<PluginPackageInstallRecoveryCursor> | undefined;
|
||||
let exhausted = false;
|
||||
@@ -501,6 +533,9 @@ export class PluginPackageRecoveryCoordinator {
|
||||
case 'superseded':
|
||||
counts.superseded += 1;
|
||||
break;
|
||||
case 'deferred':
|
||||
counts.deferred += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!page.truncated) {
|
||||
@@ -517,7 +552,8 @@ export class PluginPackageRecoveryCoordinator {
|
||||
return Object.freeze({
|
||||
...counts,
|
||||
remaining,
|
||||
safeToAdmit: !remaining && counts.manualRequired === 0,
|
||||
safeToAdmit:
|
||||
!remaining && counts.retry === 0 && counts.manualRequired === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createPluginPackageSecretBindingFromEntries,
|
||||
normalizePluginPackageSecretBinding,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingAuthorityKind,
|
||||
} from './binding';
|
||||
import {
|
||||
normalizePluginPackageSecretBindingTransitionPlan,
|
||||
type PluginPackageSecretBindingTransitionPlan,
|
||||
} from './transitionPlan';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding-transition-receipt@v1' as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES =
|
||||
192 * 1024;
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceiptAuthority {
|
||||
readonly kind: PluginPackageSecretBindingAuthorityKind;
|
||||
readonly evidenceDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceipt {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA;
|
||||
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
|
||||
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
|
||||
readonly bindingDigest: string | null;
|
||||
readonly committedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingTransitionReceiptInput {
|
||||
readonly transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>;
|
||||
readonly authority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>;
|
||||
readonly binding: Readonly<PluginPackageSecretBinding> | null;
|
||||
readonly committedAtMs: number;
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const RECEIPT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-transition-receipt-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Plugin Package Secret binding transition receipt 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 timestamp(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid('committedAtMs is invalid');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function authority(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceiptAuthority> {
|
||||
const candidate = dataRecord(value, 'authority');
|
||||
exactKeys(candidate, ['evidenceDigest', 'kind'], 'authority');
|
||||
if (
|
||||
candidate.kind !== 'approved-action-execution' &&
|
||||
candidate.kind !== 'local-owner-confirmation'
|
||||
) {
|
||||
return invalid('authority kind is invalid');
|
||||
}
|
||||
if (
|
||||
typeof candidate.evidenceDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.evidenceDigest)
|
||||
) {
|
||||
return invalid('authority evidence digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: candidate.kind,
|
||||
evidenceDigest: candidate.evidenceDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function unsigned(
|
||||
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
|
||||
bindingDigest: string | null,
|
||||
committedAtMs: number,
|
||||
): Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'> {
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA,
|
||||
transitionPlan,
|
||||
authority: normalizedAuthority,
|
||||
bindingDigest,
|
||||
committedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function receiptDigest(
|
||||
value: Omit<PluginPackageSecretBindingTransitionReceipt, 'receiptDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(RECEIPT_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function bounded(
|
||||
value: Readonly<PluginPackageSecretBindingTransitionReceipt>,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(value), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertBinding(
|
||||
transitionPlan: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
bindingValue: Readonly<PluginPackageSecretBinding> | null,
|
||||
normalizedAuthority: Readonly<PluginPackageSecretBindingTransitionReceiptAuthority>,
|
||||
committedAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
if (transitionPlan.nextBindingPlan === null) {
|
||||
if (bindingValue !== null) {
|
||||
return invalid(
|
||||
'binding is forbidden when the transition revokes all requirements',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (bindingValue === null) {
|
||||
return invalid('binding is required by the transition plan');
|
||||
}
|
||||
const binding = normalizePluginPackageSecretBinding(bindingValue);
|
||||
if (
|
||||
JSON.stringify(binding.target) !==
|
||||
JSON.stringify(transitionPlan.nextTarget) ||
|
||||
JSON.stringify(binding.entries) !==
|
||||
JSON.stringify(transitionPlan.nextBindingPlan.entries) ||
|
||||
binding.authority.kind !== normalizedAuthority.kind ||
|
||||
binding.authority.evidenceDigest !== normalizedAuthority.evidenceDigest ||
|
||||
binding.boundAtMs !== committedAtMs
|
||||
) {
|
||||
return invalid(
|
||||
'binding does not match the transition, authority, and commit',
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingFromTransitionPlan(
|
||||
planValue: Readonly<PluginPackageSecretBindingTransitionPlan>,
|
||||
authorityKind: PluginPackageSecretBindingAuthorityKind,
|
||||
evidenceDigest: string,
|
||||
boundAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
const plan = normalizePluginPackageSecretBindingTransitionPlan(planValue);
|
||||
if (plan.nextBindingPlan === null) return null;
|
||||
return createPluginPackageSecretBindingFromEntries({
|
||||
target: plan.nextTarget,
|
||||
entries: plan.nextBindingPlan.entries,
|
||||
authority: { kind: authorityKind, evidenceDigest },
|
||||
boundAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingTransitionReceipt(
|
||||
input: CreatePluginPackageSecretBindingTransitionReceiptInput,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
const candidate = dataRecord(input, 'receipt input');
|
||||
exactKeys(
|
||||
candidate,
|
||||
['authority', 'binding', 'committedAtMs', 'transitionPlan'],
|
||||
'receipt input',
|
||||
);
|
||||
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
input.transitionPlan,
|
||||
);
|
||||
const normalizedAuthority = authority(input.authority);
|
||||
const committedAtMs = timestamp(input.committedAtMs);
|
||||
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
|
||||
return invalid('commit precedes the reviewed next binding plan');
|
||||
}
|
||||
const binding = assertBinding(
|
||||
transitionPlan,
|
||||
input.binding,
|
||||
normalizedAuthority,
|
||||
committedAtMs,
|
||||
);
|
||||
const value = unsigned(
|
||||
transitionPlan,
|
||||
normalizedAuthority,
|
||||
binding?.bindingDigest ?? null,
|
||||
committedAtMs,
|
||||
);
|
||||
return bounded(
|
||||
Object.freeze({ ...value, receiptDigest: receiptDigest(value) }),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingTransitionReceipt(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTransitionReceipt> {
|
||||
const candidate = dataRecord(value, 'receipt');
|
||||
exactKeys(
|
||||
candidate,
|
||||
[
|
||||
'authority',
|
||||
'bindingDigest',
|
||||
'committedAtMs',
|
||||
'receiptDigest',
|
||||
'schema',
|
||||
'transitionPlan',
|
||||
],
|
||||
'receipt',
|
||||
);
|
||||
if (
|
||||
candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_RECEIPT_SCHEMA
|
||||
) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const transitionPlan = normalizePluginPackageSecretBindingTransitionPlan(
|
||||
candidate.transitionPlan,
|
||||
);
|
||||
const normalizedAuthority = authority(candidate.authority);
|
||||
const committedAtMs = timestamp(candidate.committedAtMs);
|
||||
if (committedAtMs < (transitionPlan.nextBindingPlan?.plannedAtMs ?? 0)) {
|
||||
return invalid('commit precedes the reviewed next binding plan');
|
||||
}
|
||||
const bindingDigestValue = candidate.bindingDigest;
|
||||
if (
|
||||
(bindingDigestValue !== null &&
|
||||
(typeof bindingDigestValue !== 'string' ||
|
||||
!DIGEST.test(bindingDigestValue))) ||
|
||||
(transitionPlan.nextBindingPlan === null) !== (bindingDigestValue === null)
|
||||
) {
|
||||
return invalid('binding digest presence is inconsistent with transition');
|
||||
}
|
||||
const normalized = unsigned(
|
||||
transitionPlan,
|
||||
normalizedAuthority,
|
||||
bindingDigestValue as string | null,
|
||||
committedAtMs,
|
||||
);
|
||||
if (
|
||||
typeof candidate.receiptDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.receiptDigest) ||
|
||||
candidate.receiptDigest !== receiptDigest(normalized)
|
||||
) {
|
||||
return invalid('receipt digest does not match content');
|
||||
}
|
||||
return bounded(
|
||||
Object.freeze({ ...normalized, receiptDigest: candidate.receiptDigest }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingTransitionReceiptRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
): Promise<Readonly<PluginPackageSecretBindingTransitionReceipt> | null>;
|
||||
}
|
||||
@@ -286,6 +286,7 @@ test('recovers queued install through stage and activation without consuming app
|
||||
retry: 0,
|
||||
manualRequired: 0,
|
||||
superseded: 0,
|
||||
deferred: 0,
|
||||
remaining: false,
|
||||
safeToAdmit: true,
|
||||
});
|
||||
@@ -296,6 +297,62 @@ test('recovers queued install through stage and activation without consuming app
|
||||
assert.deepEqual(calls, { stage: 1, publish: 1, inspect: 0 });
|
||||
});
|
||||
|
||||
test('stages but defers activation until the exact prerequisite is ready', async () => {
|
||||
const value = fixture();
|
||||
const repository = new MemoryRepository([value]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0, prerequisite: 0 };
|
||||
let ready = false;
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(lock) {
|
||||
calls.stage += 1;
|
||||
return stageEvidence(lock);
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
activationPrerequisite: {
|
||||
async inspect() {
|
||||
calls.prerequisite += 1;
|
||||
return ready
|
||||
? { status: 'ready' }
|
||||
: {
|
||||
status: 'deferred',
|
||||
reason: 'secret_binding_transition_required',
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const deferred = await coordinator.recoverPage({ limit: 1 });
|
||||
assert.equal(deferred.items[0].status, 'deferred');
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'staged',
|
||||
);
|
||||
assert.deepEqual(calls, {
|
||||
stage: 1,
|
||||
publish: 0,
|
||||
inspect: 0,
|
||||
prerequisite: 2,
|
||||
});
|
||||
|
||||
ready = true;
|
||||
const settled = await coordinator.recoverPage({ limit: 1 });
|
||||
assert.equal(settled.items[0].status, 'settled');
|
||||
assert.equal(
|
||||
(await repository.find('default', 'example-monitor')).state,
|
||||
'active',
|
||||
);
|
||||
assert.deepEqual(calls, {
|
||||
stage: 1,
|
||||
publish: 1,
|
||||
inspect: 0,
|
||||
prerequisite: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('inspects an activating install without republishing it', async () => {
|
||||
const value = activatingFixture();
|
||||
const repository = new MemoryRepository([
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||
const {
|
||||
createPluginPackageSecretBindingTransitionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
|
||||
const {
|
||||
createPluginPackageSecretBindingFromTransitionPlan,
|
||||
createPluginPackageSecretBindingTransitionReceipt,
|
||||
normalizePluginPackageSecretBindingTransitionReceipt,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
|
||||
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: 'receipt-package',
|
||||
displayName: 'Receipt package',
|
||||
version: '2.0.0',
|
||||
description: 'Transition receipt fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets,
|
||||
tools: secrets.length === 0 ? [] : ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const previousManifest = manifest([{ name: 'TOKEN', required: true }]);
|
||||
const previousGeneration = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'receipt-package',
|
||||
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: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: 'c'.repeat(64),
|
||||
},
|
||||
boundAtMs: 10,
|
||||
});
|
||||
|
||||
function transition(nextManifest, assignments) {
|
||||
return createPluginPackageSecretBindingTransitionPlan({
|
||||
previousTarget: previousBinding.target,
|
||||
previousBinding,
|
||||
previousAttemptGeneration: 1,
|
||||
nextGeneration: createPluginPackageResourceGeneration({
|
||||
installationId: 'install-2',
|
||||
projectId: 'project-1',
|
||||
packageName: 'receipt-package',
|
||||
lockDigest: 'd'.repeat(64),
|
||||
generation: 2,
|
||||
previousActiveLockDigest: previousBinding.target.lockDigest,
|
||||
contentDigest: 'e'.repeat(64),
|
||||
contents: nextManifest.spec.contents,
|
||||
}),
|
||||
nextManifest,
|
||||
assignments,
|
||||
plannedAtMs: 20,
|
||||
});
|
||||
}
|
||||
|
||||
test('binds an exact next binding and approval evidence into one receipt', () => {
|
||||
const plan = transition(previousManifest, [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'approved-action-execution',
|
||||
'f'.repeat(64),
|
||||
30,
|
||||
);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: 'f'.repeat(64),
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.equal(receipt.bindingDigest, binding.bindingDigest);
|
||||
assert.equal(receipt.transitionPlan.kind, 'rotate');
|
||||
assert.deepEqual(
|
||||
normalizePluginPackageSecretBindingTransitionReceipt(receipt),
|
||||
receipt,
|
||||
);
|
||||
});
|
||||
|
||||
test('records full revocation without inventing an empty binding', () => {
|
||||
const plan = transition(manifest([]), []);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
30,
|
||||
);
|
||||
assert.equal(binding, null);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.equal(receipt.bindingDigest, null);
|
||||
assert.equal(receipt.transitionPlan.kind, 'revoke');
|
||||
});
|
||||
|
||||
test('rejects missing, surplus, or authority-detached bindings', () => {
|
||||
const plan = transition(previousManifest, [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: null,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
plan.transitionDigest,
|
||||
30,
|
||||
);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'approved-action-execution',
|
||||
evidenceDigest: 'f'.repeat(64),
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
|
||||
const revoke = transition(manifest([]), []);
|
||||
assert.throws(() =>
|
||||
createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: revoke,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: revoke.transitionDigest,
|
||||
},
|
||||
binding,
|
||||
committedAtMs: 30,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects receipt shape and digest tampering', () => {
|
||||
const plan = transition(manifest([]), []);
|
||||
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||
transitionPlan: plan,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: plan.transitionDigest,
|
||||
},
|
||||
binding: null,
|
||||
committedAtMs: 30,
|
||||
});
|
||||
assert.throws(() =>
|
||||
normalizePluginPackageSecretBindingTransitionReceipt({
|
||||
...receipt,
|
||||
committedAtMs: 31,
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
normalizePluginPackageSecretBindingTransitionReceipt({
|
||||
...receipt,
|
||||
unexpected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user