From d41c821ce0b25fc60f683b8eb448986d8de0ac0f Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 22:56:02 +0800 Subject: [PATCH] feat(ql3): separate cluster secret transition authority --- docs/ql3-package-boundaries.json | 2 +- packages/ql3-cluster-admin/package.json | 15 + .../pluginPackageManagementClient.ts | 239 +++++- .../executor/pluginPackageApprovedAction.ts | 8 + .../executor/pluginPackageExecutorProcess.ts | 24 + .../pluginPackageManagementProcess.ts | 9 + .../pluginPackageManagementTransport.ts | 218 +++++- ...SecretBindingTransitionApprovalConsumer.ts | 175 +++++ ...geSecretBindingTransitionApprovedAction.ts | 170 ++++ ...ackageSecretBindingTransitionManagement.ts | 739 ++++++++++++++++++ .../ql3-cluster-admin/test/bootstrap.test.cjs | 2 + .../pluginPackageManagementClient.test.cjs | 122 ++- .../pluginPackageManagementTransport.test.cjs | 182 +++++ .../test/pluginPackageRecovery.test.cjs | 3 + ...SecretBindingPostgres.integration.test.cjs | 285 +++++++ ...BindingTransitionApprovalConsumer.test.cjs | 273 +++++++ ...etBindingTransitionApprovedAction.test.cjs | 289 +++++++ packages/ql3-cluster-postgres/package.json | 5 + .../src/entrypoints/packageExecutor.ts | 1 + .../src/entrypoints/packageManager.ts | 5 + .../src/migration/migrationManifest.ts | 5 + .../src/migrations/index.ts | 2 + ...ecret-binding-transition-approval-plans.ts | 307 ++++++++ ...BindingTransitionApprovalPlanRepository.ts | 367 +++++++++ .../ql3-cluster-postgres/src/schema/schema.ts | 68 ++ .../src/schema/schemaContract.ts | 55 +- .../src/schema/schemaReadiness.ts | 20 + .../postgresqlMigrationDefinitions.test.cjs | 55 ++ .../test/postgresqlSchemaReadiness.test.cjs | 38 +- packages/ql3-runtime-core/package.json | 8 + .../secret-binding/transitionApprovalPlan.ts | 316 ++++++++ ...cretBindingTransitionApprovalPlan.test.cjs | 154 ++++ scripts/ql3-cluster-dependency-audit.cjs | 9 + test/back/ql3PackageBoundaryAudit.test.cjs | 14 +- 34 files changed, 4157 insertions(+), 27 deletions(-) create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.ts create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.ts create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.ts create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovalConsumer.test.cjs create mode 100644 packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovedAction.test.cjs create mode 100644 packages/ql3-cluster-postgres/src/migrations/pg-0064-plugin-package-secret-binding-transition-approval-plans.ts create mode 100644 packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.ts create mode 100644 packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionApprovalPlan.ts create mode 100644 packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionApprovalPlan.test.cjs diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index d231b972..a8507d63 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -6,7 +6,7 @@ "reviewedDenseDirectories": [ { "kind": "ordered_ledger", - "maxDirectSourceFiles": 64, + "maxDirectSourceFiles": 65, "path": "packages/ql3-cluster-postgres/src/migrations", "rationale": "PostgreSQL migrations are an append-only version ledger whose ordering and discoverability are safer in one reviewed directory." }, diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index 4137fbcf..ff56bbc6 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -270,6 +270,21 @@ "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.js", "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovedAction.js" }, + "./plugin-package-secret-binding-transition-management": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.js" + }, + "./plugin-package-secret-binding-transition-approval-consumer": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.js" + }, + "./plugin-package-secret-binding-transition-approved-action": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.js" + }, "./plugin-package-lifecycle-management": { "types": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.d.ts", "require": "./dist/plugin-package/lifecycle/pluginPackageLifecycleManagement.js", diff --git a/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts b/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts index 4111aa31..c122f7fc 100644 --- a/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts +++ b/packages/ql3-cluster-admin/src/management-support/pluginPackageManagementClient.ts @@ -306,6 +306,27 @@ const SECRET_BINDING_PLAN_KEYS = Object.freeze([ 'planDigest', 'approvalPlanDigest', ]); +const SECRET_BINDING_TRANSITION_PLAN_KEYS = Object.freeze([ + 'actionRef', + 'approvalPlanDigest', + 'plannedAtMs', + 'expiresAtMs', + 'kind', + 'transitionDigest', + 'projectId', + 'packageName', + 'previousInstallationId', + 'previousGeneration', + 'previousGenerationDigest', + 'previousActiveLockDigest', + 'previousAttemptGeneration', + 'nextInstallationId', + 'nextGeneration', + 'nextGenerationDigest', + 'nextLockDigest', + 'nextManifestDigest', + 'changes', +]); function validateScalarSummary(value: unknown, keys: readonly string[]): void { const record = exactResponseObject(value, keys); @@ -586,10 +607,224 @@ function validateSecretBindingPlanSummary( } } +function validateSecretBindingTransitionPlanSummary( + value: unknown, + command: Readonly, +): void { + const summary = exactResponseObject( + value, + SECRET_BINDING_TRANSITION_PLAN_KEYS, + ); + const transitionRequest = command.request as { readonly actionRef: string }; + if ( + typeof summary.actionRef !== 'string' || + summary.actionRef !== transitionRequest.actionRef || + typeof summary.projectId !== 'string' || + typeof summary.packageName !== 'string' || + !PACKAGE_NAME_PATTERN.test(summary.packageName) || + typeof summary.previousInstallationId !== 'string' || + typeof summary.nextInstallationId !== 'string' || + !['carry-forward', 'rotate', 'rebind', 'revoke'].includes( + String(summary.kind), + ) || + !Number.isSafeInteger(summary.plannedAtMs) || + !Number.isSafeInteger(summary.expiresAtMs) || + (summary.expiresAtMs as number) <= (summary.plannedAtMs as number) || + !Number.isSafeInteger(summary.previousGeneration) || + !Number.isSafeInteger(summary.previousAttemptGeneration) || + !Number.isSafeInteger(summary.nextGeneration) || + (summary.previousGeneration as number) < 1 || + (summary.previousAttemptGeneration as number) < + (summary.previousGeneration as number) || + (summary.nextGeneration as number) !== + (summary.previousAttemptGeneration as number) + 1 || + !Array.isArray(summary.changes) || + summary.changes.length < 1 || + summary.changes.length > 64 + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + for (const key of [ + 'approvalPlanDigest', + 'transitionDigest', + 'previousGenerationDigest', + 'previousActiveLockDigest', + 'nextGenerationDigest', + 'nextLockDigest', + 'nextManifestDigest', + ]) { + if ( + typeof summary[key] !== 'string' || + !DIGEST_PATTERN.test(summary[key] as string) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + const nextEntries = new Map(); + const changedNames = new Set(); + for (const changeValue of summary.changes) { + const change = exactResponseObject(changeValue, [ + 'name', + 'requirement', + 'reference', + 'previous', + 'next', + ]); + if ( + typeof change.name !== 'string' || + !/^[A-Z_][A-Z0-9_]{0,127}$/.test(change.name) || + changedNames.has(change.name) || + !['added', 'removed', 'tightened', 'relaxed', 'unchanged'].includes( + String(change.requirement), + ) || + !['bound', 'revoked', 'rotated', 'rebound', 'unchanged'].includes( + String(change.reference), + ) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + changedNames.add(change.name as string); + for (const stateValue of [change.previous, change.next]) { + if (stateValue === null) continue; + const state = exactResponseObject(stateValue, ['required', 'secretRef']); + if ( + typeof state.required !== 'boolean' || + (state.secretRef !== null && typeof state.secretRef !== 'string') || + (state.required && state.secretRef === null) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + if (state.secretRef !== null) { + try { + const reference = parseSecretRef(state.secretRef as string); + if (reference.projectId !== summary.projectId) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } catch (error) { + if (error instanceof ClusterPluginPackageManagementClientRequestError) { + throw error; + } + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + } + if (change.next !== null) { + nextEntries.set( + change.name as string, + (change.next as JsonObject).secretRef as string | null, + ); + } + } + if ( + command.operation === 'plugin-package.secret-binding.transition.plan' && + (summary.projectId !== command.request.projectId || + summary.packageName !== command.request.packageName || + nextEntries.size !== command.request.assignments.length || + command.request.assignments.some( + (assignment) => + nextEntries.get(assignment.name) !== assignment.secretRef, + )) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } +} + function validateResult( value: unknown, command: Readonly, ): Readonly { + if (command.operation === 'plugin-package.secret-binding.transition.plan') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'status', + 'plan', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + !['created', 'existing'].includes(String(result.status)) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + validateSecretBindingTransitionPlanSummary(result.plan, command); + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } + if (command.operation === 'plugin-package.secret-binding.transition.propose') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'approvalStatus', + 'plan', + 'approval', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + !['created', 'existing'].includes(String(result.approvalStatus)) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + validateSecretBindingTransitionPlanSummary(result.plan, command); + validateScalarSummary(result.approval, APPROVAL_KEYS); + const plan = result.plan as JsonObject; + const approval = result.approval as JsonObject; + if ( + approval.id !== command.request.approvalRequestId || + approval.projectId !== plan.projectId || + approval.actionDigest !== plan.approvalPlanDigest || + approval.previewDigest !== plan.transitionDigest + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } + if (command.operation === 'plugin-package.secret-binding.transition.inspect') { + const result = exactResponseObject(value, [ + 'schemaVersion', + 'operation', + 'plan', + 'approval', + 'stale', + ]); + if ( + result.schemaVersion !== 1 || + result.operation !== command.operation || + typeof result.stale !== 'boolean' || + (result.plan === null && result.approval === null) + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + if (result.plan !== null) { + validateSecretBindingTransitionPlanSummary(result.plan, command); + } + if (result.approval !== null) { + validateScalarSummary(result.approval, APPROVAL_KEYS); + if ( + (result.approval as JsonObject).id !== command.request.approvalRequestId + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + if (result.plan !== null && result.approval !== null) { + const plan = result.plan as JsonObject; + const approval = result.approval as JsonObject; + if ( + approval.projectId !== plan.projectId || + approval.actionDigest !== plan.approvalPlanDigest || + approval.previewDigest !== plan.transitionDigest + ) { + throw new ClusterPluginPackageManagementClientRequestError(); + } + } + return Object.freeze( + result as unknown as ClusterPluginPackageManagementTransportResult, + ); + } if (command.operation === 'plugin-package.secret-binding.plan') { const result = exactResponseObject(value, [ 'schemaVersion', @@ -835,7 +1070,9 @@ function validateResult( if (result.approval !== null) { validateScalarSummary(result.approval, APPROVAL_KEYS); if ( - command.operation === 'plugin-package.secret-binding.decide' && + (command.operation === 'plugin-package.secret-binding.decide' || + command.operation === + 'plugin-package.secret-binding.transition.decide') && (result.approval as JsonObject).id !== command.request.approvalRequestId ) { throw new ClusterPluginPackageManagementClientRequestError(); diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts index a3f06fcb..13855818 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts @@ -10,6 +10,8 @@ import { PostgresPluginPackageInstallRepository } from '@qinglong/cluster-postgr import { PostgresPluginPackageSecretBindingApprovalPlanReader, PostgresPluginPackageSecretBindingRepository, + PostgresPluginPackageSecretBindingTransitionApprovalPlanReader, + PostgresPluginPackageSecretBindingTransitionRepository, } from '@qinglong/cluster-postgres/package-executor'; import { PostgresPluginPackageInstallProposalRepository } from '@qinglong/cluster-postgres/plugin-package-proposal'; import { @@ -27,6 +29,7 @@ import { type ClusterPluginPackagePublisherTrustTransitionExecutionPort, } from '../publisher/pluginPackagePublisherTrustTransitionApprovedAction'; import { ClusterPluginPackageSecretBindingApprovedActionHandler } from '../secret-binding/pluginPackageSecretBindingApprovedAction'; +import { ClusterPluginPackageSecretBindingTransitionApprovedActionHandler } from '../secret-binding/pluginPackageSecretBindingTransitionApprovedAction'; import type { PluginPackageSecretExistenceInspector } from '../secret-binding/projectedSecretExistenceInspector'; export const CLUSTER_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMIT = 16; @@ -66,6 +69,11 @@ export function createClusterPluginPackageApprovedActionDispatcher( new PostgresPluginPackageSecretBindingRepository(pool), secretExistenceInspector, ), + new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( + new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(pool), + new PostgresPluginPackageSecretBindingTransitionRepository(pool), + secretExistenceInspector, + ), ...(['overlap_add', 'safe_retire'] as const).map( (mode) => new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler( diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts index 633b301d..6b640d42 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts @@ -39,6 +39,11 @@ import { type ClusterPluginPackageSecretBindingApprovalSummary, type ConsumeClusterPluginPackageSecretBindingApprovalsOptions, } from '../secret-binding/pluginPackageSecretBindingApprovalConsumer'; +import { + consumeClusterPluginPackageSecretBindingTransitionApprovals, + type ClusterPluginPackageSecretBindingTransitionApprovalSummary, + type ConsumeClusterPluginPackageSecretBindingTransitionApprovalsOptions, +} from '../secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer'; import { ProjectedPluginPackageSecretExistenceInspector } from '../secret-binding/projectedSecretExistenceInspector'; import { runClusterPluginPackagePublisherRevocation, @@ -70,6 +75,7 @@ export interface ClusterPluginPackageExecutorBatchResult { readonly approvals: Readonly; readonly trustTransitionApprovals: Readonly; readonly secretBindingApprovals: Readonly; + readonly secretBindingTransitionApprovals: Readonly; readonly dispatch: Readonly; } @@ -97,6 +103,11 @@ export interface RunClusterPluginPackageExecutorProcessOptions { readonly consumeSecretBindingApprovals?: ( options: ConsumeClusterPluginPackageSecretBindingApprovalsOptions, ) => Promise>; + readonly consumeSecretBindingTransitionApprovals?: ( + options: ConsumeClusterPluginPackageSecretBindingTransitionApprovalsOptions, + ) => Promise< + Readonly + >; readonly createDispatcher?: ( options: ClusterPluginPackageApprovedActionDispatcherOptions, ) => ApprovedActionDispatcher; @@ -375,6 +386,7 @@ function isIdleBatch( batch.approvals.scanned === 0 && batch.trustTransitionApprovals.scanned === 0 && batch.secretBindingApprovals.scanned === 0 && + batch.secretBindingTransitionApprovals.scanned === 0 && batch.dispatch.scanned === 0 ); } @@ -395,6 +407,8 @@ export async function runClusterPluginPackageExecutorProcess( typeof options.consumeTrustTransitionApprovals !== 'function') || (options.consumeSecretBindingApprovals !== undefined && typeof options.consumeSecretBindingApprovals !== 'function') || + (options.consumeSecretBindingTransitionApprovals !== undefined && + typeof options.consumeSecretBindingTransitionApprovals !== 'function') || (options.createDispatcher !== undefined && typeof options.createDispatcher !== 'function') || (options.now !== undefined && typeof options.now !== 'function') @@ -431,6 +445,9 @@ export async function runClusterPluginPackageExecutorProcess( const consumeSecretBindingApprovals = options.consumeSecretBindingApprovals ?? consumeClusterPluginPackageSecretBindingApprovals; + const consumeSecretBindingTransitionApprovals = + options.consumeSecretBindingTransitionApprovals ?? + consumeClusterPluginPackageSecretBindingTransitionApprovals; const dispatcher = dispatcherFactory({ pool: database.pool, owner: config.owner, @@ -487,6 +504,12 @@ export async function runClusterPluginPackageExecutorProcess( limit: config.approvalBatchSize, ...(options.now ? { now: options.now } : {}), }); + const secretBindingTransitionApprovals = + await consumeSecretBindingTransitionApprovals({ + pool: database.pool, + limit: config.approvalBatchSize, + ...(options.now ? { now: options.now } : {}), + }); const dispatch = await dispatcher.dispatchBatch({ limit: config.dispatchBatchSize, }); @@ -494,6 +517,7 @@ export async function runClusterPluginPackageExecutorProcess( approvals, trustTransitionApprovals, secretBindingApprovals, + secretBindingTransitionApprovals, dispatch, }); batches.push(batch); diff --git a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts index e4c199dc..e772e678 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementProcess.ts @@ -29,6 +29,7 @@ import { import { createClusterPluginPackageManagementService } from './pluginPackageManagement'; import { createClusterPluginPackageLifecycleManagementService } from '../lifecycle/pluginPackageLifecycleManagement'; import { createClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement'; +import { createClusterPluginPackageSecretBindingTransitionManagementService } from '../secret-binding/pluginPackageSecretBindingTransitionManagement'; import { loadClusterPluginPackagePublisherTrustFileEvidence, type ClusterPluginPackagePublisherTrustFileEvidence, @@ -643,11 +644,19 @@ export async function startClusterPluginPackageManagementProcess( now, quota, }); + const secretBindingTransition = + createClusterPluginPackageSecretBindingTransitionManagementService({ + pool: database.pool, + approvalLifetimeMs: config.approvalLifetimeMs, + now, + quota, + }); const transport = createClusterPluginPackageManagementTransport({ service, lifecycle, publisherTrust, secretBinding, + secretBindingTransition, now, }); const privateKey = readTlsFile(config.privateKeyFile, true); diff --git a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts index bd4a944c..2a5265b5 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/management/pluginPackageManagementTransport.ts @@ -16,6 +16,7 @@ import type { PluginPackageInstallProposal } from '@qinglong/runtime-core/plugin import type { PluginPackageLifecyclePlan } from '@qinglong/runtime-core/plugin-package-lifecycle-plan'; import type { PluginPackageSecretBindingAssignment } from '@qinglong/runtime-core/plugin-package-secret-binding'; import type { PluginPackageSecretBindingApprovalPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; +import type { PluginPackageSecretBindingTransitionApprovalPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; import { normalizeSecurityPrincipal, type SecurityPrincipal, @@ -28,6 +29,7 @@ import type { InspectClusterPluginPackagePublisherTrustTransitionResult, } from '../publisher/pluginPackagePublisherTrustManagement'; import type { ClusterPluginPackageSecretBindingManagementService } from '../secret-binding/pluginPackageSecretBindingManagement'; +import type { ClusterPluginPackageSecretBindingTransitionManagementService } from '../secret-binding/pluginPackageSecretBindingTransitionManagement'; const STRONG_CLUSTER_ASSURANCES = new Set(['multi_factor', 'hardware']); @@ -202,6 +204,30 @@ export interface InspectClusterPluginPackageSecretBindingCommand { readonly request: InspectClusterPluginPackageCommand['request']; } +export interface PlanClusterPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.plan'; + readonly request: PlanClusterPluginPackageSecretBindingCommand['request']; +} + +export interface ProposeClusterPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.propose'; + readonly request: ProposeClusterPluginPackageSecretBindingCommand['request']; +} + +export interface DecideClusterPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.decide'; + readonly request: DecideClusterPluginPackageSecretBindingCommand['request']; +} + +export interface InspectClusterPluginPackageSecretBindingTransitionCommand { + readonly schemaVersion: 1; + readonly operation: 'plugin-package.secret-binding.transition.inspect'; + readonly request: InspectClusterPluginPackageSecretBindingCommand['request']; +} + export type ClusterPluginPackageManagementCommand = | ProposeClusterPluginPackageCommand | DecideClusterPluginPackageCommand @@ -220,7 +246,11 @@ export type ClusterPluginPackageManagementCommand = | PlanClusterPluginPackageSecretBindingCommand | ProposeClusterPluginPackageSecretBindingCommand | DecideClusterPluginPackageSecretBindingCommand - | InspectClusterPluginPackageSecretBindingCommand; + | InspectClusterPluginPackageSecretBindingCommand + | PlanClusterPluginPackageSecretBindingTransitionCommand + | ProposeClusterPluginPackageSecretBindingTransitionCommand + | DecideClusterPluginPackageSecretBindingTransitionCommand + | InspectClusterPluginPackageSecretBindingTransitionCommand; export type ClusterPluginPackageManagementTransportResult = | Readonly<{ @@ -342,6 +372,32 @@ export type ClusterPluginPackageManagementTransportResult = plan: ReturnType | null; approval: ReturnType | null; stale: boolean; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.plan'; + status: 'created' | 'existing'; + plan: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.propose'; + approvalStatus: 'created' | 'existing'; + plan: ReturnType; + approval: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.decide'; + status: 'decided' | 'existing'; + approval: ReturnType; + }> + | Readonly<{ + schemaVersion: 1; + operation: 'plugin-package.secret-binding.transition.inspect'; + plan: ReturnType | null; + approval: ReturnType | null; + stale: boolean; }>; export interface ClusterPluginPackageManagementTransport { @@ -356,6 +412,7 @@ export interface ClusterPluginPackageManagementTransportOptions { readonly lifecycle?: ClusterPluginPackageLifecycleManagementService; readonly publisherTrust?: ClusterPluginPackagePublisherTrustManagementService; readonly secretBinding?: ClusterPluginPackageSecretBindingManagementService; + readonly secretBindingTransition?: ClusterPluginPackageSecretBindingTransitionManagementService; readonly now?: () => number; } @@ -639,6 +696,54 @@ export function normalizeClusterPluginPackageManagementCommand( 'Secret binding inspection request', ); break; + case 'plugin-package.secret-binding.transition.plan': + exactObject( + value.request, + ['actionRef', 'assignments', 'packageName', 'projectId'], + 'Secret transition plan request', + ); + if (!Array.isArray(value.request.assignments)) { + throw new ClusterPluginPackageManagementTransportRequestError( + 'Secret transition assignments are invalid', + ); + } + for (const assignment of value.request.assignments) { + exactObject( + assignment, + ['name', 'secretRef'], + 'Secret transition assignment', + ); + } + break; + case 'plugin-package.secret-binding.transition.propose': + exactObject( + value.request, + ['actionRef', 'approvalAuditEventId', 'approvalRequestId'], + 'Secret transition proposal request', + ); + break; + case 'plugin-package.secret-binding.transition.decide': + exactObject( + value.request, + [ + 'actionRef', + 'approvalRequestId', + 'expectedVersion', + 'decisionId', + 'auditEventId', + 'decision', + 'reasonCode', + ], + 'Secret transition decision request', + ); + break; + case 'plugin-package.secret-binding.transition.inspect': + exactObject( + value.request, + ['actionRef', 'approvalRequestId', 'inspectionId'], + 'Secret transition inspection request', + ); + break; default: throw new ClusterPluginPackageManagementTransportRequestError( 'operation is not publicly available', @@ -771,6 +876,33 @@ function secretBindingPlanSummary( }); } +function secretBindingTransitionPlanSummary( + plan: Readonly, +) { + const transition = plan.transitionPlan; + return Object.freeze({ + actionRef: plan.actionRef, + approvalPlanDigest: plan.approvalPlanDigest, + plannedAtMs: plan.plannedAtMs, + expiresAtMs: plan.expiresAtMs, + kind: transition.kind, + transitionDigest: transition.transitionDigest, + projectId: transition.nextTarget.projectId, + packageName: transition.nextTarget.packageName, + previousInstallationId: transition.previousTarget.installationId, + previousGeneration: transition.previousTarget.generation, + previousGenerationDigest: transition.previousTarget.generationDigest, + previousActiveLockDigest: transition.previousActiveLockDigest, + previousAttemptGeneration: transition.previousAttemptGeneration, + nextInstallationId: transition.nextTarget.installationId, + nextGeneration: transition.nextTarget.generation, + nextGenerationDigest: transition.nextTarget.generationDigest, + nextLockDigest: transition.nextTarget.lockDigest, + nextManifestDigest: transition.nextTarget.manifestDigest, + changes: transition.changes, + }); +} + function publisherRevocationProposalSummary( proposal: NonNullable< InspectClusterPluginPackagePublisherRevocationResult['proposal'] @@ -826,6 +958,7 @@ function exactDecisionReplay( | DecideClusterPluginPackagePublisherRevocationCommand | DecideClusterPluginPackagePublisherTrustTransitionCommand | DecideClusterPluginPackageSecretBindingCommand + | DecideClusterPluginPackageSecretBindingTransitionCommand >, principal: Readonly, ): Readonly | null { @@ -859,6 +992,7 @@ export function createClusterPluginPackageManagementTransport( key !== 'lifecycle' && key !== 'publisherTrust' && key !== 'secretBinding' && + key !== 'secretBindingTransition' && key !== 'now', ) || !options.service || @@ -884,6 +1018,13 @@ export function createClusterPluginPackageManagementTransport( typeof options.secretBinding.propose !== 'function' || typeof options.secretBinding.decide !== 'function' || typeof options.secretBinding.inspectAuthorized !== 'function')) || + (options.secretBindingTransition !== undefined && + (!options.secretBindingTransition || + typeof options.secretBindingTransition.plan !== 'function' || + typeof options.secretBindingTransition.propose !== 'function' || + typeof options.secretBindingTransition.decide !== 'function' || + typeof options.secretBindingTransition.inspectAuthorized !== + 'function')) || (options.now !== undefined && typeof options.now !== 'function') ) { throw new ClusterPluginPackageManagementTransportConfigurationError( @@ -1152,6 +1293,81 @@ export function createClusterPluginPackageManagementTransport( stale: result.stale, }); } + case 'plugin-package.secret-binding.transition.plan': { + if (!options.secretBindingTransition) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret transition management is not configured', + ); + } + const result = await options.secretBindingTransition.plan({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status: result.status, + plan: secretBindingTransitionPlanSummary(result.plan), + }); + } + case 'plugin-package.secret-binding.transition.propose': { + if (!options.secretBindingTransition) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret transition management is not configured', + ); + } + const result = await options.secretBindingTransition.propose({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + approvalStatus: result.approvalStatus, + plan: secretBindingTransitionPlanSummary(result.plan), + approval: approvalSummary(result.approvalRequest), + }); + } + case 'plugin-package.secret-binding.transition.decide': { + if (!options.secretBindingTransition) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret transition management is not configured', + ); + } + const result = await options.secretBindingTransition.decide({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + status: result.status, + approval: approvalSummary(result.request), + }); + } + case 'plugin-package.secret-binding.transition.inspect': { + if (!options.secretBindingTransition) { + throw new ClusterPluginPackageManagementTransportConfigurationError( + 'Secret transition management is not configured', + ); + } + const result = + await options.secretBindingTransition.inspectAuthorized({ + ...command.request, + principal, + }); + return Object.freeze({ + schemaVersion: 1 as const, + operation: command.operation, + plan: result.plan + ? secretBindingTransitionPlanSummary(result.plan) + : null, + approval: result.approvalRequest + ? approvalSummary(result.approvalRequest) + : null, + stale: result.stale, + }); + } case 'plugin-package.publisher-revocation.propose': { if (!options.publisherTrust) { throw new ClusterPluginPackageManagementTransportConfigurationError( diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.ts new file mode 100644 index 00000000..e22e89cf --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.ts @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto'; + +import { + PostgresApprovalRequestRepository, + PostgresPluginPackageSecretBindingTransitionApprovalPlanReader, + PostgresProjectPolicyRepository, +} from '@qinglong/cluster-postgres/package-executor'; +import type { PostgresPool } from '@qinglong/runtime-core'; +import { pluginPackageSecretBindingTransitionApprovedAction } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; +import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; + +export const CLUSTER_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_BATCH_LIMIT = + 16; + +export interface ConsumeClusterPluginPackageSecretBindingTransitionApprovalsOptions { + readonly pool: PostgresPool; + readonly now?: () => number; + readonly limit?: number; +} + +export interface ClusterPluginPackageSecretBindingTransitionApprovalSummary { + readonly scanned: number; + readonly consumed: number; + readonly existing: number; + readonly expired: number; + readonly blocked: number; +} + +const CONSUMER = Object.freeze({ + type: 'system' as const, + id: 'cluster_package_executor', +}); + +function stableDigest(domain: string, value: string): string { + return createHash('sha256') + .update(domain) + .update('\0') + .update(value) + .digest('hex'); +} + +function stableId(prefix: string, domain: string, value: string): string { + return `${prefix}-${stableDigest(domain, value)}`; +} + +function stableAuditEventId(requestId: string): string { + const bytes = Buffer.from( + stableDigest( + 'qinglong/plugin-package-secret-binding-transition-consume-audit@v1', + requestId, + ), + 'hex', + ); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16, + )}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +export async function consumeClusterPluginPackageSecretBindingTransitionApprovals( + options: ConsumeClusterPluginPackageSecretBindingTransitionApprovalsOptions, +): Promise< + Readonly +> { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + Object.keys(options).some((key) => !['pool', 'now', 'limit'].includes(key)) || + !options.pool || + typeof options.pool.query !== 'function' || + typeof options.pool.connect !== 'function' + ) { + throw new TypeError('Secret transition approval consumer options are invalid'); + } + const limit = + options.limit ?? + CLUSTER_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_BATCH_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) { + throw new TypeError('Secret transition approval consumer limit is invalid'); + } + const observedAtMs = (options.now ?? Date.now)(); + if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) { + throw new TypeError('Secret transition approval consumer clock is invalid'); + } + const plans = + new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader( + options.pool, + ); + const approvals = new PostgresApprovalRequestRepository(options.pool); + const policy = new ProjectPolicyEngine( + new PostgresProjectPolicyRepository(options.pool), + ); + const requests = await plans.listApprovedRequests(limit); + let consumed = 0; + let existing = 0; + let expired = 0; + let blocked = 0; + for (const request of requests) { + const plan = await plans.findByActionRef(request.action.actionRef); + if ( + !plan || + request.projectId !== plan.transitionPlan.nextTarget.projectId || + request.decisionMode !== 'separation_of_duty' || + JSON.stringify(request.action) !== + JSON.stringify( + pluginPackageSecretBindingTransitionApprovedAction(plan), + ) || + request.requestedBy.type !== plan.requestedBy.type || + request.requestedBy.id !== plan.requestedBy.id + ) { + blocked += 1; + continue; + } + if (observedAtMs >= request.expiresAtMs || observedAtMs > plan.expiresAtMs) { + expired += 1; + continue; + } + const decision = await policy.decide({ + subject: request.requestedBy, + projectId: request.projectId, + permission: 'secret.manage', + }); + if ( + decision.fence === null || + (decision.effect !== 'allow' && decision.effect !== 'require_approval') + ) { + blocked += 1; + continue; + } + const result = await approvals.consume({ + requestId: request.id, + expectedVersion: request.version, + consumptionId: stableId( + 'psbtc', + 'qinglong/plugin-package-secret-binding-transition-consumption@v1', + request.id, + ), + dispatchId: stableId( + 'psbtd', + 'qinglong/plugin-package-secret-binding-transition-dispatch@v1', + request.id, + ), + action: request.action, + requestedBy: request.requestedBy, + consumedBy: CONSUMER, + consumedAtMs: observedAtMs, + authorizationFence: decision.fence, + audit: { + eventId: stableAuditEventId(request.id), + requestId: request.id, + operationId: 'approval.consume', + projectId: request.projectId, + subject: CONSUMER, + authenticationId: 'cluster-package-executor', + outcome: 'allowed', + reasons: ['package_secret_binding_transition_execution'], + fence: decision.fence, + occurredAtMs: observedAtMs, + }, + }); + if (result.status === 'consumed') consumed += 1; + else existing += 1; + } + return Object.freeze({ + scanned: requests.length, + consumed, + existing, + expired, + blocked, + }); +} diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.ts new file mode 100644 index 00000000..39693f94 --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.ts @@ -0,0 +1,170 @@ +import type { + ApprovedActionHandler, + ApprovedActionHandlerExecutionContext, + ApprovedActionHandlerInspection, + ApprovedActionHandlerResult, +} from '@qinglong/runtime-core/approved-action-dispatcher'; +import { + InvalidPluginPackageSecretBindingTransitionApprovalPlanError, + PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE, + normalizePluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, + type PluginPackageSecretBindingTransitionApprovalPlanRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; +import { + PluginPackageSecretBindingConflictError, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; +import type { + ApplyPostgresPluginPackageSecretBindingTransitionInput, + ApplyPostgresPluginPackageSecretBindingTransitionResult, +} from '@qinglong/cluster-postgres/package-executor'; + +import type { PluginPackageSecretExistenceInspector } from './projectedSecretExistenceInspector'; + +export interface ClusterPluginPackageSecretBindingTransitionExecutionPort { + apply( + input: Readonly, + ): Promise>; +} + +export class ClusterPluginPackageSecretBindingTransitionApprovedActionHandler + implements ApprovedActionHandler +{ + readonly actionType = PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE; + + constructor( + readonly plans: Pick< + PluginPackageSecretBindingTransitionApprovalPlanRepository, + 'findByActionRef' + >, + readonly transitions: ClusterPluginPackageSecretBindingTransitionExecutionPort, + readonly secrets: PluginPackageSecretExistenceInspector, + ) { + if ( + !plans || + typeof plans.findByActionRef !== 'function' || + !transitions || + typeof transitions.apply !== 'function' || + !secrets || + typeof secrets.assertExists !== 'function' + ) { + throw new TypeError('Secret transition Approved Action authority is invalid'); + } + } + + async inspect( + dispatch: ApprovedActionHandlerExecutionContext['dispatch'], + ): Promise { + let plan; + try { + plan = await this.plans.findByActionRef(dispatch.action.actionRef); + } catch { + return Object.freeze({ + status: 'retry', + resultCode: 'package_secret_transition_plan_unavailable', + }); + } + if (!plan) { + return Object.freeze({ + status: 'blocked', + resultCode: 'package_secret_transition_plan_missing', + }); + } + try { + const normalized = + normalizePluginPackageSecretBindingTransitionApprovalPlan(plan); + if ( + JSON.stringify(dispatch.action) !== + JSON.stringify( + pluginPackageSecretBindingTransitionApprovedAction(normalized), + ) || + dispatch.projectId !== normalized.transitionPlan.nextTarget.projectId || + dispatch.requestedBy.type !== normalized.requestedBy.type || + dispatch.requestedBy.id !== normalized.requestedBy.id || + dispatch.createdAtMs > normalized.expiresAtMs + ) { + throw new Error('dispatch does not match transition plan'); + } + const secretRefs = + normalized.transitionPlan.nextBindingPlan?.entries.flatMap((entry) => + entry.secretRef === null ? [] : [entry.secretRef], + ) ?? []; + if (secretRefs.length > 0) await this.secrets.assertExists(secretRefs); + return Object.freeze({ + status: 'ready', + actionDigest: normalized.approvalPlanDigest, + }); + } catch { + return Object.freeze({ + status: 'blocked', + resultCode: 'package_secret_transition_plan_rejected', + }); + } + } + + async execute( + context: Readonly, + ): Promise> { + const startedAtMs = context.execution.startedAtMs; + if ( + context.execution.status !== 'executing' || + startedAtMs === null || + context.execution.leaseOwner !== context.fence.owner || + context.execution.leaseToken !== context.fence.leaseToken || + context.execution.version !== context.fence.version + ) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_transition_execution_rejected', + }); + } + const plan = await this.plans.findByActionRef( + context.dispatch.action.actionRef, + ); + if (!plan) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_transition_plan_missing', + }); + } + const inspection = await this.inspect(context.dispatch); + if (inspection.status !== 'ready' || startedAtMs > plan.expiresAtMs) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_transition_plan_rejected', + }); + } + try { + const result = await this.transitions.apply({ + transitionPlan: plan.transitionPlan, + evidenceDigest: plan.approvalPlanDigest, + committedAtMs: startedAtMs, + }); + return Object.freeze({ + outcome: 'succeeded', + resultCode: + result.status === 'created' + ? 'package_secret_transition_committed' + : 'package_secret_transition_existing', + resultDigest: result.receipt.receiptDigest, + }); + } catch (error) { + if (error instanceof PluginPackageSecretBindingConflictError) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_transition_conflict', + }); + } + if ( + error instanceof + InvalidPluginPackageSecretBindingTransitionApprovalPlanError + ) { + return Object.freeze({ + outcome: 'failed', + resultCode: 'package_secret_transition_plan_rejected', + }); + } + throw error; + } + } +} diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.ts new file mode 100644 index 00000000..e0fb8681 --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.ts @@ -0,0 +1,739 @@ +import { + PostgresApprovalRequestRepository, + PostgresPluginPackageSecretBindingTransitionApprovalPlanRepository, + PostgresProjectPolicyRepository, +} from '@qinglong/cluster-postgres/package-manager'; +import type { PostgresPool } from '@qinglong/runtime-core'; +import { + createApprovalRequest, + normalizeApprovalRequestRecord, + type ApprovalRequestRecord, + type CreateApprovalRequestResult, + type DecideApprovalRequestResult, +} from '@qinglong/runtime-core/approved-action'; +import { + PluginPackageManagementAuthorizationError, + PluginPackageManagementConflictError, + PluginPackageManagementQuotaExceededError, + PluginPackageManagementRequestError, + PluginPackageManagementUnavailableError, + type PluginPackageManagementQuotaOperation, + type PluginPackageManagementQuotaPort, +} from '@qinglong/runtime-core/plugin-package-management'; +import { createPluginPackageResourceGenerationFromReferences } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import { + createPluginPackageSecretBindingTarget, + type PluginPackageSecretBindingAssignment, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; +import { createPluginPackageSecretBindingTransitionPlan } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'; +import { + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_LIFETIME_MS, + PluginPackageSecretBindingTransitionApprovalPlanConflictError, + PluginPackageSecretBindingTransitionApprovalPlanUnavailableError, + createPluginPackageSecretBindingTransitionApprovalPlan, + normalizePluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, + type CreatePluginPackageSecretBindingTransitionApprovalPlanResult, + type PluginPackageSecretBindingTransitionApprovalPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; +import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy'; +import { + normalizeSecurityPrincipal, + type SecurityPolicyFence, + type SecurityPrincipal, + type SecuritySubject, +} from '@qinglong/runtime-core/security'; +import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit'; + +const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000; +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const PACKAGE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + +export interface PlanClusterPluginPackageSecretBindingTransitionRequest { + readonly actionRef: string; + readonly projectId: string; + readonly packageName: string; + readonly assignments: readonly Readonly[]; + readonly principal: SecurityPrincipal; +} + +export interface ProposeClusterPluginPackageSecretBindingTransitionRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly approvalAuditEventId: string; + readonly principal: SecurityPrincipal; +} + +export interface DecideClusterPluginPackageSecretBindingTransitionRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly expectedVersion: number; + readonly decisionId: string; + readonly auditEventId: string; + readonly decision: 'approved' | 'rejected'; + readonly reasonCode: string; + readonly principal: SecurityPrincipal; +} + +export interface InspectClusterPluginPackageSecretBindingTransitionRequest { + readonly actionRef: string; + readonly approvalRequestId: string; + readonly inspectionId: string; + readonly principal: SecurityPrincipal; +} + +export interface ClusterPluginPackageSecretBindingTransitionManagementService { + plan( + request: PlanClusterPluginPackageSecretBindingTransitionRequest, + ): Promise< + Readonly + >; + propose( + request: ProposeClusterPluginPackageSecretBindingTransitionRequest, + ): Promise< + Readonly<{ + plan: Readonly; + approvalStatus: CreateApprovalRequestResult['status']; + approvalRequest: Readonly; + }> + >; + decide( + request: DecideClusterPluginPackageSecretBindingTransitionRequest, + ): Promise>; + inspectAuthorized( + request: InspectClusterPluginPackageSecretBindingTransitionRequest, + ): Promise< + Readonly<{ + plan: Readonly | null; + approvalRequest: Readonly | null; + stale: boolean; + }> + >; +} + +export interface ClusterPluginPackageSecretBindingTransitionManagementOptions { + readonly pool: PostgresPool; + readonly now?: () => number; + readonly planLifetimeMs?: number; + readonly approvalLifetimeMs?: number; + readonly quota?: PluginPackageManagementQuotaPort; +} + +function exact(value: unknown, keys: readonly string[], label: string): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new PluginPackageManagementRequestError(`${label} is invalid`); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new PluginPackageManagementRequestError(`${label} shape is invalid`); + } +} + +function identifier(value: unknown, label: string): string { + if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { + throw new PluginPackageManagementRequestError(`${label} is invalid`); + } + return value; +} + +function actionRef(value: unknown): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + throw new PluginPackageManagementRequestError('actionRef is invalid'); + } + return value; +} + +function currentTime(now: () => number): number { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new PluginPackageManagementUnavailableError(); + } + return value; +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function sameSubject( + left: Readonly, + right: Readonly, +): boolean { + return left.type === right.type && left.id === right.id; +} + +function assignmentsMatch( + assignments: readonly Readonly[], + plan: Readonly, +): boolean { + const expected = plan.transitionPlan.nextBindingPlan?.entries ?? []; + if (!Array.isArray(assignments) || assignments.length !== expected.length) { + return false; + } + const mapped = new Map(); + for (const value of assignments) { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join('\0') !== 'name\0secretRef' || + typeof value.name !== 'string' || + (value.secretRef !== null && typeof value.secretRef !== 'string') || + mapped.has(value.name) + ) { + return false; + } + mapped.set(value.name, value.secretRef); + } + return expected.every( + (entry) => mapped.get(entry.name) === entry.secretRef, + ); +} + +function audit( + eventId: string, + requestId: string, + operationId: 'approval.request' | 'approval.decide', + projectId: string, + principal: Readonly, + outcome: 'allowed' | 'approval_required', + fence: Readonly, + occurredAtMs: number, +): Readonly { + return Object.freeze({ + eventId, + requestId, + operationId, + projectId, + subject: principal.subject, + authenticationId: principal.authenticationId, + outcome, + reasons: Object.freeze(['package_secret_binding_transition_review']), + fence, + occurredAtMs, + }); +} + +export function createClusterPluginPackageSecretBindingTransitionManagementService( + options: ClusterPluginPackageSecretBindingTransitionManagementOptions, +): Readonly { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + Object.keys(options).some( + (key) => + !['pool', 'now', 'planLifetimeMs', 'approvalLifetimeMs', 'quota'].includes( + key, + ), + ) || + !options.pool || + typeof options.pool.query !== 'function' || + typeof options.pool.connect !== 'function' + ) { + throw new TypeError('Secret binding transition management options are invalid'); + } + const planLifetimeMs = + options.planLifetimeMs ?? + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_LIFETIME_MS; + const approvalLifetimeMs = + options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS; + for (const value of [planLifetimeMs, approvalLifetimeMs]) { + if ( + !Number.isSafeInteger(value) || + value < 1_000 || + value > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_LIFETIME_MS + ) { + throw new TypeError('Secret binding transition lifetime is invalid'); + } + } + const now = options.now ?? Date.now; + const plans = + new PostgresPluginPackageSecretBindingTransitionApprovalPlanRepository( + options.pool, + ); + const approvals = new PostgresApprovalRequestRepository(options.pool); + const policy = new ProjectPolicyEngine( + new PostgresProjectPolicyRepository(options.pool), + ); + + const consumeQuota = async ( + projectId: string, + principal: Readonly, + operation: PluginPackageManagementQuotaOperation, + idempotencyKey: string, + ): Promise => { + if (!options.quota) return; + try { + await options.quota.consume({ + projectId, + subject: principal.subject, + operation, + idempotencyKey, + }); + } catch (error) { + if (error instanceof PluginPackageManagementQuotaExceededError) throw error; + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + }; + + const authorize = async ( + principalValue: SecurityPrincipal, + projectId: string, + permission: 'secret.manage' | 'approval.decide', + observedAtMs: number, + ): Promise< + Readonly<{ + principal: Readonly; + fence: Readonly; + }> + > => { + let principal; + try { + principal = normalizeSecurityPrincipal(principalValue, observedAtMs); + } catch { + throw new PluginPackageManagementAuthorizationError(); + } + if ( + principal.subject.type !== 'user' || + (principal.assurance !== 'multi_factor' && + principal.assurance !== 'hardware') + ) { + throw new PluginPackageManagementAuthorizationError(); + } + let decision; + try { + decision = await policy.authorize(principal, projectId, permission); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (decision.effect !== 'allow' || decision.fence === null) { + throw new PluginPackageManagementAuthorizationError(); + } + return Object.freeze({ principal, fence: decision.fence }); + }; + + const loadPlan = async (requestedActionRef: string) => { + try { + const value = await plans.findByActionRef(actionRef(requestedActionRef)); + if (!value) { + throw new PluginPackageManagementConflictError( + 'Secret binding transition plan does not exist', + ); + } + return normalizePluginPackageSecretBindingTransitionApprovalPlan(value); + } catch (error) { + if (error instanceof PluginPackageManagementConflictError) throw error; + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + }; + + return Object.freeze({ + async plan( + request: PlanClusterPluginPackageSecretBindingTransitionRequest, + ) { + exact( + request, + ['actionRef', 'assignments', 'packageName', 'principal', 'projectId'], + 'Secret transition plan request', + ); + const projectId = identifier(request.projectId, 'projectId'); + if ( + typeof request.packageName !== 'string' || + !PACKAGE_NAME_PATTERN.test(request.packageName) || + !Array.isArray(request.assignments) + ) { + throw new PluginPackageManagementRequestError( + 'Secret transition target is invalid', + ); + } + const authorization = await authorize( + request.principal, + projectId, + 'secret.manage', + currentTime(now), + ); + const requestedActionRef = actionRef(request.actionRef); + await consumeQuota( + projectId, + authorization.principal, + 'plugin-package.propose', + requestedActionRef, + ); + let existing; + try { + existing = await plans.findByActionRef(requestedActionRef); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (existing) { + const normalized = + normalizePluginPackageSecretBindingTransitionApprovalPlan(existing); + if ( + normalized.transitionPlan.nextTarget.projectId !== projectId || + normalized.transitionPlan.nextTarget.packageName !== + request.packageName || + !sameSubject(normalized.requestedBy, authorization.principal.subject) || + normalized.expiresAtMs - normalized.plannedAtMs !== planLifetimeMs || + !assignmentsMatch(request.assignments, normalized) + ) { + throw new PluginPackageManagementConflictError( + 'Secret transition actionRef is bound to another request', + ); + } + return Object.freeze({ status: 'existing' as const, plan: normalized }); + } + let snapshot; + try { + snapshot = await plans.loadPlanningSnapshot( + projectId, + request.packageName, + ); + } catch (error) { + throw new PluginPackageManagementUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + if (!snapshot) { + throw new PluginPackageManagementConflictError( + 'reviewed staged Package transition does not exist', + ); + } + const generation = (value: typeof snapshot.next) => + 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, + }); + try { + const previousTarget = createPluginPackageSecretBindingTarget( + generation(snapshot.previous), + snapshot.previous.proposal.actionInput.manifest, + ); + const transitionPlan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget, + previousBinding: snapshot.previous.binding, + previousAttemptGeneration: snapshot.previousAttemptGeneration, + nextGeneration: generation(snapshot.next), + nextManifest: snapshot.next.proposal.actionInput.manifest, + assignments: request.assignments, + plannedAtMs: snapshot.observedAtMs, + }); + return await plans.create( + createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: requestedActionRef, + transitionPlan, + requestedBy: authorization.principal.subject, + plannedAtMs: snapshot.observedAtMs, + expiresAtMs: snapshot.observedAtMs + planLifetimeMs, + }), + ); + } catch (error) { + if ( + error instanceof + PluginPackageSecretBindingTransitionApprovalPlanConflictError + ) { + throw new PluginPackageManagementConflictError( + 'Secret transition actionRef or generation is already bound', + ); + } + if ( + error instanceof + PluginPackageSecretBindingTransitionApprovalPlanUnavailableError + ) { + throw new PluginPackageManagementUnavailableError({ cause: error }); + } + if (error instanceof TypeError) { + throw new PluginPackageManagementRequestError( + 'Secret transition assignments are invalid', + ); + } + throw error; + } + }, + + async propose( + request: ProposeClusterPluginPackageSecretBindingTransitionRequest, + ) { + exact( + request, + ['actionRef', 'approvalAuditEventId', 'approvalRequestId', 'principal'], + 'Secret transition proposal request', + ); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const approvalAuditEventId = identifier( + request.approvalAuditEventId, + 'approvalAuditEventId', + ); + const plan = await loadPlan(request.actionRef); + const observedAtMs = currentTime(now); + if (observedAtMs > plan.expiresAtMs) { + throw new PluginPackageManagementConflictError( + 'Secret transition plan expired', + ); + } + const authorization = await authorize( + request.principal, + plan.transitionPlan.nextTarget.projectId, + 'secret.manage', + observedAtMs, + ); + await consumeQuota( + plan.transitionPlan.nextTarget.projectId, + authorization.principal, + 'plugin-package.propose', + approvalRequestId, + ); + if (!sameSubject(plan.requestedBy, authorization.principal.subject)) { + throw new PluginPackageManagementAuthorizationError(); + } + const binding = pluginPackageSecretBindingTransitionApprovedAction(plan); + const existing = await approvals.findById(approvalRequestId); + if (existing) { + const normalized = normalizeApprovalRequestRecord(existing); + if ( + normalized.projectId !== plan.transitionPlan.nextTarget.projectId || + normalized.decisionMode !== 'separation_of_duty' || + !sameSubject(normalized.requestedBy, plan.requestedBy) || + !same(normalized.action, binding) + ) { + throw new PluginPackageManagementConflictError( + 'Approval request is bound to another Secret transition', + ); + } + return Object.freeze({ + plan, + approvalStatus: 'existing' as const, + approvalRequest: normalized, + }); + } + const expiresAtMs = Math.min( + observedAtMs + approvalLifetimeMs, + plan.expiresAtMs, + ); + if (expiresAtMs <= observedAtMs) { + throw new PluginPackageManagementConflictError( + 'Secret transition plan has no approval lifetime', + ); + } + const result = await approvals.create({ + request: createApprovalRequest({ + id: approvalRequestId, + projectId: plan.transitionPlan.nextTarget.projectId, + action: binding, + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: authorization.principal.subject, + requestedAtMs: observedAtMs, + expiresAtMs, + requestFence: authorization.fence, + }), + audit: audit( + approvalAuditEventId, + approvalRequestId, + 'approval.request', + plan.transitionPlan.nextTarget.projectId, + authorization.principal, + 'approval_required', + authorization.fence, + observedAtMs, + ), + }); + return Object.freeze({ + plan, + approvalStatus: result.status, + approvalRequest: result.request, + }); + }, + + async decide( + request: DecideClusterPluginPackageSecretBindingTransitionRequest, + ) { + exact( + request, + [ + 'actionRef', + 'approvalRequestId', + 'auditEventId', + 'decision', + 'decisionId', + 'expectedVersion', + 'principal', + 'reasonCode', + ], + 'Secret transition decision request', + ); + if ( + (request.decision !== 'approved' && request.decision !== 'rejected') || + typeof request.reasonCode !== 'string' || + !REASON_PATTERN.test(request.reasonCode) || + !Number.isSafeInteger(request.expectedVersion) || + request.expectedVersion < 1 + ) { + throw new PluginPackageManagementRequestError( + 'Secret transition decision tuple is invalid', + ); + } + const plan = await loadPlan(request.actionRef); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const decisionId = identifier(request.decisionId, 'decisionId'); + const auditEventId = identifier(request.auditEventId, 'auditEventId'); + const current = await approvals.findById(approvalRequestId); + if (!current) { + throw new PluginPackageManagementConflictError( + 'Approval request does not exist', + ); + } + const approval = normalizeApprovalRequestRecord(current); + if ( + !same( + approval.action, + pluginPackageSecretBindingTransitionApprovedAction(plan), + ) + ) { + throw new PluginPackageManagementConflictError( + 'Approval request does not match Secret transition plan', + ); + } + const observedAtMs = currentTime(now); + const authorization = await authorize( + request.principal, + approval.projectId, + 'approval.decide', + observedAtMs, + ); + await consumeQuota( + approval.projectId, + authorization.principal, + 'plugin-package.decide', + decisionId, + ); + if ( + approval.decisionId === decisionId && + approval.decision === request.decision && + approval.decisionReasonCode === request.reasonCode && + approval.decidedBy && + sameSubject(approval.decidedBy, authorization.principal.subject) + ) { + return Object.freeze({ status: 'existing' as const, request: approval }); + } + return approvals.decide({ + requestId: approvalRequestId, + expectedVersion: request.expectedVersion, + decisionId, + decision: request.decision, + reasonCode: request.reasonCode, + principal: authorization.principal, + decidedAtMs: observedAtMs, + authorizationFence: authorization.fence, + audit: audit( + auditEventId, + approvalRequestId, + 'approval.decide', + approval.projectId, + authorization.principal, + 'allowed', + authorization.fence, + observedAtMs, + ), + }); + }, + + async inspectAuthorized( + request: InspectClusterPluginPackageSecretBindingTransitionRequest, + ) { + exact( + request, + ['actionRef', 'approvalRequestId', 'inspectionId', 'principal'], + 'Secret transition inspection request', + ); + const requestedActionRef = actionRef(request.actionRef); + const approvalRequestId = identifier( + request.approvalRequestId, + 'approvalRequestId', + ); + const [planValue, approvalValue] = await Promise.all([ + plans.findByActionRef(requestedActionRef), + approvals.findById(approvalRequestId), + ]); + if (!planValue && !approvalValue) { + throw new PluginPackageManagementConflictError( + 'Secret transition review state does not exist', + ); + } + const plan = planValue + ? normalizePluginPackageSecretBindingTransitionApprovalPlan(planValue) + : null; + const approval = approvalValue + ? normalizeApprovalRequestRecord(approvalValue) + : null; + const projectId = + plan?.transitionPlan.nextTarget.projectId ?? approval?.projectId; + if (!projectId) throw new PluginPackageManagementUnavailableError(); + const observedAtMs = currentTime(now); + let authorization; + try { + authorization = await authorize( + request.principal, + projectId, + 'secret.manage', + observedAtMs, + ); + } catch (error) { + if (!(error instanceof PluginPackageManagementAuthorizationError)) { + throw error; + } + authorization = await authorize( + request.principal, + projectId, + 'approval.decide', + observedAtMs, + ); + } + await consumeQuota( + projectId, + authorization.principal, + 'plugin-package.inspect', + identifier(request.inspectionId, 'inspectionId'), + ); + return Object.freeze({ + plan, + approvalRequest: approval, + stale: + plan === null || + approval === null || + !same( + approval.action, + pluginPackageSecretBindingTransitionApprovedAction(plan), + ) || + observedAtMs > plan.expiresAtMs, + }); + }, + }); +} diff --git a/packages/ql3-cluster-admin/test/bootstrap.test.cjs b/packages/ql3-cluster-admin/test/bootstrap.test.cjs index cd22f5d2..cdd075cb 100644 --- a/packages/ql3-cluster-admin/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-admin/test/bootstrap.test.cjs @@ -186,6 +186,8 @@ function database(serverVersionNum = '160014') { 'plugin_package_workflow_task_attempt_snapshot', 'register_plugin_package_automation_disposition_event', 'create_plugin_package_secret_binding_approval_plan', + 'create_plugin_package_secret_transition_plan', + 'plugin_package_secret_binding_transition_snapshot', ].includes(functionName), isOwner: false, })), diff --git a/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs index 431b28ea..8cc77a03 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageManagementClient.test.cjs @@ -240,6 +240,47 @@ function commands() { inspectionId: 'inspection-secret-binding-1', }, }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.plan', + request: { + actionRef: 'secret-transition:cluster-monitor:2', + projectId: 'project-1', + packageName: 'cluster-monitor', + assignments: [{ + name: 'TOKEN', + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjJ9', + }], + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.propose', + request: { + actionRef: 'secret-transition:cluster-monitor:2', + approvalRequestId: 'approval-secret-transition-1', + approvalAuditEventId: 'audit-secret-transition-approval-1', + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.decide', + request: { + ...decision, + actionRef: 'secret-transition:cluster-monitor:2', + approvalRequestId: 'approval-secret-transition-1', + }, + }, + { + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.inspect', + request: { + actionRef: 'secret-transition:cluster-monitor:2', + approvalRequestId: 'approval-secret-transition-1', + inspectionId: 'inspection-secret-transition-1', + }, + }, ]; } @@ -391,6 +432,42 @@ function secretBindingPlanSummary() { }; } +function secretBindingTransitionPlanSummary() { + const secretRef = + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjJ9'; + return { + actionRef: 'secret-transition:cluster-monitor:2', + approvalPlanDigest: '1'.repeat(64), + plannedAtMs: 1_000, + expiresAtMs: 10_000, + kind: 'rotate', + transitionDigest: '2'.repeat(64), + projectId: 'project-1', + packageName: 'cluster-monitor', + previousInstallationId: 'install-cluster-monitor-1', + previousGeneration: 1, + previousGenerationDigest: '3'.repeat(64), + previousActiveLockDigest: '4'.repeat(64), + previousAttemptGeneration: 1, + nextInstallationId: 'install-cluster-monitor-2', + nextGeneration: 2, + nextGenerationDigest: '5'.repeat(64), + nextLockDigest: '6'.repeat(64), + nextManifestDigest: '7'.repeat(64), + changes: [{ + name: 'TOKEN', + requirement: 'unchanged', + reference: 'rotated', + previous: { + required: true, + secretRef: + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJwcm9qZWN0LTEiLCJuYW1lIjoicnVudGltZS10b2tlbiIsInZlcnNpb24iOjF9', + }, + next: { required: true, secretRef }, + }], + }; +} + function successfulResult(operation) { const secretApproval = { ...approvalSummary(), @@ -398,6 +475,47 @@ function successfulResult(operation) { actionDigest: 'd'.repeat(64), previewDigest: 'c'.repeat(64), }; + const transitionApproval = { + ...approvalSummary(), + id: 'approval-secret-transition-1', + projectId: 'project-1', + actionDigest: '1'.repeat(64), + previewDigest: '2'.repeat(64), + }; + if (operation === 'plugin-package.secret-binding.transition.plan') { + return { + schemaVersion: 1, + operation, + status: 'created', + plan: secretBindingTransitionPlanSummary(), + }; + } + if (operation === 'plugin-package.secret-binding.transition.propose') { + return { + schemaVersion: 1, + operation, + approvalStatus: 'created', + plan: secretBindingTransitionPlanSummary(), + approval: transitionApproval, + }; + } + if (operation === 'plugin-package.secret-binding.transition.inspect') { + return { + schemaVersion: 1, + operation, + plan: secretBindingTransitionPlanSummary(), + approval: transitionApproval, + stale: false, + }; + } + if (operation === 'plugin-package.secret-binding.transition.decide') { + return { + schemaVersion: 1, + operation, + status: 'decided', + approval: transitionApproval, + }; + } if (operation === 'plugin-package.secret-binding.plan') { return { schemaVersion: 1, @@ -743,7 +861,7 @@ test('readiness probe rejects unreviewed status and bounded response drift', asy } }); -test('permits and validates exactly the eighteen public management operations', async () => { +test('permits and validates exactly the twenty-two public management operations', async () => { const received = []; const fixture = await startServer((request, response) => { const chunks = []; @@ -788,7 +906,7 @@ test('permits and validates exactly the eighteen public management operations', return true; }, ); - assert.equal(received.length, 18); + assert.equal(received.length, 22); } finally { await fixture.close(); rmSync(files.directory, { recursive: true, force: true }); diff --git a/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs index 03d33a75..5c578113 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageManagementTransport.test.cjs @@ -410,6 +410,116 @@ function fakeSecretBinding() { }; } +function secretBindingTransitionPlan() { + const previousSecretRef = + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJkZWZhdWx0IiwibmFtZSI6InJ1bnRpbWUtdG9rZW4iLCJ2ZXJzaW9uIjoxfQ'; + const nextSecretRef = + 'qlsecret:v1:eyJwcm9qZWN0SWQiOiJkZWZhdWx0IiwibmFtZSI6InJ1bnRpbWUtdG9rZW4iLCJ2ZXJzaW9uIjoyfQ'; + return { + schema: + 'qinglong/plugin-package-secret-binding-transition-approval-plan@v1', + actionRef: 'secret-transition:cluster-monitor:2', + approvalPlanDigest: 'a'.repeat(64), + requestedBy: { type: 'user', id: 'cluster-reviewer' }, + plannedAtMs: NOW - 10, + expiresAtMs: NOW + 10_000, + transitionPlan: { + schema: 'qinglong/plugin-package-secret-binding-transition-plan@v1', + kind: 'rotate', + previousTarget: { + installationId: 'cluster-monitor-installation-v1', + projectId: 'default', + packageName: 'cluster-monitor', + lockDigest: '1'.repeat(64), + generation: 1, + generationDigest: '2'.repeat(64), + manifestDigest: '3'.repeat(64), + }, + previousBinding: null, + previousActiveLockDigest: '1'.repeat(64), + previousAttemptGeneration: 1, + nextTarget: { + installationId: 'cluster-monitor-installation-v2', + projectId: 'default', + packageName: 'cluster-monitor', + lockDigest: '4'.repeat(64), + generation: 2, + generationDigest: '5'.repeat(64), + manifestDigest: '6'.repeat(64), + }, + nextBindingPlan: null, + changes: [{ + name: 'TOKEN', + requirement: 'unchanged', + reference: 'rotated', + previous: { required: true, secretRef: previousSecretRef }, + next: { required: true, secretRef: nextSecretRef }, + }], + transitionDigest: 'b'.repeat(64), + }, + }; +} + +function fakeSecretBindingTransition() { + const calls = { plan: [], propose: [], decide: [], inspectAuthorized: [] }; + return { + calls, + service: { + async plan(request) { + calls.plan.push(request); + return { status: 'created', plan: secretBindingTransitionPlan() }; + }, + async propose(request) { + calls.propose.push(request); + return { + plan: secretBindingTransitionPlan(), + approvalStatus: 'created', + approvalRequest: approval({ + id: request.approvalRequestId, + projectId: 'default', + action: { + permission: 'secret.manage', + actionType: 'plugin_package.secret_binding.transition', + actionRef: request.actionRef, + actionDigest: 'a'.repeat(64), + previewDigest: 'b'.repeat(64), + }, + requestedBy: request.principal.subject, + }), + }; + }, + async decide(request) { + calls.decide.push(request); + return { + status: 'decided', + request: approval({ + id: request.approvalRequestId, + projectId: 'default', + version: 2, + state: request.decision, + decisionId: request.decisionId, + decision: request.decision, + decisionReasonCode: request.reasonCode, + decidedBy: request.principal.subject, + decisionAuthenticationId: request.principal.authenticationId, + decisionAssurance: request.principal.assurance, + decidedAtMs: NOW, + decisionFence: { projectVersion: 1, bindingVersion: 1 }, + }), + }; + }, + async inspectAuthorized(request) { + calls.inspectAuthorized.push(request); + return { + plan: secretBindingTransitionPlan(), + approvalRequest: null, + stale: false, + }; + }, + }, + }; +} + function lifecyclePlan() { return { schema: 'qinglong/plugin-package-lifecycle-plan@v1', @@ -982,6 +1092,78 @@ test('routes content-free Secret binding review without executor authority', asy assert.deepEqual(management.calls.dispatch, []); }); +test('routes content-free Secret transition review without executor authority', async () => { + const management = fakeService(); + const transition = fakeSecretBindingTransition(); + const transport = createClusterPluginPackageManagementTransport({ + service: management.service, + secretBindingTransition: transition.service, + now: () => NOW, + }); + const plan = secretBindingTransitionPlan(); + const assignments = plan.transitionPlan.changes + .filter(({ next }) => next !== null) + .map(({ name, next }) => ({ name, secretRef: next.secretRef })); + const planned = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.plan', + request: { + actionRef: plan.actionRef, + projectId: 'default', + packageName: 'cluster-monitor', + assignments, + }, + }, authentication().authority); + assert.equal(planned.status, 'created'); + assert.equal(planned.plan.kind, 'rotate'); + assert.equal(planned.plan.previousGeneration, 1); + assert.equal(planned.plan.nextGeneration, 2); + assert.equal(Object.hasOwn(planned.plan, 'previousBinding'), false); + + const proposed = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.propose', + request: { + actionRef: plan.actionRef, + approvalRequestId: 'approval-secret-transition-1', + approvalAuditEventId: 'audit-secret-transition-approval-1', + }, + }, authentication().authority); + assert.equal(proposed.approvalStatus, 'created'); + + const decided = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.decide', + request: { + actionRef: plan.actionRef, + approvalRequestId: 'approval-secret-transition-1', + expectedVersion: 1, + decisionId: 'decision-secret-transition-1', + auditEventId: 'audit-secret-transition-decision-1', + decision: 'approved', + reasonCode: 'reviewed', + }, + }, authentication().authority); + assert.equal(decided.status, 'decided'); + + const inspected = await transport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.inspect', + request: { + actionRef: plan.actionRef, + approvalRequestId: 'approval-secret-transition-1', + inspectionId: 'inspection-secret-transition-1', + }, + }, authentication().authority); + assert.equal(inspected.stale, false); + assert.equal(transition.calls.plan.length, 1); + assert.equal(transition.calls.propose.length, 1); + assert.equal(transition.calls.decide.length, 1); + assert.equal(transition.calls.inspectAuthorized.length, 1); + assert.deepEqual(management.calls.consume, []); + assert.deepEqual(management.calls.dispatch, []); +}); + test('routes publisher revocation proposal with derived-only low-sensitive output', async () => { const management = fakeService(); const publisherTrust = fakePublisherTrust(); diff --git a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs index ed3234c6..8897e795 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageRecovery.test.cjs @@ -54,6 +54,7 @@ function executorPrivileges() { 'approval_requests', 'plugin_package_install_proposals', 'plugin_package_secret_binding_approval_plans', + 'plugin_package_secret_binding_transition_approval_plans', 'plugin_package_task_ownerships', 'plugin_package_task_reconciliations', 'plugin_package_task_reconciliation_items', @@ -217,6 +218,8 @@ function database(serverVersionNum = '160014') { 'register_plugin_package_automation_disposition_event', 'create_plugin_package_secret_binding_approval_plan', 'plugin_package_secret_binding_planning_snapshot', + 'create_plugin_package_secret_transition_plan', + 'plugin_package_secret_binding_transition_snapshot', ].includes(functionName), isOwner: false, })), diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs index ba101fe8..64e069bd 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingPostgres.integration.test.cjs @@ -11,6 +11,7 @@ const { createPostgresDatabaseOpener, PostgresPluginPackageSecretBindingApprovalPlanReader, PostgresPluginPackageSecretBindingRepository, + PostgresPluginPackageSecretBindingTransitionApprovalPlanReader, } = require('@qinglong/cluster-postgres/package-executor'); const { PostgresApprovedActionExecutionRepository, @@ -62,6 +63,12 @@ const { const { ClusterPluginPackageSecretBindingApprovedActionHandler, } = require('@qinglong/cluster-admin/plugin-package-secret-binding-approved-action'); +const { + createClusterPluginPackageSecretBindingTransitionManagementService, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-transition-management'); +const { + consumeClusterPluginPackageSecretBindingTransitionApprovals, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-transition-approval-consumer'); const MIGRATION_URL = process.env.QL3_TEST_POSTGRES_MIGRATION_URL ?? @@ -525,6 +532,284 @@ if (!MIGRATION_URL || !MANAGER_URL || !EXECUTOR_URL) { assert.deepEqual(binding.entries, plannedPublic.plan.entries); assert.doesNotMatch(JSON.stringify(binding), /secret-value/); assert.equal((await secretDispatcher.dispatchBatch({ limit: 4 })).scanned, 0); + + const manifestV2 = Object.freeze({ + ...manifest, + metadata: Object.freeze({ ...manifest.metadata, version: '2.0.0' }), + }); + const actionInputV2 = Object.freeze({ + lockId: `lock-v2-${suffix}`, + projectId, + manifest: manifestV2, + previousManifest: manifest, + plan: planPluginPackageInstall(manifestV2, environment, manifest), + environment, + source: { + kind: 'offline', + locator: `offline:sha256:${'e'.repeat(64)}`, + artifactDigest: 'e'.repeat(64), + artifactBytes: 2048, + contentDigest: 'f'.repeat(64), + }, + architecture: 'arm64', + deploymentProfile: 'cluster-control', + targetGeneration: 2, + previousLockDigest: lock.lockDigest, + }); + const upgradeApprovalId = `upgrade-approval-${suffix}`; + now += 10; + const upgradeProposed = await installManagement.propose({ + actionRef: `install:${packageName}:v2`, + approvalRequestId: upgradeApprovalId, + proposalAuditEventId: randomUUID(), + approvalAuditEventId: randomUUID(), + requestedAtMs: now, + actionInput: actionInputV2, + principal: principal(requesterSubject, `upgrade-owner-${suffix}`, now), + }); + now += 10; + const upgradeDecision = await installManagement.decide({ + approvalRequestId: upgradeApprovalId, + expectedVersion: upgradeProposed.approvalRequest.version, + decisionId: `upgrade-decision-${suffix}`, + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + decidedAtMs: now, + principal: principal(reviewerSubject, `upgrade-reviewer-${suffix}`, now), + }); + now += 10; + const upgradeConsumed = await new PostgresApprovalRequestRepository( + executor.pool, + ).consume({ + requestId: upgradeApprovalId, + expectedVersion: upgradeDecision.request.version, + consumptionId: `upgrade-consume-${suffix}`, + dispatchId: `upgrade-dispatch-${suffix}`, + action: upgradeDecision.request.action, + requestedBy: requesterSubject, + consumedBy: { type: 'system', id: 'cluster_package_executor' }, + consumedAtMs: now, + authorizationFence: fence, + audit: audit( + randomUUID(), + upgradeApprovalId, + 'approval.consume', + projectId, + { type: 'system', id: 'cluster_package_executor' }, + now, + fence, + ), + }); + assert.equal(upgradeConsumed.status, 'consumed'); + id = 0; + now += 10; + const upgradeDispatch = + await createClusterPluginPackageApprovedActionDispatcher({ + pool: executor.pool, + owner: `upgrade-executor-${suffix}`, + clock: () => now, + createId: () => `upgrade-executor-id-${suffix}-${++id}`, + secretExistenceInspector: inspector, + }).dispatchBatch({ limit: 4 }); + assert.equal(upgradeDispatch.succeeded, 1); + const queuedV2 = await installs.find(projectId, packageName); + assert.ok(queuedV2); + assert.equal(queuedV2.targetGeneration, 2); + const lockV2 = await installs.findLock(queuedV2.lockDigest); + assert.ok(lockV2); + now += 10; + const stagedV2 = transitionPluginPackageInstall(lockV2, queuedV2, { + type: 'stage_completed', + mutationId: `stage-v2-${suffix}`, + occurredAtMs: now, + stageRef: `stage:${lockV2.lockDigest}`, + artifactDigest: lockV2.source.artifactDigest, + manifestDigest: lockV2.manifestDigest, + contentDigest: lockV2.source.contentDigest, + evidenceDigest: '8'.repeat(64), + }); + const provenanceV2 = createPluginPackagePublisherProvenance({ + projectId, + packageName, + installationId: queuedV2.installationId, + lockDigest: lockV2.lockDigest, + artifactDigest: stagedV2.stageReceipt.artifactDigest, + manifestDigest: stagedV2.stageReceipt.manifestDigest, + contentDigest: stagedV2.stageReceipt.contentDigest, + stageEvidenceDigest: stagedV2.stageReceipt.evidenceDigest, + signature: { + publisher: 'integration.qinglong.dev', + keyId: 'integration-key-1', + signatureDigest: '9'.repeat(64), + keyNotBeforeMs: now - 1, + keyNotAfterMs: now + 60_000, + verifiedAtMs: now, + }, + }); + await executor.pool.query( + `INSERT INTO "ql3"."plugin_package_publisher_provenance" ( + installation_id, project_id, package_name, lock_digest, + artifact_digest, manifest_digest, content_digest, + stage_evidence_digest, publisher, key_id, signature_digest, + key_not_before_ms, key_not_after_ms, verified_at_ms, + provenance_digest, provenance_json + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16::jsonb + )`, + [ + provenanceV2.installationId, + provenanceV2.projectId, + provenanceV2.packageName, + provenanceV2.lockDigest, + provenanceV2.artifactDigest, + provenanceV2.manifestDigest, + provenanceV2.contentDigest, + provenanceV2.stageEvidenceDigest, + provenanceV2.publisher, + provenanceV2.keyId, + provenanceV2.signatureDigest, + provenanceV2.keyNotBeforeMs, + provenanceV2.keyNotAfterMs, + provenanceV2.verifiedAtMs, + provenanceV2.provenanceDigest, + JSON.stringify(provenanceV2), + ], + ); + await installs.commit(pluginPackageInstallCommit(queuedV2, stagedV2)); + + const secretRefV2 = createSecretRef({ + projectId, + name: 'runtime-token', + version: 2, + }); + writeFileSync( + join(projectionRoot, secretProjectionFileName(secretRefV2)), + '', + { mode: 0o440 }, + ); + const transitionActionRef = `secret-transition:${packageName}:v2`; + const transitionApprovalId = `secret-transition-approval-${suffix}`; + const transitionManagement = + createClusterPluginPackageSecretBindingTransitionManagementService({ + pool: manager.pool, + now: () => now, + planLifetimeMs: 60_000, + approvalLifetimeMs: 60_000, + }); + const transitionTransport = createClusterPluginPackageManagementTransport({ + service: installManagement, + secretBindingTransition: transitionManagement, + now: () => now, + }); + const transitionPlan = await transitionTransport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.plan', + request: { + actionRef: transitionActionRef, + projectId, + packageName, + assignments: [{ name: 'TOKEN', secretRef: secretRefV2 }], + }, + }, requesterAuthentication); + assert.equal(transitionPlan.status, 'created'); + assert.equal(transitionPlan.plan.kind, 'rotate'); + assert.equal(transitionPlan.plan.previousGeneration, 1); + assert.equal(transitionPlan.plan.nextGeneration, 2); + assert.equal( + transitionPlan.plan.previousActiveLockDigest, + lock.lockDigest, + ); + now = Math.max(now, transitionPlan.plan.plannedAtMs) + 10; + const transitionProposed = await transitionTransport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.propose', + request: { + actionRef: transitionActionRef, + approvalRequestId: transitionApprovalId, + approvalAuditEventId: randomUUID(), + }, + }, requesterAuthentication); + now += 10; + const transitionDecision = await transitionTransport.execute({ + schemaVersion: 1, + operation: 'plugin-package.secret-binding.transition.decide', + request: { + actionRef: transitionActionRef, + approvalRequestId: transitionApprovalId, + expectedVersion: transitionProposed.approval.version, + decisionId: `secret-transition-decision-${suffix}`, + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + }, + }, { + async authenticate() { + return principal( + reviewerSubject, + `secret-transition-reviewer-${suffix}`, + now, + ); + }, + }); + assert.equal(transitionDecision.status, 'decided'); + now += 10; + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingTransitionApprovals({ + pool: executor.pool, + now: () => now, + limit: 4, + }), + { scanned: 1, consumed: 1, existing: 0, expired: 0, blocked: 0 }, + ); + const transitionApproval = + await new PostgresApprovalRequestRepository(executor.pool).findById( + transitionApprovalId, + ); + assert.ok(transitionApproval?.dispatchId); + const transitionExecution = + await new PostgresApprovedActionExecutionRepository( + executor.pool, + ).findExecutionByDispatchId(transitionApproval.dispatchId); + assert.ok(transitionExecution); + const storedTransitionPlan = + await new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader( + executor.pool, + ).findByActionRef(transitionActionRef); + assert.equal( + storedTransitionPlan?.approvalPlanDigest, + transitionPlan.plan.approvalPlanDigest, + ); + id = 0; + now += 10; + const transitionDispatch = + await createClusterPluginPackageApprovedActionDispatcher({ + pool: executor.pool, + owner: `transition-executor-${suffix}`, + clock: () => now, + createId: () => `transition-executor-id-${suffix}-${++id}`, + secretExistenceInspector: inspector, + }).dispatchBatch({ limit: 4 }); + assert.equal(transitionDispatch.succeeded, 1); + const transitionReceipt = await executor.pool.query( + `SELECT receipt_json AS "receiptJson" + FROM "ql3"."plugin_package_secret_binding_transition_receipts" + WHERE generation_digest = $1`, + [transitionPlan.plan.nextGenerationDigest], + ); + assert.equal(transitionReceipt.rows.length, 1); + assert.equal( + transitionReceipt.rows[0].receiptJson.authority.evidenceDigest, + transitionPlan.plan.approvalPlanDigest, + ); + const bindingV2 = await bindings.find( + transitionPlan.plan.nextGenerationDigest, + ); + assert.ok(bindingV2); + assert.deepEqual(bindingV2.entries, [ + { name: 'TOKEN', required: false, secretRef: secretRefV2 }, + ]); await assert.rejects( manager.pool.query( `SELECT * FROM "ql3"."plugin_package_secret_bindings" WHERE generation_digest = $1`, diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovalConsumer.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovalConsumer.test.cjs new file mode 100644 index 00000000..6497e01c --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovalConsumer.test.cjs @@ -0,0 +1,273 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + approvalRequestDigest, + createApprovalRequest, + decideApprovalRequest, +} = require('@qinglong/runtime-core/approved-action'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + consumeClusterPluginPackageSecretBindingTransitionApprovals, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-transition-approval-consumer'); + +const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' }); +const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' }); +const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 }); + +function manifest(version) { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version, + description: 'Secret transition consumer fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function plan() { + const previousManifest = manifest('1.0.0'); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-secret-transition-v1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: previousManifest.spec.contents, + }); + const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 1, + }), + }], + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'c'.repeat(64), + }, + boundAtMs: 80, + }); + const nextManifest = manifest('2.0.0'); + const transitionPlan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: createPluginPackageResourceGeneration({ + installationId: 'install-secret-transition-v2', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'd'.repeat(64), + generation: 2, + previousActiveLockDigest: 'a'.repeat(64), + contentDigest: 'e'.repeat(64), + contents: nextManifest.spec.contents, + }), + nextManifest, + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }], + plannedAtMs: 100, + }); + return createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: 'secret-transition:example-monitor-v2', + transitionPlan, + requestedBy: REQUESTER, + plannedAtMs: 100, + expiresAtMs: 1_000, + }); +} + +function approvedRequest(candidate) { + return decideApprovalRequest( + createApprovalRequest({ + id: 'approval-secret-transition-1', + projectId: 'project-1', + action: pluginPackageSecretBindingTransitionApprovedAction(candidate), + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: REQUESTER, + requestedAtMs: 110, + expiresAtMs: 900, + requestFence: FENCE, + }), + { + expectedVersion: 1, + decisionId: 'decision-secret-transition-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: REVIEWER, + authenticationId: 'auth-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 800, + assurance: 'multi_factor', + }, + decidedAtMs: 120, + authorizationFence: FENCE, + }, + ); +} + +function pool(candidate, request, policyEffect = 'allow') { + const dispatches = new Map(); + return { + dispatches, + async query(text, values) { + if (text.includes('JOIN "ql3"."plugin_package_secret_binding_transition_approval_plans"')) { + return { + rows: [{ + requestJson: request, + requestDigest: approvalRequestDigest(request), + }], + }; + } + if (text.includes('FROM "ql3"."plugin_package_secret_binding_transition_approval_plans"')) { + return { rows: [{ planJson: candidate }] }; + } + if (text.includes('FROM "ql3"."projects" AS project')) { + return policyEffect === 'deny' + ? { rows: [] } + : { + rows: [{ + projectId: 'project-1', + projectName: 'Project 1', + projectSlug: 'project-1', + projectStatus: 'active', + projectVersion: 3, + projectCreatedAtMs: 1, + projectUpdatedAtMs: 2, + bindingProjectId: 'project-1', + bindingSubjectType: 'user', + bindingSubjectId: 'cluster-owner', + bindingVersion: 4, + bindingState: 'active', + bindingRole: 'admin', + bindingMutationId: 'binding-owner-v4', + bindingChangedByType: 'user', + bindingChangedById: 'root-owner', + bindingCreatedAtMs: 2, + }], + }; + } + if ( + text === 'BEGIN ISOLATION LEVEL SERIALIZABLE' || + text === 'COMMIT' || + text === 'ROLLBACK' + ) return { rows: [] }; + if (text.includes('SELECT set_config(')) return { rows: [{}] }; + if (text.includes('lock_approval_policy_fence')) { + return { rows: [{ matches: true }] }; + } + if (text.includes('FROM "ql3"."approval_requests"')) { + return { + rows: [{ + requestJson: request, + requestDigest: approvalRequestDigest(request), + }], + }; + } + if (text.includes('INSERT INTO "ql3"."approved_action_dispatches"')) { + dispatches.set(values[0], values); + return { rows: [], rowCount: 1 }; + } + if (text.includes('INSERT INTO "ql3"."approved_action_executions"')) { + return { rows: [], rowCount: 1 }; + } + if (text.includes('UPDATE "ql3"."approval_requests"')) { + return { rows: [], rowCount: 1 }; + } + if (text.includes('INSERT INTO "ql3"."security_audit_events"')) { + return { rows: [], rowCount: 1 }; + } + throw new Error(`unexpected query: ${text}`); + }, + async connect() { + return { query: this.query.bind(this), release() {} }; + }, + }; +} + +test('consumes one exact approved Secret transition under a current requester fence', async () => { + const candidate = plan(); + const request = approvedRequest(candidate); + const database = pool(candidate, request); + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingTransitionApprovals({ + pool: database, + now: () => 130, + limit: 4, + }), + { scanned: 1, consumed: 1, existing: 0, expired: 0, blocked: 0 }, + ); + assert.equal(database.dispatches.size, 1); +}); + +test('does not consume expired or no-longer-authorized Secret transitions', async () => { + const candidate = plan(); + const request = approvedRequest(candidate); + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingTransitionApprovals({ + pool: pool(candidate, request), + now: () => 901, + limit: 4, + }), + { scanned: 1, consumed: 0, existing: 0, expired: 1, blocked: 0 }, + ); + assert.deepEqual( + await consumeClusterPluginPackageSecretBindingTransitionApprovals({ + pool: pool(candidate, request, 'deny'), + now: () => 130, + limit: 4, + }), + { scanned: 1, consumed: 0, existing: 0, expired: 0, blocked: 1 }, + ); +}); diff --git a/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovedAction.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovedAction.test.cjs new file mode 100644 index 00000000..17e63488 --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageSecretBindingTransitionApprovedAction.test.cjs @@ -0,0 +1,289 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + consumeApprovalRequest, + createApprovalRequest, + decideApprovalRequest, +} = require('@qinglong/runtime-core/approved-action'); +const { + claimApprovedActionExecution, + createApprovedActionExecution, + startApprovedActionExecution, +} = require('@qinglong/runtime-core/approved-action-execution'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + ClusterPluginPackageSecretBindingTransitionApprovedActionHandler, +} = require('@qinglong/cluster-admin/plugin-package-secret-binding-transition-approved-action'); + +const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' }); +const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' }); +const CONSUMER = Object.freeze({ + type: 'system', + id: 'cluster_package_executor', +}); +const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 }); + +function manifest(version) { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version, + description: 'Secret transition handler fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function approvalPlan() { + const previousManifest = manifest('1.0.0'); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-secret-transition-v1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: previousManifest.spec.contents, + }); + const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 1, + }), + }], + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'c'.repeat(64), + }, + boundAtMs: 80, + }); + const nextManifest = manifest('2.0.0'); + const transitionPlan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: createPluginPackageResourceGeneration({ + installationId: 'install-secret-transition-v2', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'd'.repeat(64), + generation: 2, + previousActiveLockDigest: 'a'.repeat(64), + contentDigest: 'e'.repeat(64), + contents: nextManifest.spec.contents, + }), + nextManifest, + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }], + plannedAtMs: 100, + }); + return createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: 'secret-transition:example-monitor-v2', + transitionPlan, + requestedBy: REQUESTER, + plannedAtMs: 100, + expiresAtMs: 1_000, + }); +} + +function dispatch(plan) { + const action = pluginPackageSecretBindingTransitionApprovedAction(plan); + const pending = createApprovalRequest({ + id: 'approval-secret-transition-1', + projectId: 'project-1', + action, + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: REQUESTER, + requestedAtMs: 110, + expiresAtMs: 900, + requestFence: FENCE, + }); + const approved = decideApprovalRequest(pending, { + expectedVersion: 1, + decisionId: 'decision-secret-transition-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: REVIEWER, + authenticationId: 'auth-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 800, + assurance: 'multi_factor', + }, + decidedAtMs: 120, + authorizationFence: FENCE, + }); + return consumeApprovalRequest(approved, { + expectedVersion: 2, + consumptionId: 'consume-secret-transition-1', + dispatchId: 'dispatch-secret-transition-1', + action, + requestedBy: REQUESTER, + consumedBy: CONSUMER, + consumedAtMs: 130, + authorizationFence: FENCE, + }).dispatch; +} + +function execution(approvedDispatch, startedAtMs = 140) { + const claimed = claimApprovedActionExecution( + createApprovedActionExecution(approvedDispatch, 5), + { + owner: 'secret-transition-executor', + leaseToken: 'lease-secret-transition-1', + nowMs: 131, + leaseDurationMs: 2_000, + }, + ); + return startApprovedActionExecution( + { dispatch: approvedDispatch, execution: claimed }, + { + dispatchId: approvedDispatch.id, + approvalRequestId: approvedDispatch.approvalRequestId, + actionDigest: approvedDispatch.action.actionDigest, + owner: claimed.leaseOwner, + leaseToken: claimed.leaseToken, + expectedVersion: claimed.version, + startedAtMs, + }, + ); +} + +test('commits exactly one approved transition and verifies projected references', async () => { + const plan = approvalPlan(); + const approvedDispatch = dispatch(plan); + const started = execution(approvedDispatch); + const applied = []; + const inspected = []; + const subject = new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( + { async findByActionRef() { return plan; } }, + { + async apply(input) { + applied.push(input); + return { + status: 'created', + receipt: { receiptDigest: 'f'.repeat(64) }, + }; + }, + }, + { async assertExists(refs) { inspected.push(...refs); } }, + ); + assert.deepEqual(await subject.inspect(approvedDispatch), { + status: 'ready', + actionDigest: plan.approvalPlanDigest, + }); + const result = await subject.execute({ + dispatch: approvedDispatch, + execution: started, + idempotencyKey: approvedDispatch.id, + fence: { + owner: started.leaseOwner, + leaseToken: started.leaseToken, + version: started.version, + }, + }); + assert.deepEqual(result, { + outcome: 'succeeded', + resultCode: 'package_secret_transition_committed', + resultDigest: 'f'.repeat(64), + }); + assert.equal(applied.length, 1); + assert.deepEqual(applied[0], { + transitionPlan: plan.transitionPlan, + evidenceDigest: plan.approvalPlanDigest, + committedAtMs: 140, + }); + assert.deepEqual(inspected, [ + plan.transitionPlan.nextBindingPlan.entries[0].secretRef, + plan.transitionPlan.nextBindingPlan.entries[0].secretRef, + ]); +}); + +test('blocks plan drift and execution that starts after plan expiry', async () => { + const plan = approvalPlan(); + const approvedDispatch = dispatch(plan); + const applied = []; + const subject = new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( + { async findByActionRef() { return plan; } }, + { async apply(input) { applied.push(input); throw new Error('must not apply'); } }, + { async assertExists() {} }, + ); + const late = execution(approvedDispatch, 1_001); + assert.deepEqual( + await subject.execute({ + dispatch: approvedDispatch, + execution: late, + idempotencyKey: approvedDispatch.id, + fence: { + owner: late.leaseOwner, + leaseToken: late.leaseToken, + version: late.version, + }, + }), + { + outcome: 'failed', + resultCode: 'package_secret_transition_plan_rejected', + }, + ); + assert.equal(applied.length, 0); + const drifted = { ...plan, approvalPlanDigest: '9'.repeat(64) }; + const driftedSubject = new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( + { async findByActionRef() { return drifted; } }, + { async apply() { throw new Error('must not apply'); } }, + { async assertExists() {} }, + ); + assert.deepEqual(await driftedSubject.inspect(approvedDispatch), { + status: 'blocked', + resultCode: 'package_secret_transition_plan_rejected', + }); +}); diff --git a/packages/ql3-cluster-postgres/package.json b/packages/ql3-cluster-postgres/package.json index 928fee6b..7ee645f1 100644 --- a/packages/ql3-cluster-postgres/package.json +++ b/packages/ql3-cluster-postgres/package.json @@ -150,6 +150,11 @@ "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.js", "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.js" }, + "./plugin-package-secret-binding-transition-approval-plan": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.js" + }, "./plugin-package-automation-publication": { "types": "./dist/plugin-package/publication/pluginPackageAutomationPublicationRepository.d.ts", "require": "./dist/plugin-package/publication/pluginPackageAutomationPublicationRepository.js", diff --git a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts index 47c7e417..3d378196 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts @@ -50,6 +50,7 @@ export { type ApplyPostgresPluginPackageSecretBindingTransitionResult, } from '../plugin-package/secret-binding/pluginPackageSecretBindingTransitionRepository'; export { PostgresPluginPackageSecretBindingApprovalPlanReader } from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; +export { PostgresPluginPackageSecretBindingTransitionApprovalPlanReader } from '../plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository'; export { PostgresPluginPackageAutomationPublicationRepository } from '../plugin-package/publication/pluginPackageAutomationPublicationRepository'; export { CLUSTER_PLUGIN_PACKAGE_QUARANTINE_TARGET_LIMIT, diff --git a/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts b/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts index cb83ace5..e8cfcca6 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts @@ -62,6 +62,11 @@ export { PostgresPluginPackageSecretBindingApprovalPlanReader, PostgresPluginPackageSecretBindingApprovalPlanRepository, } from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; +export { + PostgresPluginPackageSecretBindingTransitionApprovalPlanReader, + PostgresPluginPackageSecretBindingTransitionApprovalPlanRepository, + type PostgresPluginPackageSecretBindingTransitionPlanningSnapshot, +} from '../plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository'; export { PostgresPluginPackageInstallInventoryReader } from '../plugin-package/installation/pluginPackageInstallRepository'; export { PostgresPluginPackagePublisherRevocationProposalRepository } from '../plugin-package/publisher/pluginPackagePublisherRevocationProposalRepository'; export { PostgresPluginPackagePublisherTrustTransitionProposalRepository } from '../plugin-package/publisher/pluginPackagePublisherTrustTransitionProposalRepository'; diff --git a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts index d72569eb..54e09a5d 100644 --- a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts +++ b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts @@ -323,5 +323,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest = checksum: '20c4d6e640b7fb05fc2f44dd9bec6d2d081697b83acf4e7dd5deb9a07afe54df', }), + Object.freeze({ + id: 'pg-0064-plugin-package-secret-binding-transition-approval-plans', + checksum: + '1951b77a0265f8826169e4724424b2fbbd30061b27e27d3ba95de03430c1bac9', + }), ]), }); diff --git a/packages/ql3-cluster-postgres/src/migrations/index.ts b/packages/ql3-cluster-postgres/src/migrations/index.ts index e4444ad4..43168932 100644 --- a/packages/ql3-cluster-postgres/src/migrations/index.ts +++ b/packages/ql3-cluster-postgres/src/migrations/index.ts @@ -66,6 +66,7 @@ import { pg0060PluginPackageSecretMaterializationGuardMigration } from './pg-006 import { pg0061PluginPackageSecretBindingApprovalPlansMigration } from './pg-0061-plugin-package-secret-binding-approval-plans'; import { pg0062PluginPackageSecretBindingTargetGuardMigration } from './pg-0062-plugin-package-secret-binding-target-guard'; import { pg0063PluginPackageSecretBindingTransitionReceiptsMigration } from './pg-0063-plugin-package-secret-binding-transition-receipts'; +import { pg0064PluginPackageSecretBindingTransitionApprovalPlansMigration } from './pg-0064-plugin-package-secret-binding-transition-approval-plans'; export const postgresqlMainMigrationStream: MigrationStreamDefinition = Object.freeze({ @@ -137,5 +138,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition= 0 AND expires_at_ms > planned_at_ms AND + expires_at_ms - planned_at_ms <= 900000 + ), + CONSTRAINT ql3_pp_secret_transition_plan_json_check CHECK ( + jsonb_typeof(plan_json) = 'object' AND + octet_length(plan_json::text) BETWEEN 2 AND 229376 AND + plan_json @> jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-transition-approval-plan@v1', + 'actionRef', action_ref, + 'approvalPlanDigest', approval_plan_digest, + 'requestedBy', jsonb_build_object( + 'type', requested_by_type, + 'id', requested_by_id + ), + 'plannedAtMs', planned_at_ms, + 'expiresAtMs', expires_at_ms, + 'transitionPlan', jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-transition-plan@v1', + 'transitionDigest', transition_digest, + 'previousActiveLockDigest', previous_active_lock_digest, + 'nextTarget', jsonb_build_object( + 'generationDigest', generation_digest, + 'projectId', project_id, + 'packageName', package_name, + 'installationId', installation_id, + 'lockDigest', lock_digest, + 'generation', generation, + 'manifestDigest', manifest_digest + ) + ) + ) AND + jsonb_typeof(plan_json #> '{transitionPlan,changes}') = 'array' AND + jsonb_array_length(plan_json #> '{transitionPlan,changes}') BETWEEN 1 AND 64 + ) +) + `.trim(), + `CREATE UNIQUE INDEX ql3_pp_secret_transition_plan_digest_uidx ON "ql3"."plugin_package_secret_binding_transition_approval_plans" (approval_plan_digest)`, + `CREATE UNIQUE INDEX ql3_pp_secret_transition_plan_target_uidx ON "ql3"."plugin_package_secret_binding_transition_approval_plans" (generation_digest)`, + `CREATE INDEX ql3_pp_secret_transition_plan_expiry_idx ON "ql3"."plugin_package_secret_binding_transition_approval_plans" (expires_at_ms, action_ref)`, + ` +CREATE FUNCTION "ql3"."plugin_package_secret_binding_transition_snapshot"( + p_project_id varchar, + p_package_name varchar +) +RETURNS TABLE ( + next_record_json jsonb, + next_lock_json jsonb, + next_proposal_json jsonb, + previous_record_json jsonb, + previous_lock_json jsonb, + previous_proposal_json jsonb, + previous_binding_json jsonb, + previous_attempt_generation integer, + observed_at_ms bigint +) +LANGUAGE plpgsql +VOLATILE +SECURITY DEFINER +SET search_path = pg_catalog, ql3 +AS $ql3$ +BEGIN + IF NOT pg_has_role(session_user, 'ql3_package_manager', 'member') THEN + RAISE EXCEPTION 'Package manager authority is required' + USING ERRCODE = 'insufficient_privilege'; + END IF; + RETURN QUERY + SELECT current.record_json, + current.lock_json, + current_proposal.proposal_json, + previous.record_json, + previous.lock_json, + previous_proposal.proposal_json, + previous_binding.binding_json, + ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" AS history + WHERE history.project_id = current.project_id + AND history.package_name = current.package_name + AND history.target_generation < current.target_generation + ), + floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS current + ON current.installation_id = head.installation_id + AND current.project_id = head.project_id + AND current.package_name = head.package_name + JOIN "ql3"."plugin_package_admission_receipts" AS current_admission + ON current_admission.installation_id = current.installation_id + JOIN "ql3"."plugin_package_install_proposals" AS current_proposal + ON current_proposal.action_ref = current_admission.action_ref + JOIN "ql3"."plugin_package_installs" 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 "ql3"."plugin_package_admission_receipts" AS previous_admission + ON previous_admission.installation_id = previous.installation_id + JOIN "ql3"."plugin_package_install_proposals" AS previous_proposal + ON previous_proposal.action_ref = previous_admission.action_ref + LEFT JOIN "ql3"."plugin_package_secret_bindings" AS previous_binding + ON previous_binding.installation_id = previous.installation_id + AND previous_binding.project_id = previous.project_id + AND previous_binding.package_name = previous.package_name + AND previous_binding.lock_digest = previous.lock_digest + AND previous_binding.generation = previous.target_generation + WHERE head.project_id = p_project_id + AND head.package_name = p_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 "ql3"."plugin_package_installs" 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 + AND NOT EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_secret_binding_transition_receipts" receipt + WHERE receipt.project_id = current.project_id + AND receipt.package_name = current.package_name + AND receipt.generation = current.target_generation + ) + FOR SHARE OF head, current, previous; +END +$ql3$ + `.trim(), + ` +CREATE FUNCTION "ql3"."create_plugin_package_secret_transition_plan"( + p_plan_json jsonb +) +RETURNS varchar +LANGUAGE plpgsql +VOLATILE +SECURITY DEFINER +SET search_path = pg_catalog, ql3 +AS $ql3$ +DECLARE + existing_plan jsonb; + inserted_action_ref varchar(255); +BEGIN + IF NOT pg_has_role(session_user, 'ql3_package_manager', 'member') THEN + RAISE EXCEPTION 'Package manager authority is required' + USING ERRCODE = 'insufficient_privilege'; + END IF; + PERFORM pg_advisory_xact_lock( + hashtextextended(p_plan_json ->> 'actionRef', 70513064) + ); + SELECT plan_json INTO existing_plan + FROM "ql3"."plugin_package_secret_binding_transition_approval_plans" + WHERE action_ref = p_plan_json ->> 'actionRef' + FOR SHARE; + IF existing_plan IS NOT NULL THEN + IF existing_plan = p_plan_json THEN RETURN 'existing'; END IF; + RAISE EXCEPTION 'Secret transition actionRef is already bound' + USING ERRCODE = 'unique_violation'; + END IF; + + INSERT INTO "ql3"."plugin_package_secret_binding_transition_approval_plans" ( + action_ref, approval_plan_digest, transition_digest, generation_digest, + project_id, package_name, installation_id, lock_digest, generation, + manifest_digest, previous_active_lock_digest, requested_by_type, + requested_by_id, planned_at_ms, expires_at_ms, plan_json + ) + SELECT p_plan_json ->> 'actionRef', + p_plan_json ->> 'approvalPlanDigest', + p_plan_json #>> '{transitionPlan,transitionDigest}', + p_plan_json #>> '{transitionPlan,nextTarget,generationDigest}', + p_plan_json #>> '{transitionPlan,nextTarget,projectId}', + p_plan_json #>> '{transitionPlan,nextTarget,packageName}', + p_plan_json #>> '{transitionPlan,nextTarget,installationId}', + p_plan_json #>> '{transitionPlan,nextTarget,lockDigest}', + (p_plan_json #>> '{transitionPlan,nextTarget,generation}')::integer, + p_plan_json #>> '{transitionPlan,nextTarget,manifestDigest}', + p_plan_json #>> '{transitionPlan,previousActiveLockDigest}', + p_plan_json #>> '{requestedBy,type}', + p_plan_json #>> '{requestedBy,id}', + (p_plan_json ->> 'plannedAtMs')::bigint, + (p_plan_json ->> 'expiresAtMs')::bigint, + p_plan_json + FROM "ql3"."plugin_package_install_heads" head + JOIN "ql3"."plugin_package_installs" install + ON install.installation_id = head.installation_id + AND install.project_id = head.project_id + AND install.package_name = head.package_name + JOIN "ql3"."plugin_package_installs" previous + ON previous.project_id = install.project_id + AND previous.package_name = install.package_name + AND previous.lock_digest = install.previous_active_lock_digest + LEFT JOIN "ql3"."plugin_package_secret_bindings" previous_binding + ON previous_binding.installation_id = previous.installation_id + AND previous_binding.project_id = previous.project_id + AND previous_binding.package_name = previous.package_name + AND previous_binding.lock_digest = previous.lock_digest + AND previous_binding.generation = previous.target_generation + WHERE head.project_id = p_plan_json #>> '{transitionPlan,nextTarget,projectId}' + AND head.package_name = p_plan_json #>> '{transitionPlan,nextTarget,packageName}' + AND install.installation_id = p_plan_json #>> '{transitionPlan,nextTarget,installationId}' + AND install.lock_digest = p_plan_json #>> '{transitionPlan,nextTarget,lockDigest}' + AND install.target_generation = (p_plan_json #>> '{transitionPlan,nextTarget,generation}')::integer + AND install.lock_json ->> 'manifestDigest' = p_plan_json #>> '{transitionPlan,nextTarget,manifestDigest}' + AND install.state = 'staged' + AND install.previous_active_lock_digest = p_plan_json #>> '{transitionPlan,previousActiveLockDigest}' + AND install.active_lock_digest = install.previous_active_lock_digest + AND previous.installation_id = p_plan_json #>> '{transitionPlan,previousTarget,installationId}' + AND previous.lock_digest = p_plan_json #>> '{transitionPlan,previousTarget,lockDigest}' + AND previous.target_generation = (p_plan_json #>> '{transitionPlan,previousTarget,generation}')::integer + AND previous.lock_json ->> 'manifestDigest' = p_plan_json #>> '{transitionPlan,previousTarget,manifestDigest}' + AND previous.state = 'active' + AND previous.active_lock_digest = previous.lock_digest + AND ( + SELECT MAX(attempt.target_generation) + FROM "ql3"."plugin_package_installs" attempt + WHERE attempt.project_id = install.project_id + AND attempt.package_name = install.package_name + AND attempt.target_generation < install.target_generation + ) = (p_plan_json #>> '{transitionPlan,previousAttemptGeneration}')::integer + AND ( + (previous_binding.binding_json IS NULL AND + p_plan_json #> '{transitionPlan,previousBinding}' = 'null'::jsonb) OR + previous_binding.binding_json = p_plan_json #> '{transitionPlan,previousBinding}' + ) + AND install.target_generation = ( + SELECT MAX(history.target_generation) + FROM "ql3"."plugin_package_installs" history + WHERE history.project_id = install.project_id + AND history.package_name = install.package_name + ) + AND NOT EXISTS ( + SELECT 1 FROM "ql3"."plugin_package_secret_binding_transition_receipts" receipt + WHERE receipt.generation_digest = p_plan_json #>> '{transitionPlan,nextTarget,generationDigest}' + ) + RETURNING action_ref INTO inserted_action_ref; + IF inserted_action_ref IS NULL THEN + RAISE EXCEPTION 'Secret transition target is not current staged generation' + USING ERRCODE = 'check_violation'; + END IF; + RETURN 'created'; +END +$ql3$ + `.trim(), + `REVOKE ALL ON "ql3"."plugin_package_secret_binding_transition_approval_plans" FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `GRANT SELECT ON "ql3"."plugin_package_secret_binding_transition_approval_plans" TO ql3_package_manager, ql3_package_executor`, + `REVOKE ALL ON FUNCTION "ql3"."plugin_package_secret_binding_transition_snapshot"(varchar, varchar) FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `GRANT EXECUTE ON FUNCTION "ql3"."plugin_package_secret_binding_transition_snapshot"(varchar, varchar) TO ql3_package_manager`, + `REVOKE ALL ON FUNCTION "ql3"."create_plugin_package_secret_transition_plan"(jsonb) FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `GRANT EXECUTE ON FUNCTION "ql3"."create_plugin_package_secret_transition_plan"(jsonb) TO ql3_package_manager`, + `DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 63, migration_id = 'pg-0064-plugin-package-secret-binding-transition-approval-plans', capabilities = '${CAPABILITIES_V63}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 62 AND migration_id = 'pg-0063-plugin-package-secret-binding-transition-receipts' AND capabilities = '${CAPABILITIES_V62}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 62' USING ERRCODE = 'check_violation'; END IF; END $ql3$`, + ], + }); diff --git a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.ts new file mode 100644 index 00000000..9b15fda6 --- /dev/null +++ b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalPlanRepository.ts @@ -0,0 +1,367 @@ +import type { PostgresPool } from '@qinglong/runtime-core'; +import { + approvalRequestDigest, + normalizeApprovalRequestRecord, + type ApprovalRequestRecord, +} from '@qinglong/runtime-core/approved-action'; +import { + normalizePluginPackageInstallRecord, + normalizePluginPackageLock, + type PluginPackageInstallRecord, + type PluginPackageLock, +} from '@qinglong/runtime-core/plugin-package-install'; +import { + normalizePluginPackageInstallProposal, + type PluginPackageInstallProposal, +} from '@qinglong/runtime-core/plugin-package-proposal'; +import { + normalizePluginPackageSecretBinding, + type PluginPackageSecretBinding, +} from '@qinglong/runtime-core/plugin-package-secret-binding'; +import { + InvalidPluginPackageSecretBindingTransitionApprovalPlanError, + PluginPackageSecretBindingTransitionApprovalPlanConflictError, + PluginPackageSecretBindingTransitionApprovalPlanUnavailableError, + normalizePluginPackageSecretBindingTransitionApprovalPlan, + type CreatePluginPackageSecretBindingTransitionApprovalPlanResult, + type PluginPackageSecretBindingTransitionApprovalPlan, + type PluginPackageSecretBindingTransitionApprovalPlanRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; + +import { + postgresRequiredInteger, + postgresRequiredJsonObject, + postgresRequiredString, + postgresSqlState, +} from '../../repository/definitionRepositorySupport'; + +type Row = Record; + +export interface PostgresPluginPackageSecretBindingTransitionPlanningSnapshot { + readonly next: Readonly<{ + record: Readonly; + lock: Readonly; + proposal: Readonly; + }>; + readonly previous: Readonly<{ + record: Readonly; + lock: Readonly; + proposal: Readonly; + binding: Readonly | null; + }>; + readonly previousAttemptGeneration: number; + readonly observedAtMs: number; +} + +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; +const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const PACKAGE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +function unavailable( + cause?: unknown, +): PluginPackageSecretBindingTransitionApprovalPlanUnavailableError { + return new PluginPackageSecretBindingTransitionApprovalPlanUnavailableError({ + cause: cause instanceof Error ? cause : undefined, + }); +} + +function mapStorageError(error: unknown): Error { + if ( + error instanceof + InvalidPluginPackageSecretBindingTransitionApprovalPlanError || + error instanceof + PluginPackageSecretBindingTransitionApprovalPlanConflictError || + error instanceof + PluginPackageSecretBindingTransitionApprovalPlanUnavailableError + ) { + return error; + } + const state = postgresSqlState(error); + if (state === '23503' || state === '23505' || state === '23514') { + return new PluginPackageSecretBindingTransitionApprovalPlanConflictError( + 'plan identity or staged generation is already bound', + ); + } + return unavailable(error); +} + +function validateActionRef(value: string): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + throw new InvalidPluginPackageSecretBindingTransitionApprovalPlanError( + 'actionRef is invalid', + ); + } + return value; +} + +function normalizeRow( + row: Row, +): Readonly { + try { + return normalizePluginPackageSecretBindingTransitionApprovalPlan( + postgresRequiredJsonObject( + row.planJson, + unavailable, + ) as unknown as PluginPackageSecretBindingTransitionApprovalPlan, + ); + } catch (error) { + throw unavailable(error); + } +} + +function normalizeApprovalRow(row: Row): Readonly { + try { + const request = normalizeApprovalRequestRecord( + postgresRequiredJsonObject( + row.requestJson, + unavailable, + ) as unknown as ApprovalRequestRecord, + ); + if ( + approvalRequestDigest(request) !== + postgresRequiredString(row.requestDigest, unavailable) + ) { + throw unavailable(); + } + return request; + } catch (error) { + throw unavailable(error); + } +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export class PostgresPluginPackageSecretBindingTransitionApprovalPlanReader { + constructor(protected readonly pool: Pick) { + if (!pool || typeof pool.query !== 'function') { + throw new TypeError( + 'PostgreSQL Secret binding transition approval reader is invalid', + ); + } + } + + async findByActionRef( + actionRefValue: string, + ): Promise< + Readonly | null + > { + const actionRef = validateActionRef(actionRefValue); + try { + const result = await this.pool.query( + `SELECT plan_json AS "planJson" + FROM "ql3"."plugin_package_secret_binding_transition_approval_plans" + WHERE action_ref = $1 + LIMIT 2`, + [actionRef], + ); + if (result.rows.length === 0) return null; + if (result.rows.length !== 1) throw unavailable(); + const plan = normalizeRow(result.rows[0]!); + if (plan.actionRef !== actionRef) throw unavailable(); + return plan; + } catch (error) { + throw mapStorageError(error); + } + } + + async loadPlanningSnapshot( + projectId: string, + packageName: string, + ): Promise< + Readonly | null + > { + if ( + typeof projectId !== 'string' || + !PROJECT_PATTERN.test(projectId) || + typeof packageName !== 'string' || + !PACKAGE_PATTERN.test(packageName) + ) { + throw new InvalidPluginPackageSecretBindingTransitionApprovalPlanError( + 'planning target is invalid', + ); + } + try { + const result = await this.pool.query( + `SELECT next_record_json AS "nextRecordJson", + next_lock_json AS "nextLockJson", + next_proposal_json AS "nextProposalJson", + previous_record_json AS "previousRecordJson", + previous_lock_json AS "previousLockJson", + previous_proposal_json AS "previousProposalJson", + previous_binding_json AS "previousBindingJson", + previous_attempt_generation AS "previousAttemptGeneration", + observed_at_ms AS "observedAtMs" + FROM "ql3"."plugin_package_secret_binding_transition_snapshot"($1, $2)`, + [projectId, packageName], + ); + if (result.rows.length === 0) return null; + if (result.rows.length !== 1) throw unavailable(); + const row = result.rows[0]!; + const next = Object.freeze({ + record: normalizePluginPackageInstallRecord( + postgresRequiredJsonObject( + row.nextRecordJson, + unavailable, + ) as unknown as PluginPackageInstallRecord, + ), + lock: normalizePluginPackageLock( + postgresRequiredJsonObject( + row.nextLockJson, + unavailable, + ) as unknown as PluginPackageLock, + ), + proposal: normalizePluginPackageInstallProposal( + postgresRequiredJsonObject( + row.nextProposalJson, + unavailable, + ) as unknown as PluginPackageInstallProposal, + ), + }); + const previous = Object.freeze({ + record: normalizePluginPackageInstallRecord( + postgresRequiredJsonObject( + row.previousRecordJson, + unavailable, + ) as unknown as PluginPackageInstallRecord, + ), + lock: normalizePluginPackageLock( + postgresRequiredJsonObject( + row.previousLockJson, + unavailable, + ) as unknown as PluginPackageLock, + ), + proposal: normalizePluginPackageInstallProposal( + postgresRequiredJsonObject( + row.previousProposalJson, + unavailable, + ) as unknown as PluginPackageInstallProposal, + ), + binding: + row.previousBindingJson === null + ? null + : normalizePluginPackageSecretBinding( + postgresRequiredJsonObject(row.previousBindingJson, unavailable), + ), + }); + const previousAttemptGeneration = postgresRequiredInteger( + row.previousAttemptGeneration, + unavailable, + ); + const observedAtMs = postgresRequiredInteger( + row.observedAtMs, + unavailable, + ); + const previousBindingTarget = previous.binding?.target; + if ( + next.record.projectId !== projectId || + next.record.packageName !== packageName || + next.record.state !== 'staged' || + next.record.targetGeneration !== previousAttemptGeneration + 1 || + 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.source.contentDigest !== + next.lock.source.contentDigest || + previous.record.state !== 'active' || + 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 || + (previousBindingTarget !== undefined && + (previousBindingTarget.projectId !== previous.record.projectId || + previousBindingTarget.packageName !== previous.record.packageName || + previousBindingTarget.installationId !== + previous.record.installationId || + previousBindingTarget.lockDigest !== previous.record.lockDigest || + previousBindingTarget.generation !== + previous.record.targetGeneration || + previousBindingTarget.manifestDigest !== + previous.lock.manifestDigest)) + ) { + throw unavailable(); + } + return Object.freeze({ + next, + previous, + previousAttemptGeneration, + observedAtMs, + }); + } catch (error) { + throw mapStorageError(error); + } + } + + async listApprovedRequests( + limit: number, + ): Promise[]> { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) { + throw new TypeError('Secret transition approval page limit is invalid'); + } + try { + const result = await this.pool.query( + `SELECT request.request_json AS "requestJson", + request.request_digest AS "requestDigest" + FROM "ql3"."approval_requests" AS request + JOIN "ql3"."plugin_package_secret_binding_transition_approval_plans" AS plan + ON plan.action_ref = request.action_ref + AND plan.approval_plan_digest = request.action_digest + AND plan.transition_digest = request.preview_digest + WHERE request.state = 'approved' + AND request.action_type = 'plugin_package.secret_binding.transition' + ORDER BY request.updated_at_ms, request.request_id + LIMIT $1`, + [limit], + ); + if (result.rows.length > limit) throw unavailable(); + return Object.freeze(result.rows.map(normalizeApprovalRow)); + } catch (error) { + throw mapStorageError(error); + } + } +} + +export class PostgresPluginPackageSecretBindingTransitionApprovalPlanRepository + extends PostgresPluginPackageSecretBindingTransitionApprovalPlanReader + implements PluginPackageSecretBindingTransitionApprovalPlanRepository +{ + async create( + value: Readonly, + ): Promise< + Readonly + > { + const plan = + normalizePluginPackageSecretBindingTransitionApprovalPlan(value); + try { + const result = await this.pool.query( + `SELECT "ql3"."create_plugin_package_secret_transition_plan"( + $1::jsonb + ) AS status`, + [JSON.stringify(plan)], + ); + if (result.rows.length !== 1) throw unavailable(); + const status = postgresRequiredString( + result.rows[0]?.status, + unavailable, + ); + if (status !== 'created' && status !== 'existing') throw unavailable(); + const stored = await this.findByActionRef(plan.actionRef); + if (!stored || !same(stored, plan)) { + throw new PluginPackageSecretBindingTransitionApprovalPlanConflictError( + 'actionRef is already bound to another transition plan', + ); + } + return Object.freeze({ status, plan: stored }); + } catch (error) { + throw mapStorageError(error); + } + } +} diff --git a/packages/ql3-cluster-postgres/src/schema/schema.ts b/packages/ql3-cluster-postgres/src/schema/schema.ts index ecca0d96..312285b9 100644 --- a/packages/ql3-cluster-postgres/src/schema/schema.ts +++ b/packages/ql3-cluster-postgres/src/schema/schema.ts @@ -515,6 +515,73 @@ export const pluginPackageSecretBindingTransitionReceipts = ql3Schema.table( ], ); +export const pluginPackageSecretBindingTransitionApprovalPlans = + ql3Schema.table( + 'plugin_package_secret_binding_transition_approval_plans', + { + actionRef: varchar('action_ref', { length: 255 }).primaryKey(), + approvalPlanDigest: char('approval_plan_digest', { length: 64 }).notNull(), + transitionDigest: char('transition_digest', { length: 64 }).notNull(), + generationDigest: char('generation_digest', { length: 64 }).notNull(), + projectId: varchar('project_id', { length: 128 }).notNull(), + packageName: varchar('package_name', { length: 63 }).notNull(), + installationId: varchar('installation_id', { length: 128 }).notNull(), + lockDigest: char('lock_digest', { length: 64 }).notNull(), + generation: integer('generation').notNull(), + manifestDigest: char('manifest_digest', { length: 64 }).notNull(), + previousActiveLockDigest: char('previous_active_lock_digest', { + length: 64, + }).notNull(), + requestedByType: varchar('requested_by_type', { length: 16 }).notNull(), + requestedById: varchar('requested_by_id', { length: 255 }).notNull(), + plannedAtMs: bigint('planned_at_ms', { mode: 'number' }).notNull(), + expiresAtMs: bigint('expires_at_ms', { mode: 'number' }).notNull(), + planJson: jsonb('plan_json').$type>().notNull(), + }, + (table) => [ + foreignKey({ + name: 'ql3_pp_secret_transition_plan_project_fk', + columns: [table.projectId], + foreignColumns: [projects.id], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + name: 'ql3_pp_secret_transition_plan_install_fk', + columns: [table.installationId], + foreignColumns: [pluginPackageInstalls.installationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_pp_secret_transition_plan_identity_check', + sql`${table.actionRef} ~ '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$' and ${table.projectId} ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' and ${table.packageName} ~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' and ${table.installationId} ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' and ${table.generation} between 2 and 2147483647 and ${table.requestedByType} = 'user' and octet_length(${table.requestedById}) between 1 and 255 and ${table.requestedById} !~ '[[:cntrl:]]'`, + ), + check( + 'ql3_pp_secret_transition_plan_digest_check', + sql`${table.approvalPlanDigest} ~ '^[0-9a-f]{64}$' and ${table.transitionDigest} ~ '^[0-9a-f]{64}$' and ${table.generationDigest} ~ '^[0-9a-f]{64}$' and ${table.lockDigest} ~ '^[0-9a-f]{64}$' and ${table.manifestDigest} ~ '^[0-9a-f]{64}$' and ${table.previousActiveLockDigest} ~ '^[0-9a-f]{64}$'`, + ), + check( + 'ql3_pp_secret_transition_plan_time_check', + sql`${table.plannedAtMs} >= 0 and ${table.expiresAtMs} > ${table.plannedAtMs} and ${table.expiresAtMs} - ${table.plannedAtMs} <= 900000`, + ), + check( + 'ql3_pp_secret_transition_plan_json_check', + sql`jsonb_typeof(${table.planJson}) = 'object' and octet_length(${table.planJson}::text) between 2 and 229376 and ${table.planJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-transition-approval-plan@v1', 'actionRef', ${table.actionRef}, 'approvalPlanDigest', ${table.approvalPlanDigest}, 'requestedBy', jsonb_build_object('type', ${table.requestedByType}, 'id', ${table.requestedById}), 'plannedAtMs', ${table.plannedAtMs}, 'expiresAtMs', ${table.expiresAtMs}, 'transitionPlan', jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-transition-plan@v1', 'transitionDigest', ${table.transitionDigest}, 'previousActiveLockDigest', ${table.previousActiveLockDigest}, 'nextTarget', jsonb_build_object('generationDigest', ${table.generationDigest}, 'projectId', ${table.projectId}, 'packageName', ${table.packageName}, 'installationId', ${table.installationId}, 'lockDigest', ${table.lockDigest}, 'generation', ${table.generation}, 'manifestDigest', ${table.manifestDigest}))) and jsonb_typeof(${table.planJson} #> '{transitionPlan,changes}') = 'array' and jsonb_array_length(${table.planJson} #> '{transitionPlan,changes}') between 1 and 64`, + ), + uniqueIndex('ql3_pp_secret_transition_plan_digest_uidx').on( + table.approvalPlanDigest, + ), + uniqueIndex('ql3_pp_secret_transition_plan_target_uidx').on( + table.generationDigest, + ), + index('ql3_pp_secret_transition_plan_expiry_idx').on( + table.expiresAtMs, + table.actionRef, + ), + ], + ); + export const projectToolDefinitionSnapshots = ql3Schema.table( 'project_tool_definition_snapshots', { @@ -6003,6 +6070,7 @@ export const ql3PostgresTables = [ pluginPackageMaterializedRevisions, pluginPackageSecretBindings, pluginPackageSecretBindingApprovalPlans, + pluginPackageSecretBindingTransitionApprovalPlans, pluginPackageSecretBindingTransitionReceipts, projectToolDefinitionSnapshots, projectToolDefinitionSnapshotSources, diff --git a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts index 97693fb4..2547033b 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts @@ -21,8 +21,8 @@ export interface PostgresSchemaContractTrigger { export interface PostgresSchemaContract { readonly schema: 'ql3'; readonly contractName: 'control-core'; - readonly contractVersion: 62; - readonly migrationId: 'pg-0063-plugin-package-secret-binding-transition-receipts'; + readonly contractVersion: 63; + readonly migrationId: 'pg-0064-plugin-package-secret-binding-transition-approval-plans'; readonly minimumServerMajor: 16; readonly maximumServerMajor: 18; readonly capabilities: Readonly<{ @@ -64,6 +64,7 @@ export interface PostgresSchemaContract { plugin_package_secret_binding: 1; plugin_package_secret_binding_approval_plan: 1; plugin_package_secret_binding_transition: 1; + plugin_package_secret_binding_transition_approval_plan: 1; plugin_package_secret_binding_transition_receipt: 1; plugin_package_secret_materialization: 1; plugin_package_proposal: 1; @@ -116,8 +117,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = Object.freeze({ schema: 'ql3', contractName: 'control-core', - contractVersion: 62, - migrationId: 'pg-0063-plugin-package-secret-binding-transition-receipts', + contractVersion: 63, + migrationId: 'pg-0064-plugin-package-secret-binding-transition-approval-plans', minimumServerMajor: 16, maximumServerMajor: 18, capabilities: Object.freeze({ @@ -152,6 +153,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = plugin_package_secret_binding: 1, plugin_package_secret_binding_approval_plan: 1, plugin_package_secret_binding_transition: 1, + plugin_package_secret_binding_transition_approval_plan: 1, plugin_package_secret_binding_transition_receipt: 1, plugin_package_secret_materialization: 1, plugin_package_proposal: 1, @@ -290,6 +292,24 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'expires_at_ms', 'plan_json', ]), + table('plugin_package_secret_binding_transition_approval_plans', [ + 'action_ref', + 'approval_plan_digest', + 'transition_digest', + 'generation_digest', + 'project_id', + 'package_name', + 'installation_id', + 'lock_digest', + 'generation', + 'manifest_digest', + 'previous_active_lock_digest', + 'requested_by_type', + 'requested_by_id', + 'planned_at_ms', + 'expires_at_ms', + 'plan_json', + ]), table('plugin_package_secret_binding_transition_receipts', [ 'generation_digest', 'transition_digest', @@ -1494,6 +1514,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_package_secret_transition_receipt_transition_uidx', 'ql3_package_secret_transition_receipt_digest_uidx', 'ql3_package_secret_transition_receipt_install_idx', + 'plugin_package_secret_binding_transition_approval_plans_pkey', + 'ql3_pp_secret_transition_plan_digest_uidx', + 'ql3_pp_secret_transition_plan_target_uidx', + 'ql3_pp_secret_transition_plan_expiry_idx', 'project_tool_definition_snapshots_pkey', 'ql3_project_tool_snapshot_withdrawal_key', 'ql3_project_tool_definition_snapshot_digest_uidx', @@ -1795,6 +1819,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_package_secret_transition_receipt_identity_check', 'ql3_package_secret_transition_receipt_digest_check', 'ql3_package_secret_transition_receipt_json_check', + 'ql3_pp_secret_transition_plan_identity_check', + 'ql3_pp_secret_transition_plan_digest_check', + 'ql3_pp_secret_transition_plan_time_check', + 'ql3_pp_secret_transition_plan_json_check', 'ql3_plugin_package_quarantine_identity_check', 'ql3_plugin_package_quarantine_state_check', 'ql3_plugin_package_quarantine_subject_check', @@ -2254,6 +2282,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_approval_plan_install_fk', 'ql3_plugin_package_secret_binding_transition_receipt_project_fk', 'ql3_plugin_package_secret_binding_transition_receipt_install_fk', + 'ql3_pp_secret_transition_plan_project_fk', + 'ql3_pp_secret_transition_plan_install_fk', 'ql3_project_tool_definition_snapshot_project_fk', 'ql3_project_tool_definition_snapshot_source_snapshot_fk', 'ql3_project_tool_definition_snapshot_source_install_fk', @@ -2404,6 +2434,23 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_run_retry_policies_run_fk', ]), functions: Object.freeze([ + Object.freeze({ + name: 'plugin_package_secret_binding_transition_snapshot', + identityArguments: + 'p_project_id character varying, p_package_name character varying', + owner: 'ql3_migration', + securityDefiner: true, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), + Object.freeze({ + name: 'create_plugin_package_secret_transition_plan', + identityArguments: 'p_plan_json jsonb', + owner: 'ql3_migration', + securityDefiner: true, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), Object.freeze({ name: 'plugin_package_secret_binding_planning_snapshot', identityArguments: diff --git a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts index 4be0d5d7..5fc390c7 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts @@ -186,6 +186,12 @@ const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_transition_approval_plans: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), plugin_package_secret_binding_transition_receipts: Object.freeze({ select: false, insert: false, @@ -717,6 +723,12 @@ const REQUIRED_ADMIN_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_transition_approval_plans: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), plugin_package_secret_binding_transition_receipts: Object.freeze({ select: false, insert: false, @@ -1225,6 +1237,7 @@ const REQUIRED_PACKAGE_MANAGER_PRIVILEGES: RequiredPrivileges = Object.freeze( name === 'project_role_bindings' || name === 'plugin_package_lifecycle_plans' || name === 'plugin_package_secret_binding_approval_plans' || + name === 'plugin_package_secret_binding_transition_approval_plans' || name === 'plugin_package_automation_publications' || name === 'plugin_package_automation_publication_heads' || name === 'plugin_package_publisher_trust_transition_receipts' @@ -1268,6 +1281,7 @@ const REQUIRED_PACKAGE_EXECUTOR_PRIVILEGES: RequiredPrivileges = Object.freeze( name === 'project_role_bindings' || name === 'plugin_package_install_proposals' || name === 'plugin_package_secret_binding_approval_plans' || + name === 'plugin_package_secret_binding_transition_approval_plans' || name === 'plugin_package_publisher_revocation_proposals' || name === 'plugin_package_publisher_trust_transition_proposals' ? { ...NO_TABLE_PRIVILEGES, select: true } @@ -1538,6 +1552,7 @@ const REQUIRED_ADMIN_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = Object.freeze({ + create_plugin_package_secret_transition_plan: false, create_plugin_package_secret_binding_approval_plan: false, commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, @@ -1555,12 +1570,14 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = plugin_package_lifecycle_blocking_runs: false, plugin_package_run_start_allowed: true, plugin_package_secret_binding_planning_snapshot: false, + plugin_package_secret_binding_transition_snapshot: false, plugin_package_tool_start_allowed: true, register_plugin_package_automation_disposition_event: false, }); const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = Object.freeze({ + create_plugin_package_secret_transition_plan: true, create_plugin_package_secret_binding_approval_plan: true, commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, @@ -1578,12 +1595,14 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = plugin_package_lifecycle_blocking_runs: false, plugin_package_run_start_allowed: false, plugin_package_secret_binding_planning_snapshot: true, + plugin_package_secret_binding_transition_snapshot: true, plugin_package_tool_start_allowed: false, register_plugin_package_automation_disposition_event: false, }); const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = Object.freeze({ + create_plugin_package_secret_transition_plan: false, create_plugin_package_secret_binding_approval_plan: false, commit_plugin_package_lifecycle: true, commit_plugin_package_quarantine: true, @@ -1601,6 +1620,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges plugin_package_lifecycle_blocking_runs: true, plugin_package_run_start_allowed: false, plugin_package_secret_binding_planning_snapshot: false, + plugin_package_secret_binding_transition_snapshot: false, plugin_package_tool_start_allowed: false, register_plugin_package_automation_disposition_event: false, }); diff --git a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs index abcc3192..53030092 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs @@ -114,6 +114,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async () 'pg-0061-plugin-package-secret-binding-approval-plans', 'pg-0062-plugin-package-secret-binding-target-guard', 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ], ); for (const migration of postgresqlMainMigrationStream.migrations) { @@ -567,6 +568,11 @@ test('freezes every published PostgreSQL migration checksum', () => { checksum: '20c4d6e640b7fb05fc2f44dd9bec6d2d081697b83acf4e7dd5deb9a07afe54df', }, + { + id: 'pg-0064-plugin-package-secret-binding-transition-approval-plans', + checksum: + '1951b77a0265f8826169e4724424b2fbbd30061b27e27d3ba95de03430c1bac9', + }, ]; assert.deepEqual( postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ @@ -2172,3 +2178,52 @@ test('advances capability v62 with immutable Secret binding transition receipts' /migration_id = 'pg-0062-plugin-package-secret-binding-target-guard'/, ); }); + +test('advances capability v63 with manager-only immutable Secret transition plans', async () => { + const migration = migrationById( + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', + ); + const statements = []; + await migration.up({ + async query(statement) { + statements.push(statement); + return { rows: [] }; + }, + }); + const sql = statements.join('\n'); + assert.match( + sql, + /CREATE TABLE "ql3"\."plugin_package_secret_binding_transition_approval_plans"/, + ); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."plugin_package_secret_binding_transition_snapshot"\(/, + ); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."create_plugin_package_secret_transition_plan"\(/, + ); + assert.match(sql, /SECURITY DEFINER/); + assert.match(sql, /previous\.state = 'active'/); + assert.match(sql, /install\.state = 'staged'/); + assert.match(sql, /previous_binding\.binding_json = p_plan_json/); + assert.match( + sql, + /GRANT SELECT ON "ql3"\."plugin_package_secret_binding_transition_approval_plans" TO ql3_package_manager, ql3_package_executor/, + ); + assert.match( + sql, + /GRANT EXECUTE ON FUNCTION [^;]+ TO ql3_package_manager/, + ); + assert.doesNotMatch(sql, /GRANT EXECUTE ON FUNCTION [^;]+ TO ql3_package_executor/); + assert.match(sql, /contract_version = 63/); + assert.match( + sql, + /"plugin_package_secret_binding_transition_approval_plan":1/, + ); + assert.match(sql, /contract_version = 62/); + assert.match( + sql, + /migration_id = 'pg-0063-plugin-package-secret-binding-transition-receipts'/, + ); +}); diff --git a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs index b3f53cd4..cf047a54 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs @@ -76,6 +76,12 @@ function validPrivileges() { plugin_package_materialized_revisions: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_approval_plans: [ + false, + false, + false, + false, + ], plugin_package_secret_binding_transition_receipts: [ false, false, @@ -202,6 +208,12 @@ function validAdminPrivileges() { plugin_package_materialized_revisions: [false, false, false, false], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_approval_plans: [ + false, + false, + false, + false, + ], plugin_package_secret_binding_transition_receipts: [ false, false, @@ -302,6 +314,7 @@ function packagePrivileges(kind) { 'plugin_package_publisher_trust_transition_receipts', 'plugin_package_lifecycle_plans', 'plugin_package_secret_binding_approval_plans', + 'plugin_package_secret_binding_transition_approval_plans', 'plugin_package_automation_publications', 'plugin_package_automation_publication_heads', ...(manager @@ -686,9 +699,11 @@ function queryable(overrides = {}) { executeAllowed: overrides.functionMode === 'package-manager' ? [ + 'create_plugin_package_secret_transition_plan', 'create_plugin_package_secret_binding_approval_plan', 'lock_approval_policy_fence', 'plugin_package_secret_binding_planning_snapshot', + 'plugin_package_secret_binding_transition_snapshot', ].includes(functionName) : overrides.functionMode === 'manager' ? functionName === 'lock_approval_policy_fence' @@ -790,7 +805,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro serverMajor: 16, currentUser: 'ql3_runtime', contractName: 'control-core', - contractVersion: 62, + contractVersion: 63, migrationIds: [ 'pg-0001-schema-capability', 'pg-0002-run-core', @@ -855,6 +870,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro 'pg-0061-plugin-package-secret-binding-approval-plans', 'pg-0062-plugin-package-secret-binding-target-guard', 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ], }); }); @@ -885,10 +901,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async ( }), ); assert.equal(report.currentUser, 'ql3_admin'); - assert.equal(report.contractVersion, 62); + assert.equal(report.contractVersion, 63); assert.equal( report.migrationIds.at(-1), - 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ); }); @@ -901,10 +917,10 @@ test('accepts the isolated least-privilege automation manager role', async () => }), ); assert.equal(report.currentUser, 'ql3_automation_manager'); - assert.equal(report.contractVersion, 62); + assert.equal(report.contractVersion, 63); assert.equal( report.migrationIds.at(-1), - 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ); const widened = automationManagerPrivileges(); @@ -933,10 +949,10 @@ test('accepts the isolated least-privilege human Approval manager role', async ( }), ); assert.equal(report.currentUser, 'ql3_approval_manager'); - assert.equal(report.contractVersion, 62); + assert.equal(report.contractVersion, 63); assert.equal( report.migrationIds.at(-1), - 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ); const widened = approvalManagerPrivileges(); @@ -967,10 +983,10 @@ test('accepts the isolated least-privilege Run manager role', async () => { }), ); assert.equal(report.currentUser, 'ql3_run_manager'); - assert.equal(report.contractVersion, 62); + assert.equal(report.contractVersion, 63); assert.equal( report.migrationIds.at(-1), - 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ); const widened = runManagerPrivileges(); @@ -1102,10 +1118,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => { }), ); assert.equal(report.currentUser, 'ql3_worker_ingress'); - assert.equal(report.contractVersion, 62); + assert.equal(report.contractVersion, 63); assert.equal( report.migrationIds.at(-1), - 'pg-0063-plugin-package-secret-binding-transition-receipts', + 'pg-0064-plugin-package-secret-binding-transition-approval-plans', ); }); diff --git a/packages/ql3-runtime-core/package.json b/packages/ql3-runtime-core/package.json index 4a737900..b1340f81 100644 --- a/packages/ql3-runtime-core/package.json +++ b/packages/ql3-runtime-core/package.json @@ -95,6 +95,9 @@ "plugin-package-secret-binding-transition-receipt": [ "dist/plugin-package/secret-binding/transitionReceipt.d.ts" ], + "plugin-package-secret-binding-transition-approval-plan": [ + "dist/plugin-package/secret-binding/transitionApprovalPlan.d.ts" + ], "plugin-package-secret-binding-approval-plan": [ "dist/plugin-package/secret-binding/approvalPlan.d.ts" ], @@ -409,6 +412,11 @@ "require": "./dist/plugin-package/secret-binding/transitionReceipt.js", "default": "./dist/plugin-package/secret-binding/transitionReceipt.js" }, + "./plugin-package-secret-binding-transition-approval-plan": { + "types": "./dist/plugin-package/secret-binding/transitionApprovalPlan.d.ts", + "require": "./dist/plugin-package/secret-binding/transitionApprovalPlan.js", + "default": "./dist/plugin-package/secret-binding/transitionApprovalPlan.js" + }, "./plugin-package-secret-binding-approval-plan": { "types": "./dist/plugin-package/secret-binding/approvalPlan.d.ts", "require": "./dist/plugin-package/secret-binding/approvalPlan.js", diff --git a/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionApprovalPlan.ts b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionApprovalPlan.ts new file mode 100644 index 00000000..f3afa50b --- /dev/null +++ b/packages/ql3-runtime-core/src/plugin-package/secret-binding/transitionApprovalPlan.ts @@ -0,0 +1,316 @@ +import { createHash } from 'node:crypto'; + +import type { ApprovedActionBinding } from '../../approved-action/approvedAction'; +import type { SecuritySubject } from '../../security/security'; +import { + normalizePluginPackageSecretBindingTransitionPlan, + type PluginPackageSecretBindingTransitionPlan, +} from './transitionPlan'; + +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_SCHEMA = + 'qinglong/plugin-package-secret-binding-transition-approval-plan@v1' as const; +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE = + 'plugin_package.secret_binding.transition' as const; +export const PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PERMISSION = + 'secret.manage' as const; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_LIFETIME_MS = + 15 * 60 * 1000; +export const MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_JSON_BYTES = + 224 * 1024; + +export interface PluginPackageSecretBindingTransitionApprovalPlan { + readonly schema: typeof PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_SCHEMA; + readonly actionRef: string; + readonly transitionPlan: Readonly; + readonly requestedBy: Readonly; + readonly plannedAtMs: number; + readonly expiresAtMs: number; + readonly approvalPlanDigest: string; +} + +export interface CreatePluginPackageSecretBindingTransitionApprovalPlanInput { + readonly actionRef: string; + readonly transitionPlan: Readonly; + readonly requestedBy: SecuritySubject; + readonly plannedAtMs: number; + readonly expiresAtMs: number; +} + +export interface CreatePluginPackageSecretBindingTransitionApprovalPlanResult { + readonly status: 'created' | 'existing'; + readonly plan: Readonly; +} + +export interface PluginPackageSecretBindingTransitionApprovalPlanRepository { + create( + plan: Readonly, + ): Promise< + Readonly + >; + findByActionRef( + actionRef: string, + ): Promise | null>; +} + +export class InvalidPluginPackageSecretBindingTransitionApprovalPlanError extends TypeError { + readonly code = + 'PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_INVALID'; + + constructor(message: string) { + super( + `Plugin Package Secret binding transition approval plan is invalid: ${message}`, + ); + this.name = + 'InvalidPluginPackageSecretBindingTransitionApprovalPlanError'; + } +} + +export class PluginPackageSecretBindingTransitionApprovalPlanConflictError extends Error { + readonly code = + 'PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_CONFLICT'; + + constructor(message: string) { + super( + `Plugin Package Secret binding transition approval plan conflicts with durable state: ${message}`, + ); + this.name = + 'PluginPackageSecretBindingTransitionApprovalPlanConflictError'; + } +} + +export class PluginPackageSecretBindingTransitionApprovalPlanUnavailableError extends Error { + readonly code = + 'PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_UNAVAILABLE'; + + constructor(options?: ErrorOptions) { + super( + 'Plugin Package Secret binding transition approval plan is unavailable', + options, + ); + this.name = + 'PluginPackageSecretBindingTransitionApprovalPlanUnavailableError'; + } +} + +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; +const SUBJECT_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/; +const APPROVAL_PLAN_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-secret-binding-transition-approval-plan-digest@v1\0', + 'utf8', +); + +function invalid(message: string): never { + throw new InvalidPluginPackageSecretBindingTransitionApprovalPlanError( + message, + ); +} + +function record(value: unknown, label: string): Record { + 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; +} + +function exactKeys( + value: object, + expected: readonly string[], + label: string, +): void { + const keys = Reflect.ownKeys(value); + const canonical = [...expected].sort(); + if ( + keys.some((key) => typeof key !== 'string') || + keys.length !== canonical.length || + keys + .map(String) + .sort() + .some((key, index) => key !== canonical[index]) + ) { + invalid(`${label} shape is invalid`); + } +} + +function actionRef(value: unknown): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + return invalid('actionRef is invalid'); + } + return value; +} + +function timestamp(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + return invalid(`${label} is invalid`); + } + return value as number; +} + +function requestedBy(value: SecuritySubject): Readonly { + const candidate = record(value, 'requestedBy'); + exactKeys(candidate, ['id', 'type'], 'requestedBy'); + if ( + value.type !== 'user' || + typeof value.id !== 'string' || + value.id.length < 1 || + Buffer.byteLength(value.id, 'utf8') > 255 || + SUBJECT_CONTROL_PATTERN.test(value.id) + ) { + return invalid('requestedBy must be a User subject'); + } + return Object.freeze({ type: 'user', id: value.id }); +} + +function unsigned( + value: Omit< + PluginPackageSecretBindingTransitionApprovalPlan, + 'approvalPlanDigest' + >, +): Omit< + PluginPackageSecretBindingTransitionApprovalPlan, + 'approvalPlanDigest' +> { + if ( + value.schema !== + PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_SCHEMA + ) { + return invalid('schema is invalid'); + } + const transitionPlan = + normalizePluginPackageSecretBindingTransitionPlan(value.transitionPlan); + const plannedAtMs = timestamp(value.plannedAtMs, 'plannedAtMs'); + const expiresAtMs = timestamp(value.expiresAtMs, 'expiresAtMs'); + if ( + expiresAtMs <= plannedAtMs || + expiresAtMs - plannedAtMs > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_LIFETIME_MS || + (transitionPlan.nextBindingPlan !== null && + transitionPlan.nextBindingPlan.plannedAtMs !== plannedAtMs) + ) { + return invalid('lifetime is invalid'); + } + return Object.freeze({ + schema: + PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_SCHEMA, + actionRef: actionRef(value.actionRef), + transitionPlan, + requestedBy: requestedBy(value.requestedBy), + plannedAtMs, + expiresAtMs, + }); +} + +function approvalPlanDigest( + value: Omit< + PluginPackageSecretBindingTransitionApprovalPlan, + 'approvalPlanDigest' + >, +): string { + return createHash('sha256') + .update(APPROVAL_PLAN_DIGEST_DOMAIN) + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function bounded( + value: Readonly, +): Readonly { + if ( + Buffer.byteLength(JSON.stringify(value), 'utf8') > + MAX_PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_JSON_BYTES + ) { + return invalid('encoded plan exceeds the size limit'); + } + return value; +} + +export function createPluginPackageSecretBindingTransitionApprovalPlan( + input: CreatePluginPackageSecretBindingTransitionApprovalPlanInput, +): Readonly { + const candidate = record(input, 'approval plan input'); + exactKeys( + candidate, + [ + 'actionRef', + 'expiresAtMs', + 'plannedAtMs', + 'requestedBy', + 'transitionPlan', + ], + 'approval plan input', + ); + const normalized = unsigned({ + schema: + PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_APPROVAL_PLAN_SCHEMA, + actionRef: input.actionRef, + transitionPlan: input.transitionPlan, + requestedBy: input.requestedBy, + plannedAtMs: input.plannedAtMs, + expiresAtMs: input.expiresAtMs, + }); + return bounded( + Object.freeze({ + ...normalized, + approvalPlanDigest: approvalPlanDigest(normalized), + }), + ); +} + +export function normalizePluginPackageSecretBindingTransitionApprovalPlan( + value: PluginPackageSecretBindingTransitionApprovalPlan, +): Readonly { + const candidate = record(value, 'approval plan'); + exactKeys( + candidate, + [ + 'actionRef', + 'approvalPlanDigest', + 'expiresAtMs', + 'plannedAtMs', + 'requestedBy', + 'schema', + 'transitionPlan', + ], + 'approval plan', + ); + const normalized = unsigned(value); + const digest = approvalPlanDigest(normalized); + if ( + typeof value.approvalPlanDigest !== 'string' || + !DIGEST_PATTERN.test(value.approvalPlanDigest) || + value.approvalPlanDigest !== digest + ) { + return invalid('approvalPlanDigest does not match approval plan'); + } + return bounded(Object.freeze({ ...normalized, approvalPlanDigest: digest })); +} + +export function pluginPackageSecretBindingTransitionApprovedAction( + value: PluginPackageSecretBindingTransitionApprovalPlan, +): Readonly { + const plan = normalizePluginPackageSecretBindingTransitionApprovalPlan(value); + return Object.freeze({ + permission: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_PERMISSION, + actionType: PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE, + actionRef: plan.actionRef, + actionDigest: plan.approvalPlanDigest, + previewDigest: plan.transitionPlan.transitionDigest, + }); +} diff --git a/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionApprovalPlan.test.cjs b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionApprovalPlan.test.cjs new file mode 100644 index 00000000..e94a9b0b --- /dev/null +++ b/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionApprovalPlan.test.cjs @@ -0,0 +1,154 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +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 { + createPluginPackageSecretBindingTransitionApprovalPlan, + normalizePluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'); + +function manifest(version = '2.0.0') { + return { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version, + description: 'transition approval fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function transition() { + const previousManifest = manifest('1.0.0'); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-v1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: previousManifest.spec.contents, + }); + const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 1, + }), + }], + authority: { + kind: 'approved-action-execution', + evidenceDigest: 'c'.repeat(64), + }, + boundAtMs: 80, + }); + return createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration: createPluginPackageResourceGeneration({ + installationId: 'install-v2', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'd'.repeat(64), + generation: 2, + previousActiveLockDigest: 'a'.repeat(64), + contentDigest: 'e'.repeat(64), + contents: manifest().spec.contents, + }), + nextManifest: manifest(), + assignments: [{ + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }], + plannedAtMs: 100, + }); +} + +test('binds one transition to an immutable separation-of-duty action', () => { + const plan = createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: 'secret-transition:example-monitor-v2', + transitionPlan: transition(), + requestedBy: { type: 'user', id: 'cluster-owner' }, + plannedAtMs: 100, + expiresAtMs: 1_000, + }); + assert.deepEqual( + normalizePluginPackageSecretBindingTransitionApprovalPlan(plan), + plan, + ); + assert.deepEqual(pluginPackageSecretBindingTransitionApprovedAction(plan), { + permission: 'secret.manage', + actionType: 'plugin_package.secret_binding.transition', + actionRef: plan.actionRef, + actionDigest: plan.approvalPlanDigest, + previewDigest: plan.transitionPlan.transitionDigest, + }); +}); + +test('rejects digest, lifetime and requester drift', () => { + const plan = createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: 'secret-transition:example-monitor-v2', + transitionPlan: transition(), + requestedBy: { type: 'user', id: 'cluster-owner' }, + plannedAtMs: 100, + expiresAtMs: 1_000, + }); + assert.throws(() => + normalizePluginPackageSecretBindingTransitionApprovalPlan({ + ...plan, + approvalPlanDigest: '9'.repeat(64), + }), + ); + assert.throws(() => + createPluginPackageSecretBindingTransitionApprovalPlan({ + actionRef: plan.actionRef, + transitionPlan: plan.transitionPlan, + requestedBy: { type: 'system', id: 'cluster-control' }, + plannedAtMs: 100, + expiresAtMs: 1_000, + }), + ); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index be2f82a3..50e17a3d 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -1265,6 +1265,7 @@ function auditSourceImports(root, packagePath, findings) { '@qinglong/runtime-core/plugin-package-proposal', '@qinglong/runtime-core/plugin-package-secret-binding', '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan', + '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan', '@qinglong/runtime-core/security', ]); if ( @@ -2350,6 +2351,14 @@ function auditSourceImports(root, packagePath, findings) { (path.relative(packageDirectory, filePath) === 'src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalConsumer.ts' && specifier === '@qinglong/cluster-postgres/package-executor') || + (path.relative(packageDirectory, filePath) === + 'src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionManagement.ts' && + specifier === '@qinglong/cluster-postgres/package-manager') || + ([ + 'src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovalConsumer.ts', + 'src/plugin-package/secret-binding/pluginPackageSecretBindingTransitionApprovedAction.ts', + ].includes(path.relative(packageDirectory, filePath)) && + specifier === '@qinglong/cluster-postgres/package-executor') || ([ 'src/worker-credential/management-server/workerCredentialManagement.ts', 'src/worker-credential/management-server/workerCredentialManagementProcess.ts', diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 40382ffc..20e80d5c 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -74,7 +74,7 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( [ { directory: 'packages/ql3-cluster-postgres/src/migrations', - directSourceFiles: 64, + directSourceFiles: 65, reviewKind: 'ordered_ledger', }, { @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 158, + sourceFiles: 159, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 157, + nestedSourceFiles: 158, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 103, + sourceFiles: 106, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 102, + nestedSourceFiles: 105, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', }, @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 163, + sourceFiles: 165, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 162, + nestedSourceFiles: 164, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );