238 lines
7.7 KiB
TypeScript
238 lines
7.7 KiB
TypeScript
/**
|
||
* 审计与证据链(任务 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<string, unknown>;
|
||
}
|
||
|
||
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<string, unknown>,
|
||
prevHash: row.prev_hash,
|
||
hash: row.hash,
|
||
});
|
||
|
||
/** 只保留参与哈希的字段(去掉 hash 自己) */
|
||
function chainInput(entry: AuditEntry): Omit<AuditEntry, 'hash'> {
|
||
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<AuditEntry, 'hash'>): 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<AuditEntry, 'hash'> = {
|
||
...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 };
|
||
}
|
||
} |