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

241 lines
7.8 KiB
TypeScript
Raw Permalink Normal View History

/**
* 审计与证据链(任务 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'
| 'binding.created'
| 'binding.removed'
| 'identity.expired';
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 };
}
}