审计日志与操作追踪

审计日志是企业级 SaaS 的标配——SOC 2 审计要求、客户安全需求、问题排查都依赖它。审计日志回答"谁在什么时候做了什么"。本章从架构设计到查询面板,完整实现 SaaS 审计系统。

1. 审计日志架构

1.1 审计日志 vs 应用日志

维度审计日志应用日志
目的合规、安全、追溯调试、监控
受众租户管理员、审计员开发者
存储持久化、不可删改可轮转
内容谁做了什么系统运行状态
保留1-7 年30-90 天

1.2 数据模型

// lib/db/schema.ts
export const auditLogs = pgTable('audit_logs', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
  actorId: uuid('actor_id').references(() => users.id),
  actorEmail: text('actor_email'),       // 冗余存储,用户删除后仍可查
  action: text('action').notNull(),       // 'member.invited'
  targetType: text('target_type'),        // 'member', 'project', 'subscription'
  targetId: text('target_id'),            // 被操作对象 ID
  metadata: jsonb('metadata'),            // 变更详情
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// 索引:按租户+时间查询是最常见的访问模式
// CREATE INDEX idx_audit_tenant_time ON audit_logs (tenant_id, created_at DESC)

1.3 核心原则

原则说明
不可变审计日志只能写入,不能修改或删除
冗余存储快照(如 actor_email),不依赖外键 JOIN
异步日志写入不应阻塞业务操作
结构化metadata 用 JSON,便于查询和分析

2. 日志采集

2.1 日志记录函数

// lib/audit.ts
import { headers } from 'next/headers'
 
interface AuditLogEntry {
  tenantId: string
  actorId: string
  actorEmail: string
  action: string
  targetType?: string
  targetId?: string
  metadata?: Record<string, any>
}
 
export async function logAudit(entry: AuditLogEntry) {
  const headerList = await headers()
 
  await db.insert(auditLogs).values({
    ...entry,
    ipAddress: headerList.get('x-forwarded-for')?.split(',')[0] || 'unknown',
    userAgent: headerList.get('user-agent') || 'unknown',
  })
}

2.2 Server Action 中使用

export async function removeMember(membershipId: string) {
  const { session, membership } = await requirePermission('member:remove')
 
  const [target] = await db.select().from(memberships)
    .innerJoin(users, eq(memberships.userId, users.id))
    .where(eq(memberships.id, membershipId))
 
  await db.delete(memberships).where(eq(memberships.id, membershipId))
 
  await logAudit({
    tenantId: membership.tenantId,
    actorId: session.user.id,
    actorEmail: session.user.email,
    action: 'member.removed',
    targetType: 'member',
    targetId: target.memberships.userId,
    metadata: { removedEmail: target.users.email, role: target.memberships.role },
  })
}

2.3 标准事件分类

类别事件严重级别
认证auth.login, auth.logout, auth.failed
成员member.invited, member.removed, role.changed
组织org.updated, org.deleted
计费subscription.started, subscription.canceled
数据project.created, project.deleted
设置settings.changed, api_key.created

2.4 异步日志写入

审计日志不应阻塞业务操作。在高频场景中,用 Inngest 异步写入:

// lib/inngest/functions/audit-log.ts
import { inngest } from '../client'
 
export const writeAuditLog = inngest.createFunction(
  { id: 'write-audit-log', retries: 3 },
  { event: 'audit/log' },
  async ({ event }) => {
    const { entry, ipAddress, userAgent } = event.data
 
    await db.insert(auditLogs).values({
      ...entry,
      ipAddress,
      userAgent,
    })
  }
)
 
// 异步版本的 logAudit
export async function logAuditAsync(entry: AuditLogEntry) {
  const headerList = await headers()
 
  await inngest.send({
    name: 'audit/log',
    data: {
      entry,
      ipAddress: headerList.get('x-forwarded-for')?.split(',')[0] || 'unknown',
      userAgent: headerList.get('user-agent') || 'unknown',
    },
  })
}

2.5 变更详情记录

对于设置变更等操作,metadata 中应记录变更前后的值:

export async function updateOrganizationSettings(formData: FormData) {
  const { session, membership } = await requirePermission('billing:manage')
 
  const [currentTenant] = await db.select().from(tenants)
    .where(eq(tenants.id, membership.tenantId))
 
  const newName = formData.get('name') as string
  const newSlug = formData.get('slug') as string
 
  await db.update(tenants)
    .set({ name: newName, slug: newSlug, updatedAt: new Date() })
    .where(eq(tenants.id, membership.tenantId))
 
  // 记录变更前后的差异
  await logAudit({
    tenantId: membership.tenantId,
    actorId: session.user.id,
    actorEmail: session.user.email,
    action: 'org.updated',
    targetType: 'organization',
    targetId: membership.tenantId,
    metadata: {
      changes: {
        name: { from: currentTenant.name, to: newName },
        slug: { from: currentTenant.slug, to: newSlug },
      },
    },
  })
}

2.6 登录事件记录

// 在登录成功/失败的回调中记录
export async function recordLoginEvent(
  userId: string | null,
  email: string,
  success: boolean,
  reason?: string
) {
  const headerList = await headers()
 
  await db.insert(auditLogs).values({
    tenantId: 'system',  // 登录事件不属于特定租户
    actorId: userId,
    actorEmail: email,
    action: success ? 'auth.login' : 'auth.failed',
    metadata: success ? undefined : { reason },
    ipAddress: headerList.get('x-forwarded-for')?.split(',')[0] || 'unknown',
    userAgent: headerList.get('user-agent') || 'unknown',
  })
}

3. 日志查询

3.1 查询 Server Action

// app/(dashboard)/settings/audit-log/actions.ts
export async function getAuditLogs(filters: {
  page?: number
  action?: string
  actorId?: string
  startDate?: Date
  endDate?: Date
}) {
  const { membership } = await requirePermission('billing:manage')
  const { page = 1, action, actorId, startDate, endDate } = filters
  const limit = 50
 
  const conditions = [eq(auditLogs.tenantId, membership.tenantId)]
  if (action) conditions.push(eq(auditLogs.action, action))
  if (actorId) conditions.push(eq(auditLogs.actorId, actorId))
  if (startDate) conditions.push(gte(auditLogs.createdAt, startDate))
  if (endDate) conditions.push(lte(auditLogs.createdAt, endDate))
 
  const [logs, [{ count }]] = await Promise.all([
    db.select().from(auditLogs)
      .where(and(...conditions))
      .orderBy(desc(auditLogs.createdAt))
      .limit(limit).offset((page - 1) * limit),
    db.select({ count: sql<number>`count(*)` }).from(auditLogs)
      .where(and(...conditions)),
  ])
 
  return { logs, total: count, page, totalPages: Math.ceil(count / limit) }
}

3.2 人类可读的事件描述

// lib/audit/format.ts
const ACTION_LABELS: Record<string, string> = {
  'auth.login': 'Signed in',
  'auth.logout': 'Signed out',
  'auth.failed': 'Failed sign-in attempt',
  'member.invited': 'Invited a member',
  'member.removed': 'Removed a member',
  'member.joined': 'Joined the organization',
  'role.changed': 'Changed member role',
  'org.created': 'Created organization',
  'org.updated': 'Updated organization settings',
  'org.deleted': 'Deleted organization',
  'project.created': 'Created a project',
  'project.deleted': 'Deleted a project',
  'subscription.started': 'Started a subscription',
  'subscription.canceled': 'Canceled subscription',
  'api_key.created': 'Created an API key',
  'api_key.revoked': 'Revoked an API key',
}
 
export function formatAuditAction(action: string): string {
  return ACTION_LABELS[action] || action
}
 
export function formatAuditDescription(log: AuditLog): string {
  const label = formatAuditAction(log.action)
  const actor = log.actorEmail || 'System'
 
  if (log.metadata?.changes) {
    const fields = Object.keys(log.metadata.changes).join(', ')
    return `${actor} ${label.toLowerCase()} (changed: ${fields})`
  }
 
  if (log.metadata?.removedEmail) {
    return `${actor} ${label.toLowerCase()}: ${log.metadata.removedEmail}`
  }
 
  return `${actor} ${label.toLowerCase()}`
}

3.3 审计日志 UI 页面

// app/(dashboard)/settings/audit-log/page.tsx
import { getAuditLogs } from './actions'
import { formatAuditAction } from '@/lib/audit/format'
 
export default async function AuditLogPage({
  searchParams,
}: {
  searchParams: { page?: string; action?: string }
}) {
  const page = Number(searchParams.page) || 1
  const { logs, total, totalPages } = await getAuditLogs({
    page,
    action: searchParams.action,
  })
 
  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-semibold">Audit Log</h2>
        <ExportButton />
      </div>
 
      <AuditFilters />
 
      <table className="w-full text-sm">
        <thead>
          <tr className="border-b text-left text-muted-foreground">
            <th className="pb-2">Time</th>
            <th className="pb-2">Actor</th>
            <th className="pb-2">Action</th>
            <th className="pb-2">IP Address</th>
          </tr>
        </thead>
        <tbody>
          {logs.map((log) => (
            <tr key={log.id} className="border-b">
              <td className="py-3">{log.createdAt.toLocaleString()}</td>
              <td className="py-3">{log.actorEmail}</td>
              <td className="py-3">
                <span className="rounded bg-muted px-2 py-1 text-xs">
                  {formatAuditAction(log.action)}
                </span>
              </td>
              <td className="py-3 text-muted-foreground">{log.ipAddress}</td>
            </tr>
          ))}
        </tbody>
      </table>
 
      <Pagination page={page} totalPages={totalPages} total={total} />
    </div>
  )
}

3.4 审计日志详情展开

// components/audit-log-detail.tsx
'use client'
 
import { useState } from 'react'
 
export function AuditLogRow({ log }: { log: AuditLog }) {
  const [expanded, setExpanded] = useState(false)
 
  return (
    <>
      <tr className="border-b cursor-pointer" onClick={() => setExpanded(!expanded)}>
        <td className="py-3">{log.createdAt.toLocaleString()}</td>
        <td className="py-3">{log.actorEmail}</td>
        <td className="py-3">{formatAuditAction(log.action)}</td>
        <td className="py-3">{log.ipAddress}</td>
      </tr>
      {expanded && log.metadata && (
        <tr>
          <td colSpan={4} className="bg-muted/50 p-4">
            <pre className="text-xs overflow-x-auto">
              {JSON.stringify(log.metadata, null, 2)}
            </pre>
            <p className="mt-2 text-xs text-muted-foreground">
              User Agent: {log.userAgent}
            </p>
          </td>
        </tr>
      )}
    </>
  )
}

3.5 筛选器组件

// components/audit-filters.tsx
'use client'
 
import { useRouter, useSearchParams } from 'next/navigation'
 
const ACTION_CATEGORIES = [
  { label: 'All', value: '' },
  { label: 'Authentication', value: 'auth' },
  { label: 'Members', value: 'member' },
  { label: 'Organization', value: 'org' },
  { label: 'Billing', value: 'subscription' },
  { label: 'Projects', value: 'project' },
]
 
export function AuditFilters() {
  const router = useRouter()
  const params = useSearchParams()
 
  function onFilterChange(action: string) {
    const newParams = new URLSearchParams(params.toString())
    if (action) newParams.set('action', action)
    else newParams.delete('action')
    newParams.set('page', '1')
    router.push(`?${newParams.toString()}`)
  }
 
  return (
    <div className="flex gap-2">
      {ACTION_CATEGORIES.map((cat) => (
        <button
          key={cat.value}
          onClick={() => onFilterChange(cat.value)}
          className={`rounded-full px-3 py-1 text-sm ${
            params.get('action') === cat.value
              ? 'bg-primary text-primary-foreground'
              : 'bg-muted'
          }`}
        >
          {cat.label}
        </button>
      ))}
    </div>
  )
}

4. 数据保留与合规

4.1 保留策略

合规标准最低保留期
SOC 21 年
GDPR数据最小化原则
HIPAA6 年
PCI DSS1 年

4.2 数据归档

大量审计日志会影响数据库性能。定期将旧数据归档到冷存储:

// lib/inngest/functions/archive-audit-logs.ts
export const archiveAuditLogs = inngest.createFunction(
  { id: 'archive-audit-logs' },
  { cron: '0 2 1 * *' },  // 每月 1 日凌晨 2 点
  async () => {
    const cutoffDate = new Date()
    cutoffDate.setMonth(cutoffDate.getMonth() - 12)  // 12 个月前
 
    // 1. 导出到 S3
    const logs = await db.select().from(auditLogs)
      .where(lt(auditLogs.createdAt, cutoffDate))
      .orderBy(auditLogs.createdAt)
 
    if (logs.length === 0) return { archived: 0 }
 
    const jsonl = logs.map(l => JSON.stringify(l)).join('\n')
    const key = `audit-archive/${cutoffDate.toISOString().slice(0, 7)}.jsonl`
 
    await s3.send(new PutObjectCommand({
      Bucket: process.env.ARCHIVE_BUCKET!,
      Key: key,
      Body: jsonl,
      ContentType: 'application/jsonl',
    }))
 
    // 2. 删除已归档的记录
    await db.delete(auditLogs).where(lt(auditLogs.createdAt, cutoffDate))
 
    return { archived: logs.length, key }
  }
)

4.3 数据导出

// app/(dashboard)/settings/audit-log/actions.ts
export async function exportAuditLogs(startDate: Date, endDate: Date) {
  const { membership } = await requirePermission('org:delete')
 
  const logs = await db.select().from(auditLogs)
    .where(and(
      eq(auditLogs.tenantId, membership.tenantId),
      gte(auditLogs.createdAt, startDate),
      lte(auditLogs.createdAt, endDate),
    ))
    .orderBy(auditLogs.createdAt)
 
  // 转为 CSV
  const csv = [
    'timestamp,actor,action,target_type,target_id,ip_address,metadata',
    ...logs.map(l =>
      [
        l.createdAt.toISOString(),
        l.actorEmail,
        l.action,
        l.targetType || '',
        l.targetId || '',
        l.ipAddress || '',
        l.metadata ? JSON.stringify(l.metadata) : '',
      ].map(v => `"${v}"`).join(',')
    ),
  ].join('\n')
 
  return csv
}

4.4 导出下载 API Route

// app/api/audit-log/export/route.ts
export async function GET(request: NextRequest) {
  const { membership } = await requirePermission('org:delete')
 
  const { searchParams } = request.nextUrl
  const startDate = new Date(searchParams.get('start') || Date.now() - 30 * 86400000)
  const endDate = new Date(searchParams.get('end') || Date.now())
 
  const csv = await exportAuditLogs(startDate, endDate)
 
  return new Response(csv, {
    headers: {
      'Content-Type': 'text/csv',
      'Content-Disposition': `attachment; filename="audit-log-${startDate.toISOString().slice(0, 10)}.csv"`,
    },
  })
}

4.5 不可变性保障

措施说明
数据库权限审计表只授 INSERT + SELECT,不授 UPDATE + DELETE
应用层不提供修改/删除审计日志的 API
归档过期日志归档到 S3/冷存储
校验可选:Hash Chain 确保日志未被篡改
-- PostgreSQL 数据库级别限制
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
GRANT INSERT, SELECT ON audit_logs TO app_user;

5. 高级特性

5.1 Webhook 通知

对于高严重级别的审计事件,实时通知管理员:

// lib/audit/notify.ts
const HIGH_SEVERITY_ACTIONS = [
  'member.removed', 'org.deleted', 'role.changed',
  'subscription.canceled', 'api_key.created',
]
 
export async function notifyIfHighSeverity(entry: AuditLogEntry) {
  if (!HIGH_SEVERITY_ACTIONS.includes(entry.action)) return
 
  // 发送 Slack 通知
  if (process.env.SLACK_AUDIT_WEBHOOK) {
    await fetch(process.env.SLACK_AUDIT_WEBHOOK, {
      method: 'POST',
      body: JSON.stringify({
        text: `🔔 Audit Alert: ${entry.actorEmail} performed *${entry.action}*`,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: [
                `*Action:* ${formatAuditAction(entry.action)}`,
                `*Actor:* ${entry.actorEmail}`,
                `*Target:* ${entry.targetType}/${entry.targetId}`,
                `*Time:* ${new Date().toISOString()}`,
              ].join('\n'),
            },
          },
        ],
      }),
    })
  }
}

5.2 异常检测

// lib/audit/anomaly.ts
export async function detectAnomalies(tenantId: string) {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
 
  // 检测 1:同一用户短时间内大量操作
  const heavyUsers = await db.select({
    actorId: auditLogs.actorId,
    actorEmail: auditLogs.actorEmail,
    count: sql<number>`count(*)`,
  })
    .from(auditLogs)
    .where(and(
      eq(auditLogs.tenantId, tenantId),
      gte(auditLogs.createdAt, oneHourAgo),
    ))
    .groupBy(auditLogs.actorId, auditLogs.actorEmail)
    .having(sql`count(*) > 100`)
 
  // 检测 2:登录失败次数异常
  const failedLogins = await db.select({
    count: sql<number>`count(*)`,
  })
    .from(auditLogs)
    .where(and(
      eq(auditLogs.tenantId, tenantId),
      eq(auditLogs.action, 'auth.failed'),
      gte(auditLogs.createdAt, oneHourAgo),
    ))
 
  return {
    heavyUsers,
    failedLoginCount: failedLogins[0]?.count || 0,
    hasAnomalies: heavyUsers.length > 0 || (failedLogins[0]?.count || 0) > 10,
  }
}

5.3 统计汇总

// 审计日志仪表盘数据
export async function getAuditStats(tenantId: string) {
  const thirtyDaysAgo = new Date(Date.now() - 30 * 86400000)
 
  const [actionBreakdown, dailyTrend, topActors] = await Promise.all([
    // 按事件类型统计
    db.select({
      action: auditLogs.action,
      count: sql<number>`count(*)`,
    })
      .from(auditLogs)
      .where(and(
        eq(auditLogs.tenantId, tenantId),
        gte(auditLogs.createdAt, thirtyDaysAgo),
      ))
      .groupBy(auditLogs.action)
      .orderBy(sql`count(*) desc`),
 
    // 每日趋势
    db.select({
      date: sql<string>`date(${auditLogs.createdAt})`,
      count: sql<number>`count(*)`,
    })
      .from(auditLogs)
      .where(and(
        eq(auditLogs.tenantId, tenantId),
        gte(auditLogs.createdAt, thirtyDaysAgo),
      ))
      .groupBy(sql`date(${auditLogs.createdAt})`)
      .orderBy(sql`date(${auditLogs.createdAt})`),
 
    // 最活跃用户
    db.select({
      actorEmail: auditLogs.actorEmail,
      count: sql<number>`count(*)`,
    })
      .from(auditLogs)
      .where(and(
        eq(auditLogs.tenantId, tenantId),
        gte(auditLogs.createdAt, thirtyDaysAgo),
      ))
      .groupBy(auditLogs.actorEmail)
      .orderBy(sql`count(*) desc`)
      .limit(10),
  ])
 
  return { actionBreakdown, dailyTrend, topActors }
}

本章小结

  • 数据模型:冗余存储 actor_email,不依赖外键 JOIN,metadata 记录变更前后差异
  • 核心原则:不可变、冗余、异步、结构化
  • 日志采集logAudit() 同步入口 + logAuditAsync() 异步入口,自动获取 IP/UA
  • 变更详情:metadata 中存储 &#123; changes: &#123; field: &#123; from, to &#125; &#125; &#125; 结构
  • 登录审计:记录成功/失败登录,含 IP 和 User-Agent
  • 查询面板:按时间/操作类型/人员过滤,分页 + 总数,可展开查看 metadata
  • 筛选器:按类别筛选(认证/成员/组织/计费/项目)
  • 合规:SOC 2 最少保留 1 年,数据库级 REVOKE UPDATE/DELETE
  • 归档:Inngest 定时任务,12 个月前日志归档到 S3 后删除
  • 导出:CSV 格式下载,含完整 metadata
  • 高级:高严重事件 Slack 通知、异常检测(操作频率/登录失败)、统计汇总