mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): resolve secret action recovery manually
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
import {
|
||||
PostgresApprovalManagementIdentityKeysetLedgerRepository,
|
||||
PostgresApprovedActionManualRecoveryRepository,
|
||||
PostgresApprovalRequestRepository,
|
||||
PostgresApprovalRequestSource,
|
||||
PostgresProjectPolicyRepository,
|
||||
PostgresSecurityAuditRepository,
|
||||
} from '@qinglong/cluster-postgres/approval-manager';
|
||||
import type { PostgresPool } from '@qinglong/runtime-core';
|
||||
import {
|
||||
createApprovedActionManualRecoveryService,
|
||||
type ApprovedActionManualRecoveryInspectRequest,
|
||||
type ApprovedActionManualRecoveryResolveRequest,
|
||||
type ApprovedActionManualRecoveryService,
|
||||
} from '@qinglong/runtime-core/approved-action-manual-recovery';
|
||||
import {
|
||||
createApprovalDecisionService,
|
||||
type ApprovalDecisionRequest,
|
||||
@@ -28,6 +35,14 @@ export interface ClusterApprovalManagementService {
|
||||
request: ApprovalDecisionRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): ReturnType<ApprovalDecisionService['decide']>;
|
||||
inspectRecovery(
|
||||
request: ApprovedActionManualRecoveryInspectRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): ReturnType<ApprovedActionManualRecoveryService['inspect']>;
|
||||
resolveRecovery(
|
||||
request: ApprovedActionManualRecoveryResolveRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
): ReturnType<ApprovedActionManualRecoveryService['resolve']>;
|
||||
recordFailure(record: SecurityAuditRecord): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -69,6 +84,12 @@ export function createClusterApprovalManagementService(
|
||||
const approvals = new PostgresApprovalRequestRepository(options.pool);
|
||||
const source = new PostgresApprovalRequestSource(options.pool);
|
||||
const audit = new PostgresSecurityAuditRepository(options.pool);
|
||||
const recovery = createApprovedActionManualRecoveryService({
|
||||
repository: new PostgresApprovedActionManualRecoveryRepository(options.pool),
|
||||
policy,
|
||||
audit,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
});
|
||||
return Object.freeze({
|
||||
inspect(
|
||||
request: ApprovalInspectionRequest,
|
||||
@@ -93,6 +114,18 @@ export function createClusterApprovalManagementService(
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
}).decide(request);
|
||||
},
|
||||
inspectRecovery(
|
||||
request: ApprovedActionManualRecoveryInspectRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
) {
|
||||
return recovery.inspect(request, confirmAuthorization);
|
||||
},
|
||||
resolveRecovery(
|
||||
request: ApprovedActionManualRecoveryResolveRequest,
|
||||
confirmAuthorization: () => void | Promise<void>,
|
||||
) {
|
||||
return recovery.resolve(request, confirmAuthorization);
|
||||
},
|
||||
recordFailure(record: SecurityAuditRecord) {
|
||||
return audit.record(record);
|
||||
},
|
||||
|
||||
@@ -142,15 +142,139 @@ function decisionApproval(
|
||||
safeTime(approval.decidedAtMs);
|
||||
}
|
||||
|
||||
function recoveryResult(
|
||||
value: unknown,
|
||||
command: Extract<
|
||||
ClusterApprovalManagementCommand,
|
||||
{ operation: 'approval.recover.inspect' | 'approval.recover.resolve' }
|
||||
>,
|
||||
): void {
|
||||
const recovery = exact(value, [
|
||||
'projectId',
|
||||
'dispatchId',
|
||||
'approvalRequestId',
|
||||
'expectedAction',
|
||||
'execution',
|
||||
'resolution',
|
||||
]);
|
||||
if (
|
||||
identifier(recovery.projectId) !== command.request.projectId ||
|
||||
identifier(recovery.dispatchId) !== command.request.dispatchId
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
identifier(recovery.approvalRequestId);
|
||||
action(recovery.expectedAction);
|
||||
const execution = exact(recovery.execution, [
|
||||
'status',
|
||||
'version',
|
||||
'executionDigest',
|
||||
'attemptCount',
|
||||
'maxAttempts',
|
||||
'startedAtMs',
|
||||
'leaseExpiresAtMs',
|
||||
'resultMutationId',
|
||||
'resultCode',
|
||||
'resultDigest',
|
||||
'completedAtMs',
|
||||
'createdAtMs',
|
||||
'updatedAtMs',
|
||||
]);
|
||||
if (
|
||||
![
|
||||
'pending',
|
||||
'leased',
|
||||
'executing',
|
||||
'recovery_required',
|
||||
'retry_wait',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'blocked',
|
||||
].includes(execution.status as string) ||
|
||||
!Number.isSafeInteger(execution.version) ||
|
||||
Number(execution.version) < 1 ||
|
||||
typeof execution.executionDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(execution.executionDigest) ||
|
||||
!Number.isSafeInteger(execution.attemptCount) ||
|
||||
Number(execution.attemptCount) < 0 ||
|
||||
!Number.isSafeInteger(execution.maxAttempts) ||
|
||||
Number(execution.maxAttempts) < 1
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
safeTime(execution.startedAtMs, true);
|
||||
safeTime(execution.leaseExpiresAtMs, true);
|
||||
safeTime(execution.completedAtMs, true);
|
||||
safeTime(execution.createdAtMs);
|
||||
safeTime(execution.updatedAtMs);
|
||||
for (const field of ['resultMutationId', 'resultCode']) {
|
||||
const item = execution[field];
|
||||
if (item !== null) identifier(item);
|
||||
}
|
||||
if (
|
||||
execution.resultDigest !== null &&
|
||||
(typeof execution.resultDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(execution.resultDigest))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (recovery.resolution !== null) {
|
||||
const resolution = exact(recovery.resolution, [
|
||||
'mutationId',
|
||||
'decision',
|
||||
'evidenceDigest',
|
||||
'reasonCode',
|
||||
'resolvedBy',
|
||||
'resolvedAtMs',
|
||||
'resolutionDigest',
|
||||
]);
|
||||
identifier(resolution.mutationId);
|
||||
if (
|
||||
(resolution.decision !== 'confirm_failed' &&
|
||||
resolution.decision !== 'abandon_unknown') ||
|
||||
typeof resolution.evidenceDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(resolution.evidenceDigest) ||
|
||||
typeof resolution.reasonCode !== 'string' ||
|
||||
!/^[a-z][a-z0-9_]{0,63}$/.test(resolution.reasonCode) ||
|
||||
typeof resolution.resolutionDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(resolution.resolutionDigest)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
subject(resolution.resolvedBy);
|
||||
safeTime(resolution.resolvedAtMs);
|
||||
if (
|
||||
command.operation === 'approval.recover.resolve' &&
|
||||
(resolution.mutationId !== command.request.mutationId ||
|
||||
resolution.decision !== command.request.decision ||
|
||||
resolution.evidenceDigest !== command.request.evidenceDigest ||
|
||||
resolution.reasonCode !== command.request.reasonCode)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
if (
|
||||
command.operation === 'approval.recover.resolve' &&
|
||||
(recovery.resolution === null ||
|
||||
Number(execution.version) !== command.request.expectedExecutionVersion + 1 ||
|
||||
(execution.status !== 'failed' && execution.status !== 'blocked'))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
export function validateClusterApprovalManagementClientResult(
|
||||
value: unknown,
|
||||
command: Readonly<ClusterApprovalManagementCommand>,
|
||||
): Readonly<ClusterApprovalManagementTransportResult> {
|
||||
const recoveryOperation =
|
||||
command.operation === 'approval.recover.inspect' ||
|
||||
command.operation === 'approval.recover.resolve';
|
||||
const envelope = exact(value, [
|
||||
'schemaVersion',
|
||||
'operation',
|
||||
'status',
|
||||
'approval',
|
||||
recoveryOperation ? 'recovery' : 'approval',
|
||||
]);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
@@ -158,7 +282,22 @@ export function validateClusterApprovalManagementClientResult(
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (command.operation === 'approval.inspect') {
|
||||
if (command.operation === 'approval.recover.inspect') {
|
||||
if (
|
||||
(envelope.status !== 'found' && envelope.status !== 'absent') ||
|
||||
(envelope.status === 'absent') !== (envelope.recovery === null)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (envelope.recovery !== null) {
|
||||
recoveryResult(envelope.recovery, command);
|
||||
}
|
||||
} else if (command.operation === 'approval.recover.resolve') {
|
||||
if (envelope.status !== 'resolved' && envelope.status !== 'existing') {
|
||||
invalid();
|
||||
}
|
||||
recoveryResult(envelope.recovery, command);
|
||||
} else if (command.operation === 'approval.inspect') {
|
||||
if (
|
||||
(envelope.status !== 'found' && envelope.status !== 'absent') ||
|
||||
(envelope.status === 'absent') !== (envelope.approval === null)
|
||||
|
||||
+225
-25
@@ -7,6 +7,15 @@ import {
|
||||
normalizeApprovedActionBinding,
|
||||
type ApprovedActionBinding,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
ApprovedActionManualRecoveryAuthorizationError,
|
||||
ApprovedActionManualRecoveryFenceConflictError,
|
||||
ApprovedActionManualRecoveryTargetUnavailableError,
|
||||
ApprovedActionManualRecoveryUnavailableError,
|
||||
ApprovedActionManualRecoveryUnsupportedError,
|
||||
type ApprovedActionManualRecoverySnapshot,
|
||||
} from '@qinglong/runtime-core/approved-action-manual-recovery';
|
||||
import { approvedActionExecutionEffectiveStatus } from '@qinglong/runtime-core/approved-action-execution';
|
||||
import {
|
||||
ApprovalDecisionAuthorizationError,
|
||||
ApprovalDecisionBindingConflictError,
|
||||
@@ -30,30 +39,54 @@ const UUID_V4_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const STRONG_ASSURANCES = new Set(['multi_factor', 'hardware']);
|
||||
|
||||
interface BaseRequest {
|
||||
interface AuditBaseRequest {
|
||||
readonly projectId: string;
|
||||
readonly approvalRequestId: string;
|
||||
readonly requestId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly failureAuditEventId: string;
|
||||
}
|
||||
|
||||
interface ApprovalBaseRequest extends AuditBaseRequest {
|
||||
readonly approvalRequestId: string;
|
||||
}
|
||||
|
||||
interface RecoveryBaseRequest extends AuditBaseRequest {
|
||||
readonly dispatchId: string;
|
||||
}
|
||||
|
||||
export type ClusterApprovalManagementCommand =
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.inspect';
|
||||
request: BaseRequest;
|
||||
request: ApprovalBaseRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.decide';
|
||||
request: BaseRequest & {
|
||||
request: ApprovalBaseRequest & {
|
||||
readonly expectedVersion: 1;
|
||||
readonly expectedAction: Readonly<ApprovedActionBinding>;
|
||||
readonly decisionId: string;
|
||||
readonly decision: 'approved' | 'rejected';
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.recover.inspect';
|
||||
request: RecoveryBaseRequest;
|
||||
}>
|
||||
| Readonly<{
|
||||
schemaVersion: 1;
|
||||
operation: 'approval.recover.resolve';
|
||||
request: RecoveryBaseRequest & {
|
||||
readonly expectedExecutionVersion: number;
|
||||
readonly expectedExecutionDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly decision: 'confirm_failed' | 'abandon_unknown';
|
||||
readonly evidenceDigest: string;
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ClusterApprovalManagementTransportResult = Readonly<
|
||||
@@ -163,45 +196,109 @@ export function normalizeClusterApprovalManagementCommand(
|
||||
const envelope = exact(value, ['schemaVersion', 'operation', 'request']);
|
||||
if (
|
||||
envelope.schemaVersion !== 1 ||
|
||||
(envelope.operation !== 'approval.inspect' &&
|
||||
envelope.operation !== 'approval.decide')
|
||||
![
|
||||
'approval.inspect',
|
||||
'approval.decide',
|
||||
'approval.recover.inspect',
|
||||
'approval.recover.resolve',
|
||||
].includes(envelope.operation as string)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const operation = envelope.operation;
|
||||
const base = [
|
||||
const operation = envelope.operation as ClusterApprovalManagementCommand['operation'];
|
||||
const auditBase = [
|
||||
'projectId',
|
||||
'approvalRequestId',
|
||||
'requestId',
|
||||
'auditEventId',
|
||||
'failureAuditEventId',
|
||||
];
|
||||
const recoveryOperation =
|
||||
operation === 'approval.recover.inspect' ||
|
||||
operation === 'approval.recover.resolve';
|
||||
const base = [
|
||||
...auditBase,
|
||||
recoveryOperation ? 'dispatchId' : 'approvalRequestId',
|
||||
];
|
||||
const request = exact(
|
||||
envelope.request,
|
||||
operation === 'approval.inspect'
|
||||
operation === 'approval.inspect' || operation === 'approval.recover.inspect'
|
||||
? base
|
||||
: [
|
||||
: operation === 'approval.decide'
|
||||
? [
|
||||
...base,
|
||||
'expectedVersion',
|
||||
'expectedAction',
|
||||
'decisionId',
|
||||
'decision',
|
||||
'reasonCode',
|
||||
]
|
||||
: [
|
||||
...base,
|
||||
'expectedExecutionVersion',
|
||||
'expectedExecutionDigest',
|
||||
'mutationId',
|
||||
'decision',
|
||||
'evidenceDigest',
|
||||
'reasonCode',
|
||||
],
|
||||
);
|
||||
const normalizedBase = {
|
||||
const normalizedAuditBase = {
|
||||
projectId: identifier(request.projectId),
|
||||
approvalRequestId: identifier(request.approvalRequestId),
|
||||
requestId: identifier(request.requestId),
|
||||
auditEventId: uuid(request.auditEventId),
|
||||
failureAuditEventId: uuid(request.failureAuditEventId),
|
||||
};
|
||||
if (normalizedBase.auditEventId === normalizedBase.failureAuditEventId) invalid();
|
||||
if (normalizedAuditBase.auditEventId === normalizedAuditBase.failureAuditEventId) {
|
||||
invalid();
|
||||
}
|
||||
if (operation === 'approval.inspect') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
request: Object.freeze(normalizedBase),
|
||||
operation: 'approval.inspect',
|
||||
request: Object.freeze({
|
||||
...normalizedAuditBase,
|
||||
approvalRequestId: identifier(request.approvalRequestId),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (operation === 'approval.recover.inspect') {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.recover.inspect',
|
||||
request: Object.freeze({
|
||||
...normalizedAuditBase,
|
||||
dispatchId: identifier(request.dispatchId),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (operation === 'approval.recover.resolve') {
|
||||
if (
|
||||
!Number.isSafeInteger(request.expectedExecutionVersion) ||
|
||||
Number(request.expectedExecutionVersion) < 1 ||
|
||||
typeof request.expectedExecutionDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(request.expectedExecutionDigest) ||
|
||||
(request.decision !== 'confirm_failed' &&
|
||||
request.decision !== 'abandon_unknown') ||
|
||||
typeof request.evidenceDigest !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(request.evidenceDigest) ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.recover.resolve',
|
||||
request: Object.freeze({
|
||||
...normalizedAuditBase,
|
||||
dispatchId: identifier(request.dispatchId),
|
||||
expectedExecutionVersion: Number(request.expectedExecutionVersion),
|
||||
expectedExecutionDigest: request.expectedExecutionDigest,
|
||||
mutationId: identifier(request.mutationId),
|
||||
decision: request.decision,
|
||||
evidenceDigest: request.evidenceDigest,
|
||||
reasonCode: request.reasonCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -222,9 +319,10 @@ export function normalizeClusterApprovalManagementCommand(
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation,
|
||||
operation: 'approval.decide',
|
||||
request: Object.freeze({
|
||||
...normalizedBase,
|
||||
...normalizedAuditBase,
|
||||
approvalRequestId: identifier(request.approvalRequestId),
|
||||
expectedVersion: 1,
|
||||
expectedAction,
|
||||
decisionId: identifier(request.decisionId),
|
||||
@@ -284,11 +382,15 @@ function failureReason(error: unknown, authenticated: boolean): Readonly<{
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalInspectionAuthorizationError ||
|
||||
error instanceof ApprovalDecisionAuthorizationError
|
||||
error instanceof ApprovalDecisionAuthorizationError ||
|
||||
error instanceof ApprovedActionManualRecoveryAuthorizationError
|
||||
) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'policy_rejected' });
|
||||
}
|
||||
if (error instanceof ApprovalDecisionTargetUnavailableError) {
|
||||
if (
|
||||
error instanceof ApprovalDecisionTargetUnavailableError ||
|
||||
error instanceof ApprovedActionManualRecoveryTargetUnavailableError
|
||||
) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'approval_target_unavailable' });
|
||||
}
|
||||
if (error instanceof ApprovalDecisionBindingConflictError) {
|
||||
@@ -299,7 +401,9 @@ function failureReason(error: unknown, authenticated: boolean): Readonly<{
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError
|
||||
error instanceof ApprovalPolicyFenceConflictError ||
|
||||
error instanceof ApprovedActionManualRecoveryFenceConflictError ||
|
||||
error instanceof ApprovedActionManualRecoveryUnsupportedError
|
||||
) {
|
||||
return Object.freeze({ outcome: 'denied', reason: 'approval_state_or_fence_conflict' });
|
||||
}
|
||||
@@ -320,11 +424,15 @@ function observedTime(now: () => number): number {
|
||||
function mapped(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovalInspectionAuthorizationError ||
|
||||
error instanceof ApprovalDecisionAuthorizationError
|
||||
error instanceof ApprovalDecisionAuthorizationError ||
|
||||
error instanceof ApprovedActionManualRecoveryAuthorizationError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportAuthorizationError();
|
||||
}
|
||||
if (error instanceof ApprovalDecisionTargetUnavailableError) {
|
||||
if (
|
||||
error instanceof ApprovalDecisionTargetUnavailableError ||
|
||||
error instanceof ApprovedActionManualRecoveryTargetUnavailableError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportTargetUnavailableError();
|
||||
}
|
||||
if (
|
||||
@@ -333,13 +441,16 @@ function mapped(error: unknown): Error {
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalRequestExpiredError ||
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError
|
||||
error instanceof ApprovalPolicyFenceConflictError ||
|
||||
error instanceof ApprovedActionManualRecoveryFenceConflictError ||
|
||||
error instanceof ApprovedActionManualRecoveryUnsupportedError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportConflictError();
|
||||
}
|
||||
if (
|
||||
error instanceof ApprovalInspectionUnavailableError ||
|
||||
error instanceof ApprovalDecisionUnavailableError
|
||||
error instanceof ApprovalDecisionUnavailableError ||
|
||||
error instanceof ApprovedActionManualRecoveryUnavailableError
|
||||
) {
|
||||
return new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
@@ -348,6 +459,47 @@ function mapped(error: unknown): Error {
|
||||
: new ClusterApprovalManagementTransportUnavailableError();
|
||||
}
|
||||
|
||||
function recoveryProjection(
|
||||
snapshot: Readonly<ApprovedActionManualRecoverySnapshot>,
|
||||
nowMs: number,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
const execution = snapshot.execution.execution;
|
||||
const resolution = snapshot.resolution;
|
||||
return Object.freeze({
|
||||
projectId: snapshot.execution.dispatch.projectId,
|
||||
dispatchId: snapshot.execution.dispatch.id,
|
||||
approvalRequestId: snapshot.execution.dispatch.approvalRequestId,
|
||||
expectedAction: snapshot.execution.dispatch.action,
|
||||
execution: Object.freeze({
|
||||
status: approvedActionExecutionEffectiveStatus(execution, nowMs),
|
||||
version: execution.version,
|
||||
executionDigest: execution.executionDigest,
|
||||
attemptCount: execution.attemptCount,
|
||||
maxAttempts: execution.maxAttempts,
|
||||
startedAtMs: execution.startedAtMs,
|
||||
leaseExpiresAtMs: execution.leaseExpiresAtMs,
|
||||
resultMutationId: execution.resultMutationId,
|
||||
resultCode: execution.resultCode,
|
||||
resultDigest: execution.resultDigest,
|
||||
completedAtMs: execution.completedAtMs,
|
||||
createdAtMs: execution.createdAtMs,
|
||||
updatedAtMs: execution.updatedAtMs,
|
||||
}),
|
||||
resolution:
|
||||
resolution === null
|
||||
? null
|
||||
: Object.freeze({
|
||||
mutationId: resolution.mutationId,
|
||||
decision: resolution.decision,
|
||||
evidenceDigest: resolution.evidenceDigest,
|
||||
reasonCode: resolution.reasonCode,
|
||||
resolvedBy: resolution.resolvedBy,
|
||||
resolvedAtMs: resolution.resolvedAtMs,
|
||||
resolutionDigest: resolution.resolutionDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterApprovalManagementTransport(options: Readonly<{
|
||||
service: ClusterApprovalManagementService;
|
||||
now?: () => number;
|
||||
@@ -359,6 +511,8 @@ export function createClusterApprovalManagementTransport(options: Readonly<{
|
||||
Object.keys(options).some((key) => key !== 'service' && key !== 'now') ||
|
||||
typeof options.service?.inspect !== 'function' ||
|
||||
typeof options.service?.decide !== 'function' ||
|
||||
typeof options.service?.inspectRecovery !== 'function' ||
|
||||
typeof options.service?.resolveRecovery !== 'function' ||
|
||||
typeof options.service?.recordFailure !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
@@ -407,6 +561,52 @@ export function createClusterApprovalManagementTransport(options: Readonly<{
|
||||
throw new ClusterApprovalManagementTransportAuthenticationError();
|
||||
}
|
||||
};
|
||||
if (command.operation === 'approval.recover.inspect') {
|
||||
const snapshot = await options.service.inspectRecovery(
|
||||
{
|
||||
projectId: command.request.projectId,
|
||||
dispatchId: command.request.dispatchId,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal,
|
||||
},
|
||||
confirmAuthorization,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: snapshot === null ? ('absent' as const) : ('found' as const),
|
||||
recovery:
|
||||
snapshot === null
|
||||
? null
|
||||
: recoveryProjection(snapshot, observedTime(now)),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'approval.recover.resolve') {
|
||||
const result = await options.service.resolveRecovery(
|
||||
{
|
||||
projectId: command.request.projectId,
|
||||
dispatchId: command.request.dispatchId,
|
||||
expectedExecutionVersion:
|
||||
command.request.expectedExecutionVersion,
|
||||
expectedExecutionDigest: command.request.expectedExecutionDigest,
|
||||
mutationId: command.request.mutationId,
|
||||
decision: command.request.decision,
|
||||
evidenceDigest: command.request.evidenceDigest,
|
||||
reasonCode: command.request.reasonCode,
|
||||
auditEventId: command.request.auditEventId,
|
||||
requestId: command.request.requestId,
|
||||
principal,
|
||||
},
|
||||
confirmAuthorization,
|
||||
);
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
operation: command.operation,
|
||||
status: result.status,
|
||||
recovery: recoveryProjection(result.snapshot, observedTime(now)),
|
||||
});
|
||||
}
|
||||
if (command.operation === 'approval.inspect') {
|
||||
const detail = await options.service.inspect(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,13 @@ const BASE_REQUEST = Object.freeze({
|
||||
auditEventId: '60000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '60000000-0000-4000-8000-000000000002',
|
||||
});
|
||||
const RECOVERY_BASE_REQUEST = Object.freeze({
|
||||
projectId: 'default',
|
||||
dispatchId: 'dispatch-1',
|
||||
requestId: 'recovery-command-1',
|
||||
auditEventId: '60000000-0000-4000-8000-000000000003',
|
||||
failureAuditEventId: '60000000-0000-4000-8000-000000000004',
|
||||
});
|
||||
|
||||
function privateWrite(filePath, value) {
|
||||
writeFileSync(filePath, value, { mode: 0o600 });
|
||||
@@ -204,6 +211,117 @@ test('validates the durable decision tuple and rejects server-side drift', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('validates recovery inspection and binds terminal resolution to the command fence', () => {
|
||||
const recoveryAction = {
|
||||
...ACTION,
|
||||
permission: 'secret.manage',
|
||||
actionType: 'plugin_package.secret_binding.bind',
|
||||
actionRef: 'secret-binding:1',
|
||||
};
|
||||
const inspectCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.recover.inspect',
|
||||
request: RECOVERY_BASE_REQUEST,
|
||||
};
|
||||
const recovery = {
|
||||
projectId: 'default',
|
||||
dispatchId: 'dispatch-1',
|
||||
approvalRequestId: 'approval-1',
|
||||
expectedAction: recoveryAction,
|
||||
execution: {
|
||||
status: 'recovery_required',
|
||||
version: 3,
|
||||
executionDigest: 'c'.repeat(64),
|
||||
attemptCount: 1,
|
||||
maxAttempts: 3,
|
||||
startedAtMs: 1_400,
|
||||
leaseExpiresAtMs: 1_800,
|
||||
resultMutationId: null,
|
||||
resultCode: null,
|
||||
resultDigest: null,
|
||||
completedAtMs: null,
|
||||
createdAtMs: 1_200,
|
||||
updatedAtMs: 1_400,
|
||||
},
|
||||
resolution: null,
|
||||
};
|
||||
assert.deepEqual(
|
||||
validateClusterApprovalManagementClientResult(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: inspectCommand.operation,
|
||||
status: 'found',
|
||||
recovery,
|
||||
},
|
||||
inspectCommand,
|
||||
).recovery,
|
||||
recovery,
|
||||
);
|
||||
|
||||
const resolveCommand = {
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.recover.resolve',
|
||||
request: {
|
||||
...RECOVERY_BASE_REQUEST,
|
||||
expectedExecutionVersion: 3,
|
||||
expectedExecutionDigest: 'c'.repeat(64),
|
||||
mutationId: 'manual-recovery-1',
|
||||
decision: 'abandon_unknown',
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
reasonCode: 'orphan_absence_verified',
|
||||
},
|
||||
};
|
||||
const resolved = {
|
||||
...recovery,
|
||||
execution: {
|
||||
...recovery.execution,
|
||||
status: 'blocked',
|
||||
version: 4,
|
||||
executionDigest: 'd'.repeat(64),
|
||||
leaseExpiresAtMs: null,
|
||||
resultMutationId: 'manual-recovery-1',
|
||||
resultCode: 'manual_recovery_abandoned_unknown',
|
||||
completedAtMs: 2_000,
|
||||
updatedAtMs: 2_000,
|
||||
},
|
||||
resolution: {
|
||||
mutationId: 'manual-recovery-1',
|
||||
decision: 'abandon_unknown',
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
reasonCode: 'orphan_absence_verified',
|
||||
resolvedBy: { type: 'user', id: 'owner-1' },
|
||||
resolvedAtMs: 2_000,
|
||||
resolutionDigest: 'f'.repeat(64),
|
||||
},
|
||||
};
|
||||
const result = {
|
||||
schemaVersion: 1,
|
||||
operation: resolveCommand.operation,
|
||||
status: 'resolved',
|
||||
recovery: resolved,
|
||||
};
|
||||
assert.deepEqual(
|
||||
validateClusterApprovalManagementClientResult(result, resolveCommand),
|
||||
result,
|
||||
);
|
||||
for (const changed of [
|
||||
{ ...resolved, execution: { ...resolved.execution, version: 3 } },
|
||||
{
|
||||
...resolved,
|
||||
resolution: { ...resolved.resolution, evidenceDigest: '0'.repeat(64) },
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
validateClusterApprovalManagementClientResult(
|
||||
{ ...result, recovery: changed },
|
||||
resolveCommand,
|
||||
),
|
||||
ClusterPluginPackageManagementClientRequestError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts only the exact Approval route before opening one mTLS connection', async () => {
|
||||
let connects = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -2,9 +2,16 @@ const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
ClusterApprovalManagementTransportAuthenticationError,
|
||||
ClusterApprovalManagementTransportRequestError,
|
||||
@@ -32,6 +39,13 @@ const BASE_REQUEST = Object.freeze({
|
||||
auditEventId: '40000000-0000-4000-8000-000000000001',
|
||||
failureAuditEventId: '40000000-0000-4000-8000-000000000002',
|
||||
});
|
||||
const RECOVERY_BASE_REQUEST = Object.freeze({
|
||||
projectId: 'default',
|
||||
dispatchId: 'dispatch-1',
|
||||
requestId: 'recovery-command-1',
|
||||
auditEventId: '40000000-0000-4000-8000-000000000003',
|
||||
failureAuditEventId: '40000000-0000-4000-8000-000000000004',
|
||||
});
|
||||
|
||||
function pending() {
|
||||
return createApprovalRequest({
|
||||
@@ -70,6 +84,64 @@ function decideCommand() {
|
||||
};
|
||||
}
|
||||
|
||||
function executingRecoverySnapshot() {
|
||||
const action = {
|
||||
...ACTION,
|
||||
permission: 'secret.manage',
|
||||
actionType: 'plugin_package.secret_binding.bind',
|
||||
actionRef: 'secret-binding:1',
|
||||
};
|
||||
const recoveryPending = createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 900,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
});
|
||||
const approved = decideApprovalRequest(recoveryPending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-recovery-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: PRINCIPAL,
|
||||
decidedAtMs: 1_100,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
});
|
||||
const dispatch = consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consumption-1',
|
||||
dispatchId: 'dispatch-1',
|
||||
action,
|
||||
requestedBy: approved.requestedBy,
|
||||
consumedBy: { type: 'system', id: 'package-executor' },
|
||||
consumedAtMs: 1_200,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
}).dispatch;
|
||||
const leased = claimApprovedActionExecution(createApprovedActionExecution(dispatch), {
|
||||
owner: 'executor-1',
|
||||
leaseToken: 'lease-1',
|
||||
nowMs: 1_300,
|
||||
leaseDurationMs: 500,
|
||||
});
|
||||
const execution = startApprovedActionExecution(
|
||||
{ dispatch, execution: leased },
|
||||
{
|
||||
dispatchId: dispatch.id,
|
||||
approvalRequestId: dispatch.approvalRequestId,
|
||||
actionDigest: dispatch.action.actionDigest,
|
||||
owner: leased.leaseOwner,
|
||||
leaseToken: leased.leaseToken,
|
||||
expectedVersion: leased.version,
|
||||
startedAtMs: 1_400,
|
||||
},
|
||||
);
|
||||
return { execution: { dispatch, execution }, resolution: null };
|
||||
}
|
||||
|
||||
test('inspects and decides through fresh strong authentication without leaking principal facts', async () => {
|
||||
const calls = [];
|
||||
const failures = [];
|
||||
@@ -104,6 +176,12 @@ test('inspects and decides through fresh strong authentication without leaking p
|
||||
}),
|
||||
};
|
||||
},
|
||||
async inspectRecovery() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveRecovery() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async recordFailure(record) {
|
||||
failures.push(record);
|
||||
},
|
||||
@@ -147,6 +225,12 @@ test('records unauthenticated and reauthentication failures with schema-valid id
|
||||
async decide() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectRecovery() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveRecovery() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async recordFailure(record) {
|
||||
failures.push(record);
|
||||
},
|
||||
@@ -191,6 +275,8 @@ test('rejects widened or ambiguously audited commands before authentication', as
|
||||
service: {
|
||||
async inspect() {},
|
||||
async decide() {},
|
||||
async inspectRecovery() {},
|
||||
async resolveRecovery() {},
|
||||
async recordFailure() {},
|
||||
},
|
||||
});
|
||||
@@ -229,3 +315,90 @@ test('rejects widened or ambiguously audited commands before authentication', as
|
||||
);
|
||||
assert.equal(authenticationCalls, 0);
|
||||
});
|
||||
|
||||
test('inspects and resolves recovery without exposing execution lease or authentication facts', async () => {
|
||||
const source = executingRecoverySnapshot();
|
||||
const nextExecution = completeApprovedActionExecution(source.execution.execution, {
|
||||
owner: source.execution.execution.leaseOwner,
|
||||
leaseToken: source.execution.execution.leaseToken,
|
||||
expectedVersion: source.execution.execution.version,
|
||||
resultMutationId: 'manual-recovery-1',
|
||||
outcome: 'indeterminate',
|
||||
resultCode: 'manual_recovery_abandoned_unknown',
|
||||
completedAtMs: 2_000,
|
||||
});
|
||||
const resolution = {
|
||||
mutationId: 'manual-recovery-1',
|
||||
decision: 'abandon_unknown',
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
reasonCode: 'orphan_absence_verified',
|
||||
resolvedBy: { type: 'user', id: 'owner-1' },
|
||||
resolvedAtMs: 2_000,
|
||||
resolutionDigest: 'f'.repeat(64),
|
||||
};
|
||||
const transport = createClusterApprovalManagementTransport({
|
||||
service: {
|
||||
async inspect() {},
|
||||
async decide() {},
|
||||
async inspectRecovery(_request, confirmAuthorization) {
|
||||
await confirmAuthorization();
|
||||
return source;
|
||||
},
|
||||
async resolveRecovery(_request, confirmAuthorization) {
|
||||
await confirmAuthorization();
|
||||
return {
|
||||
status: 'resolved',
|
||||
snapshot: {
|
||||
execution: { dispatch: source.execution.dispatch, execution: nextExecution },
|
||||
resolution: {
|
||||
schema: 'qinglong/approved-action-manual-recovery@v1',
|
||||
dispatchId: 'dispatch-1',
|
||||
dispatchDigest: source.execution.execution.dispatchDigest,
|
||||
projectId: 'default',
|
||||
actionType: source.execution.dispatch.action.actionType,
|
||||
actionDigest: source.execution.dispatch.action.actionDigest,
|
||||
executionVersion: source.execution.execution.version,
|
||||
executionDigest: source.execution.execution.executionDigest,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
assurance: PRINCIPAL.assurance,
|
||||
authenticatedAtMs: PRINCIPAL.authenticatedAtMs,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
auditEventId: RECOVERY_BASE_REQUEST.auditEventId,
|
||||
...resolution,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
async recordFailure() {},
|
||||
},
|
||||
now: () => 2_000,
|
||||
});
|
||||
const authentication = { async authenticate() { return PRINCIPAL; } };
|
||||
const inspected = await transport.execute(
|
||||
{ schemaVersion: 1, operation: 'approval.recover.inspect', request: RECOVERY_BASE_REQUEST },
|
||||
authentication,
|
||||
);
|
||||
const resolved = await transport.execute(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.recover.resolve',
|
||||
request: {
|
||||
...RECOVERY_BASE_REQUEST,
|
||||
expectedExecutionVersion: source.execution.execution.version,
|
||||
expectedExecutionDigest: source.execution.execution.executionDigest,
|
||||
mutationId: resolution.mutationId,
|
||||
decision: resolution.decision,
|
||||
evidenceDigest: resolution.evidenceDigest,
|
||||
reasonCode: resolution.reasonCode,
|
||||
},
|
||||
},
|
||||
authentication,
|
||||
);
|
||||
assert.equal(inspected.recovery.execution.status, 'recovery_required');
|
||||
assert.equal(resolved.recovery.execution.status, 'blocked');
|
||||
assert.equal(resolved.recovery.resolution.decision, 'abandon_unknown');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify([inspected, resolved]),
|
||||
/leaseOwner|leaseToken|authenticationId|authenticatedAtMs|assurance/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,6 +188,7 @@ function database(serverVersionNum = '160014') {
|
||||
'create_plugin_package_secret_binding_approval_plan',
|
||||
'create_plugin_package_secret_transition_plan',
|
||||
'plugin_package_secret_binding_transition_snapshot',
|
||||
'resolve_approved_action_manual_recovery',
|
||||
].includes(functionName),
|
||||
isOwner: false,
|
||||
})),
|
||||
|
||||
@@ -220,6 +220,7 @@ function database(serverVersionNum = '160014') {
|
||||
'plugin_package_secret_binding_planning_snapshot',
|
||||
'create_plugin_package_secret_transition_plan',
|
||||
'plugin_package_secret_binding_transition_snapshot',
|
||||
'resolve_approved_action_manual_recovery',
|
||||
].includes(functionName),
|
||||
isOwner: false,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user