/** * 审计与证据链(任务 A4.5)。 * * 一条执行要能回答四个问题:谁发起的、要动什么、谁批准的、结果是什么。 * 做法是**只追加**的哈希链:每条记录的 hash = sha256(上一条 hash + 本条内容), * 改掉中间任何一条,verify() 就会在那一处断开——这是「证据」和「日志」的区别。 * * 不提供 update / delete:这一层没有可变更的接口,改动只能靠再追加一条新记录。 */ import { createHash, randomUUID } from 'node:crypto'; import type { Database } from '../storage/db.js'; export type AuditEvent = | 'policy.denied' | 'policy.requires_confirmation' | 'approval.requested' | 'approval.decided' | 'approval.expired' | 'execution.started' | 'execution.finished' | 'provider.denied'; export interface AuditEntryInput { actorUserId: string; tenantId: string; event: AuditEvent; executionId?: string; approvalId?: string; sessionId?: string; capabilityId?: string; providerId?: string; paramsDigest?: string; /** 决策或动作:平台策略码、用户决定(approved/rejected)、业务系统的拒绝等 */ decision?: string; detail?: Record; } export interface AuditEntry extends AuditEntryInput { seq: number; auditId: string; ts: number; prevHash: string; hash: string; } interface AuditRow { seq: number; audit_id: string; ts: number; actor_user_id: string; tenant_id: string; event: string; execution_id: string | null; approval_id: string | null; session_id: string | null; capability_id: string | null; provider_id: string | null; params_digest: string | null; decision: string | null; detail: string; prev_hash: string; hash: string; } const GENESIS = 'sha256:genesis'; const toRow = (entry: AuditEntry) => ({ seq: entry.seq, audit_id: entry.auditId, ts: entry.ts, actor_user_id: entry.actorUserId, tenant_id: entry.tenantId, event: entry.event, execution_id: entry.executionId ?? null, approval_id: entry.approvalId ?? null, session_id: entry.sessionId ?? null, capability_id: entry.capabilityId ?? null, provider_id: entry.providerId ?? null, params_digest: entry.paramsDigest ?? null, decision: entry.decision ?? null, detail: JSON.stringify(entry.detail ?? {}), prev_hash: entry.prevHash, hash: entry.hash, }); const fromRow = (row: AuditRow): AuditEntry => ({ seq: row.seq, auditId: row.audit_id, ts: row.ts, actorUserId: row.actor_user_id, tenantId: row.tenant_id, event: row.event as AuditEvent, ...(row.execution_id === null ? {} : { executionId: row.execution_id }), ...(row.approval_id === null ? {} : { approvalId: row.approval_id }), ...(row.session_id === null ? {} : { sessionId: row.session_id }), ...(row.capability_id === null ? {} : { capabilityId: row.capability_id }), ...(row.provider_id === null ? {} : { providerId: row.provider_id }), ...(row.params_digest === null ? {} : { paramsDigest: row.params_digest }), ...(row.decision === null ? {} : { decision: row.decision }), detail: JSON.parse(row.detail) as Record, prevHash: row.prev_hash, hash: row.hash, }); /** 只保留参与哈希的字段(去掉 hash 自己) */ function chainInput(entry: AuditEntry): Omit { return { seq: entry.seq, auditId: entry.auditId, ts: entry.ts, actorUserId: entry.actorUserId, tenantId: entry.tenantId, event: entry.event, ...(entry.executionId === undefined ? {} : { executionId: entry.executionId }), ...(entry.approvalId === undefined ? {} : { approvalId: entry.approvalId }), ...(entry.sessionId === undefined ? {} : { sessionId: entry.sessionId }), ...(entry.capabilityId === undefined ? {} : { capabilityId: entry.capabilityId }), ...(entry.providerId === undefined ? {} : { providerId: entry.providerId }), ...(entry.paramsDigest === undefined ? {} : { paramsDigest: entry.paramsDigest }), ...(entry.decision === undefined ? {} : { decision: entry.decision }), detail: entry.detail ?? {}, prevHash: entry.prevHash, }; } /** 链式哈希:字段顺序固定,换字段顺序不影响结果的前提是这里不重排 */ function chainHash(entry: Omit): string { const canonical = JSON.stringify([ entry.seq, entry.auditId, entry.ts, entry.actorUserId, entry.tenantId, entry.event, entry.executionId ?? null, entry.approvalId ?? null, entry.sessionId ?? null, entry.capabilityId ?? null, entry.providerId ?? null, entry.paramsDigest ?? null, entry.decision ?? null, entry.detail ?? {}, entry.prevHash, ]); return `sha256:${createHash('sha256').update(canonical).digest('hex')}`; } export class AuditLog { private readonly memory: AuditEntry[] = []; constructor( private readonly db?: Database, private readonly now: () => number = Date.now, ) {} append(input: AuditEntryInput): AuditEntry { const previous = this.last(); const base: Omit = { ...input, seq: (previous?.seq ?? 0) + 1, auditId: `aud-${randomUUID()}`, ts: this.now(), prevHash: previous?.hash ?? GENESIS, detail: input.detail ?? {}, }; const entry: AuditEntry = { ...base, hash: chainHash(base) }; if (this.db) { const row = toRow(entry); this.db .prepare( `insert into audit_log (seq, audit_id, ts, actor_user_id, tenant_id, event, execution_id, approval_id, session_id, capability_id, provider_id, params_digest, decision, detail, prev_hash, hash) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( row.seq, row.audit_id, row.ts, row.actor_user_id, row.tenant_id, row.event, row.execution_id, row.approval_id, row.session_id, row.capability_id, row.provider_id, row.params_digest, row.decision, row.detail, row.prev_hash, row.hash, ); } else { this.memory.push(entry); } return entry; } last(): AuditEntry | undefined { if (this.db) { const row = this.db.prepare('select * from audit_log order by seq desc limit 1').get() as AuditRow | undefined; return row ? fromRow(row) : undefined; } return this.memory[this.memory.length - 1]; } /** 按执行追整条链路:策略 → 确认 → 执行 → 回执 */ trace(executionId: string): AuditEntry[] { if (this.db) { const rows = this.db.prepare('select * from audit_log where execution_id = ? order by seq').all(executionId) as unknown as AuditRow[]; return rows.map(fromRow); } return this.memory.filter((entry) => entry.executionId === executionId); } list(limit = 100): AuditEntry[] { if (this.db) { const rows = this.db.prepare('select * from audit_log order by seq desc limit ?').all(limit) as unknown as AuditRow[]; return rows.map(fromRow).reverse(); } return this.memory.slice(-limit); } /** 校验整条链:任何一条被改过都会在这里断开 */ verify(): { ok: boolean; brokenAt?: number; reason?: string } { const entries = this.db ? (this.db.prepare('select * from audit_log order by seq').all() as unknown as AuditRow[]).map(fromRow) : this.memory; let prevHash = GENESIS; for (const entry of entries) { if (entry.prevHash !== prevHash) return { ok: false, brokenAt: entry.seq, reason: '前一条的 hash 与当前记录的 prevHash 不一致' }; const expected = chainHash(chainInput(entry)); if (expected !== entry.hash) return { ok: false, brokenAt: entry.seq, reason: '记录内容与链式哈希不匹配' }; prevHash = entry.hash; } return { ok: true }; } }