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

@@ -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<Response> {
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<Response> {
return withAuthAudit(req);
}

View File

@@ -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,