aps-agent/server/core/approval/store.ts

330 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 待确认动作(任务 A4.4):确认卡与一次性执行授权的持久层。
*
* 一张表同时承担「待确认动作」与「执行记录」两件事(A4 备注:不设单独的凭据表与审批事件表):
* 参数摘要、状态、回执、幂等键、有效期都在一行里,审批与执行的状态迁移是同一行的状态迁移。
*
* 三条不变量:
* 1. 确认卡绑定动作、参数摘要、目标版本与有效期——版本变了确认即失效
* 2. 确认人必须是会话发起人本人(单用户场景不做委托与转派)
* 3. 待确认状态落库,进程重启后仍在;同一写请求重试不重复生效
*/
import { randomUUID } from 'node:crypto';
import type { ExecutionReceipt } from '@aps/contracts';
import type { SealedSecret } from '../credentials/broker.js';
import type { Database } from '../storage/db.js';
export type PendingStatus = 'pending-confirmation' | 'succeeded' | 'failed' | 'rejected' | 'expired';
export type ApprovalDecision = 'approved' | 'rejected' | 'expired';
export interface PendingAction {
executionId: string;
approvalId: string;
sessionId: string;
projectId: string | null;
ownerUserId: string;
tenantId: string;
capabilityId: string;
providerId: string;
paramsDigest: string;
/** 脱敏后的关键参数:给确认卡看的,不是完整参数 */
paramsPreview: Record<string, unknown>;
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
paramsSealed: SealedSecret;
impactSummary: string;
/** 目标数据版本;版本变了确认就失效 */
revision: number;
status: PendingStatus;
decision?: ApprovalDecision;
decidedBy?: string;
decidedAt?: number;
/** 执行幂等键:同一写请求重试不重复生效 */
idempotencyKey?: string;
/** 确认请求的幂等键 */
decisionIdempotencyKey?: string;
grantId?: string;
receipt: ExecutionReceipt;
expiresAt: number;
createdAt: number;
updatedAt: number;
}
export interface CreatePendingInput {
sessionId: string;
projectId: string | null;
ownerUserId: string;
tenantId: string;
capabilityId: string;
providerId: string;
paramsDigest: string;
paramsPreview: Record<string, unknown>;
/** 原始参数的密文:确认后要照原样执行,但库里不留明文 */
paramsSealed: SealedSecret;
impactSummary: string;
revision: number;
expiresAt: number;
idempotencyKey?: string;
}
export type DecideKind = 'approved' | 'rejected' | 'expired' | 'replayed' | 'not-found' | 'revision-mismatch';
export interface DecideResult {
kind: DecideKind;
action: PendingAction;
}
interface PendingRow {
execution_id: string;
approval_id: string;
session_id: string;
project_id: string | null;
owner_user_id: string;
tenant_id: string;
capability_id: string;
provider_id: string;
params_digest: string;
params_preview: string;
params_iv: string | null;
params_ciphertext: string | null;
params_tag: string | null;
impact_summary: string;
revision: number;
status: string;
decision: string | null;
decided_by: string | null;
decided_at: number | null;
idempotency_key: string | null;
decision_idempotency_key: string | null;
grant_id: string | null;
receipt: string;
expires_at: number;
created_at: number;
updated_at: number;
}
function sealedFromRow(row: PendingRow): SealedSecret {
return { iv: row.params_iv ?? '', ciphertext: row.params_ciphertext ?? '', tag: row.params_tag ?? '' };
}
function fromRow(row: PendingRow): PendingAction {
return {
executionId: row.execution_id,
approvalId: row.approval_id,
sessionId: row.session_id,
projectId: row.project_id,
ownerUserId: row.owner_user_id,
tenantId: row.tenant_id,
capabilityId: row.capability_id,
providerId: row.provider_id,
paramsDigest: row.params_digest,
paramsPreview: JSON.parse(row.params_preview) as Record<string, unknown>,
paramsSealed: sealedFromRow(row),
impactSummary: row.impact_summary,
revision: row.revision,
status: row.status as PendingStatus,
...(row.decision === null ? {} : { decision: row.decision as ApprovalDecision }),
...(row.decided_by === null ? {} : { decidedBy: row.decided_by }),
...(row.decided_at === null ? {} : { decidedAt: row.decided_at }),
...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }),
...(row.decision_idempotency_key === null ? {} : { decisionIdempotencyKey: row.decision_idempotency_key }),
...(row.grant_id === null ? {} : { grantId: row.grant_id }),
receipt: JSON.parse(row.receipt) as ExecutionReceipt,
expiresAt: row.expires_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class PendingActionStore {
private readonly memory = new Map<string, PendingAction>();
constructor(
private readonly db?: Database,
private readonly now: () => number = Date.now,
) {}
create(input: CreatePendingInput): PendingAction {
const at = this.now();
const action: PendingAction = {
executionId: `exec-${randomUUID()}`,
approvalId: `apr-${randomUUID()}`,
sessionId: input.sessionId,
projectId: input.projectId,
ownerUserId: input.ownerUserId,
tenantId: input.tenantId,
capabilityId: input.capabilityId,
providerId: input.providerId,
paramsDigest: input.paramsDigest,
paramsPreview: input.paramsPreview,
paramsSealed: input.paramsSealed,
impactSummary: input.impactSummary,
revision: input.revision,
status: 'pending-confirmation',
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
receipt: { executionId: '', status: 'pending-confirmation' },
expiresAt: input.expiresAt,
createdAt: at,
updatedAt: at,
};
action.receipt = { executionId: action.executionId, status: 'pending-confirmation', approvalId: action.approvalId };
this.save(action);
return action;
}
get(approvalId: string): PendingAction | undefined {
if (this.db) {
const row = this.db.prepare('select * from pending_actions where approval_id = ?').get(approvalId) as PendingRow | undefined;
return row ? fromRow(row) : undefined;
}
return this.memory.get(approvalId);
}
byExecution(executionId: string): PendingAction | undefined {
if (this.db) {
const row = this.db.prepare('select * from pending_actions where execution_id = ?').get(executionId) as PendingRow | undefined;
return row ? fromRow(row) : undefined;
}
return [...this.memory.values()].find((action) => action.executionId === executionId);
}
/** 同一写请求的重试:找到原待确认动作,不新建第二张确认卡 */
findByIdempotencyKey(sessionId: string, idempotencyKey: string): PendingAction | undefined {
if (this.db) {
const row = this.db
.prepare('select * from pending_actions where session_id = ? and idempotency_key = ? limit 1')
.get(sessionId, idempotencyKey) as PendingRow | undefined;
return row ? fromRow(row) : undefined;
}
return [...this.memory.values()].find((action) => action.sessionId === sessionId && action.idempotencyKey === idempotencyKey);
}
listBySession(sessionId: string): PendingAction[] {
if (this.db) {
const rows = this.db
.prepare('select * from pending_actions where session_id = ? order by created_at')
.all(sessionId) as unknown as PendingRow[];
return rows.map(fromRow);
}
return [...this.memory.values()].filter((action) => action.sessionId === sessionId).sort((a, b) => a.createdAt - b.createdAt);
}
/** 确认或拒绝。返回 kind 描述这次调用发生了什么;已决策过的再次调用返回 replayed(幂等) */
decide(
approvalId: string,
input: { decision: 'approve' | 'reject'; revision: number; decidedBy: string; idempotencyKey: string },
): DecideResult | undefined {
const action = this.get(approvalId);
if (!action) return undefined;
// 已经决策过:同一请求重放返回同一结果;不重复执行
if (action.decision !== undefined && action.decision !== 'expired') return { kind: 'replayed', action };
const at = this.now();
if (at > action.expiresAt) {
const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at };
this.save(expired);
return { kind: 'expired', action: expired };
}
if (input.revision !== action.revision) {
// 目标数据在等待确认期间变了:这次确认作废,必须重新发起(协议第 7 节)
const expired: PendingAction = { ...action, status: 'expired', decision: 'expired', decidedBy: input.decidedBy, decidedAt: at, decisionIdempotencyKey: input.idempotencyKey, updatedAt: at };
this.save(expired);
return { kind: 'revision-mismatch', action: expired };
}
if (input.decision === 'reject') {
const rejected: PendingAction = {
...action,
status: 'rejected',
decision: 'rejected',
decidedBy: input.decidedBy,
decidedAt: at,
decisionIdempotencyKey: input.idempotencyKey,
updatedAt: at,
};
this.save(rejected);
return { kind: 'rejected', action: rejected };
}
const approved: PendingAction = {
...action,
decision: 'approved',
decidedBy: input.decidedBy,
decidedAt: at,
decisionIdempotencyKey: input.idempotencyKey,
updatedAt: at,
};
this.save(approved);
return { kind: 'approved', action: approved };
}
/** 签发一次性授权后记账 */
attachGrant(approvalId: string, grantId: string): PendingAction | undefined {
const action = this.get(approvalId);
if (!action) return undefined;
const updated: PendingAction = { ...action, grantId, updatedAt: this.now() };
this.save(updated);
return updated;
}
/** 执行结束:回执与状态落到同一行 */
complete(executionId: string, receipt: ExecutionReceipt): PendingAction | undefined {
const action = this.byExecution(executionId);
if (!action) return undefined;
const status: PendingStatus = receipt.status === 'succeeded' ? 'succeeded' : receipt.status === 'failed' ? 'failed' : 'pending-confirmation';
const updated: PendingAction = { ...action, status, receipt, updatedAt: this.now() };
this.save(updated);
return updated;
}
private save(action: PendingAction): void {
if (this.db) {
this.db
.prepare(
`insert into pending_actions (execution_id, approval_id, session_id, project_id, owner_user_id, tenant_id, capability_id, provider_id, params_digest, params_preview, params_iv, params_ciphertext, params_tag, impact_summary, revision, status, decision, decided_by, decided_at, idempotency_key, decision_idempotency_key, grant_id, receipt, expires_at, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(execution_id) do update set
status = excluded.status,
decision = excluded.decision,
decided_by = excluded.decided_by,
decided_at = excluded.decided_at,
decision_idempotency_key = excluded.decision_idempotency_key,
grant_id = excluded.grant_id,
receipt = excluded.receipt,
updated_at = excluded.updated_at`,
)
.run(
action.executionId,
action.approvalId,
action.sessionId,
action.projectId,
action.ownerUserId,
action.tenantId,
action.capabilityId,
action.providerId,
action.paramsDigest,
JSON.stringify(action.paramsPreview),
action.paramsSealed.iv,
action.paramsSealed.ciphertext,
action.paramsSealed.tag,
action.impactSummary,
action.revision,
action.status,
action.decision ?? null,
action.decidedBy ?? null,
action.decidedAt ?? null,
action.idempotencyKey ?? null,
action.decisionIdempotencyKey ?? null,
action.grantId ?? null,
JSON.stringify(action.receipt),
action.expiresAt,
action.createdAt,
action.updatedAt,
);
return;
}
this.memory.set(action.approvalId, action);
}
}