审计日志

记录事件

log.audit、log.audit.deny、独立 audit()、withAudit 自动埋点、defineAuditAction 和 defineAuditCatalog 注册表,以及 auditDiff 变更补丁。

五个 API 覆盖了审计记录的所有形态:请求内、被拒绝、独立、自动埋点和类型化。

log.audit()

log.audit()log.set({ audit: ... }) 加上尾部采样强制保留的语法糖:

log.audit({
  action: 'invoice.refund',
  actor: { type: 'user', id: user.id },
  target: { type: 'invoice', id: 'inv_889' },
  outcome: 'success',
})

// 严格等价于:
log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } })

这是你最常使用的形式。审计事件会落在与请求其余部分相同的宽事件上。

log.audit.deny()

log.audit.deny(reason, fields) 用于记录被 AuthZ 拒绝的操作。大多数团队都会忘记记录拒绝,但这恰恰是审计员和安全团队最关心的内容:

if (!user.canRefund(invoice)) {
  log.audit.deny('权限不足', {
    action: 'invoice.refund',
    actor: { type: 'user', id: user.id },
    target: { type: 'invoice', id: invoice.id },
  })
  throw createError({ status: 403, message: 'Forbidden' })
}

独立 audit()

对于非请求上下文(任务、脚本、CLI),请使用独立的 audit()

import { audit } from 'evlog'

audit({
  action: 'cron.cleanup',
  actor: { type: 'system', id: 'cron' },
  target: { type: 'job', id: 'cleanup-stale-sessions' },
  outcome: 'success',
})
独立 audit() 事件没有 requestId、没有 context.ip、也没有 userAgent —— 因为没有可供补充信息的请求。当取证需要时,请手动添加你自己的上下文(context: { jobId, queue, runId })。

defineAuditAction()

在一个地方定义审计动作,避免魔法字符串,并让 target 获得完整的类型安全:

import { defineAuditAction } from 'evlog'

const refund = defineAuditAction('invoice.refund', { target: 'invoice' })

log.audit(refund({
  actor: { type: 'user', id: user.id },
  target: { id: 'inv_889' }, // 类型推断为 'invoice'
  outcome: 'success',
}))

将此与 Schema → Action naming 中的动作字典配合使用。

defineAuditCatalog()

对于不止寥寥几个动作的情况,请将它们分组到一个类型化的 catalog 中,而不是逐个声明 defineAuditAction。与错误 catalog 的约定相同:UPPER_SNAKE_CASE 键、lower.dot.case 前缀,最终的 wire action${prefix}.${KEY}

import { defineAuditCatalog } from 'evlog'

export const billingAudit = defineAuditCatalog('billing', {
  INVOICE_REFUND: {
    target: 'invoice',
    severity: 'high',
    requiresChanges: true,
    description: '将发票退款给客户',
    redactPaths: ['cardNumber'],
  },
  INVOICE_CREATE:      { target: 'invoice' },
  INVOICE_VOID:        { target: 'invoice', severity: 'high', requiresReason: true },
  SUBSCRIPTION_CANCEL: { target: 'subscription', severity: 'high' },
})

每个条目都会生成一个围绕 defineAuditAction 的轻量封装(target 类型在定义时固定,action 名称自动加前缀)。catalog 元数据会在每个工厂以及 _actions / _prefix 上暴露:

billingAudit.INVOICE_REFUND.action           // 'billing.INVOICE_REFUND'(字面量类型)
billingAudit.INVOICE_REFUND.target           // 'invoice'
billingAudit.INVOICE_REFUND.severity         // 'high'
billingAudit.INVOICE_REFUND.requiresChanges // true
billingAudit.INVOICE_REFUND.redactPaths      // ['cardNumber']
billingAudit._actions                        // readonly ['billing.INVOICE_REFUND', ...]
Entry fieldPurpose
target在调用处注入的默认 target.type
description面向人类的标签,用于文档、SIEM 规则和审查工具
severity'low' | 'medium' | 'high' | 'critical' —— 告警和审查优先级
requiresChanges说明调用方应附加 changes(例如通过 auditDiff
requiresReason说明调用方应附加 reason(尤其是拒绝场景)
redactPaths该动作上 auditDiff({ redactPaths: [...] }) 的默认路径

defineAuditAction vs defineAuditCatalog — 何时选择

二者都会生成相同的调用处工厂形状。按规模选择:

  • defineAuditAction(action, opts?)——一次性动作,或在超大型仓库中按文件组织。与 defineError 相呼应。等价于只有单个条目的 catalog,但不做前缀推导:你直接写完整的 wire action
  • defineAuditCatalog(prefix, map)——将超出寥寥几个的相关动作归入一个前缀下。与 defineErrorCatalog 相呼应。wire action 会自动推导为 ${prefix}.${KEY},catalog 元数据(_actions_prefix)可用于内省,并且只需一行 declare module 'evlog' 即可将整个 bundle 暴露到类型化的 AuditAction 联合类型中。

你可以在同一代码库中混用这两者——把跨领域的一次性动作保留为 defineAuditAction,把有边界的上下文(billingauthsubscription)分组为 catalogs。

无处不在的类型安全动作(可选)

通过扩展 RegisteredAuditCatalogs 来模仿错误 catalog 的增强:

import type { billingAudit } from './audit/billing'

declare module 'evlog' {
  interface RegisteredAuditCatalogs {
    billing: typeof billingAudit
  }
}

这会在类型化的 AuditAction 导出上呈现所有已注册动作的联合类型,适用于共享辅助函数、仪表盘以及可安全重构的比较逻辑。

进一步了解。 专门的 Catalogs 页面 讲解了错误和审计 catalogs 的扩展路径(单文件 → 文件夹 → 功能 → npm 包),以及 npm 打包、组合模式和类型增强的深入解析。

auditDiff()

对于会修改数据的操作,请使用 auditDiff() 生成紧凑、支持脱敏感知的 JSON Patch:

不要把整行数据库记录直接传给 auditDiff() 在做 diff 之前,请移除计算列、哈希密码、内部标志以及大型 JSON blob。changes 的重点是语义上发生了什么变化(状态从 paidrefunded),而不是字节层面发生了什么变化lastModified 时间戳变了)。冗长嘈杂的 changes 字段会让审计日志最先变得不可读。
import { auditDiff } from 'evlog'

const before = await db.users.byId(id)
const after = await db.users.update(id, patch)

log.audit({
  action: 'user.update',
  actor: { type: 'user', id: actorId },
  target: { type: 'user', id },
  outcome: 'success',
  changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }),
})

withAudit() — 自动埋点

开发者经常忘记调用 log.audit()。把函数包装起来,就再也不会漏记:

何时包装,何时手动调用。 对于纯粹的、值得审计的操作(退款、删除、角色变更、重置密码)请使用包装——结果判定是自动的,而且不会不小心漏掉调用。若审计只是更大处理器中的多个决策之一,或者你需要在操作完成之前发出审计(例如“用户请求删除”),则继续手动使用 log.audit()
import { withAudit, AuditDeniedError } from 'evlog'

const refundInvoice = withAudit(
  { action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) },
  async (input: { id: string }, ctx) => {
    if (!ctx.actor) throw new AuditDeniedError('匿名退款被拒绝')
    return await db.invoices.refund(input.id)
  },
)

await refundInvoice({ id: 'inv_889' }, {
  actor: { type: 'user', id: user.id },
  correlationId: requestId,
})

结果判定:

  • fn 返回成功 → outcome: 'success'
  • fn 抛出 AuditDeniedError(或任何 status === 403 的错误)→ outcome: 'denied',错误消息会成为 reason
  • 其他抛出的错误 → outcome: 'failure',随后重新抛出。