diff --git a/docs/ql3-package-boundaries.json b/docs/ql3-package-boundaries.json index 9e7caffd..b269e125 100644 --- a/docs/ql3-package-boundaries.json +++ b/docs/ql3-package-boundaries.json @@ -6,7 +6,7 @@ "reviewedDenseDirectories": [ { "kind": "ordered_ledger", - "maxDirectSourceFiles": 61, + "maxDirectSourceFiles": 62, "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-postgres/package.json b/packages/ql3-cluster-postgres/package.json index 81398dc7..928fee6b 100644 --- a/packages/ql3-cluster-postgres/package.json +++ b/packages/ql3-cluster-postgres/package.json @@ -15,37 +15,37 @@ "require": "./dist/index.js", "default": "./dist/index.js" }, - "./runtime": { + "./runtime": { "types": "./dist/entrypoints/runtime.d.ts", "require": "./dist/entrypoints/runtime.js", "default": "./dist/entrypoints/runtime.js" }, - "./admin": { + "./admin": { "types": "./dist/entrypoints/admin.d.ts", "require": "./dist/entrypoints/admin.js", "default": "./dist/entrypoints/admin.js" }, - "./package-manager": { + "./package-manager": { "types": "./dist/entrypoints/packageManager.d.ts", "require": "./dist/entrypoints/packageManager.js", "default": "./dist/entrypoints/packageManager.js" }, - "./package-executor": { + "./package-executor": { "types": "./dist/entrypoints/packageExecutor.d.ts", "require": "./dist/entrypoints/packageExecutor.js", "default": "./dist/entrypoints/packageExecutor.js" }, - "./ai-maintenance": { + "./ai-maintenance": { "types": "./dist/entrypoints/aiMaintenance.d.ts", "require": "./dist/entrypoints/aiMaintenance.js", "default": "./dist/entrypoints/aiMaintenance.js" }, - "./ai-credential-manager": { + "./ai-credential-manager": { "types": "./dist/entrypoints/aiCredentialManager.d.ts", "require": "./dist/entrypoints/aiCredentialManager.js", "default": "./dist/entrypoints/aiCredentialManager.js" }, - "./ai-credential-tester": { + "./ai-credential-tester": { "types": "./dist/entrypoints/aiCredentialTester.d.ts", "require": "./dist/entrypoints/aiCredentialTester.js", "default": "./dist/entrypoints/aiCredentialTester.js" @@ -85,7 +85,7 @@ "require": "./dist/approval-management/index.js", "default": "./dist/approval-management/index.js" }, - "./worker-ingress": { + "./worker-ingress": { "types": "./dist/entrypoints/workerIngress.d.ts", "require": "./dist/entrypoints/workerIngress.js", "default": "./dist/entrypoints/workerIngress.js" @@ -145,6 +145,11 @@ "require": "./dist/plugin-package/installation/pluginPackageSecretBindingRepository.js", "default": "./dist/plugin-package/installation/pluginPackageSecretBindingRepository.js" }, + "./plugin-package-secret-binding-approval-plan": { + "types": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.d.ts", + "require": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.js", + "default": "./dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.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 df41aebd..5c4cb0fe 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageExecutor.ts @@ -41,6 +41,7 @@ export { export { PostgresPluginPackageMaterializedRevisionRepository } from '../plugin-package/installation/pluginPackageMaterializedRevisionRepository'; export { PostgresPluginPackageSecretBindingRepository } from '../plugin-package/installation/pluginPackageSecretBindingRepository'; +export { PostgresPluginPackageSecretBindingApprovalPlanReader } from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; 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 20e1eb13..216a0fbe 100644 --- a/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts +++ b/packages/ql3-cluster-postgres/src/entrypoints/packageManager.ts @@ -56,6 +56,10 @@ export { export { PostgresPluginPackagePublisherTrustAuthorityRepository } from '../plugin-package/publisher/pluginPackagePublisherTrustAuthorityRepository'; export { PostgresPluginPackageLifecyclePlanReader } from '../plugin-package/lifecycle/pluginPackageLifecyclePlanRepository'; +export { + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingApprovalPlanRepository, +} from '../plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'; 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 3703b5f2..e878d789 100644 --- a/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts +++ b/packages/ql3-cluster-postgres/src/migration/migrationManifest.ts @@ -308,5 +308,10 @@ export const postgresqlMainMigrationManifest: MigrationStreamManifest = checksum: '28284ca860b39ff9de5b2aa1a2a60ef2c463fd6a72798d237040174272b64b1e', }), + Object.freeze({ + id: 'pg-0061-plugin-package-secret-binding-approval-plans', + checksum: + 'c995b7846ae8a57d3abb4b5523961e81aeba890e7405a030bcb505dfc6be3d25', + }), ]), }); diff --git a/packages/ql3-cluster-postgres/src/migrations/index.ts b/packages/ql3-cluster-postgres/src/migrations/index.ts index f364e3b1..89839d3a 100644 --- a/packages/ql3-cluster-postgres/src/migrations/index.ts +++ b/packages/ql3-cluster-postgres/src/migrations/index.ts @@ -63,6 +63,7 @@ import { pg0057RunManagementStopBoundaryMigration } from '../run-management/pg-0 import { pg0058PluginPackageAutomationDispositionEventsMigration } from './pg-0058-plugin-package-automation-disposition-events'; import { pg0059PluginPackageSecretBindingsMigration } from './pg-0059-plugin-package-secret-bindings'; import { pg0060PluginPackageSecretMaterializationGuardMigration } from './pg-0060-plugin-package-secret-materialization-guard'; +import { pg0061PluginPackageSecretBindingApprovalPlansMigration } from './pg-0061-plugin-package-secret-binding-approval-plans'; export const postgresqlMainMigrationStream: MigrationStreamDefinition = Object.freeze({ @@ -131,5 +132,6 @@ export const postgresqlMainMigrationStream: MigrationStreamDefinition= 0 AND + expires_at_ms > planned_at_ms AND + expires_at_ms - planned_at_ms <= 900000 + ), + CONSTRAINT ql3_plugin_package_secret_binding_approval_plan_json_check CHECK ( + jsonb_typeof(plan_json) = 'object' AND + octet_length(plan_json::text) BETWEEN 2 AND 98304 AND + plan_json @> jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-approval-plan@v1', + 'actionRef', action_ref, + 'approvalPlanDigest', approval_plan_digest, + 'requestedBy', jsonb_build_object( + 'type', requested_by_type, + 'id', requested_by_id + ), + 'expiresAtMs', expires_at_ms, + 'bindingPlan', jsonb_build_object( + 'schema', 'qinglong/plugin-package-secret-binding-plan@v1', + 'planDigest', binding_plan_digest, + 'plannedAtMs', planned_at_ms, + 'target', 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 #> '{bindingPlan,entries}') = 'array' AND + jsonb_array_length(plan_json #> '{bindingPlan,entries}') BETWEEN 1 AND 64 + ) +) + `.trim(), + `CREATE UNIQUE INDEX ql3_plugin_package_secret_binding_approval_plan_digest_uidx ON "ql3"."plugin_package_secret_binding_approval_plans" (approval_plan_digest)`, + `CREATE INDEX ql3_plugin_package_secret_binding_approval_plan_target_idx ON "ql3"."plugin_package_secret_binding_approval_plans" (project_id, package_name, generation, action_ref)`, + `CREATE INDEX ql3_plugin_package_secret_binding_approval_plan_expiry_idx ON "ql3"."plugin_package_secret_binding_approval_plans" (expires_at_ms, action_ref)`, + ` +CREATE FUNCTION "ql3"."plugin_package_secret_binding_planning_snapshot"( + p_project_id varchar, + p_package_name varchar +) +RETURNS TABLE ( + record_json jsonb, + lock_json jsonb, + proposal_json jsonb, + 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; + IF p_project_id !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' OR + p_package_name !~ '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$' THEN + RAISE EXCEPTION 'Package planning identity is invalid' + USING ERRCODE = 'invalid_parameter_value'; + END IF; + + RETURN QUERY + SELECT install.record_json, + install.lock_json, + proposal.proposal_json, + floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS install + ON install.installation_id = head.installation_id + JOIN "ql3"."plugin_package_admission_receipts" AS admission + ON admission.installation_id = install.installation_id + JOIN "ql3"."plugin_package_install_proposals" AS proposal + ON proposal.action_ref = admission.action_ref + LEFT JOIN "ql3"."plugin_package_quarantine_events" AS quarantine + ON quarantine.project_id = install.project_id + AND quarantine.package_name = install.package_name + AND quarantine.installation_id = install.installation_id + AND quarantine.lock_digest = install.lock_digest + LEFT JOIN "ql3"."plugin_package_lifecycle_heads" AS lifecycle + ON lifecycle.project_id = install.project_id + AND lifecycle.package_name = install.package_name + AND lifecycle.installation_id = install.installation_id + AND lifecycle.lock_digest = install.lock_digest + WHERE head.project_id = p_project_id + AND head.package_name = p_package_name + AND install.state = 'active' + AND install.active_lock_digest = install.lock_digest + AND quarantine.event_digest IS NULL + AND COALESCE(lifecycle.disposition, 'active') = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_secret_bindings" AS binding + WHERE binding.project_id = install.project_id + AND binding.package_name = install.package_name + AND binding.generation = install.target_generation + ) + FOR SHARE OF head, install; +END +$ql3$ + `.trim(), + ` +CREATE FUNCTION "ql3"."create_plugin_package_secret_binding_approval_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; + IF jsonb_typeof(p_plan_json) IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'Secret binding approval plan is invalid' + USING ERRCODE = 'invalid_parameter_value'; + END IF; + IF COALESCE(p_plan_json ->> 'actionRef', '') !~ + '^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$' THEN + RAISE EXCEPTION 'Secret binding approval actionRef is invalid' + USING ERRCODE = 'invalid_parameter_value'; + END IF; + + PERFORM pg_advisory_xact_lock( + hashtextextended(p_plan_json ->> 'actionRef', 70513061) + ); + + SELECT plan_json INTO existing_plan + FROM "ql3"."plugin_package_secret_binding_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 binding approval actionRef is already bound' + USING ERRCODE = 'unique_violation'; + END IF; + + INSERT INTO "ql3"."plugin_package_secret_binding_approval_plans" ( + action_ref, approval_plan_digest, binding_plan_digest, + generation_digest, project_id, package_name, installation_id, + lock_digest, generation, manifest_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 #>> '{bindingPlan,planDigest}', + p_plan_json #>> '{bindingPlan,target,generationDigest}', + p_plan_json #>> '{bindingPlan,target,projectId}', + p_plan_json #>> '{bindingPlan,target,packageName}', + p_plan_json #>> '{bindingPlan,target,installationId}', + p_plan_json #>> '{bindingPlan,target,lockDigest}', + (p_plan_json #>> '{bindingPlan,target,generation}')::integer, + p_plan_json #>> '{bindingPlan,target,manifestDigest}', + p_plan_json #>> '{requestedBy,type}', + p_plan_json #>> '{requestedBy,id}', + (p_plan_json #>> '{bindingPlan,plannedAtMs}')::bigint, + (p_plan_json ->> 'expiresAtMs')::bigint, + p_plan_json + FROM "ql3"."plugin_package_install_heads" AS head + JOIN "ql3"."plugin_package_installs" AS install + ON install.installation_id = head.installation_id + AND install.project_id = head.project_id + AND install.package_name = head.package_name + LEFT JOIN "ql3"."plugin_package_quarantine_events" AS quarantine + ON quarantine.project_id = install.project_id + AND quarantine.package_name = install.package_name + AND quarantine.installation_id = install.installation_id + AND quarantine.lock_digest = install.lock_digest + LEFT JOIN "ql3"."plugin_package_lifecycle_heads" AS lifecycle + ON lifecycle.project_id = install.project_id + AND lifecycle.package_name = install.package_name + AND lifecycle.installation_id = install.installation_id + AND lifecycle.lock_digest = install.lock_digest + WHERE head.project_id = p_plan_json #>> '{bindingPlan,target,projectId}' + AND head.package_name = p_plan_json #>> '{bindingPlan,target,packageName}' + AND install.installation_id = + p_plan_json #>> '{bindingPlan,target,installationId}' + AND install.lock_digest = + p_plan_json #>> '{bindingPlan,target,lockDigest}' + AND install.target_generation = + (p_plan_json #>> '{bindingPlan,target,generation}')::integer + AND install.lock_json ->> 'manifestDigest' = + p_plan_json #>> '{bindingPlan,target,manifestDigest}' + AND install.state = 'active' + AND install.active_lock_digest = install.lock_digest + AND quarantine.event_digest IS NULL + AND COALESCE(lifecycle.disposition, 'active') = 'active' + AND NOT EXISTS ( + SELECT 1 + FROM "ql3"."plugin_package_secret_bindings" AS binding + WHERE binding.generation_digest = + p_plan_json #>> '{bindingPlan,target,generationDigest}' + OR ( + binding.project_id = install.project_id AND + binding.package_name = install.package_name AND + binding.generation = install.target_generation + ) + ) + RETURNING action_ref INTO inserted_action_ref; + + IF inserted_action_ref IS NULL THEN + RAISE EXCEPTION 'Secret binding target is not the current unbound generation' + USING ERRCODE = 'check_violation'; + END IF; + RETURN 'created'; +END +$ql3$ + `.trim(), + `REVOKE ALL ON "ql3"."plugin_package_secret_binding_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_approval_plans" TO ql3_package_manager`, + `GRANT SELECT ON "ql3"."plugin_package_secret_binding_approval_plans" TO ql3_package_executor`, + `REVOKE ALL ON FUNCTION "ql3"."plugin_package_secret_binding_planning_snapshot"(varchar, varchar) FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `REVOKE ALL ON FUNCTION "ql3"."create_plugin_package_secret_binding_approval_plan"(jsonb) FROM PUBLIC, ql3_runtime, ql3_admin, ql3_package_manager, ql3_package_executor, ql3_worker_ingress`, + `GRANT EXECUTE ON FUNCTION "ql3"."plugin_package_secret_binding_planning_snapshot"(varchar, varchar) TO ql3_package_manager`, + `GRANT EXECUTE ON FUNCTION "ql3"."create_plugin_package_secret_binding_approval_plan"(jsonb) TO ql3_package_manager`, + `DO $ql3$ BEGIN UPDATE "ql3"."schema_capabilities" SET contract_version = 60, migration_id = 'pg-0061-plugin-package-secret-binding-approval-plans', capabilities = '${CAPABILITIES_V60}'::jsonb, updated_at_ms = floor(extract(epoch FROM transaction_timestamp()) * 1000)::bigint WHERE contract_name = 'control-core' AND contract_version = 59 AND migration_id = 'pg-0060-plugin-package-secret-materialization-guard' AND capabilities = '${CAPABILITIES_V59}'::jsonb; IF NOT FOUND THEN RAISE EXCEPTION 'control-core capability is not at version 59' USING ERRCODE = 'check_violation'; END IF; END $ql3$`, + ], + }); diff --git a/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts new file mode 100644 index 00000000..d5d48adf --- /dev/null +++ b/packages/ql3-cluster-postgres/src/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository.ts @@ -0,0 +1,234 @@ +import type { PostgresPool } from '@qinglong/runtime-core'; +import { + normalizePluginPackageInstallProposal, + type PluginPackageInstallProposal, +} from '@qinglong/runtime-core/plugin-package-proposal'; +import { + normalizePluginPackageInstallRecord, + normalizePluginPackageLock, + type PluginPackageInstallRecord, + type PluginPackageLock, +} from '@qinglong/runtime-core/plugin-package-install'; +import { + InvalidPluginPackageSecretBindingApprovalPlanError, + PluginPackageSecretBindingApprovalPlanConflictError, + PluginPackageSecretBindingApprovalPlanUnavailableError, + normalizePluginPackageSecretBindingApprovalPlan, + type CreatePluginPackageSecretBindingApprovalPlanResult, + type PluginPackageSecretBindingApprovalPlan, + type PluginPackageSecretBindingApprovalPlanRepository, +} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; + +import { + postgresRequiredInteger, + postgresRequiredJsonObject, + postgresRequiredString, + postgresSqlState, +} from '../../repository/definitionRepositorySupport'; + +type Row = Record; + +export interface PostgresPluginPackageSecretBindingPlanningSnapshot { + readonly record: Readonly; + readonly lock: Readonly; + readonly proposal: Readonly; + readonly observedAtMs: number; +} + +const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; + +function unavailable( + cause?: unknown, +): PluginPackageSecretBindingApprovalPlanUnavailableError { + return new PluginPackageSecretBindingApprovalPlanUnavailableError({ + cause: cause instanceof Error ? cause : undefined, + }); +} + +function mapStorageError(error: unknown): Error { + if ( + error instanceof InvalidPluginPackageSecretBindingApprovalPlanError || + error instanceof PluginPackageSecretBindingApprovalPlanConflictError || + error instanceof PluginPackageSecretBindingApprovalPlanUnavailableError + ) { + return error; + } + const state = postgresSqlState(error); + if (state === '23503' || state === '23505' || state === '23514') { + return new PluginPackageSecretBindingApprovalPlanConflictError( + 'plan identity or Package generation is already bound', + ); + } + return unavailable(error); +} + +function validateActionRef(value: string): string { + if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) { + throw new InvalidPluginPackageSecretBindingApprovalPlanError( + 'actionRef is invalid', + ); + } + return value; +} + +function normalizeRow( + row: Row, +): Readonly { + try { + return normalizePluginPackageSecretBindingApprovalPlan( + postgresRequiredJsonObject( + row.planJson, + unavailable, + ) as unknown as PluginPackageSecretBindingApprovalPlan, + ); + } catch (error) { + throw unavailable(error); + } +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export class PostgresPluginPackageSecretBindingApprovalPlanReader { + constructor(protected readonly pool: Pick) { + if (!pool || typeof pool.query !== 'function') { + throw new TypeError( + 'PostgreSQL Plugin Package Secret binding approval plan reader is invalid', + ); + } + } + + async findByActionRef( + actionRefValue: string, + ): Promise | null> { + const actionRef = validateActionRef(actionRefValue); + try { + const result = await this.pool.query( + `SELECT plan_json AS "planJson" + FROM "ql3"."plugin_package_secret_binding_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( + projectIdValue: string, + packageNameValue: string, + ): Promise | null> { + if ( + typeof projectIdValue !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectIdValue) || + typeof packageNameValue !== 'string' || + !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(packageNameValue) + ) { + throw new InvalidPluginPackageSecretBindingApprovalPlanError( + 'planning target is invalid', + ); + } + try { + const result = await this.pool.query( + `SELECT record_json AS "recordJson", + lock_json AS "lockJson", + proposal_json AS "proposalJson", + observed_at_ms AS "observedAtMs" + FROM "ql3"."plugin_package_secret_binding_planning_snapshot"($1, $2)`, + [projectIdValue, packageNameValue], + ); + if (result.rows.length === 0) return null; + if (result.rows.length !== 1) throw unavailable(); + const row = result.rows[0]!; + const record = normalizePluginPackageInstallRecord( + postgresRequiredJsonObject( + row.recordJson, + unavailable, + ) as unknown as PluginPackageInstallRecord, + ); + const lock = normalizePluginPackageLock( + postgresRequiredJsonObject( + row.lockJson, + unavailable, + ) as unknown as PluginPackageLock, + ); + const proposal = normalizePluginPackageInstallProposal( + postgresRequiredJsonObject( + row.proposalJson, + unavailable, + ) as unknown as PluginPackageInstallProposal, + ); + const observedAtMs = postgresRequiredInteger( + row.observedAtMs, + unavailable, + ); + if ( + record.projectId !== projectIdValue || + record.packageName !== packageNameValue || + record.lockDigest !== lock.lockDigest || + proposal.actionDigest !== lock.approval.actionDigest || + proposal.previewDigest !== lock.approval.previewDigest || + proposal.actionInput.projectId !== projectIdValue || + proposal.actionInput.manifest.metadata.name !== packageNameValue || + proposal.actionInput.targetGeneration !== record.targetGeneration || + proposal.actionInput.source.contentDigest !== + lock.source.contentDigest || + proposal.actionInput.manifest.metadata.version !== + record.packageVersion || + proposal.actionInput.manifest.spec.permissions.secrets.length === 0 || + !proposal.actionInput.manifest.spec.permissions.tools.includes( + 'secret.use', + ) + ) { + throw unavailable(); + } + return Object.freeze({ record, lock, proposal, observedAtMs }); + } catch (error) { + throw mapStorageError(error); + } + } +} + +export class PostgresPluginPackageSecretBindingApprovalPlanRepository + extends PostgresPluginPackageSecretBindingApprovalPlanReader + implements PluginPackageSecretBindingApprovalPlanRepository +{ + async create( + planValue: Readonly, + ): Promise> { + const plan = normalizePluginPackageSecretBindingApprovalPlan(planValue); + try { + const inserted = await this.pool.query( + `SELECT "ql3"."create_plugin_package_secret_binding_approval_plan"( + $1::jsonb + ) AS status`, + [JSON.stringify(plan)], + ); + if (inserted.rows.length !== 1) throw unavailable(); + const status = postgresRequiredString( + inserted.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 PluginPackageSecretBindingApprovalPlanConflictError( + 'actionRef is already bound to another Secret binding 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 8f5fc586..06aa925d 100644 --- a/packages/ql3-cluster-postgres/src/schema/schema.ts +++ b/packages/ql3-cluster-postgres/src/schema/schema.ts @@ -386,6 +386,72 @@ export const pluginPackageSecretBindings = ql3Schema.table( ], ); +export const pluginPackageSecretBindingApprovalPlans = ql3Schema.table( + 'plugin_package_secret_binding_approval_plans', + { + actionRef: varchar('action_ref', { length: 255 }).primaryKey(), + approvalPlanDigest: char('approval_plan_digest', { length: 64 }).notNull(), + bindingPlanDigest: char('binding_plan_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(), + 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_plugin_package_secret_binding_approval_plan_project_fk', + columns: [table.projectId], + foreignColumns: [projects.id], + }) + .onDelete('restrict') + .onUpdate('restrict'), + foreignKey({ + name: 'ql3_plugin_package_secret_binding_approval_plan_install_fk', + columns: [table.installationId], + foreignColumns: [pluginPackageInstalls.installationId], + }) + .onDelete('restrict') + .onUpdate('restrict'), + check( + 'ql3_plugin_package_secret_binding_approval_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 1 and 2147483647 and ${table.requestedByType} = 'user' and octet_length(${table.requestedById}) between 1 and 255 and ${table.requestedById} !~ '[[:cntrl:]]'`, + ), + check( + 'ql3_plugin_package_secret_binding_approval_plan_digest_check', + sql`${table.approvalPlanDigest} ~ '^[0-9a-f]{64}$' and ${table.bindingPlanDigest} ~ '^[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}$'`, + ), + check( + 'ql3_plugin_package_secret_binding_approval_plan_time_check', + sql`${table.plannedAtMs} >= 0 and ${table.expiresAtMs} > ${table.plannedAtMs} and ${table.expiresAtMs} - ${table.plannedAtMs} <= 900000`, + ), + check( + 'ql3_plugin_package_secret_binding_approval_plan_json_check', + sql`jsonb_typeof(${table.planJson}) = 'object' and octet_length(${table.planJson}::text) between 2 and 98304 and ${table.planJson} @> jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-approval-plan@v1', 'actionRef', ${table.actionRef}, 'approvalPlanDigest', ${table.approvalPlanDigest}, 'requestedBy', jsonb_build_object('type', ${table.requestedByType}, 'id', ${table.requestedById}), 'expiresAtMs', ${table.expiresAtMs}, 'bindingPlan', jsonb_build_object('schema', 'qinglong/plugin-package-secret-binding-plan@v1', 'planDigest', ${table.bindingPlanDigest}, 'plannedAtMs', ${table.plannedAtMs}, 'target', 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} #> '{bindingPlan,entries}') = 'array' and jsonb_array_length(${table.planJson} #> '{bindingPlan,entries}') between 1 and 64`, + ), + uniqueIndex( + 'ql3_plugin_package_secret_binding_approval_plan_digest_uidx', + ).on(table.approvalPlanDigest), + index('ql3_plugin_package_secret_binding_approval_plan_target_idx').on( + table.projectId, + table.packageName, + table.generation, + table.actionRef, + ), + index('ql3_plugin_package_secret_binding_approval_plan_expiry_idx').on( + table.expiresAtMs, + table.actionRef, + ), + ], +); + export const projectToolDefinitionSnapshots = ql3Schema.table( 'project_tool_definition_snapshots', { @@ -5873,6 +5939,7 @@ export const ql3PostgresTables = [ pluginPackageInstallMutations, pluginPackageMaterializedRevisions, pluginPackageSecretBindings, + pluginPackageSecretBindingApprovalPlans, projectToolDefinitionSnapshots, projectToolDefinitionSnapshotSources, pluginPackageQuarantineEvents, diff --git a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts index 8a2bd8f8..76d2f9ee 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaContract.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaContract.ts @@ -15,8 +15,8 @@ export interface PostgresSchemaContractFunction { export interface PostgresSchemaContract { readonly schema: 'ql3'; readonly contractName: 'control-core'; - readonly contractVersion: 59; - readonly migrationId: 'pg-0060-plugin-package-secret-materialization-guard'; + readonly contractVersion: 60; + readonly migrationId: 'pg-0061-plugin-package-secret-binding-approval-plans'; readonly minimumServerMajor: 16; readonly maximumServerMajor: 18; readonly capabilities: Readonly<{ @@ -56,6 +56,7 @@ export interface PostgresSchemaContract { plugin_package_management_quota: 1; plugin_package_materialized_revision: 1; plugin_package_secret_binding: 1; + plugin_package_secret_binding_approval_plan: 1; plugin_package_secret_materialization: 1; plugin_package_proposal: 1; plugin_package_publisher_provenance: 1; @@ -106,8 +107,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = Object.freeze({ schema: 'ql3', contractName: 'control-core', - contractVersion: 59, - migrationId: 'pg-0060-plugin-package-secret-materialization-guard', + contractVersion: 60, + migrationId: 'pg-0061-plugin-package-secret-binding-approval-plans', minimumServerMajor: 16, maximumServerMajor: 18, capabilities: Object.freeze({ @@ -140,6 +141,7 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = plugin_package_management_quota: 1, plugin_package_materialized_revision: 1, plugin_package_secret_binding: 1, + plugin_package_secret_binding_approval_plan: 1, plugin_package_secret_materialization: 1, plugin_package_proposal: 1, plugin_package_publisher_provenance: 1, @@ -260,6 +262,23 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'binding_digest', 'binding_json', ]), + table('plugin_package_secret_binding_approval_plans', [ + 'action_ref', + 'approval_plan_digest', + 'binding_plan_digest', + 'generation_digest', + 'project_id', + 'package_name', + 'installation_id', + 'lock_digest', + 'generation', + 'manifest_digest', + 'requested_by_type', + 'requested_by_id', + 'planned_at_ms', + 'expires_at_ms', + 'plan_json', + ]), table('project_tool_definition_snapshots', [ 'project_id', 'active_vector_digest', @@ -1439,6 +1458,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_generation_uidx', 'ql3_plugin_package_secret_binding_digest_uidx', 'ql3_plugin_package_secret_binding_install_idx', + 'plugin_package_secret_binding_approval_plans_pkey', + 'ql3_plugin_package_secret_binding_approval_plan_digest_uidx', + 'ql3_plugin_package_secret_binding_approval_plan_target_idx', + 'ql3_plugin_package_secret_binding_approval_plan_expiry_idx', 'project_tool_definition_snapshots_pkey', 'ql3_project_tool_snapshot_withdrawal_key', 'ql3_project_tool_definition_snapshot_digest_uidx', @@ -1733,6 +1756,10 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_secret_binding_identity_check', 'ql3_plugin_package_secret_binding_digest_check', 'ql3_plugin_package_secret_binding_json_check', + 'ql3_plugin_package_secret_binding_approval_plan_identity_check', + 'ql3_plugin_package_secret_binding_approval_plan_digest_check', + 'ql3_plugin_package_secret_binding_approval_plan_time_check', + 'ql3_plugin_package_secret_binding_approval_plan_json_check', 'ql3_plugin_package_quarantine_identity_check', 'ql3_plugin_package_quarantine_state_check', 'ql3_plugin_package_quarantine_subject_check', @@ -2188,6 +2215,8 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_plugin_package_materialized_revision_project_fk', 'ql3_plugin_package_secret_binding_project_fk', 'ql3_plugin_package_secret_binding_install_fk', + 'ql3_plugin_package_secret_binding_approval_plan_project_fk', + 'ql3_plugin_package_secret_binding_approval_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', @@ -2338,6 +2367,23 @@ export const postgresqlControlSchemaContract: PostgresSchemaContract = 'ql3_run_retry_policies_run_fk', ]), functions: Object.freeze([ + Object.freeze({ + name: 'plugin_package_secret_binding_planning_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_binding_approval_plan', + identityArguments: 'p_plan_json jsonb', + owner: 'ql3_migration', + securityDefiner: true, + volatility: 'volatile', + configuration: Object.freeze(['search_path=pg_catalog, ql3']), + }), Object.freeze({ name: 'enforce_plugin_package_secret_materialization', identityArguments: '', diff --git a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts index 12602d0a..e38f9378 100644 --- a/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts +++ b/packages/ql3-cluster-postgres/src/schema/schemaReadiness.ts @@ -173,6 +173,12 @@ const REQUIRED_RUNTIME_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_approval_plans: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), project_tool_definition_snapshots: Object.freeze({ select: false, insert: false, @@ -692,6 +698,12 @@ const REQUIRED_ADMIN_PRIVILEGES = Object.freeze({ update: false, delete: false, }), + plugin_package_secret_binding_approval_plans: Object.freeze({ + select: false, + insert: false, + update: false, + delete: false, + }), project_tool_definition_snapshots: Object.freeze({ select: false, insert: false, @@ -1193,6 +1205,7 @@ const REQUIRED_PACKAGE_MANAGER_PRIVILEGES: RequiredPrivileges = Object.freeze( name === 'projects' || name === 'project_role_bindings' || name === 'plugin_package_lifecycle_plans' || + name === 'plugin_package_secret_binding_approval_plans' || name === 'plugin_package_automation_publications' || name === 'plugin_package_automation_publication_heads' || name === 'plugin_package_publisher_trust_transition_receipts' @@ -1235,6 +1248,7 @@ const REQUIRED_PACKAGE_EXECUTOR_PRIVILEGES: RequiredPrivileges = Object.freeze( name === 'projects' || name === 'project_role_bindings' || name === 'plugin_package_install_proposals' || + name === 'plugin_package_secret_binding_approval_plans' || name === 'plugin_package_publisher_revocation_proposals' || name === 'plugin_package_publisher_trust_transition_proposals' ? { ...NO_TABLE_PRIVILEGES, select: true } @@ -1504,6 +1518,7 @@ const REQUIRED_ADMIN_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = Object.freeze({ + create_plugin_package_secret_binding_approval_plan: false, commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, @@ -1517,12 +1532,14 @@ const REQUIRED_RUNTIME_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = plugin_package_workflow_task_attempt_snapshot: true, plugin_package_lifecycle_blocking_runs: false, plugin_package_run_start_allowed: true, + plugin_package_secret_binding_planning_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_binding_approval_plan: true, commit_plugin_package_lifecycle: false, commit_plugin_package_quarantine: false, commit_plugin_package_task_reconciliation: false, @@ -1536,12 +1553,14 @@ const REQUIRED_PACKAGE_MANAGER_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges = plugin_package_workflow_task_attempt_snapshot: false, plugin_package_lifecycle_blocking_runs: false, plugin_package_run_start_allowed: false, + plugin_package_secret_binding_planning_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_binding_approval_plan: false, commit_plugin_package_lifecycle: true, commit_plugin_package_quarantine: true, commit_plugin_package_task_reconciliation: true, @@ -1555,6 +1574,7 @@ const REQUIRED_PACKAGE_EXECUTOR_FUNCTION_PRIVILEGES: RequiredFunctionPrivileges plugin_package_workflow_task_attempt_snapshot: false, plugin_package_lifecycle_blocking_runs: true, plugin_package_run_start_allowed: false, + plugin_package_secret_binding_planning_snapshot: false, plugin_package_tool_start_allowed: false, register_plugin_package_automation_disposition_event: false, }); diff --git a/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs new file mode 100644 index 00000000..eb33c5bf --- /dev/null +++ b/packages/ql3-cluster-postgres/test/pluginPackageSecretBindingApprovalPlanRepository.test.cjs @@ -0,0 +1,310 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + PLUGIN_PACKAGE_API_VERSION, + PLUGIN_PACKAGE_KIND, + planPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package'); +const { + createPluginPackageInstall, + createPluginPackageLock, + pluginPackageActivationIntentDigest, + pluginPackageInstallActionDigest, + pluginPackageInstallPlanDigest, + transitionPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package-install'); +const { + createPluginPackageInstallProposal, +} = require('@qinglong/runtime-core/plugin-package-proposal'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { + PluginPackageSecretBindingApprovalPlanConflictError, + PluginPackageSecretBindingApprovalPlanUnavailableError, + createPluginPackageSecretBindingApprovalPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingApprovalPlanRepository, +} = require('../dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'); + +function fixture() { + const manifest = { + apiVersion: PLUGIN_PACKAGE_API_VERSION, + kind: PLUGIN_PACKAGE_KIND, + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'PostgreSQL Secret binding plan 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: [] }, + }, + }; + const environment = { + qinglongVersion: '3.0.0-alpha.0', + architecture: 'arm64', + deploymentProfile: 'cluster-control', + runtimes: [], + availableMemoryBytes: 128 * 1024 * 1024, + availableDiskBytes: 256 * 1024 * 1024, + }; + const installPlan = planPluginPackageInstall(manifest, environment); + const actionInput = { + lockId: 'lock-secret-binding-1', + projectId: 'project-1', + manifest, + plan: installPlan, + environment, + source: { + kind: 'offline', + locator: `offline:sha256:${'a'.repeat(64)}`, + artifactDigest: 'a'.repeat(64), + artifactBytes: 2048, + contentDigest: 'b'.repeat(64), + }, + architecture: 'arm64', + deploymentProfile: 'cluster-control', + targetGeneration: 1, + }; + const proposal = createPluginPackageInstallProposal({ + actionRef: 'proposal:secret-binding-install-v1', + actionInput, + proposedBy: { type: 'user', id: 'cluster-owner' }, + proposalFence: { projectVersion: 1, bindingVersion: 1 }, + createdAtMs: 90, + }); + const lock = createPluginPackageLock({ + ...actionInput, + approval: { + requestId: 'approval-secret-binding-install-v1', + requestVersion: 1, + dispatchId: 'dispatch-secret-binding-install-v1', + actionDigest: pluginPackageInstallActionDigest(actionInput), + previewDigest: pluginPackageInstallPlanDigest(installPlan), + approvedBy: { type: 'user', id: 'install-reviewer' }, + approvedAtMs: 100, + expiresAtMs: 1_000, + fence: { projectVersion: 1, bindingVersion: 1 }, + }, + createdAtMs: 200, + }); + const queued = createPluginPackageInstall(lock, { + installationId: 'install-secret-binding-1', + mutationId: 'mutation-secret-binding-create', + occurredAtMs: 201, + }); + const staged = transitionPluginPackageInstall(lock, queued, { + type: 'stage_completed', + mutationId: 'mutation-secret-binding-stage', + occurredAtMs: 202, + stageRef: 'stage-secret-binding-1', + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'c'.repeat(64), + }); + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: 'mutation-secret-binding-activate', + occurredAtMs: 203, + }); + const record = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: 'mutation-secret-binding-commit', + occurredAtMs: 204, + activationRef: 'activation-secret-binding-1', + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: 1, + contentDigest: lock.source.contentDigest, + }); + const generation = createPluginPackageResourceGeneration({ + installationId: record.installationId, + projectId: record.projectId, + packageName: record.packageName, + lockDigest: record.lockDigest, + generation: record.targetGeneration, + previousActiveLockDigest: record.previousActiveLockDigest, + contentDigest: lock.source.contentDigest, + contents: manifest.spec.contents, + }); + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }), + }, + ], + plannedAtMs: 300, + }); + const approvalPlan = createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:example-monitor-v1', + bindingPlan, + requestedBy: { type: 'user', id: 'cluster-owner' }, + expiresAtMs: 900, + }); + return { approvalPlan, lock, proposal, record }; +} + +test('reads and creates one exact approval plan only through the narrow database function', async () => { + const { approvalPlan } = fixture(); + const calls = []; + const repository = + new PostgresPluginPackageSecretBindingApprovalPlanRepository({ + async query(text, parameters) { + calls.push({ text, parameters }); + if ( + text.includes('create_plugin_package_secret_binding_approval_plan') + ) { + return { rows: [{ status: 'created' }] }; + } + return { rows: [{ planJson: approvalPlan }] }; + }, + }); + + const result = await repository.create(approvalPlan); + assert.deepEqual(result, { status: 'created', plan: approvalPlan }); + assert.equal(calls.length, 2); + assert.match( + calls[0].text, + /create_plugin_package_secret_binding_approval_plan/, + ); + assert.deepEqual(calls[0].parameters, [JSON.stringify(approvalPlan)]); + assert.doesNotMatch(calls[0].text, /INSERT INTO/); + assert.match(calls[1].text, /WHERE action_ref = \$1/); +}); + +test('loads one current unbound generation snapshot and rejects provenance drift', async () => { + const { lock, proposal, record } = fixture(); + const pool = { + async query(text, parameters) { + assert.match(text, /plugin_package_secret_binding_planning_snapshot/); + assert.deepEqual(parameters, ['project-1', 'example-monitor']); + return { + rows: [ + { + recordJson: record, + lockJson: lock, + proposalJson: proposal, + observedAtMs: '500', + }, + ], + }; + }, + }; + const reader = new PostgresPluginPackageSecretBindingApprovalPlanReader(pool); + assert.deepEqual( + await reader.loadPlanningSnapshot('project-1', 'example-monitor'), + { record, lock, proposal, observedAtMs: 500 }, + ); + + const drifted = new PostgresPluginPackageSecretBindingApprovalPlanReader({ + async query() { + return { + rows: [ + { + recordJson: record, + lockJson: lock, + proposalJson: { ...proposal, previewDigest: 'f'.repeat(64) }, + observedAtMs: 500, + }, + ], + }; + }, + }); + await assert.rejects( + drifted.loadPlanningSnapshot('project-1', 'example-monitor'), + PluginPackageSecretBindingApprovalPlanUnavailableError, + ); +}); + +test('returns absence and fails closed on replay drift or storage conflict', async () => { + const { approvalPlan } = fixture(); + const reader = new PostgresPluginPackageSecretBindingApprovalPlanReader({ + async query() { + return { rows: [] }; + }, + }); + assert.equal(await reader.findByActionRef(approvalPlan.actionRef), null); + + const otherPlan = createPluginPackageSecretBindingApprovalPlan({ + actionRef: approvalPlan.actionRef, + bindingPlan: approvalPlan.bindingPlan, + requestedBy: { type: 'user', id: 'another-owner' }, + expiresAtMs: approvalPlan.expiresAtMs, + }); + + const drifted = new PostgresPluginPackageSecretBindingApprovalPlanRepository({ + async query(text) { + return text.includes('create_plugin_package_secret_binding_approval_plan') + ? { rows: [{ status: 'existing' }] } + : { rows: [{ planJson: otherPlan }] }; + }, + }); + await assert.rejects( + drifted.create(approvalPlan), + PluginPackageSecretBindingApprovalPlanConflictError, + ); + + const conflict = new PostgresPluginPackageSecretBindingApprovalPlanRepository( + { + async query() { + const error = new Error('unique violation'); + error.code = '23505'; + throw error; + }, + }, + ); + await assert.rejects( + conflict.create(approvalPlan), + PluginPackageSecretBindingApprovalPlanConflictError, + ); +}); + +test('exports plan creation only to the Package manager and readback to the executor', () => { + const manager = require('@qinglong/cluster-postgres/package-manager'); + const executor = require('@qinglong/cluster-postgres/package-executor'); + assert.equal( + manager.PostgresPluginPackageSecretBindingApprovalPlanRepository, + PostgresPluginPackageSecretBindingApprovalPlanRepository, + ); + assert.equal( + executor.PostgresPluginPackageSecretBindingApprovalPlanRepository, + undefined, + ); + assert.equal( + executor.PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingApprovalPlanReader, + ); +}); diff --git a/packages/ql3-cluster-postgres/test/postgres.integration.test.cjs b/packages/ql3-cluster-postgres/test/postgres.integration.test.cjs index eac48cbe..3bbef27f 100644 --- a/packages/ql3-cluster-postgres/test/postgres.integration.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgres.integration.test.cjs @@ -108,7 +108,10 @@ const { } = require('@qinglong/runtime-core/plugin-package-admission'); const { pluginPackageInstallActionDigest, + pluginPackageActivationIntentDigest, + pluginPackageInstallCommit, pluginPackageInstallPlanDigest, + transitionPluginPackageInstall, } = require('@qinglong/runtime-core/plugin-package-install'); const { createPluginPackageInstallProposal, @@ -183,6 +186,17 @@ const { const { createPluginPackageResourceGenerationFromReferences, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { + createPluginPackageSecretBindingApprovalPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + PostgresPluginPackageSecretBindingApprovalPlanReader, + PostgresPluginPackageSecretBindingApprovalPlanRepository, +} = require('../dist/plugin-package/secret-binding/pluginPackageSecretBindingApprovalPlanRepository'); const { createProjectToolDefinitionSnapshot, projectToolDefinitionRegistry, @@ -291,8 +305,8 @@ function admissionPackageAction() { }, permissions: { network: { allowedHosts: [] }, - secrets: [], - tools: [], + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], }, contents: { tasks: [], workflows: [], prompts: [], tools: [] }, }, @@ -882,14 +896,16 @@ if (!migrationConnectionString) { GROUP BY run.id, attempt.id`, [accepted.runId], ); - assert.deepEqual(durable.rows, [{ - status: 'queued', - version: 2, - eventSequence: 2, - trigger_type: 'task_start', - attemptStatus: 'claimed', - eventCount: 2, - }]); + assert.deepEqual(durable.rows, [ + { + status: 'queued', + version: 2, + eventSequence: 2, + trigger_type: 'task_start', + attemptStatus: 'claimed', + eventCount: 2, + }, + ]); } finally { if (runtimeDatabase && runtimeDatabase !== migrationDatabase) { await runtimeDatabase.close(); @@ -2087,12 +2103,14 @@ if (!migrationConnectionString) { runtimeDatabase.pool, ); assert.deepEqual(await taskAttempts.listCandidates({ limit: 8 }), { - candidates: [{ - runId: plan.runId, - stepRunId: plan.steps[0].stepRunId, - readyAtMs: plan.plannedAtMs, - planDigest: plan.planDigest, - }], + candidates: [ + { + runId: plan.runId, + stepRunId: plan.steps[0].stepRunId, + readyAtMs: plan.plannedAtMs, + planDigest: plan.planDigest, + }, + ], truncated: false, }); const taskAttempt = await taskAttempts.admit( @@ -2167,29 +2185,34 @@ if (!migrationConnectionString) { GROUP BY run.id, attempt.id, step.id`, [plan.runId, taskAttempt.receipt.attemptId], ); - assert.deepEqual(recovered.rows, [{ - runStatus: 'running', - runVersion: 6, - eventSequence: 6, - attemptStatus: 'lost', - stepStatus: 'ready', - stepVersion: 2, - stepAttemptCount: 0, - events: [ - 'workflow.task_attempt_admitted', - 'workflow.task_attempt.lost', - 'step.ready', - ], - }]); + assert.deepEqual(recovered.rows, [ + { + runStatus: 'running', + runVersion: 6, + eventSequence: 6, + attemptStatus: 'lost', + stepStatus: 'ready', + stepVersion: 2, + stepAttemptCount: 0, + events: [ + 'workflow.task_attempt_admitted', + 'workflow.task_attempt.lost', + 'step.ready', + ], + }, + ]); const requeued = await taskAttempts.listCandidates({ limit: 8 }); assert.deepEqual( - requeued.candidates.map( - ({ runId, stepRunId }) => ({ runId, stepRunId }), - ), - [{ - runId: plan.runId, - stepRunId: plan.steps[0].stepRunId, - }], + requeued.candidates.map(({ runId, stepRunId }) => ({ + runId, + stepRunId, + })), + [ + { + runId: plan.runId, + stepRunId: plan.steps[0].stepRunId, + }, + ], ); const secondTaskAttempt = await taskAttempts.admit( plan.runId, @@ -2242,20 +2265,22 @@ if (!migrationConnectionString) { WHERE run.id = $1`, [plan.runId], ); - assert.deepEqual(cancelled.rows, [{ - runStatus: 'cancelled', - runVersion: 11, - eventSequence: 11, - attemptStatuses: ['cancelled', 'lost'], - stepStatuses: ['cancelled'], - events: [ - 'workflow.task_attempt_admitted', - 'workflow.task_attempt.cancelled', - 'step.cancelled', - 'step.cancelled', - 'workflow.cancelled', - ], - }]); + assert.deepEqual(cancelled.rows, [ + { + runStatus: 'cancelled', + runVersion: 11, + eventSequence: 11, + attemptStatuses: ['cancelled', 'lost'], + stepStatuses: ['cancelled'], + events: [ + 'workflow.task_attempt_admitted', + 'workflow.task_attempt.cancelled', + 'step.cancelled', + 'step.cancelled', + 'workflow.cancelled', + ], + }, + ]); } finally { await runtimeDatabase.close(); } @@ -3607,10 +3632,181 @@ if (!migrationConnectionString) { await repository.findAdmissionReceipt(consumed.dispatch.id), admitted.receipt, ); + + const staged = transitionPluginPackageInstall(lock, admitted.record, { + type: 'stage_completed', + mutationId: 'stage-package-secret-binding-integration-1', + occurredAtMs: admittedAtMs + 1, + stageRef: `stage:${lock.lockDigest}`, + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'd'.repeat(64), + }); + const stageProvenance = createPluginPackagePublisherProvenance({ + projectId: staged.projectId, + packageName: staged.packageName, + installationId: staged.installationId, + lockDigest: staged.lockDigest, + artifactDigest: staged.stageReceipt.artifactDigest, + manifestDigest: staged.stageReceipt.manifestDigest, + contentDigest: staged.stageReceipt.contentDigest, + stageEvidenceDigest: staged.stageReceipt.evidenceDigest, + signature: { + publisher: 'integration.qinglong.dev', + keyId: 'integration-key-1', + signatureDigest: 'e'.repeat(64), + keyNotBeforeMs: 0, + keyNotAfterMs: admittedAtMs + 60_000, + verifiedAtMs: admittedAtMs, + }, + }); + await executorDatabase.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 + )`, + [ + stageProvenance.installationId, + stageProvenance.projectId, + stageProvenance.packageName, + stageProvenance.lockDigest, + stageProvenance.artifactDigest, + stageProvenance.manifestDigest, + stageProvenance.contentDigest, + stageProvenance.stageEvidenceDigest, + stageProvenance.publisher, + stageProvenance.keyId, + stageProvenance.signatureDigest, + stageProvenance.keyNotBeforeMs, + stageProvenance.keyNotAfterMs, + stageProvenance.verifiedAtMs, + stageProvenance.provenanceDigest, + JSON.stringify(stageProvenance), + ], + ); + await repository.commit( + pluginPackageInstallCommit(admitted.record, staged), + ); + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: 'activate-package-secret-binding-integration-1', + occurredAtMs: admittedAtMs + 2, + }); + await repository.commit(pluginPackageInstallCommit(staged, activating)); + const active = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: 'commit-package-secret-binding-integration-1', + occurredAtMs: admittedAtMs + 3, + activationRef: `active:${lock.lockDigest}`, + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: lock.targetGeneration, + contentDigest: lock.source.contentDigest, + }); + await repository.commit(pluginPackageInstallCommit(activating, active)); } finally { await executorDatabase.close(); } + const bindingManagerDatabase = await open('package-manager'); + let approvalPlan; + try { + const plans = + new PostgresPluginPackageSecretBindingApprovalPlanRepository( + bindingManagerDatabase.pool, + ); + const snapshot = await plans.loadPlanningSnapshot( + 'default', + 'postgres-monitor', + ); + assert.ok(snapshot); + const generation = createPluginPackageResourceGenerationFromReferences({ + installationId: snapshot.record.installationId, + projectId: snapshot.record.projectId, + packageName: snapshot.record.packageName, + lockDigest: snapshot.record.lockDigest, + generation: snapshot.record.targetGeneration, + previousActiveLockDigest: snapshot.record.previousActiveLockDigest, + contentDigest: snapshot.lock.source.contentDigest, + resources: [], + }); + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest: snapshot.proposal.actionInput.manifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'default', + name: 'postgres-monitor-token', + version: 1, + }), + }, + ], + plannedAtMs: snapshot.observedAtMs, + }); + approvalPlan = createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:postgres-monitor-v1', + bindingPlan, + requestedBy: requester, + expiresAtMs: snapshot.observedAtMs + 60_000, + }); + assert.equal((await plans.create(approvalPlan)).status, 'created'); + assert.equal((await plans.create(approvalPlan)).status, 'existing'); + await assert.rejects( + bindingManagerDatabase.pool.query( + `INSERT INTO "ql3"."plugin_package_secret_binding_approval_plans" ( + action_ref + ) VALUES ('forbidden-direct-manager-insert')`, + ), + (error) => error?.code === '42501', + ); + } finally { + await bindingManagerDatabase.close(); + } + + const bindingExecutorDatabase = await open('package-executor'); + try { + assert.deepEqual( + await new PostgresPluginPackageSecretBindingApprovalPlanReader( + bindingExecutorDatabase.pool, + ).findByActionRef(approvalPlan.actionRef), + approvalPlan, + ); + await assert.rejects( + bindingExecutorDatabase.pool.query( + `SELECT "ql3"."create_plugin_package_secret_binding_approval_plan"( + $1::jsonb + )`, + [JSON.stringify(approvalPlan)], + ), + (error) => error?.code === '42501', + ); + } finally { + await bindingExecutorDatabase.close(); + } + + const runtimeDatabase = await open('runtime'); + try { + await assert.rejects( + runtimeDatabase.pool.query( + `SELECT * + FROM "ql3"."plugin_package_secret_binding_planning_snapshot"( + 'default', 'postgres-monitor' + )`, + ), + (error) => error?.code === '42501', + ); + } finally { + await runtimeDatabase.close(); + } + const verificationDatabase = await open('migration'); try { const facts = await verificationDatabase.pool.query( @@ -3623,13 +3819,17 @@ if (!migrationConnectionString) { FROM "ql3"."plugin_package_admission_receipts") AS receipts, (SELECT count(*)::integer FROM "ql3"."security_audit_events" - WHERE operation_id = 'plugin_package.admit') AS audits`, + WHERE operation_id = 'plugin_package.admit') AS audits, + (SELECT count(*)::integer + FROM "ql3"."plugin_package_secret_binding_approval_plans") + AS "approvalPlans"`, ); assert.deepEqual(facts.rows[0], { installs: 1, - mutations: 1, + mutations: 4, receipts: 1, audits: 1, + approvalPlans: 1, }); } finally { await verificationDatabase.close(); @@ -4718,10 +4918,9 @@ if (!migrationConnectionString) { new PostgresTaskDefinitionAdministrationRepository( automationDatabase.pool, ); - const triggerMutations = - new PostgresTriggerAdministrationRepository( - automationDatabase.pool, - ); + const triggerMutations = new PostgresTriggerAdministrationRepository( + automationDatabase.pool, + ); const taskBase = { projectId, taskId: 'automation-task', diff --git a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs index a855bbf8..62b10a2a 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlMigrationDefinitions.test.cjs @@ -111,6 +111,7 @@ test('defines the immutable PostgreSQL capability and Run core stream', async () 'pg-0058-plugin-package-automation-disposition-events', 'pg-0059-plugin-package-secret-bindings', 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ], ); for (const migration of postgresqlMainMigrationStream.migrations) { @@ -163,6 +164,7 @@ test('keeps local-only and legacy tables out of the cluster baseline', async () 'plugin_package_install_mutations', 'plugin_package_materialized_revisions', 'plugin_package_secret_bindings', + 'plugin_package_secret_binding_approval_plans', 'plugin_package_lifecycle_events', 'plugin_package_lifecycle_heads', 'plugin_package_lifecycle_receipts', @@ -547,6 +549,11 @@ test('freezes every published PostgreSQL migration checksum', () => { checksum: '28284ca860b39ff9de5b2aa1a2a60ef2c463fd6a72798d237040174272b64b1e', }, + { + id: 'pg-0061-plugin-package-secret-binding-approval-plans', + checksum: + 'c995b7846ae8a57d3abb4b5523961e81aeba890e7405a030bcb505dfc6be3d25', + }, ]; assert.deepEqual( postgresqlMainMigrationStream.migrations.map(({ id, checksum }) => ({ @@ -2030,3 +2037,49 @@ test('advances capability v59 with fail-closed Package Secret materialization', assert.match(sql, /contract_version = 58/); assert.match(sql, /migration_id = 'pg-0059-plugin-package-secret-bindings'/); }); + +test('advances capability v60 with least-privilege Package Secret binding approval plans', async () => { + const migration = migrationById( + 'pg-0061-plugin-package-secret-binding-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_approval_plans"/, + ); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."plugin_package_secret_binding_planning_snapshot"\([\s\S]+p_project_id varchar,[\s\S]+p_package_name varchar[\s\S]+\)/, + ); + assert.match( + sql, + /CREATE FUNCTION "ql3"\."create_plugin_package_secret_binding_approval_plan"\([\s\S]+p_plan_json jsonb[\s\S]+\)/, + ); + assert.match(sql, /SECURITY DEFINER/); + assert.match( + sql, + /GRANT SELECT ON "ql3"\."plugin_package_secret_binding_approval_plans" TO ql3_package_manager/, + ); + assert.match( + sql, + /GRANT SELECT ON "ql3"\."plugin_package_secret_binding_approval_plans" TO ql3_package_executor/, + ); + assert.doesNotMatch( + sql, + /GRANT (?:INSERT|UPDATE|DELETE)[^;]+plugin_package_secret_binding_approval_plans[^;]+ql3_package_manager/, + ); + assert.match(sql, /contract_version = 60/); + assert.match(sql, /"plugin_package_secret_binding_approval_plan":1/); + assert.match(sql, /contract_version = 59/); + assert.match( + sql, + /migration_id = 'pg-0060-plugin-package-secret-materialization-guard'/, + ); +}); diff --git a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs index 99bec3b6..ff1621db 100644 --- a/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs +++ b/packages/ql3-cluster-postgres/test/postgresqlSchemaReadiness.test.cjs @@ -75,6 +75,7 @@ function validPrivileges() { plugin_package_install_mutations: [false, false, false, false], 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], project_tool_definition_snapshots: [false, false, false, false], project_tool_definition_snapshot_sources: [false, false, false, false], plugin_package_quarantine_events: [false, false, false, false], @@ -194,6 +195,7 @@ function validAdminPrivileges() { plugin_package_install_mutations: [false, false, false, false], 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], project_tool_definition_snapshots: [false, false, false, false], project_tool_definition_snapshot_sources: [false, false, false, false], plugin_package_quarantine_events: [false, false, false, false], @@ -287,6 +289,7 @@ function packagePrivileges(kind) { 'plugin_package_publisher_trust_transition_proposals', 'plugin_package_publisher_trust_transition_receipts', 'plugin_package_lifecycle_plans', + 'plugin_package_secret_binding_approval_plans', 'plugin_package_automation_publications', 'plugin_package_automation_publication_heads', ...(manager @@ -667,7 +670,13 @@ function queryable(overrides = {}) { contract.functions.map(({ name: functionName }) => ({ functionName, executeAllowed: - overrides.functionMode === 'manager' + overrides.functionMode === 'package-manager' + ? [ + 'create_plugin_package_secret_binding_approval_plan', + 'lock_approval_policy_fence', + 'plugin_package_secret_binding_planning_snapshot', + ].includes(functionName) + : overrides.functionMode === 'manager' ? functionName === 'lock_approval_policy_fence' : overrides.functionMode === 'run-manager' ? functionName === 'lock_run_management_policy_fence' @@ -754,7 +763,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro serverMajor: 16, currentUser: 'ql3_runtime', contractName: 'control-core', - contractVersion: 59, + contractVersion: 60, migrationIds: [ 'pg-0001-schema-capability', 'pg-0002-run-core', @@ -816,6 +825,7 @@ test('accepts the exact PostgreSQL control schema and least-privilege runtime ro 'pg-0058-plugin-package-automation-disposition-events', 'pg-0059-plugin-package-secret-bindings', 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ], }); }); @@ -846,10 +856,10 @@ test('accepts the exact schema and isolated least-privilege admin role', async ( }), ); assert.equal(report.currentUser, 'ql3_admin'); - assert.equal(report.contractVersion, 59); + assert.equal(report.contractVersion, 60); assert.equal( report.migrationIds.at(-1), - 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ); }); @@ -862,10 +872,10 @@ test('accepts the isolated least-privilege automation manager role', async () => }), ); assert.equal(report.currentUser, 'ql3_automation_manager'); - assert.equal(report.contractVersion, 59); + assert.equal(report.contractVersion, 60); assert.equal( report.migrationIds.at(-1), - 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ); const widened = automationManagerPrivileges(); @@ -894,10 +904,10 @@ test('accepts the isolated least-privilege human Approval manager role', async ( }), ); assert.equal(report.currentUser, 'ql3_approval_manager'); - assert.equal(report.contractVersion, 59); + assert.equal(report.contractVersion, 60); assert.equal( report.migrationIds.at(-1), - 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ); const widened = approvalManagerPrivileges(); @@ -928,10 +938,10 @@ test('accepts the isolated least-privilege Run manager role', async () => { }), ); assert.equal(report.currentUser, 'ql3_run_manager'); - assert.equal(report.contractVersion, 59); + assert.equal(report.contractVersion, 60); assert.equal( report.migrationIds.at(-1), - 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ); const widened = runManagerPrivileges(); @@ -983,7 +993,7 @@ test('accepts isolated Package manager and executor roles', async () => { queryable({ currentUser: 'ql3_package_manager', privileges: packagePrivileges('manager'), - functionMode: 'manager', + functionMode: 'package-manager', }), ); assert.equal(manager.currentUser, 'ql3_package_manager'); @@ -1063,10 +1073,10 @@ test('accepts the exact schema and isolated Worker ingress role', async () => { }), ); assert.equal(report.currentUser, 'ql3_worker_ingress'); - assert.equal(report.contractVersion, 59); + assert.equal(report.contractVersion, 60); assert.equal( report.migrationIds.at(-1), - 'pg-0060-plugin-package-secret-materialization-guard', + 'pg-0061-plugin-package-secret-binding-approval-plans', ); }); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 66d66864..c8b27e0c 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: 61, + directSourceFiles: 62, reviewKind: 'ordered_ledger', }, { @@ -97,10 +97,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localAdmin.rootSourceFileRoles, }, { - sourceFiles: 43, + sourceFiles: 44, rootSourceFiles: 1, rootSourceLines: 9, - nestedSourceFiles: 42, + nestedSourceFiles: 43, rootSourceFileRoles: { 'runtime.ts': 'public_export' }, }, ); @@ -299,10 +299,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: runtimeCore.rootSourceFileRoles, }, { - sourceFiles: 153, + sourceFiles: 156, rootSourceFiles: 1, rootSourceLines: 160, - nestedSourceFiles: 152, + nestedSourceFiles: 155, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 99, + sourceFiles: 100, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 98, + nestedSourceFiles: 99, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', }, @@ -421,10 +421,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterPostgres.rootSourceFileRoles, }, { - sourceFiles: 157, + sourceFiles: 159, rootSourceFiles: 1, rootSourceLines: 126, - nestedSourceFiles: 156, + nestedSourceFiles: 158, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, ); @@ -540,10 +540,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localSqlite.rootSourceFileRoles, }, { - sourceFiles: 187, + sourceFiles: 188, rootSourceFiles: 1, rootSourceLines: 31, - nestedSourceFiles: 186, + nestedSourceFiles: 187, rootSourceFileRoles: { 'index.ts': 'public_export' }, }, );