diff --git a/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabPublisher.ts b/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabPublisher.ts index d4d49188..95d0ef6a 100644 --- a/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabPublisher.ts +++ b/packages/ql3-local-admin/src/legacy-adoption/legacyCrontabPublisher.ts @@ -48,6 +48,20 @@ export interface PublishReviewedLegacyCrontabAdoptionOptions { ) => void | Promise; } +export type PublishVerifiedLegacyCrontabAdoptionOptions = Pick< + PublishReviewedLegacyCrontabAdoptionOptions, + | 'sourceClient' + | 'targetPath' + | 'profile' + | 'timezone' + | 'projectId' + | 'mutationId' + | 'requestId' + | 'observedAtMs' + | 'confirmSourceIdentity' + | 'confirmReviewerAuthority' +>; + export class LegacyCrontabPublicationAuthorizationError extends Error { readonly code = 'LEGACY_CRONTAB_PUBLICATION_NOT_AUTHORIZED'; constructor() { @@ -65,7 +79,7 @@ export class LegacyCrontabPublicationUnavailableError extends Error { } function auditRecord( - options: PublishReviewedLegacyCrontabAdoptionOptions, + options: PublishVerifiedLegacyCrontabAdoptionOptions, scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope, decision: Readonly | null, outcome: SecurityAuditRecord['outcome'], @@ -157,96 +171,114 @@ export async function publishReviewedLegacyCrontabAdoption( options.observedAtMs, ), }, - async (scope) => { - options.confirmSourceIdentity(); - await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer); - const target = await openLocalSqliteAdoptionDatabase({ - databasePath: options.targetPath, - profile: options.profile, - }); + (scope) => publishVerifiedLegacyCrontabAdoption(options, scope), + ); +} + +export async function publishVerifiedLegacyCrontabAdoption( + options: PublishVerifiedLegacyCrontabAdoptionOptions, + scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope, +): Promise { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + typeof options.confirmSourceIdentity !== 'function' || + !scope || + typeof scope !== 'object' || + typeof scope.confirmIdentity !== 'function' + ) { + throw new LegacyCrontabPublicationUnavailableError( + 'Verified Legacy publication scope is invalid', + ); + } + options.confirmSourceIdentity(); + await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer); + const target = await openLocalSqliteAdoptionDatabase({ + databasePath: options.targetPath, + profile: options.profile, + }); + try { + const policy = new ProjectPolicyEngine(target.projectPolicy); + let decision: Readonly; + try { + decision = await policy.authorize( + scope.result.receipt.reviewer, + options.projectId, + 'project.manage', + ); + } catch (error) { + if (!(error instanceof ProjectPolicyUnavailableError)) { + throw new LegacyCrontabPublicationUnavailableError(); + } try { - const policy = new ProjectPolicyEngine(target.projectPolicy); - let decision: Readonly; - try { - decision = await policy.authorize( - scope.result.receipt.reviewer, - options.projectId, - 'project.manage', - ); - } catch (error) { - if (!(error instanceof ProjectPolicyUnavailableError)) { - throw new LegacyCrontabPublicationUnavailableError(); - } - try { - await target.securityAudit.record( - auditRecord(options, scope, null, 'authorization_unavailable', [ - 'policy_unavailable', - ]), - ); - } catch { - throw new LegacyCrontabPublicationUnavailableError(); - } - throw new LegacyCrontabPublicationUnavailableError(); - } - if (decision.effect !== 'allow') { - try { - await target.securityAudit.record( - auditRecord( - options, - scope, - decision, - decision.effect === 'require_approval' - ? 'approval_required' - : 'denied', - decision.reasons, - ), - ); - } catch { - throw new LegacyCrontabPublicationUnavailableError(); - } - throw new LegacyCrontabPublicationAuthorizationError(); - } - if (!decision.fence || decision.fence.bindingVersion === null) { - throw new LegacyCrontabPublicationUnavailableError(); - } - return await target.publisher.publish({ - mutationId: options.mutationId, - decisionId: scope.result.receipt.decisionId, - projectId: options.projectId, - profile: options.profile, - planDigest: scope.result.receipt.planDigest, - inventoryDigest: scope.result.receipt.inventoryDigest, - decisionDigest: scope.result.receipt.decisions.decisionDigest, - receiptDigest: scope.result.receipt.receiptDigest, - authorizationFileDigest: scope.result.file.fileDigest, - rowCount: scope.result.receipt.decisions.rowCount, - skippedCount: scope.result.receipt.decisions.dispositions.skip, - subject: scope.result.receipt.reviewer.subject, - fence: decision.fence, - audit: auditRecord( + await target.securityAudit.record( + auditRecord(options, scope, null, 'authorization_unavailable', [ + 'policy_unavailable', + ]), + ); + } catch { + throw new LegacyCrontabPublicationUnavailableError(); + } + throw new LegacyCrontabPublicationUnavailableError(); + } + if (decision.effect !== 'allow') { + try { + await target.securityAudit.record( + auditRecord( options, scope, decision, - 'allowed', + decision.effect === 'require_approval' + ? 'approval_required' + : 'denied', decision.reasons, ), - candidates: reviewedCandidates( - options.sourceClient, - options.timezone, - scope.decisions, - ), - async confirmExternalAuthority() { - options.confirmSourceIdentity(); - scope.confirmIdentity(); - await options.confirmReviewerAuthority?.( - scope.result.receipt.reviewer, - ); - }, - createdAtMs: options.observedAtMs, - }); - } finally { - await target.close(); + ); + } catch { + throw new LegacyCrontabPublicationUnavailableError(); } - }, - ); + throw new LegacyCrontabPublicationAuthorizationError(); + } + if (!decision.fence || decision.fence.bindingVersion === null) { + throw new LegacyCrontabPublicationUnavailableError(); + } + return await target.publisher.publish({ + mutationId: options.mutationId, + decisionId: scope.result.receipt.decisionId, + projectId: options.projectId, + profile: options.profile, + planDigest: scope.result.receipt.planDigest, + inventoryDigest: scope.result.receipt.inventoryDigest, + decisionDigest: scope.result.receipt.decisions.decisionDigest, + receiptDigest: scope.result.receipt.receiptDigest, + authorizationFileDigest: scope.result.file.fileDigest, + rowCount: scope.result.receipt.decisions.rowCount, + skippedCount: scope.result.receipt.decisions.dispositions.skip, + subject: scope.result.receipt.reviewer.subject, + fence: decision.fence, + audit: auditRecord( + options, + scope, + decision, + 'allowed', + decision.reasons, + ), + candidates: reviewedCandidates( + options.sourceClient, + options.timezone, + scope.decisions, + ), + async confirmExternalAuthority() { + options.confirmSourceIdentity(); + scope.confirmIdentity(); + await options.confirmReviewerAuthority?.( + scope.result.receipt.reviewer, + ); + }, + createdAtMs: options.observedAtMs, + }); + } finally { + await target.close(); + } } diff --git a/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts b/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts index 772d4f9c..89a64c15 100644 --- a/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts +++ b/packages/ql3-local-admin/src/legacy-adoption/reconciliationAutomationDecision.ts @@ -2,6 +2,11 @@ import type { DatabaseSync } from 'node:sqlite'; import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret'; import type { SecurityPrincipal } from '@qinglong/runtime-core/security'; +import { + publishVerifiedLegacyCrontabAdoption, + type PublishReviewedLegacyCrontabAdoptionResult, + type PublishVerifiedLegacyCrontabAdoptionOptions, +} from './legacyCrontabPublisher'; import { publishLegacyCrontabDecisionAuthorizationFile, withVerifiedLegacyCrontabDecisionAuthorizationFile, @@ -35,7 +40,7 @@ export interface ReconciliationAutomationDecisionIdentity { readonly inventoryDigest: string; } -interface ReconciliationAutomationDecisionVerificationOptions +export interface ReconciliationAutomationDecisionVerificationOptions extends ReconciliationAutomationDecisionIdentity { readonly authorizationPath: string; readonly sourceClient: DatabaseSync; @@ -47,6 +52,13 @@ interface ReconciliationAutomationDecisionVerificationOptions readonly allowedParentModes?: readonly (0o500 | 0o700)[]; } +export interface ApplyReconciliationAutomationDecisionOptions + extends ReconciliationAutomationDecisionVerificationOptions, + Omit< + PublishVerifiedLegacyCrontabAdoptionOptions, + 'sourceClient' | 'profile' | 'timezone' + > {} + export interface IssueReconciliationAutomationDecisionOptions extends ReconciliationAutomationDecisionVerificationOptions { readonly reviewFilePath: string; @@ -409,3 +421,29 @@ export async function verifyReconciliationAutomationDecision( return scope.result; }); } + +export async function applyReconciliationAutomationDecision( + options: ApplyReconciliationAutomationDecisionOptions, +): Promise { + return withVerifiedReconciliationAutomationDecision(options, (scope) => + publishVerifiedLegacyCrontabAdoption( + { + sourceClient: options.sourceClient, + targetPath: options.targetPath, + profile: options.profile, + timezone: options.timezone, + projectId: options.projectId, + mutationId: options.mutationId, + requestId: options.requestId, + observedAtMs: options.observedAtMs, + confirmSourceIdentity: options.confirmSourceIdentity, + ...(options.confirmReviewerAuthority === undefined + ? {} + : { + confirmReviewerAuthority: options.confirmReviewerAuthority, + }), + }, + scope, + ), + ); +} diff --git a/packages/ql3-local-admin/test/adoption.test.cjs b/packages/ql3-local-admin/test/adoption.test.cjs index dc16e2ca..7daa9b67 100644 --- a/packages/ql3-local-admin/test/adoption.test.cjs +++ b/packages/ql3-local-admin/test/adoption.test.cjs @@ -24,6 +24,9 @@ const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration'); const { visitLegacyCrontabAdoptionInspections, } = require('../dist/legacy-adoption/legacyCrontabAdoption'); +const { + applyReconciliationAutomationDecision, +} = require('../dist/legacy-adoption/reconciliationAutomationDecision'); const REVIEWED_AT_MS = 1_760_000_000_000; const REVIEWER = Object.freeze({ @@ -442,19 +445,20 @@ test('publishes one reviewed legacy task set into the target atomically', async authorizationDirectory, `${decisionId}.ndjson`, ); - await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({ - sourcePath: value.sourcePath, - profile: 'edge', - legacyTimezone: 'UTC', - expectedPlanDigest: plan.planDigest, - decisionId, - reviewer: REVIEWER, - issuedAtMs: REVIEWED_AT_MS, - expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000, - decisions: page.diagnostics.map(decisionFor), - authorizationPath, - keyProvider: authorizationKeyProvider(), - }); + const authorization = + await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({ + sourcePath: value.sourcePath, + profile: 'edge', + legacyTimezone: 'UTC', + expectedPlanDigest: plan.planDigest, + decisionId, + reviewer: REVIEWER, + issuedAtMs: REVIEWED_AT_MS, + expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000, + decisions: page.diagnostics.map(decisionFor), + authorizationPath, + keyProvider: authorizationKeyProvider(), + }); await migrateLocalSqlitePath({ databasePath: value.targetPath, profile: 'edge', @@ -492,13 +496,42 @@ test('publishes one reviewed legacy task set into the target atomically', async reviewerAuthorityChecks += 1; }, }; - const inserted = await publishReviewedLegacyCrontabAdoption( - publicationOptions, - ); + const publicationSource = new DatabaseSync(value.sourcePath, { + readOnly: true, + }); + let sourceIdentityChecks = 0; + const inserted = await applyReconciliationAutomationDecision({ + authorizationPath, + decisionId, + profile: 'edge', + automationPlanDigest: plan.planDigest, + inventoryDigest: authorization.receipt.inventoryDigest, + sourceClient: publicationSource, + timezone: 'UTC', + keyProvider: authorizationKeyProvider(), + observedAtMs: REVIEWED_AT_MS + 1, + openRequirements: () => + page.diagnostics.map((diagnostic) => ({ + rowOrdinal: diagnostic.rowOrdinal, + sourceDigest: diagnostic.sourceDigest, + classification: diagnostic.classification, + requirement: 'review_adopt', + })), + targetPath: value.targetPath, + projectId: 'default', + mutationId, + requestId: 'reconciliation-automation-publication-test', + confirmSourceIdentity() { + sourceIdentityChecks += 1; + }, + confirmReviewerAuthority: publicationOptions.confirmReviewerAuthority, + }); + publicationSource.close(); assert.equal(inserted.status, 'inserted'); assert.equal(inserted.adoption.adoptedTaskCount, 1); assert.equal(inserted.adoption.adoptedTriggerCount, 1); assert.equal(reviewerAuthorityChecks, 2); + assert.equal(sourceIdentityChecks, 2); assert.equal( (await publishReviewedLegacyCrontabAdoption(publicationOptions)).status, 'existing',