2026-09-29 10:59:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 审计与证据链(任务 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'
|
2026-09-30 14:26:35 +08:00
|
|
|
|
| 'provider.denied'
|
|
|
|
|
|
| 'binding.created'
|
|
|
|
|
|
| 'binding.removed'
|
|
|
|
|
|
| 'identity.expired';
|
2026-09-29 10:59:34 +08:00
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|