From d2171ea79b5abdbffc6fb44a36bf3803d215a758 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 20:35:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(audit):=20comprehensive=20logging=20?= =?UTF-8?q?=E2=80=94=20auth=20events,=20severity,=20source,=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/db-reset.ts | 2 +- scripts/dev-open-browser.ts | 5 +- src/app/api/auth/[...all]/route.ts | 146 +++++++++++++++++- src/app/api/v1/admin/audit/route.ts | 4 + src/components/admin/audit/audit-log-card.tsx | 13 +- src/components/admin/audit/audit-log-list.tsx | 134 ++++++++++++++-- src/lib/audit.ts | 33 +++- .../0044_audit_log_severity_source.sql | 21 +++ src/lib/db/schema/system.ts | 8 + src/lib/queue/workers/webhooks.ts | 35 +++++ src/lib/services/audit-search.service.ts | 6 + src/lib/services/webhooks.service.ts | 2 +- 12 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 src/lib/db/migrations/0044_audit_log_severity_source.sql diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts index 0784d21..630b154 100644 --- a/scripts/db-reset.ts +++ b/scripts/db-reset.ts @@ -16,7 +16,7 @@ import 'dotenv/config'; import postgres from 'postgres'; -const url = process.env.DATABASE_URL; +const url: string = process.env.DATABASE_URL ?? ''; if (!url) { console.error('DATABASE_URL is not set; aborting.'); process.exit(1); diff --git a/scripts/dev-open-browser.ts b/scripts/dev-open-browser.ts index 877258c..9d19d2e 100644 --- a/scripts/dev-open-browser.ts +++ b/scripts/dev-open-browser.ts @@ -16,7 +16,10 @@ */ import 'dotenv/config'; -import { chromium } from 'playwright'; +// @playwright/test re-exports the same chromium driver and is already +// installed as a dev dep; using it avoids needing to add the standalone +// `playwright` package as a separate dependency. +import { chromium } from '@playwright/test'; const USERS: Record = { super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' }, diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 9900a3b..b79e685 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,146 @@ -import { auth } from '@/lib/auth'; +import type { NextRequest } from 'next/server'; import { toNextJsHandler } from 'better-auth/next-js'; -export const { GET, POST } = toNextJsHandler(auth); +import { auth } from '@/lib/auth'; +import { createAuditLog } from '@/lib/audit'; +import { logger } from '@/lib/logger'; + +const upstream = toNextJsHandler(auth); + +/** + * Wrap better-auth's `[...all]` handler so we can stamp the audit log on + * authentication events. Better-auth itself doesn't fire any callback we + * can hook on sign-in / sign-out / failed-login — we inspect the route + * + response status after the upstream handler finishes. + * + * Successful sign-in → action 'login' (severity info) + * Failed sign-in → action 'login' (severity warning, ok=false) + * Sign-out → action 'logout' (userId resolved before cookie + * is cleared) + * + * Audit writes are fire-and-forget (createAuditLog never throws). + */ + +interface AuthBody { + user?: { id?: string; email?: string }; + id?: string; + email?: string; +} + +function clientMeta(req: NextRequest): { ipAddress: string; userAgent: string } { + const ip = + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? ''; + return { ipAddress: ip, userAgent: req.headers.get('user-agent') ?? '' }; +} + +function logSignIn(args: { + req: NextRequest; + responseBody: string; + status: number; + attemptedEmail: string | null; +}) { + const meta = clientMeta(args.req); + let parsed: AuthBody | null = null; + try { + parsed = JSON.parse(args.responseBody) as AuthBody; + } catch { + /* upstream returned non-JSON */ + } + const userId = parsed?.user?.id ?? parsed?.id ?? null; + const email = parsed?.user?.email ?? parsed?.email ?? args.attemptedEmail ?? null; + const ok = args.status >= 200 && args.status < 300; + + void createAuditLog({ + userId, + portId: null, + action: 'login', + entityType: 'session', + entityId: userId ?? args.attemptedEmail ?? 'unknown', + metadata: { + ok, + status: args.status, + attemptedEmail: args.attemptedEmail ?? email ?? null, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + severity: ok ? 'info' : 'warning', + source: 'auth', + }); +} + +async function logSignOut(req: NextRequest) { + const meta = clientMeta(req); + let userId: string | null = null; + try { + const session = await auth.api.getSession({ headers: req.headers }); + userId = session?.user?.id ?? null; + } catch { + /* unauthenticated or expired */ + } + + void createAuditLog({ + userId, + portId: null, + action: 'logout', + entityType: 'session', + entityId: userId ?? 'unknown', + metadata: {}, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + severity: 'info', + source: 'auth', + }); +} + +async function withAuthAudit(req: NextRequest): Promise { + const url = new URL(req.url); + const path = url.pathname; + const isSignIn = path.endsWith('/sign-in/email') || path.endsWith('/sign-in'); + const isSignOut = path.endsWith('/sign-out'); + + // Read the request body BEFORE forwarding so we can extract the + // attempted email even when the credentials are wrong (the upstream + // handler will consume the body stream and we can't read it twice). + let attemptedEmail: string | null = null; + let forwardReq: NextRequest = req; + if (isSignIn && req.method === 'POST') { + try { + const raw = await req.text(); + try { + attemptedEmail = (JSON.parse(raw) as { email?: string }).email ?? null; + } catch { + /* form-encoded or non-JSON */ + } + // Reconstruct a fresh Request so the upstream handler can read it. + forwardReq = new Request(req.url, { + method: req.method, + headers: req.headers, + body: raw, + }) as unknown as NextRequest; + } catch (err) { + logger.warn({ err }, 'Failed to read sign-in body for audit'); + } + } + + // Capture sign-out userId BEFORE the upstream handler clears the cookie. + const signOutPromise = isSignOut ? logSignOut(req) : null; + + const res = await upstream.POST(forwardReq); + + if (isSignIn) { + try { + const body = await res.clone().text(); + logSignIn({ req, responseBody: body, status: res.status, attemptedEmail }); + } catch (err) { + logger.warn({ err }, 'Failed to capture sign-in response for audit'); + } + } + if (signOutPromise) void signOutPromise; + + return res; +} + +export const GET = upstream.GET; +export async function POST(req: NextRequest): Promise { + return withAuthAudit(req); +} diff --git a/src/app/api/v1/admin/audit/route.ts b/src/app/api/v1/admin/audit/route.ts index a2afa46..74854a9 100644 --- a/src/app/api/v1/admin/audit/route.ts +++ b/src/app/api/v1/admin/audit/route.ts @@ -15,6 +15,8 @@ const auditQuerySchema = z.object({ action: z.string().optional(), userId: z.string().optional(), entityId: z.string().optional(), + severity: z.enum(['info', 'warning', 'error', 'critical']).optional(), + source: z.enum(['user', 'system', 'auth', 'webhook', 'cron', 'job']).optional(), dateFrom: z.string().optional(), dateTo: z.string().optional(), /** Free-text query against the tsvector `search_text` column. */ @@ -39,6 +41,8 @@ export const GET = withAuth( action: query.action, entityType: query.entityType, entityId: query.entityId, + severity: query.severity, + source: query.source, from: query.dateFrom ? new Date(query.dateFrom) : undefined, to: query.dateTo ? new Date(query.dateTo) : undefined, cursor, diff --git a/src/components/admin/audit/audit-log-card.tsx b/src/components/admin/audit/audit-log-card.tsx index 28d406c..6bf08be 100644 --- a/src/components/admin/audit/audit-log-card.tsx +++ b/src/components/admin/audit/audit-log-card.tsx @@ -17,6 +17,9 @@ interface AuditEntry { newValue: Record | null; metadata: Record | null; ipAddress: string | null; + userAgent?: string | null; + severity?: 'info' | 'warning' | 'error' | 'critical'; + source?: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'; createdAt: string; actor: { id: string; email: string; name: string } | null; } @@ -110,11 +113,19 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {

- {/* Timestamp meta line */} + {/* Timestamp + IP meta line */}
}> {formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })} + {entry.ipAddress ? ( + {entry.ipAddress} + ) : null} + {entry.severity && entry.severity !== 'info' ? ( + + {entry.severity} + + ) : null}
{/* Action badge + changed-fields chips */} diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 074c98d..bb1e2eb 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -32,6 +32,9 @@ interface AuditEntry { newValue: Record | null; metadata: Record | null; ipAddress: string | null; + userAgent: string | null; + severity: 'info' | 'warning' | 'error' | 'critical'; + source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'; createdAt: string; actor: { id: string; email: string; name: string } | null; } @@ -47,10 +50,37 @@ const ACTION_COLORS: Record = { delete: 'bg-red-600', archive: 'bg-orange-500', restore: 'bg-teal-500', - login: 'bg-gray-500', + login: 'bg-slate-500', + logout: 'bg-slate-400', permission_denied: 'bg-red-800', merge: 'bg-purple-500', revert: 'bg-amber-500', + hard_delete: 'bg-red-900', + request_hard_delete_code: 'bg-orange-700', + send: 'bg-indigo-500', + view: 'bg-gray-400', + webhook_delivered: 'bg-emerald-500', + webhook_failed: 'bg-amber-600', + webhook_dead_letter: 'bg-red-700', + webhook_retried: 'bg-indigo-600', + job_failed: 'bg-rose-700', + cron_run: 'bg-sky-500', +}; + +const SEVERITY_BADGE: Record = { + info: 'bg-slate-200 text-slate-800', + warning: 'bg-amber-200 text-amber-900', + error: 'bg-red-200 text-red-900', + critical: 'bg-red-600 text-white', +}; + +const SOURCE_LABEL: Record = { + user: 'User', + system: 'System', + auth: 'Auth', + webhook: 'Webhook', + cron: 'Cron', + job: 'Job', }; const ENTITY_TYPES = [ @@ -91,6 +121,8 @@ export function AuditLogList() { const [search, setSearch] = useState(''); const [entityType, setEntityType] = useState('all'); const [action, setAction] = useState('all'); + const [severity, setSeverity] = useState('all'); + const [source, setSource] = useState('all'); const [userId, setUserId] = useState(''); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); @@ -102,6 +134,8 @@ export function AuditLogList() { const params = new URLSearchParams({ limit: '50' }); if (entityType !== 'all') params.set('entityType', entityType); if (action !== 'all') params.set('action', action); + if (severity !== 'all') params.set('severity', severity); + if (source !== 'all') params.set('source', source); if (debouncedSearch) params.set('search', debouncedSearch); if (debouncedUserId) params.set('userId', debouncedUserId); if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString()); @@ -111,7 +145,7 @@ export function AuditLogList() { params.set('dateTo', end.toISOString()); } return params.toString(); - }, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]); + }, [entityType, action, severity, source, debouncedSearch, debouncedUserId, dateFrom, dateTo]); const fetchFirstPage = useCallback(async () => { setLoading(true); @@ -147,6 +181,8 @@ export function AuditLogList() { setSearch(''); setEntityType('all'); setAction('all'); + setSeverity('all'); + setSource('all'); setUserId(''); setDateFrom(''); setDateTo(''); @@ -156,6 +192,8 @@ export function AuditLogList() { Boolean(search) || entityType !== 'all' || action !== 'all' || + severity !== 'all' || + source !== 'all' || Boolean(userId) || Boolean(dateFrom) || Boolean(dateTo); @@ -178,13 +216,33 @@ export function AuditLogList() { accessorKey: 'action', header: 'Action', cell: ({ row }) => ( - - {row.original.action} - +
+ + {row.original.action} + + {row.original.severity !== 'info' && ( + + {row.original.severity} + + )} +
), - size: 110, + size: 180, + }, + { + accessorKey: 'source', + header: 'Source', + cell: ({ row }) => ( + + {SOURCE_LABEL[row.original.source] ?? row.original.source} + + ), + size: 80, }, { accessorKey: 'entityType', @@ -236,7 +294,18 @@ export function AuditLogList() { } return system; }, - size: 200, + size: 180, + }, + { + id: 'ip', + header: 'IP', + cell: ({ row }) => + row.original.ipAddress ? ( + {row.original.ipAddress} + ) : ( + + ), + size: 130, }, ]; @@ -287,7 +356,7 @@ export function AuditLogList() {
+
+ +
+ + +
+ +
+ +
diff --git a/src/lib/audit.ts b/src/lib/audit.ts index e6ee6ab..591437f 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -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> = { + permission_denied: 'warning', + hard_delete: 'critical', +}; +const AUTH_ACTIONS = new Set(['login', 'logout', 'password_change']); + export async function createAuditLog(params: AuditLogParams): Promise { 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 { 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 diff --git a/src/lib/db/migrations/0044_audit_log_severity_source.sql b/src/lib/db/migrations/0044_audit_log_severity_source.sql new file mode 100644 index 0000000..4f15c5c --- /dev/null +++ b/src/lib/db/migrations/0044_audit_log_severity_source.sql @@ -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); diff --git a/src/lib/db/schema/system.ts b/src/lib/db/schema/system.ts index de5fce2..a5b2dad 100644 --- a/src/lib/db/schema/system.ts +++ b/src/lib/db/schema/system.ts @@ -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), ], ); diff --git a/src/lib/queue/workers/webhooks.ts b/src/lib/queue/workers/webhooks.ts index 5b8fe1d..5c2ae28 100644 --- a/src/lib/queue/workers/webhooks.ts +++ b/src/lib/queue/workers/webhooks.ts @@ -200,6 +200,8 @@ export const webhooksWorker = new Worker( const maxAttempts = QUEUE_CONFIGS.webhooks.maxAttempts; const isFinalAttempt = attempt >= maxAttempts; + const { createAuditLog } = await import('@/lib/audit'); + if (success) { // 6a. Record success await db @@ -214,6 +216,17 @@ export const webhooksWorker = new Worker( .where(eq(webhookDeliveries.id, deliveryId)); logger.info({ webhookId, deliveryId, event }, 'Webhook delivered successfully'); + + void createAuditLog({ + userId: null, + portId, + action: 'webhook_delivered', + entityType: 'webhook_delivery', + entityId: deliveryId, + metadata: { webhookId, event, responseStatus, attempt }, + source: 'webhook', + severity: 'info', + }); } else if (!success && isFinalAttempt) { // 6b. Final failure → dead_letter + system alert await db @@ -231,6 +244,17 @@ export const webhooksWorker = new Worker( 'Webhook delivery permanently failed - dead_letter', ); + void createAuditLog({ + userId: null, + portId, + action: 'webhook_dead_letter', + entityType: 'webhook_delivery', + entityId: deliveryId, + metadata: { webhookId, event, responseStatus, attempt, responseBody }, + source: 'webhook', + severity: 'error', + }); + // Notify all super admins try { const superAdmins = await db @@ -272,6 +296,17 @@ export const webhooksWorker = new Worker( }) .where(eq(webhookDeliveries.id, deliveryId)); + void createAuditLog({ + userId: null, + portId, + action: 'webhook_failed', + entityType: 'webhook_delivery', + entityId: deliveryId, + metadata: { webhookId, event, responseStatus, attempt }, + source: 'webhook', + severity: 'warning', + }); + throw new Error( `Webhook delivery attempt ${attempt} failed. Status: ${responseStatus ?? 'network error'}. Retrying...`, ); diff --git a/src/lib/services/audit-search.service.ts b/src/lib/services/audit-search.service.ts index dfe58c8..5916526 100644 --- a/src/lib/services/audit-search.service.ts +++ b/src/lib/services/audit-search.service.ts @@ -22,6 +22,10 @@ export interface AuditSearchOptions { entityType?: string; /** Filter by exact entity id (e.g. paste a uuid into search). */ entityId?: string; + /** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */ + severity?: string; + /** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */ + source?: string; /** Inclusive date range. */ from?: Date; to?: Date; @@ -42,6 +46,8 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise if (options.action) conds.push(eq(auditLogs.action, options.action)); if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType)); if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId)); + if (options.severity) conds.push(eq(auditLogs.severity, options.severity)); + if (options.source) conds.push(eq(auditLogs.source, options.source)); if (options.from) conds.push(gte(auditLogs.createdAt, options.from)); if (options.to) conds.push(lte(auditLogs.createdAt, options.to)); if (options.q) { diff --git a/src/lib/services/webhooks.service.ts b/src/lib/services/webhooks.service.ts index d2cb07a..16680a0 100644 --- a/src/lib/services/webhooks.service.ts +++ b/src/lib/services/webhooks.service.ts @@ -330,7 +330,7 @@ export async function redeliverWebhookDelivery( void createAuditLog({ userId: meta.userId, portId, - action: 'send', + action: 'webhook_retried', entityType: 'webhook_delivery', entityId: next!.id, metadata: { redeliveredFrom: deliveryId, originalStatus: source.status },