mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add owner-confirmed package secret binding
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
- D-304/ADR-0392(已接受):Plugin Package 进入安全 quarantine 时,Workflow/Prompt automation publication 不再仅依赖运行时 start guard 间接拒绝,而是与 quarantine event、Package-owned Task disabled revisions、Project Tool snapshot 和 withdrawal receipt 在同一 SQLite/PostgreSQL 事务中收敛为 `withdrawn`。原先仅能引用普通 lifecycle event 的外键升级为 append-only disposition-event 联合引用,历史 migration 与 publication digest schema 保持不变;SQLite edge/standalone 崩溃矩阵覆盖 automation publication insert 后、event/task/receipt/COMMIT 前后,PostgreSQL 通过触发器在既有 `SECURITY DEFINER` quarantine commit 内登记 disposition,再由同一外层 SERIALIZABLE transaction CAS publication head。能力位为 `plugin_package_automation_security_withdrawal@1`;不新增 package、daemon、timer、连接或常驻缓存,适用于低配路由设备和集群节点。SQLite 全量 228/228;PostgreSQL package 311 pass/1 条外部 URL 条件 skip;完整 18-package build/test 退出 0,backend 1,188 pass/2 skip,package/dependency boundary 零 finding;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`。
|
||||
- D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。
|
||||
- D-306A/ADR-0394(已接受):Package Task source 以 `package-secret` placeholder 引用逻辑 requirement,materialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`;Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digest,Local/Cluster 启动发布复用既有 repository/pool,Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 不另造协议。SQLite/PostgreSQL INSERT trigger 同时防止直接写库绕过;Local 只读 readiness 继续不加载 DDL。Local contract v47、Cluster v59;不新增 package、表、索引、连接、daemon、watcher、timer、cache 或 workload。完整 18-package clean build/test 退出 0;backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 finding,workspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 source;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。D-306A 不冒充产品闭环:Local bind/rebind 命令、Cluster Approved Action/API 与新 generation rotation/revocation 编排属于 D-306B。
|
||||
- D-306B1/ADR-0395(进行中):先完成当前 active、尚未绑定 Package generation 的首次 Secret binding,不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建,调用方不能注入 generation 或 digest。Local 已复用短生命周期 `ql3-package` 私有 command-file 和单 SQLite authority:强 `local-console` User 必须同时具备 `secret.manage` 与当前 Project Owner,human confirmation 后在一个 `BEGIN IMMEDIATE` transaction 内复验 Owner fence、Package provenance、精确 Secret version、content-free audit 与 immutable binding;plan 后撤权原子回滚,exact replay 收敛,不同 audit identity 或当前代重复绑定失败关闭。未新增 package、migration、表、索引、依赖、进程、连接、listener、timer、watcher 或 cache。针对包粒度复审确认 18 个 workspace package 不按 LOC 机械合并;D-306 contract/plan 与 SQLite repository/administration 分别收敛为 owning-domain 两文件目录,公开 subpath 不变、无 wrapper。Cluster separation-of-duty Approved Action、PostgreSQL durable plan/proposal 与 HA 证据仍未完成,所以 ADR 保持 Proposed;rebind/rotation/revocation 继续属于必须创建新 generation 的 D-306B2。
|
||||
- D-302/ADR-0390(已接受)
|
||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR-0395:Owner 确认的 Plugin Package Secret 首次绑定
|
||||
|
||||
- 状态:Proposed
|
||||
- 日期:2026-08-13
|
||||
- 关联 RFC:QL-RFC-0001 D-306B1
|
||||
- 关联 ADR:ADR-0364、ADR-0393、ADR-0394
|
||||
|
||||
## 问题
|
||||
|
||||
D-305 已提供按 Package generation 不可变的 Secret binding ledger,D-306A 已让 materialization 消费该 binding,但部署者仍缺少受支持的产品入口。直接开放 repository 会绕过 User、Project Policy、Owner confirmation、Secret version existence 与当前 generation fence;让既有 generation 原地改绑则会使同一 materialized revision 在不同时间指向不同 Secret。
|
||||
|
||||
QingLong 3.0 同时面向低配路由设备和集群节点。本机产品入口不能引入常驻进程、第二条 SQLite 连接、watcher、timer 或 cache;Cluster 后续入口必须复用 separation-of-duty Approved Action,而不是复制 Local Owner authority。
|
||||
|
||||
## 决策
|
||||
|
||||
1. D-306B 拆成两个可独立验证的阶段:B1 只允许当前 active、尚未绑定 generation 的首次 binding;B2 才通过新 Package generation 完成 rebind、rotation 与 revocation。任何阶段都不得更新或删除历史 binding。
|
||||
2. B1 使用共享、content-free 的 `qinglong/plugin-package-secret-binding-plan@v1`。服务端从当前 installation、admission proposal、lock、Manifest 与 resource generation 重建目标和 canonical entries,调用方不能提交 generation、Manifest digest、lock digest 或 binding digest。
|
||||
3. Local 入口复用短生命周期 `ql3-package` 私有 command-file 链路。plan 与 execute 都要求强 `local-console` User、`secret.manage` 和当前 Project `owner`;execute 在 human confirmation 后进入既有单 SQLite operation authority,并在同一 `BEGIN IMMEDIATE` transaction 内重新验证 Owner fence、当前 generation、Package provenance、精确 Secret version existence、content-free audit 与 binding publish。
|
||||
4. plan 只包含 SecretRef 元数据,不包含 Secret 明文;响应只返回目标摘要、canonical entry、plan/binding digest 与 created/existing 状态。required requirement 不能为 `null`,optional requirement 可以显式为 `null`,所有非空引用必须同 Project且固定 version。
|
||||
5. plan 后撤销 Owner 必须使 execute 回滚且不留下 audit/binding。完全相同的 plan 与 audit identity 可以 exact replay;同一当前 generation 的第二次 bind/rebind、不同 audit identity 或任何 provenance drift 必须失败关闭,并提示通过新 generation 执行 B2。
|
||||
6. workspace package 继续按部署制品、authority、依赖隔离、adapter 与多消费者裁决,不按文件数合并。D-306 的 Runtime contract/plan 归入同包 `plugin-package/secret-binding/{binding,plan}`,SQLite repository/administration 归入同包 `plugin-package/secret-binding/{repository,administration}`;稳定公开 subpath 不变,不保留旧物理路径 wrapper,也不为单个 plan 新增微型 package。
|
||||
7. Local 实现不新增 package、migration、表、索引、依赖、进程、listener、连接、timer、watcher 或 cache。Cluster B1 仍须以 Approved Action、package-manager/package-executor 最小权限和 PostgreSQL HA 证据完成后,本 ADR 才能进入 Accepted。
|
||||
|
||||
## 明确不在 B1 中完成
|
||||
|
||||
- 不允许对已经绑定的 generation 原地 rebind、rotate 或 revoke。
|
||||
- 不读取、解密、输出或复制 Secret value。
|
||||
- 不让常驻 Local application、Cluster Control 或 Worker 获得管理 authority。
|
||||
- 不以 Local Owner confirmation 替代 Cluster separation-of-duty Approved Action。
|
||||
|
||||
## 验证
|
||||
|
||||
- Runtime contract:plan canonicalization、digest/shape drift、Local/Cluster authority 派生与稳定 package subpath。
|
||||
- Local 产品纵切面:真实 Package proposal/approval/activation,缺失 Secret version 拒绝,plan 后撤权原子回滚,恢复 Owner 后首次 publish、exact replay、重复 bind 与 audit identity drift 拒绝。
|
||||
- 完整 18-package clean build/test;需要本机 TLS listener 的 Worker 测试在允许回环监听的等价环境复跑。
|
||||
- package/dependency/edge/service-manager/local-image 审计与 GitNexus compare-to-`develop` 变更影响检查。
|
||||
|
||||
## 当前证据
|
||||
|
||||
- Runtime Core、Local SQLite、Local Owner CLI closure type-check 已通过;Local 产品定向纵切面 3/3。
|
||||
- 18-package clean build/test 中除受限沙箱禁止绑定 `127.0.0.1` 导致的 Worker TLS 三项外,其余已执行测试均为 0 fail;同一 Worker 完整包在允许回环监听的环境为 133/133。
|
||||
- Cluster Approved Action、PostgreSQL proposal/plan ledger、最小权限、真库与 physical HA 证据仍待 B1 后半段完成,因此本 ADR 保持 Proposed。
|
||||
@@ -398,6 +398,7 @@
|
||||
| [ADR-0392](./ADR-0392-atomic-plugin-package-automation-security-withdrawal.md) | Plugin Package Automation 安全隔离原子撤回 | Accepted |
|
||||
| [ADR-0393](./ADR-0393-generation-bound-plugin-package-secret-binding-ledger.md) | 按 Generation 固定的 Plugin Package Secret 绑定账本 | Accepted |
|
||||
| [ADR-0394](./ADR-0394-generation-bound-plugin-package-secret-materialization.md) | 按 Generation 固定的 Plugin Package Secret Materialization | Accepted |
|
||||
| [ADR-0395](./ADR-0395-owner-confirmed-plugin-package-secret-binding.md) | Owner 确认的 Plugin Package Secret 首次绑定 | Proposed |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
"require": "./dist/plugin-package/pluginPackageLifecycle.js",
|
||||
"default": "./dist/plugin-package/pluginPackageLifecycle.js"
|
||||
},
|
||||
"./package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBinding.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBinding.js"
|
||||
},
|
||||
"./package-recovery-catalog": {
|
||||
"types": "./dist/plugin-package/pluginPackageRecoveryCatalog.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageRecoveryCatalog.js",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createLocalPluginPackageSecretBindingService,
|
||||
LocalPluginPackageSecretBindingConflictError,
|
||||
LocalPluginPackageSecretBindingUnavailableError,
|
||||
type ExecuteLocalPluginPackageSecretBindingRequest,
|
||||
type LocalPluginPackageSecretBindingService,
|
||||
type PlanLocalPluginPackageSecretBindingRequest,
|
||||
} from '@qinglong/local-sqlite/plugin-package-secret-binding-administration';
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
type PluginPackageLifecycleImpact,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import {
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
import {
|
||||
pluginPackageInstallRecoveryAction,
|
||||
type PluginPackageInstallActionInput,
|
||||
@@ -35,6 +39,7 @@ import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin
|
||||
|
||||
import { createLocalPluginPackageManagementService } from '@qinglong/local-admin/package-management';
|
||||
import { createLocalPluginPackageLifecycleService } from '@qinglong/local-admin/package-lifecycle';
|
||||
import { createLocalPluginPackageSecretBindingService } from '@qinglong/local-admin/package-secret-binding';
|
||||
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_DISPATCH_LIMIT = 64;
|
||||
@@ -177,6 +182,30 @@ export interface ExecuteLocalPluginPackageLifecycleCommand {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.plan';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingCommand {
|
||||
readonly schemaVersion: 1;
|
||||
readonly operation: 'plugin-package.secret-binding.execute';
|
||||
readonly options: LocalPluginPackageCommandOptions;
|
||||
readonly request: {
|
||||
readonly plan: PluginPackageSecretBindingPlan;
|
||||
readonly auditEventId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalPluginPackageCommand =
|
||||
| ProposeLocalPluginPackageCommand
|
||||
| DecideLocalPluginPackageCommand
|
||||
@@ -186,7 +215,9 @@ export type LocalPluginPackageCommand =
|
||||
| ListLocalPluginPackageInstallationsCommand
|
||||
| DispatchLocalPluginPackageCommand
|
||||
| PlanLocalPluginPackageLifecycleCommand
|
||||
| ExecuteLocalPluginPackageLifecycleCommand;
|
||||
| ExecuteLocalPluginPackageLifecycleCommand
|
||||
| PlanLocalPluginPackageSecretBindingCommand
|
||||
| ExecuteLocalPluginPackageSecretBindingCommand;
|
||||
|
||||
export interface LocalPluginPackageCommandRunner {
|
||||
run(
|
||||
@@ -251,6 +282,19 @@ export type LocalPluginPackageCommandResult =
|
||||
status: 'created' | 'existing';
|
||||
approval: ReturnType<typeof approvalSummary>;
|
||||
receipt: ReturnType<typeof lifecycleReceiptSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.plan';
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>;
|
||||
summary: ReturnType<typeof secretBindingPlanSummary>;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'plugin-package.secret-binding.execute';
|
||||
status: 'created' | 'existing';
|
||||
bindingDigest: string;
|
||||
generationDigest: string;
|
||||
}>;
|
||||
|
||||
export class LocalPluginPackageCommandConfigurationError extends TypeError {
|
||||
@@ -558,6 +602,65 @@ function normalizeCommand(value: unknown): Readonly<LocalPluginPackageCommand> {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.plan':
|
||||
exactObject(
|
||||
value.request,
|
||||
['assignments', 'packageName', 'projectId'],
|
||||
'Secret binding plan request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.projectId !== 'string' ||
|
||||
!PROJECT_ID_PATTERN.test(value.request.projectId) ||
|
||||
typeof value.request.packageName !== 'string' ||
|
||||
!PACKAGE_NAME_PATTERN.test(value.request.packageName) ||
|
||||
!Array.isArray(value.request.assignments) ||
|
||||
value.request.assignments.length < 1 ||
|
||||
value.request.assignments.length > 64
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding plan request is invalid',
|
||||
);
|
||||
}
|
||||
for (const assignment of value.request.assignments) {
|
||||
exactObject(assignment, ['name', 'secretRef'], 'Secret assignment');
|
||||
if (
|
||||
typeof assignment.name !== 'string' ||
|
||||
!/^[A-Z_][A-Z0-9_]{0,127}$/.test(assignment.name) ||
|
||||
(assignment.secretRef !== null &&
|
||||
(typeof assignment.secretRef !== 'string' ||
|
||||
Buffer.byteLength(assignment.secretRef, 'utf8') > 512))
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret assignment is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'plugin-package.secret-binding.execute':
|
||||
exactObject(
|
||||
value.request,
|
||||
['auditEventId', 'plan'],
|
||||
'Secret binding execution request',
|
||||
);
|
||||
if (
|
||||
typeof value.request.auditEventId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value.request.auditEventId,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding audit event ID is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
normalizePluginPackageSecretBindingPlan(value.request.plan);
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'Secret binding plan is invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new LocalPluginPackageCommandConfigurationError(
|
||||
'operation is invalid',
|
||||
@@ -714,8 +817,7 @@ function lifecycleReceiptSummary(
|
||||
disposition: receipt.lifecycle.disposition,
|
||||
capabilityStatus: receipt.capability.status,
|
||||
taskTransitions: receipt.capability.taskTransitions.length,
|
||||
previousActiveVectorDigest:
|
||||
receipt.capability.previousActiveVectorDigest,
|
||||
previousActiveVectorDigest: receipt.capability.previousActiveVectorDigest,
|
||||
currentActiveVectorDigest: receipt.capability.currentActiveVectorDigest,
|
||||
currentToolSnapshotDigest: receipt.capability.currentToolSnapshotDigest,
|
||||
retainedSourceCount: receipt.capability.retainedSourceCount,
|
||||
@@ -724,12 +826,68 @@ function lifecycleReceiptSummary(
|
||||
});
|
||||
}
|
||||
|
||||
function secretBindingPlanSummary(
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
) {
|
||||
return Object.freeze({
|
||||
projectId: plan.target.projectId,
|
||||
packageName: plan.target.packageName,
|
||||
installationId: plan.target.installationId,
|
||||
generation: plan.target.generation,
|
||||
generationDigest: plan.target.generationDigest,
|
||||
manifestDigest: plan.target.manifestDigest,
|
||||
assignments: Object.freeze(
|
||||
plan.entries.map((entry) =>
|
||||
Object.freeze({
|
||||
name: entry.name,
|
||||
required: entry.required,
|
||||
bound: entry.secretRef !== null,
|
||||
secretRef: entry.secretRef,
|
||||
}),
|
||||
),
|
||||
),
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
planDigest: plan.planDigest,
|
||||
});
|
||||
}
|
||||
|
||||
async function execute(
|
||||
command: Readonly<LocalPluginPackageCommand>,
|
||||
database: LocalSqlitePluginPackageManagementDatabase,
|
||||
authenticated: Readonly<AuthenticatedLocalCommand>,
|
||||
): Promise<Readonly<LocalPluginPackageCommandResult>> {
|
||||
await authenticated.confirm();
|
||||
if (command.operation === 'plugin-package.secret-binding.plan') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const plan = await service.plan({
|
||||
...command.request,
|
||||
principal: authenticated.principal,
|
||||
plannedAtMs: Date.now(),
|
||||
});
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: command.operation,
|
||||
plan,
|
||||
summary: secretBindingPlanSummary(plan),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'plugin-package.secret-binding.execute') {
|
||||
const service = createLocalPluginPackageSecretBindingService(
|
||||
database.authority,
|
||||
);
|
||||
const result = await service.execute({
|
||||
...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,
|
||||
|
||||
@@ -82,6 +82,7 @@ const {
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
materializePluginPackageResources,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-materialization');
|
||||
@@ -200,7 +201,7 @@ function packageArtifact(manifest) {
|
||||
]);
|
||||
}
|
||||
|
||||
function actionInput() {
|
||||
function actionInput(secretAware = false) {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
@@ -224,8 +225,10 @@ function actionInput() {
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: ['system.command'],
|
||||
secrets: secretAware ? [{ name: 'TOKEN', required: true }] : [],
|
||||
tools: secretAware
|
||||
? ['secret.use', 'system.command']
|
||||
: ['system.command'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.json'],
|
||||
@@ -507,6 +510,46 @@ async function activatePackageAutomation(databasePath, lock, manifest) {
|
||||
}
|
||||
}
|
||||
|
||||
async function activatePackageOnly(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',
|
||||
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));
|
||||
const activating = transitionPluginPackageInstall(lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'secret-binding-start-package',
|
||||
occurredAtMs: staged.updatedAtMs + 1,
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(staged, activating));
|
||||
const active = transitionPluginPackageInstall(lock, activating, {
|
||||
type: 'activation_committed',
|
||||
mutationId: 'secret-binding-commit-package',
|
||||
occurredAtMs: activating.updatedAtMs + 1,
|
||||
activationRef: `activation:${lock.lockDigest}`,
|
||||
intentDigest: pluginPackageActivationIntentDigest(lock, activating),
|
||||
generation: lock.targetGeneration,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
await installs.commit(pluginPackageInstallCommit(activating, active));
|
||||
return { active, lock };
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function publisherTrustRunnerWithOneSnapshotFault() {
|
||||
let injectFault = true;
|
||||
return createLocalPluginPackagePublisherTrustCommandRunner({
|
||||
@@ -1348,6 +1391,277 @@ test('runs the private command-file package lifecycle with replay-safe IDs', asy
|
||||
assertNoSensitiveMaterial(installationList);
|
||||
});
|
||||
|
||||
test('plans and atomically binds a versioned Secret to the current Package generation', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const input = actionInput(true);
|
||||
const actionRef = 'proposal:secret-aware-cli-monitor-v1';
|
||||
const approvalRequestId = 'approval-secret-aware-cli-monitor-v1';
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.propose',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
proposalAuditEventId: '21000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '21000000-0000-4000-8000-000000000002',
|
||||
actionInput: input,
|
||||
},
|
||||
'secret-01-propose',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.decide',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-secret-aware-cli-monitor-v1',
|
||||
auditEventId: '21000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
},
|
||||
'secret-02-decide',
|
||||
),
|
||||
);
|
||||
await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.consume',
|
||||
{
|
||||
actionRef,
|
||||
approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-secret-aware-cli-monitor-v1',
|
||||
dispatchId: 'dispatch-secret-aware-cli-monitor-v1',
|
||||
auditEventId: '21000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
'secret-03-consume',
|
||||
),
|
||||
);
|
||||
const dispatched = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.dispatch',
|
||||
{ limit: 1 },
|
||||
'secret-04-dispatch',
|
||||
),
|
||||
);
|
||||
assert.equal(dispatched.summary.succeeded, 1);
|
||||
const { active } = await activatePackageOnly(
|
||||
value.databasePath,
|
||||
'default',
|
||||
'cli-monitor',
|
||||
);
|
||||
const secretRef = createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'runtime-token',
|
||||
version: 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'default',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
'secret-05-missing-version',
|
||||
),
|
||||
),
|
||||
/version is unavailable/,
|
||||
);
|
||||
const secretDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
secretDatabase
|
||||
.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',
|
||||
'runtime-token',
|
||||
'secret-aware-fixture-v1',
|
||||
'fixture-key-v1',
|
||||
Buffer.alloc(12, 1),
|
||||
Buffer.from('ciphertext'),
|
||||
Buffer.alloc(16, 2),
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
secretDatabase.close();
|
||||
}
|
||||
|
||||
const planResult = await runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'secret-05-plan',
|
||||
),
|
||||
);
|
||||
assert.equal(planResult.summary.installationId, active.installationId);
|
||||
assert.equal(planResult.summary.generation, 1);
|
||||
assert.deepEqual(planResult.summary.assignments, [
|
||||
{ name: 'TOKEN', required: true, bound: true, secretRef },
|
||||
]);
|
||||
assertNoSensitiveMaterial(planResult);
|
||||
|
||||
const revokeDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
revokeDatabase
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 2, 'revoked', NULL,
|
||||
'secret-binding-revoke-owner', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(Date.now());
|
||||
} finally {
|
||||
revokeDatabase.close();
|
||||
}
|
||||
const executePath = commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.execute',
|
||||
{
|
||||
plan: planResult.plan,
|
||||
auditEventId: '21000000-0000-4000-8000-000000000005',
|
||||
},
|
||||
'secret-06-execute',
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(executePath),
|
||||
/denies Secret management|Project policy changed/,
|
||||
);
|
||||
const restoreDatabase = new DatabaseSync(value.databasePath);
|
||||
try {
|
||||
assert.equal(
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageSecretBindings"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.secret.bind'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
restoreDatabase
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 3, 'active', 'owner',
|
||||
'secret-binding-restore-owner', 'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(Date.now());
|
||||
} finally {
|
||||
restoreDatabase.close();
|
||||
}
|
||||
|
||||
const created = await runLocalPluginPackageCommandFile(executePath);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(
|
||||
created.generationDigest,
|
||||
planResult.plan.target.generationDigest,
|
||||
);
|
||||
assert.match(created.bindingDigest, /^[0-9a-f]{64}$/);
|
||||
assertNoSensitiveMaterial(created);
|
||||
const replay = await runLocalPluginPackageCommandFile(executePath);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.bindingDigest, created.bindingDigest);
|
||||
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.plan',
|
||||
{
|
||||
projectId: 'default',
|
||||
packageName: 'cli-monitor',
|
||||
assignments: [{ name: 'TOKEN', secretRef }],
|
||||
},
|
||||
'secret-07-rebind-current-generation',
|
||||
),
|
||||
),
|
||||
/rebind requires a new generation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
runLocalPluginPackageCommandFile(
|
||||
commandFile(
|
||||
value,
|
||||
'plugin-package.secret-binding.execute',
|
||||
{
|
||||
plan: planResult.plan,
|
||||
auditEventId: '21000000-0000-4000-8000-000000000006',
|
||||
},
|
||||
'secret-08-different-audit',
|
||||
),
|
||||
),
|
||||
/another audit identity/,
|
||||
);
|
||||
|
||||
const inspection = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
inspection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageSecretBindings"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
inspection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.secret.bind'`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
inspection.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denies an authenticated non-owner before package proposal mutation', async (t) => {
|
||||
const value = await fixture(t, false);
|
||||
await assert.rejects(
|
||||
|
||||
@@ -86,9 +86,14 @@
|
||||
"default": "./dist/plugin-package/pluginPackageMaterializedRevisionRepository.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBindingRepository.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBindingRepository.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBindingRepository.js"
|
||||
"types": "./dist/plugin-package/secret-binding/repository.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/repository.js",
|
||||
"default": "./dist/plugin-package/secret-binding/repository.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-administration": {
|
||||
"types": "./dist/plugin-package/secret-binding/administration.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/administration.js",
|
||||
"default": "./dist/plugin-package/secret-binding/administration.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliationRepository.d.ts",
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
normalizePluginPackageInstallProposal,
|
||||
type PluginPackageInstallProposal,
|
||||
} from '@qinglong/runtime-core/plugin-package-proposal';
|
||||
import {
|
||||
normalizePluginPackageLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
createPluginPackageSecretBindingFromPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
type PluginPackageSecretBindingPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-plan';
|
||||
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';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
import { LocalSqliteProjectPolicyRepository } from '../../security/projectPolicyRepository';
|
||||
import {
|
||||
insertLocalSecurityAudit,
|
||||
localSecurityAuditFromRow,
|
||||
LOCAL_SECURITY_AUDIT_SELECT,
|
||||
sameSecurityAuditSemantic,
|
||||
} from '../../security/securityPersistence';
|
||||
import { LocalSqlitePluginPackageSecretBindingRepository } from './repository';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
export interface PlanLocalPluginPackageSecretBindingRequest {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly assignments: readonly Readonly<{
|
||||
name: string;
|
||||
secretRef: string | null;
|
||||
}>[];
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageSecretBindingRequest {
|
||||
readonly plan: PluginPackageSecretBindingPlan;
|
||||
readonly auditEventId: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageSecretBindingService {
|
||||
plan(
|
||||
request: PlanLocalPluginPackageSecretBindingRequest,
|
||||
): Promise<Readonly<PluginPackageSecretBindingPlan>>;
|
||||
execute(request: ExecuteLocalPluginPackageSecretBindingRequest): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
bindingDigest: string;
|
||||
generationDigest: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export class LocalPluginPackageSecretBindingConflictError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Local Plugin Package Secret binding conflicts with state: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackageSecretBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackageSecretBindingUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_SECRET_BINDING_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Local Plugin Package Secret binding is unavailable', options);
|
||||
this.name = 'LocalPluginPackageSecretBindingUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function rowText(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string')
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function identity(value: unknown, label: string, pattern: RegExp): string {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function loadCurrent(
|
||||
client: DatabaseSync,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Readonly<{
|
||||
record: PluginPackageInstallRecord;
|
||||
lock: PluginPackageLock;
|
||||
proposal: PluginPackageInstallProposal;
|
||||
}> {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT install.record_json AS "recordJson",
|
||||
install.lock_json AS "lockJson",
|
||||
proposal.proposal_json AS "proposalJson"
|
||||
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
|
||||
LEFT JOIN "QingLong3PluginPackageQuarantineEvents" AS quarantine
|
||||
ON quarantine.project_id = install.project_id
|
||||
AND quarantine.package_name = install.package_name
|
||||
AND quarantine.installation_id = install.installation_id
|
||||
AND quarantine.lock_digest = install.lock_digest
|
||||
LEFT JOIN "QingLong3PluginPackageLifecycleHeads" AS lifecycle
|
||||
ON lifecycle.project_id = install.project_id
|
||||
AND lifecycle.package_name = install.package_name
|
||||
AND lifecycle.installation_id = install.installation_id
|
||||
AND lifecycle.lock_digest = install.lock_digest
|
||||
WHERE head.project_id = ?
|
||||
AND head.package_name = ?
|
||||
AND install.state = 'active'
|
||||
AND install.active_lock_digest = install.lock_digest
|
||||
AND quarantine.event_digest IS NULL
|
||||
AND COALESCE(lifecycle.disposition, 'active') = 'active'
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(projectId, packageName) as Row[];
|
||||
if (row.length !== 1) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'current active Package authority is absent or ambiguous',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const record = normalizePluginPackageInstallRecord(
|
||||
JSON.parse(rowText(row[0]!, 'recordJson')),
|
||||
);
|
||||
const lock = normalizePluginPackageLock(
|
||||
JSON.parse(rowText(row[0]!, 'lockJson')),
|
||||
);
|
||||
const proposal = normalizePluginPackageInstallProposal(
|
||||
JSON.parse(rowText(row[0]!, 'proposalJson')),
|
||||
);
|
||||
if (
|
||||
record.projectId !== projectId ||
|
||||
record.packageName !== packageName ||
|
||||
record.lockDigest !== lock.lockDigest ||
|
||||
proposal.actionDigest !== lock.approval.actionDigest ||
|
||||
proposal.previewDigest !== lock.approval.previewDigest ||
|
||||
proposal.actionInput.projectId !== projectId ||
|
||||
proposal.actionInput.manifest.metadata.name !== packageName ||
|
||||
proposal.actionInput.targetGeneration !== record.targetGeneration ||
|
||||
proposal.actionInput.source.contentDigest !== lock.source.contentDigest ||
|
||||
proposal.actionInput.manifest.metadata.version !==
|
||||
record.packageVersion ||
|
||||
proposal.actionInput.manifest.spec.permissions.secrets.length === 0 ||
|
||||
!proposal.actionInput.manifest.spec.permissions.tools.includes(
|
||||
'secret.use',
|
||||
)
|
||||
) {
|
||||
throw new Error('current Package provenance drift');
|
||||
}
|
||||
return Object.freeze({ record, lock, proposal });
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackageSecretBindingConflictError)
|
||||
throw error;
|
||||
throw new LocalPluginPackageSecretBindingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function generationFrom(current: ReturnType<typeof loadCurrent>) {
|
||||
const { record, lock } = current;
|
||||
return 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,
|
||||
});
|
||||
}
|
||||
|
||||
function auditRecord(
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
eventId: string,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId: `package_secret_binding:${plan.planDigest}`,
|
||||
operationId: 'plugin_package.secret.bind',
|
||||
projectId: plan.target.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['owner_confirmed_secret_binding']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function verifySecretVersions(
|
||||
client: DatabaseSync,
|
||||
plan: Readonly<PluginPackageSecretBindingPlan>,
|
||||
): void {
|
||||
for (const entry of plan.entries) {
|
||||
if (entry.secretRef === null) continue;
|
||||
const reference = parseSecretRef(entry.secretRef);
|
||||
if (reference.version === undefined) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
`Secret ${entry.name} reference is not version-pinned`,
|
||||
);
|
||||
}
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT 1 AS present
|
||||
FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE "project_id" = ? AND "secret_name" = ? AND "version" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(reference.projectId, reference.name, reference.version) as Row[];
|
||||
if (row.length !== 1) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
`Secret ${entry.name} version is unavailable`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPolicyFence(
|
||||
client: DatabaseSync,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
projectId: string,
|
||||
): void {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT project."status" AS "status",
|
||||
project."version" AS "projectVersion",
|
||||
binding."version" AS "bindingVersion",
|
||||
binding."state" AS "bindingState",
|
||||
binding."role" AS "role"
|
||||
FROM "QingLong3Projects" AS project
|
||||
LEFT JOIN "QingLong3ProjectRoleBindings" AS binding
|
||||
ON binding."project_id" = project."id"
|
||||
AND binding."subject_type" = ?
|
||||
AND binding."subject_id" = ?
|
||||
AND binding."version" = (
|
||||
SELECT MAX(current."version")
|
||||
FROM "QingLong3ProjectRoleBindings" AS current
|
||||
WHERE current."project_id" = project."id"
|
||||
AND current."subject_type" = ?
|
||||
AND current."subject_id" = ?
|
||||
)
|
||||
WHERE project."id" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(
|
||||
principal.subject.type,
|
||||
principal.subject.id,
|
||||
principal.subject.type,
|
||||
principal.subject.id,
|
||||
projectId,
|
||||
) as Row[];
|
||||
const value = row[0];
|
||||
if (
|
||||
row.length !== 1 ||
|
||||
!value ||
|
||||
value.status !== 'active' ||
|
||||
value.projectVersion !== fence.projectVersion ||
|
||||
value.bindingVersion !== fence.bindingVersion ||
|
||||
value.bindingState !== 'active' ||
|
||||
value.role !== 'owner'
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'Project policy changed after planning',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageSecretBindingService(
|
||||
authorityValue: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
now: () => number = Date.now,
|
||||
): Readonly<LocalPluginPackageSecretBindingService> {
|
||||
const authority =
|
||||
authorityValue instanceof LocalSqliteOperationAuthority
|
||||
? authorityValue
|
||||
: new LocalSqliteOperationAuthority(authorityValue);
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new LocalSqliteProjectPolicyRepository(authority),
|
||||
);
|
||||
const bindings = new LocalSqlitePluginPackageSecretBindingRepository(
|
||||
authority,
|
||||
);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
observedAtMs: number,
|
||||
) => {
|
||||
const principal = normalizeSecurityPrincipal(principalValue, observedAtMs);
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
principal.assurance !== 'local_console'
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'binding requires a local-console User',
|
||||
);
|
||||
}
|
||||
const decision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'secret.manage',
|
||||
);
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'current Project policy denies Secret management',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async plan(request: PlanLocalPluginPackageSecretBindingRequest) {
|
||||
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 current = loadCurrent(authority.client, projectId, packageName);
|
||||
const generation = generationFrom(current);
|
||||
if (bindings.findInTransaction(generation.generationDigest)) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'current generation is already bound; rebind requires a new generation',
|
||||
);
|
||||
}
|
||||
const plan = createPluginPackageSecretBindingPlan({
|
||||
generation,
|
||||
manifest: current.proposal.actionInput.manifest,
|
||||
assignments: request.assignments,
|
||||
plannedAtMs,
|
||||
});
|
||||
verifySecretVersions(authority.client, plan);
|
||||
return plan;
|
||||
},
|
||||
() => new LocalPluginPackageSecretBindingUnavailableError(),
|
||||
);
|
||||
},
|
||||
|
||||
async execute(request: ExecuteLocalPluginPackageSecretBindingRequest) {
|
||||
const plan = normalizePluginPackageSecretBindingPlan(request.plan);
|
||||
if (typeof request.confirmAuthorization !== 'function') {
|
||||
throw new TypeError('confirmAuthorization is invalid');
|
||||
}
|
||||
const observedAtMs = timestamp(now(), 'binding execution clock');
|
||||
const authorization = await authorize(
|
||||
request.principal,
|
||||
plan.target.projectId,
|
||||
observedAtMs,
|
||||
);
|
||||
await request.confirmAuthorization();
|
||||
return authority.enqueue(
|
||||
async () => {
|
||||
authority.client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const current = loadCurrent(
|
||||
authority.client,
|
||||
plan.target.projectId,
|
||||
plan.target.packageName,
|
||||
);
|
||||
const expected = createPluginPackageSecretBindingPlan({
|
||||
generation: generationFrom(current),
|
||||
manifest: current.proposal.actionInput.manifest,
|
||||
assignments: plan.entries.map(({ name, secretRef }) => ({
|
||||
name,
|
||||
secretRef,
|
||||
})),
|
||||
plannedAtMs: plan.plannedAtMs,
|
||||
});
|
||||
if (expected.planDigest !== plan.planDigest) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'current Package generation changed after planning',
|
||||
);
|
||||
}
|
||||
verifyPolicyFence(
|
||||
authority.client,
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
plan.target.projectId,
|
||||
);
|
||||
verifySecretVersions(authority.client, plan);
|
||||
const audit = auditRecord(
|
||||
plan,
|
||||
identity(request.auditEventId, 'audit event ID', UUID),
|
||||
authorization.principal,
|
||||
authorization.fence,
|
||||
observedAtMs,
|
||||
);
|
||||
const auditsForPlan = authority.client
|
||||
.prepare(
|
||||
`SELECT ${LOCAL_SECURITY_AUDIT_SELECT}
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "request_id" = ? AND "operation_id" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(audit.requestId, audit.operationId) as Row[];
|
||||
if (
|
||||
auditsForPlan.length > 1 ||
|
||||
(auditsForPlan.length === 1 &&
|
||||
rowText(auditsForPlan[0]!, 'eventId') !== audit.eventId)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'binding plan already has another audit identity',
|
||||
);
|
||||
}
|
||||
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 existingBinding = bindings.findInTransaction(
|
||||
plan.target.generationDigest,
|
||||
);
|
||||
const result = existingBinding
|
||||
? (() => {
|
||||
if (
|
||||
existingBinding.authority.kind !==
|
||||
'local-owner-confirmation' ||
|
||||
existingBinding.authority.evidenceDigest !==
|
||||
plan.planDigest ||
|
||||
JSON.stringify(existingBinding.target) !==
|
||||
JSON.stringify(plan.target) ||
|
||||
JSON.stringify(existingBinding.entries) !==
|
||||
JSON.stringify(plan.entries)
|
||||
) {
|
||||
throw new LocalPluginPackageSecretBindingConflictError(
|
||||
'current generation is already bound by another plan',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
binding: existingBinding,
|
||||
});
|
||||
})()
|
||||
: bindings.publishInTransaction(
|
||||
createPluginPackageSecretBindingFromPlan(
|
||||
plan,
|
||||
'local-owner-confirmation',
|
||||
observedAtMs,
|
||||
),
|
||||
);
|
||||
authority.client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: result.status,
|
||||
bindingDigest: result.binding.bindingDigest,
|
||||
generationDigest: result.binding.target.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(),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
+23
-3
@@ -10,7 +10,7 @@ import {
|
||||
type PluginPackageSecretBindingRepository,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteOperationAuthority } from '../../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
@@ -143,6 +143,16 @@ export class LocalSqlitePluginPackageSecretBindingRepository
|
||||
return row ? this.parse(row) : null;
|
||||
}
|
||||
|
||||
findInTransaction(
|
||||
digest: string,
|
||||
): Readonly<PluginPackageSecretBinding> | null {
|
||||
try {
|
||||
return this.findStored(generationDigest(digest));
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(work: () => T): Promise<T> {
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
@@ -169,9 +179,17 @@ export class LocalSqlitePluginPackageSecretBindingRepository
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}>
|
||||
> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
return this.enqueue(() => this.publishInTransaction(binding));
|
||||
}
|
||||
|
||||
publishInTransaction(value: Readonly<PluginPackageSecretBinding>): Readonly<{
|
||||
status: 'created' | 'existing';
|
||||
binding: Readonly<PluginPackageSecretBinding>;
|
||||
}> {
|
||||
const binding = normalizePluginPackageSecretBinding(value);
|
||||
const bindingJson = serialize(binding);
|
||||
return this.enqueue(() => {
|
||||
try {
|
||||
const existing = this.findStored(binding.target.generationDigest);
|
||||
if (existing) {
|
||||
if (JSON.stringify(existing) !== bindingJson) {
|
||||
@@ -245,6 +263,8 @@ export class LocalSqlitePluginPackageSecretBindingRepository
|
||||
result.changes === 1 ? ('created' as const) : ('existing' as const),
|
||||
binding: stored,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw mapStorageError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
},
|
||||
pluginPackageSecretBindings() {
|
||||
pluginPackageSecretBindingsPromise ??= import(
|
||||
'../plugin-package/pluginPackageSecretBindingRepository.js'
|
||||
'../plugin-package/secret-binding/repository.js'
|
||||
).then(
|
||||
({ LocalSqlitePluginPackageSecretBindingRepository }) =>
|
||||
new LocalSqlitePluginPackageSecretBindingRepository(authority),
|
||||
|
||||
@@ -13,7 +13,7 @@ const {
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
const {
|
||||
LocalSqlitePluginPackageSecretBindingRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBindingRepository');
|
||||
} = require('../dist/plugin-package/secret-binding/repository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
"dist/plugin-package/pluginPackageResourceMaterialization.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding": [
|
||||
"dist/plugin-package/pluginPackageSecretBinding.d.ts"
|
||||
"dist/plugin-package/secret-binding/binding.d.ts"
|
||||
],
|
||||
"plugin-package-secret-binding-plan": [
|
||||
"dist/plugin-package/secret-binding/plan.d.ts"
|
||||
],
|
||||
"plugin-package-task-reconciliation": [
|
||||
"dist/plugin-package/pluginPackageTaskReconciliation.d.ts"
|
||||
@@ -375,9 +378,14 @@
|
||||
"default": "./dist/plugin-package/pluginPackageResourceMaterialization.js"
|
||||
},
|
||||
"./plugin-package-secret-binding": {
|
||||
"types": "./dist/plugin-package/pluginPackageSecretBinding.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageSecretBinding.js",
|
||||
"default": "./dist/plugin-package/pluginPackageSecretBinding.js"
|
||||
"types": "./dist/plugin-package/secret-binding/binding.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/binding.js",
|
||||
"default": "./dist/plugin-package/secret-binding/binding.js"
|
||||
},
|
||||
"./plugin-package-secret-binding-plan": {
|
||||
"types": "./dist/plugin-package/secret-binding/plan.d.ts",
|
||||
"require": "./dist/plugin-package/secret-binding/plan.js",
|
||||
"default": "./dist/plugin-package/secret-binding/plan.js"
|
||||
},
|
||||
"./plugin-package-task-reconciliation": {
|
||||
"types": "./dist/plugin-package/pluginPackageTaskReconciliation.d.ts",
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import {
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingRepository,
|
||||
} from './pluginPackageSecretBinding';
|
||||
} from './secret-binding/binding';
|
||||
import {
|
||||
normalizeTaskDefinitionLabels,
|
||||
normalizeTaskDefinitionSpec,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type PluginPackageResourceLockSource,
|
||||
} from './pluginPackageResourceMaterialization';
|
||||
import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration';
|
||||
import type { PluginPackageSecretBindingRepository } from './pluginPackageSecretBinding';
|
||||
import type { PluginPackageSecretBindingRepository } from './secret-binding/binding';
|
||||
import {
|
||||
InvalidPluginPackageTaskReconciliationError,
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
|
||||
+44
-17
@@ -5,13 +5,13 @@ import {
|
||||
normalizePluginPackageManifest,
|
||||
type PluginPackageManifest,
|
||||
type PluginPackageSecretRequirement,
|
||||
} from './pluginPackage';
|
||||
import { pluginPackageManifestDigest } from './installation/pluginPackageInstall';
|
||||
} from '../pluginPackage';
|
||||
import { pluginPackageManifestDigest } from '../installation/pluginPackageInstall';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from './pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../secret/secretReference';
|
||||
} from '../pluginPackageResourceGeneration';
|
||||
import { parseSecretRef } from '../../secret/secretReference';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding@v1' as const;
|
||||
@@ -67,6 +67,13 @@ export interface CreatePluginPackageSecretBindingInput {
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingFromEntriesInput {
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly authority: Readonly<PluginPackageSecretBindingAuthority>;
|
||||
readonly boundAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginPackageSecretBindingRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
@@ -408,19 +415,24 @@ export function createPluginPackageSecretBinding(
|
||||
return Object.freeze({ ...unsigned, bindingDigest: bindingDigest(unsigned) });
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
export function createPluginPackageSecretBindingFromEntries(
|
||||
input: CreatePluginPackageSecretBindingFromEntriesInput,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = dataRecord(value, 'binding');
|
||||
exactKeys(
|
||||
binding,
|
||||
['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'],
|
||||
'binding',
|
||||
);
|
||||
if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const targetValue = dataRecord(binding.target, 'target');
|
||||
const target = normalizeTarget(input.target);
|
||||
const entries = normalizeEntries(input.entries, target.projectId);
|
||||
const authority = normalizeAuthority(input.authority);
|
||||
const boundAtMs = timestamp(input.boundAtMs);
|
||||
const unsigned = unsignedBinding(target, entries, authority, boundAtMs);
|
||||
return normalizePluginPackageSecretBinding({
|
||||
...unsigned,
|
||||
bindingDigest: bindingDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTarget(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingTarget> {
|
||||
const targetValue = dataRecord(value, 'target');
|
||||
exactKeys(
|
||||
targetValue,
|
||||
[
|
||||
@@ -434,7 +446,7 @@ export function normalizePluginPackageSecretBinding(
|
||||
],
|
||||
'target',
|
||||
);
|
||||
const target = Object.freeze({
|
||||
return Object.freeze({
|
||||
installationId: identifier(targetValue.installationId, 'installation ID'),
|
||||
projectId: identifier(targetValue.projectId, 'Project ID'),
|
||||
packageName: packageName(targetValue.packageName),
|
||||
@@ -443,6 +455,21 @@ export function normalizePluginPackageSecretBinding(
|
||||
generationDigest: digest(targetValue.generationDigest, 'generation digest'),
|
||||
manifestDigest: digest(targetValue.manifestDigest, 'Manifest digest'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBinding(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const binding = dataRecord(value, 'binding');
|
||||
exactKeys(
|
||||
binding,
|
||||
['authority', 'bindingDigest', 'boundAtMs', 'entries', 'schema', 'target'],
|
||||
'binding',
|
||||
);
|
||||
if (binding.schema !== PLUGIN_PACKAGE_SECRET_BINDING_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
const target = normalizeTarget(binding.target);
|
||||
const entries = normalizeEntries(binding.entries, target.projectId);
|
||||
const authority = normalizeAuthority(binding.authority);
|
||||
const boundAtMs = timestamp(binding.boundAtMs);
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createPluginPackageSecretBinding,
|
||||
createPluginPackageSecretBindingFromEntries,
|
||||
type PluginPackageSecretBinding,
|
||||
type PluginPackageSecretBindingAssignment,
|
||||
type PluginPackageSecretBindingEntry,
|
||||
type PluginPackageSecretBindingTarget,
|
||||
} from './binding';
|
||||
import type { PluginPackageManifest } from '../pluginPackage';
|
||||
import type { PluginPackageResourceGeneration } from '../pluginPackageResourceGeneration';
|
||||
|
||||
export const PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA =
|
||||
'qinglong/plugin-package-secret-binding-plan@v1' as const;
|
||||
export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES = 64 * 1024;
|
||||
|
||||
export interface PluginPackageSecretBindingPlan {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA;
|
||||
readonly target: Readonly<PluginPackageSecretBindingTarget>;
|
||||
readonly entries: readonly Readonly<PluginPackageSecretBindingEntry>[];
|
||||
readonly plannedAtMs: number;
|
||||
readonly planDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackageSecretBindingPlanInput {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly manifest: Readonly<PluginPackageManifest>;
|
||||
readonly assignments: readonly Readonly<PluginPackageSecretBindingAssignment>[];
|
||||
readonly plannedAtMs: number;
|
||||
}
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const PLACEHOLDER_EVIDENCE_DIGEST = '0'.repeat(64);
|
||||
const PLAN_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/plugin-package-secret-binding-plan-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new TypeError(
|
||||
`Plugin Package Secret binding plan is invalid: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid('shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function unsignedPlan(
|
||||
binding: Pick<PluginPackageSecretBinding, 'target' | 'entries'>,
|
||||
plannedAtMs: number,
|
||||
): Omit<PluginPackageSecretBindingPlan, 'planDigest'> {
|
||||
if (!Number.isSafeInteger(plannedAtMs) || plannedAtMs < 0) {
|
||||
return invalid('plannedAtMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA,
|
||||
target: binding.target,
|
||||
entries: binding.entries,
|
||||
plannedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function planDigest(
|
||||
value: Omit<PluginPackageSecretBindingPlan, 'planDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(PLAN_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value), 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function withDigest(
|
||||
value: Omit<PluginPackageSecretBindingPlan, 'planDigest'>,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
const normalized = Object.freeze({
|
||||
...value,
|
||||
planDigest: planDigest(value),
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_PLUGIN_PACKAGE_SECRET_BINDING_PLAN_JSON_BYTES
|
||||
) {
|
||||
return invalid('durable JSON byte budget exceeded');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingPlan(
|
||||
input: CreatePluginPackageSecretBindingPlanInput,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
const draft = createPluginPackageSecretBinding({
|
||||
generation: input.generation,
|
||||
manifest: input.manifest,
|
||||
assignments: input.assignments,
|
||||
authority: Object.freeze({
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST,
|
||||
}),
|
||||
boundAtMs: 0,
|
||||
});
|
||||
return withDigest(unsignedPlan(draft, input.plannedAtMs));
|
||||
}
|
||||
|
||||
export function normalizePluginPackageSecretBindingPlan(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackageSecretBindingPlan> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('value must be an object');
|
||||
}
|
||||
exactKeys(value, [
|
||||
'entries',
|
||||
'planDigest',
|
||||
'plannedAtMs',
|
||||
'schema',
|
||||
'target',
|
||||
]);
|
||||
const candidate = value as PluginPackageSecretBindingPlan;
|
||||
if (candidate.schema !== PLUGIN_PACKAGE_SECRET_BINDING_PLAN_SCHEMA) {
|
||||
return invalid('schema is unsupported');
|
||||
}
|
||||
if (
|
||||
typeof candidate.planDigest !== 'string' ||
|
||||
!DIGEST.test(candidate.planDigest)
|
||||
) {
|
||||
return invalid('plan digest is invalid');
|
||||
}
|
||||
const normalizedBinding = createPluginPackageSecretBindingFromEntries({
|
||||
target: candidate.target,
|
||||
entries: candidate.entries,
|
||||
authority: {
|
||||
kind: 'local-owner-confirmation',
|
||||
evidenceDigest: PLACEHOLDER_EVIDENCE_DIGEST,
|
||||
},
|
||||
boundAtMs: 0,
|
||||
});
|
||||
const unsigned = unsignedPlan(normalizedBinding, candidate.plannedAtMs);
|
||||
const normalized = withDigest(unsigned);
|
||||
if (normalized.planDigest !== candidate.planDigest) {
|
||||
return invalid('plan digest does not match content');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createPluginPackageSecretBindingFromPlan(
|
||||
planValue: unknown,
|
||||
authorityKind: PluginPackageSecretBinding['authority']['kind'],
|
||||
boundAtMs: number,
|
||||
): Readonly<PluginPackageSecretBinding> {
|
||||
const plan = normalizePluginPackageSecretBindingPlan(planValue);
|
||||
return createPluginPackageSecretBindingFromEntries({
|
||||
target: plan.target,
|
||||
entries: plan.entries,
|
||||
authority: {
|
||||
kind: authorityKind,
|
||||
evidenceDigest: plan.planDigest,
|
||||
},
|
||||
boundAtMs,
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
const { createSecretRef } = require('../dist/secret/secretReference');
|
||||
const {
|
||||
createPluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
} = require('../dist/plugin-package/secret-binding/binding');
|
||||
const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
assertPluginPackageSecretBindingMatches,
|
||||
createPluginPackageSecretBinding,
|
||||
normalizePluginPackageSecretBinding,
|
||||
} = require('../dist/plugin-package/pluginPackageSecretBinding');
|
||||
} = require('../dist/plugin-package/secret-binding/binding');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceGeneration');
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createPluginPackageSecretBindingFromPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
normalizePluginPackageSecretBindingPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||
|
||||
const manifest = {
|
||||
apiVersion: 'qinglong.io/v1alpha1',
|
||||
kind: 'Package',
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.0.0',
|
||||
description: 'Secret binding plan fixture',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [
|
||||
{ name: 'OPTIONAL_TOKEN', required: false },
|
||||
{ name: 'TOKEN', required: true },
|
||||
],
|
||||
tools: ['secret.use'],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const generation = createPluginPackageResourceGeneration({
|
||||
installationId: 'install-1',
|
||||
projectId: 'project-1',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
contents: manifest.spec.contents,
|
||||
});
|
||||
|
||||
function plan() {
|
||||
return createPluginPackageSecretBindingPlan({
|
||||
generation,
|
||||
manifest,
|
||||
assignments: [
|
||||
{ name: 'OPTIONAL_TOKEN', secretRef: null },
|
||||
{
|
||||
name: 'TOKEN',
|
||||
secretRef: createSecretRef({
|
||||
projectId: 'project-1',
|
||||
name: 'runtime-token',
|
||||
version: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
plannedAtMs: 100,
|
||||
});
|
||||
}
|
||||
|
||||
test('creates one canonical content-free Secret binding plan', () => {
|
||||
const value = plan();
|
||||
assert.match(value.planDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(value.plannedAtMs, 100);
|
||||
assert.deepEqual(normalizePluginPackageSecretBindingPlan(value), value);
|
||||
assert.equal(JSON.stringify(value).includes('secret-value'), false);
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
});
|
||||
|
||||
test('rejects target, entry, time and digest drift', () => {
|
||||
const value = plan();
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({ ...value, plannedAtMs: -1 }),
|
||||
/plannedAtMs is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({
|
||||
...value,
|
||||
target: { ...value.target, projectId: 'project-2' },
|
||||
}),
|
||||
/crosses Project boundary|plan digest/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizePluginPackageSecretBindingPlan({ ...value, extra: true }),
|
||||
/shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePluginPackageSecretBindingPlan({
|
||||
...value,
|
||||
planDigest: 'c'.repeat(64),
|
||||
}),
|
||||
/plan digest does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates Local and Cluster authority bindings from the same plan', () => {
|
||||
const value = plan();
|
||||
const local = createPluginPackageSecretBindingFromPlan(
|
||||
value,
|
||||
'local-owner-confirmation',
|
||||
110,
|
||||
);
|
||||
const cluster = createPluginPackageSecretBindingFromPlan(
|
||||
value,
|
||||
'approved-action-execution',
|
||||
120,
|
||||
);
|
||||
assert.equal(local.authority.evidenceDigest, value.planDigest);
|
||||
assert.equal(cluster.authority.evidenceDigest, value.planDigest);
|
||||
assert.equal(local.boundAtMs, 110);
|
||||
assert.equal(cluster.boundAtMs, 120);
|
||||
assert.notEqual(local.bindingDigest, cluster.bindingDigest);
|
||||
});
|
||||
|
||||
test('exports the plan contract only through its explicit subpath', () => {
|
||||
assert.equal(
|
||||
require('../dist').createPluginPackageSecretBindingPlan,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding-plan')
|
||||
.createPluginPackageSecretBindingPlan,
|
||||
createPluginPackageSecretBindingPlan,
|
||||
);
|
||||
});
|
||||
@@ -1844,6 +1844,11 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
'@qinglong/local-sqlite/task-definition-administration',
|
||||
].includes(specifier)
|
||||
) &&
|
||||
!(
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/plugin-package/pluginPackageCommand.ts' &&
|
||||
specifier === '@qinglong/local-admin/package-secret-binding'
|
||||
) &&
|
||||
![
|
||||
{
|
||||
file: 'src/plugin-package/plugin-package-workflow-command/contractAuthority.ts',
|
||||
@@ -2042,7 +2047,11 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
'@qinglong/local-sqlite/operation-authority',
|
||||
'@qinglong/local-sqlite/plugin-package-lifecycle',
|
||||
'@qinglong/local-sqlite/project-policy',
|
||||
].includes(specifier))
|
||||
].includes(specifier)) ||
|
||||
(path.relative(packageDirectory, filePath) ===
|
||||
'src/plugin-package/pluginPackageSecretBinding.ts' &&
|
||||
specifier ===
|
||||
'@qinglong/local-sqlite/plugin-package-secret-binding-administration')
|
||||
)
|
||||
) {
|
||||
findings.push({
|
||||
@@ -2831,6 +2840,17 @@ function auditSourceImports(root, packagePath, findings) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
'src/plugin-package/pluginPackageCommand.ts' &&
|
||||
[
|
||||
'@qinglong/local-admin/package-secret-binding',
|
||||
'@qinglong/runtime-core/plugin-package-secret-binding-plan',
|
||||
].includes(specifier)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
packagePath === 'packages/ql3-local-owner-cli' &&
|
||||
path.relative(packageDirectory, filePath) ===
|
||||
|
||||
Reference in New Issue
Block a user