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

@@ -0,0 +1,21 @@
-- Audit log gets two new columns so the inspector can surface system
-- events alongside user actions on a single timeline.
--
-- severity: 'info' | 'warning' | 'error' | 'critical'
-- default 'info'. Most user actions are 'info'; a permission
-- denied is 'warning'; a webhook DLQ entry is 'error'; a
-- hard-delete or a CRITICAL alert is 'critical'.
--
-- source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'
-- default 'user'. Lets the UI filter "show me only the
-- system events" without grepping action names.
--
-- Both default-friendly + nullable-friendly so the back-history rows
-- retain their existing semantics ('user' / 'info').
ALTER TABLE audit_logs
ADD COLUMN IF NOT EXISTS severity text NOT NULL DEFAULT 'info',
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'user';
CREATE INDEX IF NOT EXISTS idx_al_severity ON audit_logs (port_id, severity, created_at);
CREATE INDEX IF NOT EXISTS idx_al_source ON audit_logs (port_id, source, created_at);

View File

@@ -41,6 +41,12 @@ export const auditLogs = pgTable(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
revertOf: text('revert_of').references((): any => auditLogs.id),
metadata: jsonb('metadata').default({}),
/** 'info' | 'warning' | 'error' | 'critical' — drives the row badge
* in the inspector. Most user actions are 'info'. */
severity: text('severity').notNull().default('info'),
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
* UI filter by event origin without grepping action names. */
source: text('source').notNull().default('user'),
/** Full-text search column. Stored generated; updated by the migration's
* GENERATED ALWAYS expression covering action + entityType + entityId
* + actor email lookup. */
@@ -52,6 +58,8 @@ export const auditLogs = pgTable(
index('idx_al_entity').on(table.entityType, table.entityId),
index('idx_al_user').on(table.userId, table.createdAt),
index('idx_al_created').on(table.createdAt),
index('idx_al_severity').on(table.portId, table.severity, table.createdAt),
index('idx_al_source').on(table.portId, table.source, table.createdAt),
],
);