feat(ql3): add owner-confirmed package secret binding

This commit is contained in:
whyour
2026-08-13 11:28:06 +08:00
parent d72631a2b0
commit 8ba27dc148
21 changed files with 1503 additions and 40 deletions
@@ -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(