mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): resolve secret action recovery manually
This commit is contained in:
@@ -552,6 +552,11 @@
|
||||
"require": "./dist/approved-action/approvedActionExecution.js",
|
||||
"default": "./dist/approved-action/approvedActionExecution.js"
|
||||
},
|
||||
"./approved-action-manual-recovery": {
|
||||
"types": "./dist/approved-action/approvedActionManualRecovery.d.ts",
|
||||
"require": "./dist/approved-action/approvedActionManualRecovery.js",
|
||||
"default": "./dist/approved-action/approvedActionManualRecovery.js"
|
||||
},
|
||||
"./approved-action-dispatcher": {
|
||||
"types": "./dist/approved-action/approvedActionDispatcher.d.ts",
|
||||
"require": "./dist/approved-action/approvedActionDispatcher.js",
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
approvedActionExecutionEffectiveStatus,
|
||||
completeApprovedActionExecution,
|
||||
normalizeApprovedActionExecutionRecord,
|
||||
normalizeApprovedActionExecutionSnapshot,
|
||||
type ApprovedActionExecutionRecord,
|
||||
type ApprovedActionExecutionSnapshot,
|
||||
} from './approvedActionExecution';
|
||||
import type { ProjectPolicyEngine } from '../security/project-policy/projectPolicy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '../security/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
type SecurityAuditSink,
|
||||
} from '../security/audit/securityAudit';
|
||||
|
||||
export const APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA =
|
||||
'qinglong/approved-action-manual-recovery@v1' as const;
|
||||
export const APPROVED_ACTION_MANUAL_RECOVERY_DECISIONS = [
|
||||
'confirm_failed',
|
||||
'abandon_unknown',
|
||||
] as const;
|
||||
export const APPROVED_ACTION_MANUAL_RECOVERY_ACTION_TYPES = [
|
||||
'plugin_package.secret_binding.bind',
|
||||
'plugin_package.secret_binding.transition',
|
||||
] as const;
|
||||
export const MAX_APPROVED_ACTION_MANUAL_RECOVERY_AUTH_AGE_MS = 5 * 60_000;
|
||||
|
||||
export type ApprovedActionManualRecoveryDecision =
|
||||
(typeof APPROVED_ACTION_MANUAL_RECOVERY_DECISIONS)[number];
|
||||
|
||||
export interface ApprovedActionManualRecoveryResolutionRecord {
|
||||
readonly schema: typeof APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA;
|
||||
readonly dispatchId: string;
|
||||
readonly dispatchDigest: string;
|
||||
readonly projectId: string;
|
||||
readonly actionType: string;
|
||||
readonly actionDigest: string;
|
||||
readonly executionVersion: number;
|
||||
readonly executionDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly decision: ApprovedActionManualRecoveryDecision;
|
||||
readonly evidenceDigest: string;
|
||||
readonly reasonCode: string;
|
||||
readonly resolvedBy: Readonly<SecuritySubject>;
|
||||
readonly authenticationId: string;
|
||||
readonly assurance: 'multi_factor' | 'hardware';
|
||||
readonly authenticatedAtMs: number;
|
||||
readonly authorizationFence: Readonly<SecurityPolicyFence>;
|
||||
readonly auditEventId: string;
|
||||
readonly resolvedAtMs: number;
|
||||
readonly resolutionDigest: string;
|
||||
}
|
||||
|
||||
export interface ApprovedActionManualRecoverySnapshot {
|
||||
readonly execution: Readonly<ApprovedActionExecutionSnapshot>;
|
||||
readonly resolution: Readonly<ApprovedActionManualRecoveryResolutionRecord> | null;
|
||||
}
|
||||
|
||||
export interface ApprovedActionManualRecoveryInspectRequest {
|
||||
readonly projectId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly auditEventId: string;
|
||||
readonly requestId: string;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
}
|
||||
|
||||
export interface ApprovedActionManualRecoveryResolveRequest
|
||||
extends ApprovedActionManualRecoveryInspectRequest {
|
||||
readonly expectedExecutionVersion: number;
|
||||
readonly expectedExecutionDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly decision: ApprovedActionManualRecoveryDecision;
|
||||
readonly evidenceDigest: string;
|
||||
readonly reasonCode: string;
|
||||
}
|
||||
|
||||
export interface ResolveApprovedActionManualRecoveryCommand {
|
||||
readonly previous: Readonly<ApprovedActionExecutionSnapshot>;
|
||||
readonly nextExecution: Readonly<ApprovedActionExecutionRecord>;
|
||||
readonly resolution: Readonly<ApprovedActionManualRecoveryResolutionRecord>;
|
||||
readonly audit: Readonly<SecurityAuditRecord>;
|
||||
}
|
||||
|
||||
export interface ResolveApprovedActionManualRecoveryResult {
|
||||
readonly status: 'resolved' | 'existing';
|
||||
readonly snapshot: Readonly<ApprovedActionManualRecoverySnapshot>;
|
||||
}
|
||||
|
||||
export interface ApprovedActionManualRecoveryRepository {
|
||||
findByDispatchId(
|
||||
dispatchId: string,
|
||||
): Promise<Readonly<ApprovedActionManualRecoverySnapshot> | null>;
|
||||
resolve(
|
||||
command: Readonly<ResolveApprovedActionManualRecoveryCommand>,
|
||||
): Promise<Readonly<ResolveApprovedActionManualRecoveryResult>>;
|
||||
}
|
||||
|
||||
export interface ApprovedActionManualRecoveryService {
|
||||
inspect(
|
||||
request: ApprovedActionManualRecoveryInspectRequest,
|
||||
confirmAuthorization?: () => void | Promise<void>,
|
||||
): Promise<Readonly<ApprovedActionManualRecoverySnapshot> | null>;
|
||||
resolve(
|
||||
request: ApprovedActionManualRecoveryResolveRequest,
|
||||
confirmAuthorization?: () => void | Promise<void>,
|
||||
): Promise<Readonly<ResolveApprovedActionManualRecoveryResult>>;
|
||||
}
|
||||
|
||||
export class InvalidApprovedActionManualRecoveryError extends TypeError {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Approved Action manual recovery is invalid: ${message}`);
|
||||
this.name = 'InvalidApprovedActionManualRecoveryError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryAuthorizationError extends Error {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_AUTHORIZATION_REJECTED';
|
||||
|
||||
constructor() {
|
||||
super('Approved Action manual recovery authorization was rejected');
|
||||
this.name = 'ApprovedActionManualRecoveryAuthorizationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryTargetUnavailableError extends Error {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_TARGET_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Approved Action manual recovery target is unavailable');
|
||||
this.name = 'ApprovedActionManualRecoveryTargetUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryUnsupportedError extends Error {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_UNSUPPORTED';
|
||||
|
||||
constructor() {
|
||||
super('Approved Action does not support manual recovery');
|
||||
this.name = 'ApprovedActionManualRecoveryUnsupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryFenceConflictError extends Error {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Approved Action manual recovery fence changed');
|
||||
this.name = 'ApprovedActionManualRecoveryFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovedActionManualRecoveryUnavailableError extends Error {
|
||||
readonly code = 'APPROVED_ACTION_MANUAL_RECOVERY_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Approved Action manual recovery is unavailable', options);
|
||||
this.name = 'ApprovedActionManualRecoveryUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
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}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidApprovedActionManualRecoveryError(message);
|
||||
}
|
||||
|
||||
function exact(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
invalid('value must be an object');
|
||||
}
|
||||
const actual = Object.keys(value as object).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid('value shape is invalid');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
invalid(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reason(value: unknown): string {
|
||||
if (typeof value !== 'string' || !REASON_PATTERN.test(value)) {
|
||||
invalid('reason code is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
invalid(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uuid(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) {
|
||||
invalid(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, name: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum ||
|
||||
value > 2_147_483_647
|
||||
) {
|
||||
invalid(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, name: string): number {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
invalid(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decision(value: unknown): ApprovedActionManualRecoveryDecision {
|
||||
if (
|
||||
!APPROVED_ACTION_MANUAL_RECOVERY_DECISIONS.includes(
|
||||
value as ApprovedActionManualRecoveryDecision,
|
||||
)
|
||||
) {
|
||||
invalid('decision is invalid');
|
||||
}
|
||||
return value as ApprovedActionManualRecoveryDecision;
|
||||
}
|
||||
|
||||
function supportedActionType(value: string): boolean {
|
||||
return APPROVED_ACTION_MANUAL_RECOVERY_ACTION_TYPES.includes(
|
||||
value as (typeof APPROVED_ACTION_MANUAL_RECOVERY_ACTION_TYPES)[number],
|
||||
);
|
||||
}
|
||||
|
||||
function subject(value: unknown): Readonly<SecuritySubject> {
|
||||
const record = exact(value, ['type', 'id']);
|
||||
if (record.type !== 'user') invalid('resolved subject must be a User');
|
||||
return Object.freeze({
|
||||
type: 'user' as const,
|
||||
id: identifier(record.id, 'resolved subject id'),
|
||||
});
|
||||
}
|
||||
|
||||
function fence(value: unknown): Readonly<SecurityPolicyFence> {
|
||||
const record = exact(value, ['projectVersion', 'bindingVersion']);
|
||||
const bindingVersion = integer(record.bindingVersion, 'binding version', 1);
|
||||
return Object.freeze({
|
||||
projectVersion: integer(record.projectVersion, 'project version', 1),
|
||||
bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
function resolutionWithoutDigest(
|
||||
value: Omit<ApprovedActionManualRecoveryResolutionRecord, 'resolutionDigest'>,
|
||||
): Omit<ApprovedActionManualRecoveryResolutionRecord, 'resolutionDigest'> {
|
||||
return {
|
||||
schema: APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA,
|
||||
dispatchId: value.dispatchId,
|
||||
dispatchDigest: value.dispatchDigest,
|
||||
projectId: value.projectId,
|
||||
actionType: value.actionType,
|
||||
actionDigest: value.actionDigest,
|
||||
executionVersion: value.executionVersion,
|
||||
executionDigest: value.executionDigest,
|
||||
mutationId: value.mutationId,
|
||||
decision: value.decision,
|
||||
evidenceDigest: value.evidenceDigest,
|
||||
reasonCode: value.reasonCode,
|
||||
resolvedBy: value.resolvedBy,
|
||||
authenticationId: value.authenticationId,
|
||||
assurance: value.assurance,
|
||||
authenticatedAtMs: value.authenticatedAtMs,
|
||||
authorizationFence: value.authorizationFence,
|
||||
auditEventId: value.auditEventId,
|
||||
resolvedAtMs: value.resolvedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function resolutionDigest(
|
||||
value: Omit<ApprovedActionManualRecoveryResolutionRecord, 'resolutionDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(resolutionWithoutDigest(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizeApprovedActionManualRecoveryResolution(
|
||||
value: ApprovedActionManualRecoveryResolutionRecord,
|
||||
): Readonly<ApprovedActionManualRecoveryResolutionRecord> {
|
||||
const record = exact(value, [
|
||||
'schema',
|
||||
'dispatchId',
|
||||
'dispatchDigest',
|
||||
'projectId',
|
||||
'actionType',
|
||||
'actionDigest',
|
||||
'executionVersion',
|
||||
'executionDigest',
|
||||
'mutationId',
|
||||
'decision',
|
||||
'evidenceDigest',
|
||||
'reasonCode',
|
||||
'resolvedBy',
|
||||
'authenticationId',
|
||||
'assurance',
|
||||
'authenticatedAtMs',
|
||||
'authorizationFence',
|
||||
'auditEventId',
|
||||
'resolvedAtMs',
|
||||
'resolutionDigest',
|
||||
]);
|
||||
if (record.schema !== APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA) {
|
||||
invalid('schema is invalid');
|
||||
}
|
||||
const normalized = resolutionWithoutDigest({
|
||||
schema: APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA,
|
||||
dispatchId: identifier(record.dispatchId, 'dispatch id'),
|
||||
dispatchDigest: digest(record.dispatchDigest, 'dispatch digest'),
|
||||
projectId: identifier(record.projectId, 'project id'),
|
||||
actionType: identifier(record.actionType, 'action type'),
|
||||
actionDigest: digest(record.actionDigest, 'action digest'),
|
||||
executionVersion: integer(record.executionVersion, 'execution version', 1),
|
||||
executionDigest: digest(record.executionDigest, 'execution digest'),
|
||||
mutationId: identifier(record.mutationId, 'mutation id'),
|
||||
decision: decision(record.decision),
|
||||
evidenceDigest: digest(record.evidenceDigest, 'evidence digest'),
|
||||
reasonCode: reason(record.reasonCode),
|
||||
resolvedBy: subject(record.resolvedBy),
|
||||
authenticationId: identifier(record.authenticationId, 'authentication id'),
|
||||
assurance:
|
||||
record.assurance === 'multi_factor' || record.assurance === 'hardware'
|
||||
? record.assurance
|
||||
: invalid('assurance is invalid'),
|
||||
authenticatedAtMs: timestamp(record.authenticatedAtMs, 'authentication time'),
|
||||
authorizationFence: fence(record.authorizationFence),
|
||||
auditEventId: uuid(record.auditEventId, 'audit event id'),
|
||||
resolvedAtMs: timestamp(record.resolvedAtMs, 'resolution time'),
|
||||
});
|
||||
if (
|
||||
!supportedActionType(normalized.actionType) ||
|
||||
normalized.authenticatedAtMs > normalized.resolvedAtMs ||
|
||||
normalized.resolvedAtMs - normalized.authenticatedAtMs >
|
||||
MAX_APPROVED_ACTION_MANUAL_RECOVERY_AUTH_AGE_MS
|
||||
) {
|
||||
invalid('resolution authority is invalid');
|
||||
}
|
||||
const normalizedDigest = digest(record.resolutionDigest, 'resolution digest');
|
||||
if (normalizedDigest !== resolutionDigest(normalized)) {
|
||||
invalid('resolution digest does not match');
|
||||
}
|
||||
return Object.freeze({ ...normalized, resolutionDigest: normalizedDigest });
|
||||
}
|
||||
|
||||
export function normalizeApprovedActionManualRecoverySnapshot(
|
||||
value: ApprovedActionManualRecoverySnapshot,
|
||||
): Readonly<ApprovedActionManualRecoverySnapshot> {
|
||||
const record = exact(value, ['execution', 'resolution']);
|
||||
const execution = normalizeApprovedActionExecutionSnapshot(
|
||||
record.execution as unknown as ApprovedActionExecutionSnapshot,
|
||||
);
|
||||
const resolution =
|
||||
record.resolution === null
|
||||
? null
|
||||
: normalizeApprovedActionManualRecoveryResolution(
|
||||
record.resolution as ApprovedActionManualRecoveryResolutionRecord,
|
||||
);
|
||||
if (
|
||||
resolution &&
|
||||
(resolution.dispatchId !== execution.dispatch.id ||
|
||||
resolution.dispatchDigest !== execution.execution.dispatchDigest ||
|
||||
resolution.projectId !== execution.dispatch.projectId ||
|
||||
resolution.actionType !== execution.dispatch.action.actionType ||
|
||||
resolution.actionDigest !== execution.dispatch.action.actionDigest ||
|
||||
execution.execution.version !== resolution.executionVersion + 1 ||
|
||||
execution.execution.resultMutationId !== resolution.mutationId ||
|
||||
execution.execution.resultCode !== resultCodeFor(resolution.decision) ||
|
||||
execution.execution.resultDigest !== null ||
|
||||
execution.execution.completedAtMs !== resolution.resolvedAtMs ||
|
||||
execution.execution.status !== statusFor(resolution.decision))
|
||||
) {
|
||||
invalid('resolution is not bound to the execution');
|
||||
}
|
||||
return Object.freeze({ execution, resolution });
|
||||
}
|
||||
|
||||
function statusFor(
|
||||
value: ApprovedActionManualRecoveryDecision,
|
||||
): 'failed' | 'blocked' {
|
||||
return value === 'confirm_failed' ? 'failed' : 'blocked';
|
||||
}
|
||||
|
||||
function outcomeFor(
|
||||
value: ApprovedActionManualRecoveryDecision,
|
||||
): 'failed' | 'indeterminate' {
|
||||
return value === 'confirm_failed' ? 'failed' : 'indeterminate';
|
||||
}
|
||||
|
||||
function resultCodeFor(value: ApprovedActionManualRecoveryDecision): string {
|
||||
return value === 'confirm_failed'
|
||||
? 'manual_recovery_confirmed_failed'
|
||||
: 'manual_recovery_abandoned_unknown';
|
||||
}
|
||||
|
||||
function observedTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ApprovedActionManualRecoveryUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function strongPrincipal(
|
||||
value: Readonly<SecurityPrincipal>,
|
||||
nowMs: number,
|
||||
): Readonly<SecurityPrincipal> {
|
||||
let principal: Readonly<SecurityPrincipal>;
|
||||
try {
|
||||
principal = normalizeSecurityPrincipal(value, nowMs);
|
||||
} catch {
|
||||
throw new ApprovedActionManualRecoveryAuthorizationError();
|
||||
}
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
(principal.assurance !== 'multi_factor' && principal.assurance !== 'hardware') ||
|
||||
principal.authenticatedAtMs > nowMs ||
|
||||
nowMs - principal.authenticatedAtMs >
|
||||
MAX_APPROVED_ACTION_MANUAL_RECOVERY_AUTH_AGE_MS
|
||||
) {
|
||||
throw new ApprovedActionManualRecoveryAuthorizationError();
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function normalizeInspectRequest(
|
||||
value: ApprovedActionManualRecoveryInspectRequest,
|
||||
): Readonly<ApprovedActionManualRecoveryInspectRequest> {
|
||||
const record = exact(value, [
|
||||
'projectId',
|
||||
'dispatchId',
|
||||
'auditEventId',
|
||||
'requestId',
|
||||
'principal',
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: identifier(record.projectId, 'project id'),
|
||||
dispatchId: identifier(record.dispatchId, 'dispatch id'),
|
||||
auditEventId: uuid(record.auditEventId, 'audit event id'),
|
||||
requestId: identifier(record.requestId, 'request id'),
|
||||
principal: record.principal as Readonly<SecurityPrincipal>,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeResolveRequest(
|
||||
value: ApprovedActionManualRecoveryResolveRequest,
|
||||
): Readonly<ApprovedActionManualRecoveryResolveRequest> {
|
||||
const record = exact(value, [
|
||||
'projectId',
|
||||
'dispatchId',
|
||||
'auditEventId',
|
||||
'requestId',
|
||||
'principal',
|
||||
'expectedExecutionVersion',
|
||||
'expectedExecutionDigest',
|
||||
'mutationId',
|
||||
'decision',
|
||||
'evidenceDigest',
|
||||
'reasonCode',
|
||||
]);
|
||||
return Object.freeze({
|
||||
...normalizeInspectRequest({
|
||||
projectId: record.projectId as string,
|
||||
dispatchId: record.dispatchId as string,
|
||||
auditEventId: record.auditEventId as string,
|
||||
requestId: record.requestId as string,
|
||||
principal: record.principal as Readonly<SecurityPrincipal>,
|
||||
}),
|
||||
expectedExecutionVersion: integer(
|
||||
record.expectedExecutionVersion,
|
||||
'expected execution version',
|
||||
1,
|
||||
),
|
||||
expectedExecutionDigest: digest(
|
||||
record.expectedExecutionDigest,
|
||||
'expected execution digest',
|
||||
),
|
||||
mutationId: identifier(record.mutationId, 'mutation id'),
|
||||
decision: decision(record.decision),
|
||||
evidenceDigest: digest(record.evidenceDigest, 'evidence digest'),
|
||||
reasonCode: reason(record.reasonCode),
|
||||
});
|
||||
}
|
||||
|
||||
function allowedAudit(
|
||||
request: Readonly<ApprovedActionManualRecoveryInspectRequest>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
policyFence: Readonly<SecurityPolicyFence>,
|
||||
operationId: 'approval.recover.inspect' | 'approval.recover.resolve',
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: request.auditEventId,
|
||||
requestId: request.requestId,
|
||||
operationId,
|
||||
projectId: request.projectId,
|
||||
subject: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['role_grant', 'strong_authentication', 'manual_recovery'],
|
||||
fence: policyFence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function createResolution(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
request: Readonly<ApprovedActionManualRecoveryResolveRequest>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
policyFence: Readonly<SecurityPolicyFence>,
|
||||
resolvedAtMs: number,
|
||||
): Readonly<ApprovedActionManualRecoveryResolutionRecord> {
|
||||
const withoutDigest = resolutionWithoutDigest({
|
||||
schema: APPROVED_ACTION_MANUAL_RECOVERY_SCHEMA,
|
||||
dispatchId: snapshot.dispatch.id,
|
||||
dispatchDigest: snapshot.execution.dispatchDigest,
|
||||
projectId: snapshot.dispatch.projectId,
|
||||
actionType: snapshot.dispatch.action.actionType,
|
||||
actionDigest: snapshot.dispatch.action.actionDigest,
|
||||
executionVersion: snapshot.execution.version,
|
||||
executionDigest: snapshot.execution.executionDigest,
|
||||
mutationId: request.mutationId,
|
||||
decision: request.decision,
|
||||
evidenceDigest: request.evidenceDigest,
|
||||
reasonCode: request.reasonCode,
|
||||
resolvedBy: principal.subject,
|
||||
authenticationId: principal.authenticationId,
|
||||
assurance: principal.assurance as 'multi_factor' | 'hardware',
|
||||
authenticatedAtMs: principal.authenticatedAtMs,
|
||||
authorizationFence: policyFence,
|
||||
auditEventId: request.auditEventId,
|
||||
resolvedAtMs,
|
||||
});
|
||||
return normalizeApprovedActionManualRecoveryResolution({
|
||||
...withoutDigest,
|
||||
resolutionDigest: resolutionDigest(withoutDigest),
|
||||
});
|
||||
}
|
||||
|
||||
function exactReplay(
|
||||
resolution: Readonly<ApprovedActionManualRecoveryResolutionRecord>,
|
||||
request: Readonly<ApprovedActionManualRecoveryResolveRequest>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
): boolean {
|
||||
return (
|
||||
resolution.projectId === request.projectId &&
|
||||
resolution.executionVersion === request.expectedExecutionVersion &&
|
||||
resolution.executionDigest === request.expectedExecutionDigest &&
|
||||
resolution.mutationId === request.mutationId &&
|
||||
resolution.decision === request.decision &&
|
||||
resolution.evidenceDigest === request.evidenceDigest &&
|
||||
resolution.reasonCode === request.reasonCode &&
|
||||
resolution.auditEventId === request.auditEventId &&
|
||||
resolution.resolvedBy.type === principal.subject.type &&
|
||||
resolution.resolvedBy.id === principal.subject.id &&
|
||||
resolution.authenticationId === principal.authenticationId
|
||||
);
|
||||
}
|
||||
|
||||
async function authorized(
|
||||
policy: Pick<ProjectPolicyEngine, 'authorize'>,
|
||||
principal: Readonly<SecurityPrincipal>,
|
||||
projectId: string,
|
||||
): Promise<Readonly<SecurityPolicyFence>> {
|
||||
let decision;
|
||||
try {
|
||||
decision = await policy.authorize(principal, projectId, 'approval.recover');
|
||||
} catch (error) {
|
||||
throw new ApprovedActionManualRecoveryUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (
|
||||
decision.effect !== 'allow' ||
|
||||
decision.fence === null ||
|
||||
decision.fence.bindingVersion === null
|
||||
) {
|
||||
throw new ApprovedActionManualRecoveryAuthorizationError();
|
||||
}
|
||||
return decision.fence;
|
||||
}
|
||||
|
||||
export function createApprovedActionManualRecoveryService(options: Readonly<{
|
||||
repository: ApprovedActionManualRecoveryRepository;
|
||||
policy: Pick<ProjectPolicyEngine, 'authorize'>;
|
||||
audit: SecurityAuditSink;
|
||||
now?: () => number;
|
||||
}>): Readonly<ApprovedActionManualRecoveryService> {
|
||||
exact(options, [
|
||||
'repository',
|
||||
'policy',
|
||||
'audit',
|
||||
...(options?.now === undefined ? [] : ['now']),
|
||||
]);
|
||||
if (
|
||||
typeof options.repository?.findByDispatchId !== 'function' ||
|
||||
typeof options.repository?.resolve !== 'function' ||
|
||||
typeof options.policy?.authorize !== 'function' ||
|
||||
typeof options.audit?.record !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
invalid('options are invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
return Object.freeze({
|
||||
async inspect(
|
||||
requestValue: ApprovedActionManualRecoveryInspectRequest,
|
||||
confirmAuthorization?: () => void | Promise<void>,
|
||||
) {
|
||||
const request = normalizeInspectRequest(requestValue);
|
||||
const inspectedAtMs = observedTime(now);
|
||||
const principal = strongPrincipal(request.principal, inspectedAtMs);
|
||||
const policyFence = await authorized(options.policy, principal, request.projectId);
|
||||
let snapshot: Readonly<ApprovedActionManualRecoverySnapshot> | null;
|
||||
try {
|
||||
const found = await options.repository.findByDispatchId(request.dispatchId);
|
||||
snapshot = found ? normalizeApprovedActionManualRecoverySnapshot(found) : null;
|
||||
} catch (error) {
|
||||
throw new ApprovedActionManualRecoveryUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (snapshot && snapshot.execution.dispatch.projectId !== request.projectId) {
|
||||
snapshot = null;
|
||||
}
|
||||
if (
|
||||
snapshot &&
|
||||
!supportedActionType(snapshot.execution.dispatch.action.actionType)
|
||||
) {
|
||||
throw new ApprovedActionManualRecoveryUnsupportedError();
|
||||
}
|
||||
await confirmAuthorization?.();
|
||||
try {
|
||||
await options.audit.record(
|
||||
allowedAudit(
|
||||
request,
|
||||
principal,
|
||||
policyFence,
|
||||
'approval.recover.inspect',
|
||||
inspectedAtMs,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ApprovedActionManualRecoveryUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async resolve(
|
||||
requestValue: ApprovedActionManualRecoveryResolveRequest,
|
||||
confirmAuthorization?: () => void | Promise<void>,
|
||||
) {
|
||||
const request = normalizeResolveRequest(requestValue);
|
||||
const resolvedAtMs = observedTime(now);
|
||||
const principal = strongPrincipal(request.principal, resolvedAtMs);
|
||||
const policyFence = await authorized(options.policy, principal, request.projectId);
|
||||
let current: Readonly<ApprovedActionManualRecoverySnapshot> | null;
|
||||
try {
|
||||
const found = await options.repository.findByDispatchId(request.dispatchId);
|
||||
current = found ? normalizeApprovedActionManualRecoverySnapshot(found) : null;
|
||||
} catch (error) {
|
||||
throw new ApprovedActionManualRecoveryUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (!current || current.execution.dispatch.projectId !== request.projectId) {
|
||||
throw new ApprovedActionManualRecoveryTargetUnavailableError();
|
||||
}
|
||||
if (!supportedActionType(current.execution.dispatch.action.actionType)) {
|
||||
throw new ApprovedActionManualRecoveryUnsupportedError();
|
||||
}
|
||||
if (current.resolution) {
|
||||
if (!exactReplay(current.resolution, request, principal)) {
|
||||
throw new ApprovedActionManualRecoveryFenceConflictError();
|
||||
}
|
||||
await confirmAuthorization?.();
|
||||
return Object.freeze({ status: 'existing' as const, snapshot: current });
|
||||
}
|
||||
if (
|
||||
current.execution.execution.version !== request.expectedExecutionVersion ||
|
||||
current.execution.execution.executionDigest !==
|
||||
request.expectedExecutionDigest ||
|
||||
approvedActionExecutionEffectiveStatus(
|
||||
current.execution.execution,
|
||||
resolvedAtMs,
|
||||
) !== 'recovery_required' ||
|
||||
current.execution.execution.leaseOwner === null ||
|
||||
current.execution.execution.leaseToken === null
|
||||
) {
|
||||
throw new ApprovedActionManualRecoveryFenceConflictError();
|
||||
}
|
||||
const resolution = createResolution(
|
||||
current.execution,
|
||||
request,
|
||||
principal,
|
||||
policyFence,
|
||||
resolvedAtMs,
|
||||
);
|
||||
const nextExecution = completeApprovedActionExecution(
|
||||
current.execution.execution,
|
||||
{
|
||||
owner: current.execution.execution.leaseOwner,
|
||||
leaseToken: current.execution.execution.leaseToken,
|
||||
expectedVersion: current.execution.execution.version,
|
||||
resultMutationId: request.mutationId,
|
||||
outcome: outcomeFor(request.decision),
|
||||
resultCode: resultCodeFor(request.decision),
|
||||
completedAtMs: resolvedAtMs,
|
||||
},
|
||||
);
|
||||
await confirmAuthorization?.();
|
||||
try {
|
||||
return await options.repository.resolve({
|
||||
previous: current.execution,
|
||||
nextExecution,
|
||||
resolution,
|
||||
audit: allowedAudit(
|
||||
request,
|
||||
principal,
|
||||
policyFence,
|
||||
'approval.recover.resolve',
|
||||
resolvedAtMs,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApprovedActionManualRecoveryFenceConflictError ||
|
||||
error instanceof ApprovedActionManualRecoveryTargetUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApprovedActionManualRecoveryUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
approvedActionDispatchDigest,
|
||||
consumeApprovalRequest,
|
||||
createApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
claimApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
ApprovedActionManualRecoveryAuthorizationError,
|
||||
ApprovedActionManualRecoveryFenceConflictError,
|
||||
ApprovedActionManualRecoveryUnsupportedError,
|
||||
createApprovedActionManualRecoveryService,
|
||||
normalizeApprovedActionManualRecoverySnapshot,
|
||||
} = require('@qinglong/runtime-core/approved-action-manual-recovery');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-1' }),
|
||||
authenticationId: 'oidc:session-1',
|
||||
authenticatedAtMs: 100,
|
||||
expiresAtMs: 20_000,
|
||||
assurance: 'hardware',
|
||||
});
|
||||
|
||||
function executing(actionType = 'plugin_package.secret_binding.bind') {
|
||||
const action = {
|
||||
permission: 'secret.manage',
|
||||
actionType,
|
||||
actionRef: 'secret-binding:1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
};
|
||||
const pending = createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'agent-1' },
|
||||
requestedAtMs: 800,
|
||||
expiresAtMs: 10_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
});
|
||||
const approved = decideApprovalRequest(pending, {
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: PRINCIPAL,
|
||||
decidedAtMs: 900,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
});
|
||||
const dispatch = consumeApprovalRequest(approved, {
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consumption-1',
|
||||
dispatchId: 'dispatch-1',
|
||||
action,
|
||||
requestedBy: pending.requestedBy,
|
||||
consumedBy: { type: 'system', id: 'package-executor' },
|
||||
consumedAtMs: 950,
|
||||
authorizationFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
}).dispatch;
|
||||
const baseline = createApprovedActionExecution(dispatch);
|
||||
const leased = claimApprovedActionExecution(baseline, {
|
||||
owner: 'executor-1',
|
||||
leaseToken: 'lease-1',
|
||||
nowMs: 1_000,
|
||||
leaseDurationMs: 500,
|
||||
});
|
||||
const started = 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_100,
|
||||
},
|
||||
);
|
||||
assert.equal(started.dispatchDigest, approvedActionDispatchDigest(dispatch));
|
||||
return Object.freeze({ dispatch, execution: started });
|
||||
}
|
||||
|
||||
function harness(options = {}) {
|
||||
let current = {
|
||||
execution: options.execution ?? executing(),
|
||||
resolution: null,
|
||||
};
|
||||
const audits = [];
|
||||
const calls = [];
|
||||
const service = createApprovedActionManualRecoveryService({
|
||||
repository: {
|
||||
async findByDispatchId() {
|
||||
return current;
|
||||
},
|
||||
async resolve(command) {
|
||||
calls.push(command);
|
||||
current = normalizeApprovedActionManualRecoverySnapshot({
|
||||
execution: {
|
||||
dispatch: command.previous.dispatch,
|
||||
execution: command.nextExecution,
|
||||
},
|
||||
resolution: command.resolution,
|
||||
});
|
||||
return { status: 'resolved', snapshot: current };
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
async authorize(_principal, _projectId, permission) {
|
||||
assert.equal(permission, 'approval.recover');
|
||||
return options.denied
|
||||
? { effect: 'deny', reasons: ['permission_missing'], fence: null }
|
||||
: {
|
||||
effect: 'allow',
|
||||
reasons: ['role_grant'],
|
||||
fence: { projectVersion: 1, bindingVersion: 2 },
|
||||
};
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async record(record) {
|
||||
audits.push(record);
|
||||
},
|
||||
},
|
||||
now: () => options.now ?? 2_000,
|
||||
});
|
||||
return { service, audits, calls, current: () => current };
|
||||
}
|
||||
|
||||
function inspectRequest() {
|
||||
return {
|
||||
projectId: 'default',
|
||||
dispatchId: 'dispatch-1',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000001',
|
||||
requestId: 'recover-inspect-1',
|
||||
principal: PRINCIPAL,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRequest(decision = 'abandon_unknown') {
|
||||
const snapshot = executing();
|
||||
return {
|
||||
...inspectRequest(),
|
||||
auditEventId: '10000000-0000-4000-8000-000000000002',
|
||||
requestId: 'recover-resolve-1',
|
||||
expectedExecutionVersion: snapshot.execution.version,
|
||||
expectedExecutionDigest: snapshot.execution.executionDigest,
|
||||
mutationId: 'manual-recovery-1',
|
||||
decision,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
reasonCode: 'orphan_absence_verified',
|
||||
};
|
||||
}
|
||||
|
||||
test('inspects only after strong authorization and writes a bounded audit', async () => {
|
||||
const { service, audits } = harness();
|
||||
let confirmed = 0;
|
||||
const result = await service.inspect(inspectRequest(), () => {
|
||||
confirmed += 1;
|
||||
});
|
||||
assert.equal(result.execution.execution.status, 'executing');
|
||||
assert.equal(result.resolution, null);
|
||||
assert.equal(confirmed, 1);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].operationId, 'approval.recover.inspect');
|
||||
assert.deepEqual(audits[0].reasons, [
|
||||
'role_grant',
|
||||
'strong_authentication',
|
||||
'manual_recovery',
|
||||
]);
|
||||
});
|
||||
|
||||
test('abandons an expired executing action as blocked with immutable evidence', async () => {
|
||||
const { service, calls, current } = harness();
|
||||
const result = await service.resolve(resolveRequest());
|
||||
assert.equal(result.status, 'resolved');
|
||||
assert.equal(result.snapshot.execution.execution.status, 'blocked');
|
||||
assert.equal(
|
||||
result.snapshot.execution.execution.resultCode,
|
||||
'manual_recovery_abandoned_unknown',
|
||||
);
|
||||
assert.equal(result.snapshot.resolution.evidenceDigest, 'e'.repeat(64));
|
||||
assert.equal(result.snapshot.resolution.decision, 'abandon_unknown');
|
||||
assert.equal(calls[0].audit.operationId, 'approval.recover.resolve');
|
||||
assert.equal(current().resolution.resolvedBy.id, 'owner-1');
|
||||
});
|
||||
|
||||
test('confirms a verified no-effect execution as failed and replays exactly', async () => {
|
||||
const { service } = harness();
|
||||
const request = resolveRequest('confirm_failed');
|
||||
const first = await service.resolve(request);
|
||||
const replay = await service.resolve(request);
|
||||
assert.equal(first.snapshot.execution.execution.status, 'failed');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(
|
||||
replay.snapshot.execution.execution.resultCode,
|
||||
'manual_recovery_confirmed_failed',
|
||||
);
|
||||
await assert.rejects(
|
||||
service.resolve({ ...request, evidenceDigest: 'f'.repeat(64) }),
|
||||
ApprovedActionManualRecoveryFenceConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects live leases, stale fences, unsupported actions and weak Users', async () => {
|
||||
const live = executing();
|
||||
const { service: liveService } = harness({ execution: live, now: 1_200 });
|
||||
await assert.rejects(
|
||||
liveService.resolve({
|
||||
...resolveRequest(),
|
||||
expectedExecutionVersion: live.execution.version,
|
||||
expectedExecutionDigest: live.execution.executionDigest,
|
||||
}),
|
||||
ApprovedActionManualRecoveryFenceConflictError,
|
||||
);
|
||||
|
||||
const { service: unsupported } = harness({
|
||||
execution: executing('tool.invoke'),
|
||||
});
|
||||
await assert.rejects(
|
||||
unsupported.inspect(inspectRequest()),
|
||||
ApprovedActionManualRecoveryUnsupportedError,
|
||||
);
|
||||
|
||||
const { service: weak } = harness();
|
||||
await assert.rejects(
|
||||
weak.inspect({
|
||||
...inspectRequest(),
|
||||
principal: { ...PRINCIPAL, assurance: 'single_factor' },
|
||||
}),
|
||||
ApprovedActionManualRecoveryAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing approval.recover permission before storage mutation', async () => {
|
||||
const { service, calls } = harness({ denied: true });
|
||||
await assert.rejects(
|
||||
service.resolve(resolveRequest()),
|
||||
ApprovedActionManualRecoveryAuthorizationError,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user