aps-agent/server/core/audit/log.ts

238 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 审计与证据链(任务 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 };
}
}