feat(audit): comprehensive logging — auth events, severity, source, IP

Audit log was previously silent on authentication and on background
work. This wires:

- Login (success + failed) and logout via a wrapper around better-auth's
  [...all] handler. Failed logins are severity 'warning' and carry the
  attempted email so brute-force attempts surface in the inspector.
- New severity (info|warning|error|critical) and source (user|auth|
  system|webhook|cron|job) columns on audit_logs. permission_denied
  defaults to 'warning', hard_delete to 'critical'.
- Webhook delivery success/failure/DLQ/retry now write audit rows
  alongside the webhook_deliveries detail table.
- IP address is now visible as a column in the inspector (was already
  captured at the helper level).
- Audit UI: severity badges per row, severity + source dropdowns, IP
  column, expanded action filter covering hard-delete, webhook events,
  job/cron events.

Migration 0044 adds the two columns + their port-scoped indexes.
1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 20:35:34 +02:00
parent 4592789712
commit d2171ea79b
12 changed files with 392 additions and 17 deletions

View File

@@ -25,7 +25,14 @@ export type AuditAction =
| 'send'
| 'view'
| 'request_hard_delete_code'
| 'hard_delete';
| 'hard_delete'
// System / background events.
| 'webhook_delivered'
| 'webhook_failed'
| 'webhook_dead_letter'
| 'webhook_retried'
| 'job_failed'
| 'cron_run';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -40,6 +47,9 @@ export interface AuditMeta {
userAgent: string;
}
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
export interface AuditLogParams {
/** Null for system-generated events. */
userId: string | null;
@@ -56,6 +66,14 @@ export interface AuditLogParams {
* jobs, internal helpers) may omit. */
ipAddress?: string;
userAgent?: string;
/** Defaults to 'info'. Bump to 'warning' for permission_denied,
* 'error' for failed background jobs / webhook DLQ, 'critical' for
* hard-deletes / security-relevant events. */
severity?: AuditSeverity;
/** Defaults to 'user'. Use 'auth' for session lifecycle,
* 'webhook' for delivery events, 'job' / 'cron' / 'system' for
* background work. The inspector filters on this column. */
source?: AuditSource;
}
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
@@ -103,8 +121,19 @@ export function diffFields(
* This function NEVER throws - errors are caught and logged so that an audit
* failure never rolls back or disrupts the parent operation.
*/
// Some actions get a default severity bump so callers don't have to
// remember; explicit `severity` on the call still wins.
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
permission_denied: 'warning',
hard_delete: 'critical',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);
export async function createAuditLog(params: AuditLogParams): Promise<void> {
try {
const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');
await db.insert(auditLogs).values({
portId: params.portId,
userId: params.userId,
@@ -117,6 +146,8 @@ export async function createAuditLog(params: AuditLogParams): Promise<void> {
metadata: params.metadata ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
severity,
source,
});
} catch (err) {
// Strip old/new values from the log to avoid secondary exposure of the data