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:
@@ -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
|
||||
|
||||
21
src/lib/db/migrations/0044_audit_log_severity_source.sql
Normal file
21
src/lib/db/migrations/0044_audit_log_severity_source.sql
Normal 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);
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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...`,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user