import { MAX_APPROVAL_LIFETIME_MS, createApprovalRequest, normalizeApprovalRequestRecord, type ApprovalDecision, type ApprovalDecisionMode, type ApprovalRequestRecord, type ApprovalRequestRepository, type ApprovalRisk, type CreateApprovalRequestResult, type DecideApprovalRequestResult, type ConsumeDurableApprovalRequestResult, } from '../approved-action/approvedAction'; import type { ApprovedActionDispatchBatchSummary } from '../approved-action/approvedActionDispatcher'; import { createPluginPackageInstallProposal, normalizePluginPackageInstallProposal, type CreatePluginPackageInstallProposalResult, type PluginPackageInstallProposal, type PluginPackageInstallProposalRepository, } from './pluginPackageProposal'; import { normalizePluginPackageInstallActionInput, type PluginPackageInstallActionInput, } from './installation/pluginPackageInstall'; import { ProjectPolicyEngine, type ProjectPolicyRequest, } from '../security/project-policy/projectPolicy'; import { normalizeSecurityPrincipal, type SecurityPrincipal, type SecuritySubject, } from '../security/security'; import type { SecurityAuditRecord } from '../security/audit/securityAudit'; const DEFAULT_APPROVAL_LIFETIME_MS = 15 * 60 * 1000; const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/; const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; const AUTHENTICATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; export interface PluginPackageManagementDispatchPort { dispatchBatch( options?: Readonly<{ limit?: number }>, ): Promise>; } export const PLUGIN_PACKAGE_MANAGEMENT_QUOTA_OPERATIONS = [ 'plugin-package.propose', 'plugin-package.decide', 'plugin-package.inspect', ] as const; export type PluginPackageManagementQuotaOperation = (typeof PLUGIN_PACKAGE_MANAGEMENT_QUOTA_OPERATIONS)[number]; export interface ConsumePluginPackageManagementQuotaCommand { readonly projectId: string; readonly subject: SecuritySubject; readonly operation: PluginPackageManagementQuotaOperation; readonly idempotencyKey: string; } export interface PluginPackageManagementQuotaResult { readonly remaining: number; readonly resetAtMs: number; readonly observedAtMs: number; } export interface PluginPackageManagementQuotaPort { consume( command: ConsumePluginPackageManagementQuotaCommand, ): Promise>; } export interface PluginPackageManagementOptions { readonly decisionMode: ApprovalDecisionMode; readonly consumer: Readonly<{ subject: SecuritySubject; authenticationId: string; }>; readonly approvalLifetimeMs?: number; readonly risk?: ApprovalRisk; readonly now?: () => number; readonly quota?: PluginPackageManagementQuotaPort; } export interface ProposePluginPackageInstallRequest { readonly actionRef: string; readonly approvalRequestId: string; readonly proposalAuditEventId: string; readonly approvalAuditEventId: string; readonly requestedAtMs: number; readonly actionInput: PluginPackageInstallActionInput; readonly principal: SecurityPrincipal; } export interface ProposePluginPackageInstallResult { readonly proposalStatus: CreatePluginPackageInstallProposalResult['status']; readonly approvalStatus: CreateApprovalRequestResult['status']; readonly proposal: Readonly; readonly approvalRequest: Readonly; } export interface DecidePluginPackageInstallRequest { readonly approvalRequestId: string; readonly expectedVersion: number; readonly decisionId: string; readonly auditEventId: string; readonly decision: ApprovalDecision; readonly reasonCode: string; readonly decidedAtMs: number; readonly principal: SecurityPrincipal; } export interface ConsumePluginPackageInstallRequest { readonly approvalRequestId: string; readonly expectedVersion: number; readonly consumptionId: string; readonly dispatchId: string; readonly auditEventId: string; readonly consumedAtMs: number; } export interface InspectPluginPackageInstallResult { readonly proposal: Readonly | null; readonly approvalRequest: Readonly | null; } export interface PluginPackageManagementService { propose( request: ProposePluginPackageInstallRequest, ): Promise>; decide( request: DecidePluginPackageInstallRequest, ): Promise>; consume( request: ConsumePluginPackageInstallRequest, ): Promise>; inspect( actionRef: string, approvalRequestId: string, ): Promise>; dispatch( limit?: number, ): Promise>; } export class PluginPackageManagementConfigurationError extends TypeError { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_CONFIGURATION_INVALID'; constructor(message: string) { super(`Plugin Package management configuration is invalid: ${message}`); this.name = 'PluginPackageManagementConfigurationError'; } } export class PluginPackageManagementRequestError extends TypeError { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_REQUEST_INVALID'; constructor(message: string) { super(`Plugin Package management request is invalid: ${message}`); this.name = 'PluginPackageManagementRequestError'; } } export class PluginPackageManagementAuthorizationError extends Error { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_FORBIDDEN'; constructor() { super('Plugin Package management is not authorized'); this.name = 'PluginPackageManagementAuthorizationError'; } } export class PluginPackageManagementConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_CONFLICT'; constructor(message = 'Plugin Package management state conflicts') { super(message); this.name = 'PluginPackageManagementConflictError'; } } export class PluginPackageManagementUnavailableError extends Error { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Plugin Package management is unavailable', options); this.name = 'PluginPackageManagementUnavailableError'; } } export class PluginPackageManagementQuotaExceededError extends Error { readonly code = 'PLUGIN_PACKAGE_MANAGEMENT_QUOTA_EXCEEDED'; constructor(readonly retryAfterMs: number) { if ( !Number.isSafeInteger(retryAfterMs) || retryAfterMs < 1 || retryAfterMs > 5 * 60_000 ) { throw new TypeError( 'Plugin Package management quota retry delay is invalid', ); } super('Plugin Package management quota is exhausted'); this.name = 'PluginPackageManagementQuotaExceededError'; } } function exactObject( value: unknown, expectedKeys: readonly string[], label: string, ): asserts value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new PluginPackageManagementRequestError(`${label} must be an object`); } const actual = Object.keys(value).sort(); const expected = [...expectedKeys].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( 'action reference is invalid', ); } return value; } function timestamp(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { throw new PluginPackageManagementRequestError(`${label} is invalid`); } return value as number; } function positiveInteger( value: unknown, label: string, maximum: number, ): number { if ( !Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum ) { throw new PluginPackageManagementRequestError(`${label} is invalid`); } return value as number; } function sameSubject( left: Readonly, right: Readonly, ): boolean { return left.type === right.type && left.id === right.id; } function sameProposal( stored: Readonly, expected: Readonly, ): boolean { return stored.proposalDigest === expected.proposalDigest; } function sameApprovalRequest( stored: Readonly, expected: Readonly, ): boolean { return ( stored.id === expected.id && stored.projectId === expected.projectId && stored.action.actionRef === expected.action.actionRef && stored.action.actionType === expected.action.actionType && stored.action.permission === expected.action.permission && stored.action.actionDigest === expected.action.actionDigest && stored.action.previewDigest === expected.action.previewDigest && stored.risk === expected.risk && stored.decisionMode === expected.decisionMode && sameSubject(stored.requestedBy, expected.requestedBy) && stored.requestedAtMs === expected.requestedAtMs && stored.expiresAtMs === expected.expiresAtMs ); } function allowedPolicy( effect: 'allow' | 'deny' | 'require_approval', allowApproval: boolean, ): boolean { return effect === 'allow' || (allowApproval && effect === 'require_approval'); } function audit( eventId: string, requestId: string, operationId: string, projectId: string, subject: Readonly, authenticationId: string, outcome: 'allowed' | 'approval_required', reasons: readonly string[], fence: Readonly<{ projectVersion: number; bindingVersion: number | null }>, occurredAtMs: number, ): SecurityAuditRecord { return { eventId, requestId, operationId, projectId, subject, authenticationId, outcome, reasons, fence, occurredAtMs, }; } export function createPluginPackageManagementService( policy: ProjectPolicyEngine, proposals: PluginPackageInstallProposalRepository, approvals: ApprovalRequestRepository, dispatcher: PluginPackageManagementDispatchPort, options: PluginPackageManagementOptions, ): PluginPackageManagementService { if ( !policy || typeof policy.authorize !== 'function' || typeof policy.decide !== 'function' ) { throw new PluginPackageManagementConfigurationError( 'Project policy is invalid', ); } if ( !proposals || typeof proposals.findProposalByActionRef !== 'function' || typeof proposals.createProposal !== 'function' ) { throw new PluginPackageManagementConfigurationError( 'proposal repository is invalid', ); } if ( !approvals || typeof approvals.findById !== 'function' || typeof approvals.create !== 'function' || typeof approvals.decide !== 'function' || typeof approvals.consume !== 'function' ) { throw new PluginPackageManagementConfigurationError( 'approval repository is invalid', ); } if (!dispatcher || typeof dispatcher.dispatchBatch !== 'function') { throw new PluginPackageManagementConfigurationError( 'dispatcher is invalid', ); } if (!options || typeof options !== 'object' || Array.isArray(options)) { throw new PluginPackageManagementConfigurationError('options are invalid'); } const optionKeys = Object.keys(options); if ( optionKeys.some( (key) => key !== 'decisionMode' && key !== 'consumer' && key !== 'approvalLifetimeMs' && key !== 'risk' && key !== 'now' && key !== 'quota', ) ) { throw new PluginPackageManagementConfigurationError( 'options shape is invalid', ); } if ( options.decisionMode !== 'human_confirmation' && options.decisionMode !== 'separation_of_duty' ) { throw new PluginPackageManagementConfigurationError( 'decision mode is invalid', ); } if ( !options.consumer || typeof options.consumer !== 'object' || Array.isArray(options.consumer) || Object.keys(options.consumer).length !== 2 || !Object.hasOwn(options.consumer, 'subject') || !Object.hasOwn(options.consumer, 'authenticationId') || options.consumer.subject?.type !== 'system' || typeof options.consumer.subject.id !== 'string' || options.consumer.subject.id.length < 1 || options.consumer.subject.id.length > 255 || !AUTHENTICATION_ID_PATTERN.test(options.consumer.authenticationId) ) { throw new PluginPackageManagementConfigurationError( 'consumer authority is invalid', ); } const approvalLifetimeMs = options.approvalLifetimeMs ?? DEFAULT_APPROVAL_LIFETIME_MS; if ( !Number.isSafeInteger(approvalLifetimeMs) || approvalLifetimeMs < 1_000 || approvalLifetimeMs > MAX_APPROVAL_LIFETIME_MS ) { throw new PluginPackageManagementConfigurationError( 'approval lifetime is invalid', ); } const risk = options.risk ?? 'high'; if (!['low', 'medium', 'high', 'critical'].includes(risk)) { throw new PluginPackageManagementConfigurationError('risk is invalid'); } if (options.now !== undefined && typeof options.now !== 'function') { throw new PluginPackageManagementConfigurationError('clock is invalid'); } if ( options.quota !== undefined && (!options.quota || typeof options.quota !== 'object' || Array.isArray(options.quota) || typeof options.quota.consume !== 'function') ) { throw new PluginPackageManagementConfigurationError('quota is invalid'); } const now = options.now ?? Date.now; const consumer = Object.freeze({ subject: Object.freeze({ type: 'system' as const, id: options.consumer.subject.id, }), authenticationId: options.consumer.authenticationId, }); const currentTime = (): number => { const value = now(); if (!Number.isSafeInteger(value) || value < 0) { throw new PluginPackageManagementUnavailableError(); } return value; }; const authorizePrincipal = async ( principalValue: SecurityPrincipal, projectId: string, permission: 'package.manage' | 'approval.decide', observedAtMs: number, allowApproval: boolean, ) => { let principal: Readonly; try { principal = normalizeSecurityPrincipal(principalValue, observedAtMs); } catch { 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 ( !allowedPolicy(decision.effect, allowApproval) || decision.fence === null ) { throw new PluginPackageManagementAuthorizationError(); } return Object.freeze({ principal, fence: decision.fence }); }; 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 propose = async ( request: ProposePluginPackageInstallRequest, ): Promise> => { exactObject( request, [ 'actionRef', 'approvalRequestId', 'proposalAuditEventId', 'approvalAuditEventId', 'requestedAtMs', 'actionInput', 'principal', ], 'proposal request', ); const requestedActionRef = actionRef(request.actionRef); const approvalRequestId = identifier( request.approvalRequestId, 'approval request id', ); const requestedAtMs = timestamp(request.requestedAtMs, 'request time'); const observedAtMs = currentTime(); if ( requestedAtMs > observedAtMs || observedAtMs >= requestedAtMs + approvalLifetimeMs ) { throw new PluginPackageManagementRequestError( 'request time is outside the approval lifetime', ); } let normalizedActionInput: Readonly; try { normalizedActionInput = normalizePluginPackageInstallActionInput( request.actionInput, ); } catch { throw new PluginPackageManagementRequestError( 'install action input is invalid', ); } const { principal, fence } = await authorizePrincipal( request.principal, normalizedActionInput.projectId, 'package.manage', observedAtMs, true, ); await consumeQuota( normalizedActionInput.projectId, principal, 'plugin-package.propose', requestedActionRef, ); const expectedProposal = createPluginPackageInstallProposal({ actionRef: requestedActionRef, actionInput: normalizedActionInput, proposedBy: principal.subject, proposalFence: fence, createdAtMs: requestedAtMs, }); let proposalResult: Readonly; const existingProposal = await proposals.findProposalByActionRef( requestedActionRef, ); if (existingProposal) { const normalized = normalizePluginPackageInstallProposal(existingProposal); if (!sameProposal(normalized, expectedProposal)) { throw new PluginPackageManagementConflictError( 'Plugin Package proposal identity already has different content', ); } proposalResult = Object.freeze({ status: 'existing' as const, proposal: normalized, }); } else { proposalResult = await proposals.createProposal({ proposal: expectedProposal, audit: audit( identifier(request.proposalAuditEventId, 'proposal audit event id'), requestedActionRef, 'plugin_package.propose', expectedProposal.projectId, principal.subject, principal.authenticationId, 'allowed', ['package_proposal'], fence, requestedAtMs, ), }); } const proposal = normalizePluginPackageInstallProposal( proposalResult.proposal, ); const approval = createApprovalRequest({ id: approvalRequestId, projectId: proposal.projectId, action: { permission: proposal.permission, actionType: proposal.actionType, actionRef: proposal.actionRef, actionDigest: proposal.actionDigest, previewDigest: proposal.previewDigest, }, risk, decisionMode: options.decisionMode, requestedBy: principal.subject, requestedAtMs, expiresAtMs: requestedAtMs + approvalLifetimeMs, requestFence: fence, }); let approvalResult: Readonly; const existingApproval = await approvals.findById(approvalRequestId); if (existingApproval) { const normalized = normalizeApprovalRequestRecord(existingApproval); if (!sameApprovalRequest(normalized, approval)) { throw new PluginPackageManagementConflictError( 'Approval request identity already has different content', ); } approvalResult = Object.freeze({ status: 'existing' as const, request: normalized, }); } else { approvalResult = await approvals.create({ request: approval, audit: audit( identifier(request.approvalAuditEventId, 'approval audit event id'), approvalRequestId, 'approval.request', proposal.projectId, principal.subject, principal.authenticationId, 'approval_required', ['package_review'], fence, requestedAtMs, ), }); } return Object.freeze({ proposalStatus: proposalResult.status, approvalStatus: approvalResult.status, proposal, approvalRequest: approvalResult.request, }); }; const decide = async ( request: DecidePluginPackageInstallRequest, ): Promise> => { exactObject( request, [ 'approvalRequestId', 'expectedVersion', 'decisionId', 'auditEventId', 'decision', 'reasonCode', 'decidedAtMs', 'principal', ], 'decision request', ); const approvalRequestId = identifier( request.approvalRequestId, 'approval request id', ); const expectedVersion = positiveInteger( request.expectedVersion, 'expected version', 2_147_483_647, ); const decisionId = identifier(request.decisionId, 'decision id'); if (request.decision !== 'approved' && request.decision !== 'rejected') { throw new PluginPackageManagementRequestError('decision is invalid'); } if ( typeof request.reasonCode !== 'string' || !REASON_PATTERN.test(request.reasonCode) ) { throw new PluginPackageManagementRequestError( 'decision reason is invalid', ); } const decidedAtMs = timestamp(request.decidedAtMs, 'decision time'); const observedAtMs = currentTime(); if (decidedAtMs > observedAtMs) { throw new PluginPackageManagementRequestError( 'decision time is in the future', ); } const current = await approvals.findById(approvalRequestId); if (!current) { throw new PluginPackageManagementConflictError( 'Approval request does not exist', ); } const approval = normalizeApprovalRequestRecord(current); const { principal, fence } = await authorizePrincipal( request.principal, approval.projectId, 'approval.decide', observedAtMs, false, ); await consumeQuota( approval.projectId, principal, 'plugin-package.decide', decisionId, ); return approvals.decide({ requestId: approvalRequestId, expectedVersion, decisionId, decision: request.decision, reasonCode: request.reasonCode, principal, decidedAtMs, authorizationFence: fence, audit: audit( identifier(request.auditEventId, 'decision audit event id'), approvalRequestId, 'approval.decide', approval.projectId, principal.subject, principal.authenticationId, 'allowed', ['package_review'], fence, decidedAtMs, ), }); }; const consume = async ( request: ConsumePluginPackageInstallRequest, ): Promise> => { exactObject( request, [ 'approvalRequestId', 'expectedVersion', 'consumptionId', 'dispatchId', 'auditEventId', 'consumedAtMs', ], 'consumption request', ); const approvalRequestId = identifier( request.approvalRequestId, 'approval request id', ); const expectedVersion = positiveInteger( request.expectedVersion, 'expected version', 2_147_483_647, ); const consumptionId = identifier(request.consumptionId, 'consumption id'); const dispatchId = identifier(request.dispatchId, 'dispatch id'); const consumedAtMs = timestamp(request.consumedAtMs, 'consumption time'); const observedAtMs = currentTime(); if (consumedAtMs > observedAtMs) { throw new PluginPackageManagementRequestError( 'consumption time is in the future', ); } const current = await approvals.findById(approvalRequestId); if (!current) { throw new PluginPackageManagementConflictError( 'Approval request does not exist', ); } const approval = normalizeApprovalRequestRecord(current); let policyDecision; try { const policyRequest: ProjectPolicyRequest = { subject: approval.requestedBy, projectId: approval.projectId, permission: 'package.manage', }; policyDecision = await policy.decide(policyRequest); } catch (error) { throw new PluginPackageManagementUnavailableError({ cause: error instanceof Error ? error : undefined, }); } if (!allowedPolicy(policyDecision.effect, true) || !policyDecision.fence) { throw new PluginPackageManagementAuthorizationError(); } const fence = policyDecision.fence; return approvals.consume({ requestId: approvalRequestId, expectedVersion, consumptionId, dispatchId, action: approval.action, requestedBy: approval.requestedBy, consumedBy: consumer.subject, consumedAtMs, authorizationFence: fence, audit: audit( identifier(request.auditEventId, 'consumption audit event id'), approvalRequestId, 'approval.consume', approval.projectId, consumer.subject, consumer.authenticationId, 'allowed', ['package_review'], fence, consumedAtMs, ), }); }; return Object.freeze({ propose, decide, consume, async inspect(requestedActionRef: string, approvalRequestIdValue: string) { const normalizedActionRef = actionRef(requestedActionRef); const approvalRequestId = identifier( approvalRequestIdValue, 'approval request id', ); const [proposal, approvalRequest] = await Promise.all([ proposals.findProposalByActionRef(normalizedActionRef), approvals.findById(approvalRequestId), ]); return Object.freeze({ proposal: proposal ? normalizePluginPackageInstallProposal(proposal) : null, approvalRequest: approvalRequest ? normalizeApprovalRequestRecord(approvalRequest) : null, }); }, dispatch(limit?: number) { return dispatcher.dispatchBatch( limit === undefined ? undefined : { limit: positiveInteger(limit, 'dispatch limit', 64) }, ); }, }); }