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:
@@ -16,7 +16,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
const url = process.env.DATABASE_URL;
|
const url: string = process.env.DATABASE_URL ?? '';
|
||||||
if (!url) {
|
if (!url) {
|
||||||
console.error('DATABASE_URL is not set; aborting.');
|
console.error('DATABASE_URL is not set; aborting.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dotenv/config';
|
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<string, { email: string; password: string }> = {
|
const USERS: Record<string, { email: string; password: string }> = {
|
||||||
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
||||||
|
|||||||
@@ -1,4 +1,146 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import type { NextRequest } from 'next/server';
|
||||||
import { toNextJsHandler } from 'better-auth/next-js';
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const auditQuerySchema = z.object({
|
|||||||
action: z.string().optional(),
|
action: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
entityId: 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(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
/** Free-text query against the tsvector `search_text` column. */
|
/** Free-text query against the tsvector `search_text` column. */
|
||||||
@@ -39,6 +41,8 @@ export const GET = withAuth(
|
|||||||
action: query.action,
|
action: query.action,
|
||||||
entityType: query.entityType,
|
entityType: query.entityType,
|
||||||
entityId: query.entityId,
|
entityId: query.entityId,
|
||||||
|
severity: query.severity,
|
||||||
|
source: query.source,
|
||||||
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||||
to: query.dateTo ? new Date(query.dateTo) : undefined,
|
to: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ interface AuditEntry {
|
|||||||
newValue: Record<string, unknown> | null;
|
newValue: Record<string, unknown> | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
severity?: 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
source?: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
actor: { id: string; email: string; name: string } | null;
|
actor: { id: string; email: string; name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -110,11 +113,19 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Timestamp meta line */}
|
{/* Timestamp + IP meta line */}
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
||||||
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
|
||||||
</ListCardMeta>
|
</ListCardMeta>
|
||||||
|
{entry.ipAddress ? (
|
||||||
|
<span className="font-mono text-[11px]">{entry.ipAddress}</span>
|
||||||
|
) : null}
|
||||||
|
{entry.severity && entry.severity !== 'info' ? (
|
||||||
|
<span className="uppercase font-semibold tracking-wide text-[10px]">
|
||||||
|
{entry.severity}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action badge + changed-fields chips */}
|
{/* Action badge + changed-fields chips */}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ interface AuditEntry {
|
|||||||
newValue: Record<string, unknown> | null;
|
newValue: Record<string, unknown> | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
severity: 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
actor: { id: string; email: string; name: string } | null;
|
actor: { id: string; email: string; name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -47,10 +50,37 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
delete: 'bg-red-600',
|
delete: 'bg-red-600',
|
||||||
archive: 'bg-orange-500',
|
archive: 'bg-orange-500',
|
||||||
restore: 'bg-teal-500',
|
restore: 'bg-teal-500',
|
||||||
login: 'bg-gray-500',
|
login: 'bg-slate-500',
|
||||||
|
logout: 'bg-slate-400',
|
||||||
permission_denied: 'bg-red-800',
|
permission_denied: 'bg-red-800',
|
||||||
merge: 'bg-purple-500',
|
merge: 'bg-purple-500',
|
||||||
revert: 'bg-amber-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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
user: 'User',
|
||||||
|
system: 'System',
|
||||||
|
auth: 'Auth',
|
||||||
|
webhook: 'Webhook',
|
||||||
|
cron: 'Cron',
|
||||||
|
job: 'Job',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENTITY_TYPES = [
|
const ENTITY_TYPES = [
|
||||||
@@ -91,6 +121,8 @@ export function AuditLogList() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [entityType, setEntityType] = useState<string>('all');
|
const [entityType, setEntityType] = useState<string>('all');
|
||||||
const [action, setAction] = useState<string>('all');
|
const [action, setAction] = useState<string>('all');
|
||||||
|
const [severity, setSeverity] = useState<string>('all');
|
||||||
|
const [source, setSource] = useState<string>('all');
|
||||||
const [userId, setUserId] = useState('');
|
const [userId, setUserId] = useState('');
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
@@ -102,6 +134,8 @@ export function AuditLogList() {
|
|||||||
const params = new URLSearchParams({ limit: '50' });
|
const params = new URLSearchParams({ limit: '50' });
|
||||||
if (entityType !== 'all') params.set('entityType', entityType);
|
if (entityType !== 'all') params.set('entityType', entityType);
|
||||||
if (action !== 'all') params.set('action', action);
|
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 (debouncedSearch) params.set('search', debouncedSearch);
|
||||||
if (debouncedUserId) params.set('userId', debouncedUserId);
|
if (debouncedUserId) params.set('userId', debouncedUserId);
|
||||||
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
|
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
|
||||||
@@ -111,7 +145,7 @@ export function AuditLogList() {
|
|||||||
params.set('dateTo', end.toISOString());
|
params.set('dateTo', end.toISOString());
|
||||||
}
|
}
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
|
}, [entityType, action, severity, source, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
|
||||||
|
|
||||||
const fetchFirstPage = useCallback(async () => {
|
const fetchFirstPage = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -147,6 +181,8 @@ export function AuditLogList() {
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
setEntityType('all');
|
setEntityType('all');
|
||||||
setAction('all');
|
setAction('all');
|
||||||
|
setSeverity('all');
|
||||||
|
setSource('all');
|
||||||
setUserId('');
|
setUserId('');
|
||||||
setDateFrom('');
|
setDateFrom('');
|
||||||
setDateTo('');
|
setDateTo('');
|
||||||
@@ -156,6 +192,8 @@ export function AuditLogList() {
|
|||||||
Boolean(search) ||
|
Boolean(search) ||
|
||||||
entityType !== 'all' ||
|
entityType !== 'all' ||
|
||||||
action !== 'all' ||
|
action !== 'all' ||
|
||||||
|
severity !== 'all' ||
|
||||||
|
source !== 'all' ||
|
||||||
Boolean(userId) ||
|
Boolean(userId) ||
|
||||||
Boolean(dateFrom) ||
|
Boolean(dateFrom) ||
|
||||||
Boolean(dateTo);
|
Boolean(dateTo);
|
||||||
@@ -178,13 +216,33 @@ export function AuditLogList() {
|
|||||||
accessorKey: 'action',
|
accessorKey: 'action',
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge
|
<div className="flex items-center gap-1.5">
|
||||||
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
<Badge
|
||||||
>
|
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||||
{row.original.action}
|
>
|
||||||
</Badge>
|
{row.original.action}
|
||||||
|
</Badge>
|
||||||
|
{row.original.severity !== 'info' && (
|
||||||
|
<Badge
|
||||||
|
className={`${SEVERITY_BADGE[row.original.severity] ?? ''} text-[10px] px-1.5 py-0 uppercase`}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{row.original.severity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
size: 110,
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'source',
|
||||||
|
header: 'Source',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{SOURCE_LABEL[row.original.source] ?? row.original.source}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 80,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'entityType',
|
accessorKey: 'entityType',
|
||||||
@@ -236,7 +294,18 @@ export function AuditLogList() {
|
|||||||
}
|
}
|
||||||
return <span className="text-xs text-muted-foreground">system</span>;
|
return <span className="text-xs text-muted-foreground">system</span>;
|
||||||
},
|
},
|
||||||
size: 200,
|
size: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
header: 'IP',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.ipAddress ? (
|
||||||
|
<code className="text-xs text-muted-foreground">{row.original.ipAddress}</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
size: 130,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -287,7 +356,7 @@ export function AuditLogList() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">Action</Label>
|
<Label className="text-xs">Action</Label>
|
||||||
<Select value={action} onValueChange={setAction}>
|
<Select value={action} onValueChange={setAction}>
|
||||||
<SelectTrigger className="w-36" data-testid="audit-action">
|
<SelectTrigger className="w-44" data-testid="audit-action">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -300,7 +369,52 @@ export function AuditLogList() {
|
|||||||
<SelectItem value="merge">Merge</SelectItem>
|
<SelectItem value="merge">Merge</SelectItem>
|
||||||
<SelectItem value="revert">Revert</SelectItem>
|
<SelectItem value="revert">Revert</SelectItem>
|
||||||
<SelectItem value="login">Login</SelectItem>
|
<SelectItem value="login">Login</SelectItem>
|
||||||
|
<SelectItem value="logout">Logout</SelectItem>
|
||||||
<SelectItem value="permission_denied">Permission denied</SelectItem>
|
<SelectItem value="permission_denied">Permission denied</SelectItem>
|
||||||
|
<SelectItem value="hard_delete">Hard delete</SelectItem>
|
||||||
|
<SelectItem value="request_hard_delete_code">Hard-delete code req</SelectItem>
|
||||||
|
<SelectItem value="send">Send</SelectItem>
|
||||||
|
<SelectItem value="view">View</SelectItem>
|
||||||
|
<SelectItem value="webhook_delivered">Webhook delivered</SelectItem>
|
||||||
|
<SelectItem value="webhook_failed">Webhook failed</SelectItem>
|
||||||
|
<SelectItem value="webhook_dead_letter">Webhook DLQ</SelectItem>
|
||||||
|
<SelectItem value="webhook_retried">Webhook retried</SelectItem>
|
||||||
|
<SelectItem value="job_failed">Job failed</SelectItem>
|
||||||
|
<SelectItem value="cron_run">Cron run</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Severity</Label>
|
||||||
|
<Select value={severity} onValueChange={setSeverity}>
|
||||||
|
<SelectTrigger className="w-32" data-testid="audit-severity">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All severities</SelectItem>
|
||||||
|
<SelectItem value="info">Info</SelectItem>
|
||||||
|
<SelectItem value="warning">Warning</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
<SelectItem value="critical">Critical</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Source</Label>
|
||||||
|
<Select value={source} onValueChange={setSource}>
|
||||||
|
<SelectTrigger className="w-32" data-testid="audit-source">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All sources</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="auth">Auth</SelectItem>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
<SelectItem value="webhook">Webhook</SelectItem>
|
||||||
|
<SelectItem value="cron">Cron</SelectItem>
|
||||||
|
<SelectItem value="job">Job</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ export type AuditAction =
|
|||||||
| 'send'
|
| 'send'
|
||||||
| 'view'
|
| 'view'
|
||||||
| 'request_hard_delete_code'
|
| '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
|
* Common shape passed to service functions so they can stamp audit logs and
|
||||||
@@ -40,6 +47,9 @@ export interface AuditMeta {
|
|||||||
userAgent: string;
|
userAgent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
/** Null for system-generated events. */
|
/** Null for system-generated events. */
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
@@ -56,6 +66,14 @@ export interface AuditLogParams {
|
|||||||
* jobs, internal helpers) may omit. */
|
* jobs, internal helpers) may omit. */
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: 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']);
|
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
|
* This function NEVER throws - errors are caught and logged so that an audit
|
||||||
* failure never rolls back or disrupts the parent operation.
|
* 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> {
|
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
||||||
try {
|
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({
|
await db.insert(auditLogs).values({
|
||||||
portId: params.portId,
|
portId: params.portId,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
@@ -117,6 +146,8 @@ export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
|||||||
metadata: params.metadata ?? null,
|
metadata: params.metadata ?? null,
|
||||||
ipAddress: params.ipAddress ?? null,
|
ipAddress: params.ipAddress ?? null,
|
||||||
userAgent: params.userAgent ?? null,
|
userAgent: params.userAgent ?? null,
|
||||||
|
severity,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Strip old/new values from the log to avoid secondary exposure of the data
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
revertOf: text('revert_of').references((): any => auditLogs.id),
|
revertOf: text('revert_of').references((): any => auditLogs.id),
|
||||||
metadata: jsonb('metadata').default({}),
|
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
|
/** Full-text search column. Stored generated; updated by the migration's
|
||||||
* GENERATED ALWAYS expression covering action + entityType + entityId
|
* GENERATED ALWAYS expression covering action + entityType + entityId
|
||||||
* + actor email lookup. */
|
* + actor email lookup. */
|
||||||
@@ -52,6 +58,8 @@ export const auditLogs = pgTable(
|
|||||||
index('idx_al_entity').on(table.entityType, table.entityId),
|
index('idx_al_entity').on(table.entityType, table.entityId),
|
||||||
index('idx_al_user').on(table.userId, table.createdAt),
|
index('idx_al_user').on(table.userId, table.createdAt),
|
||||||
index('idx_al_created').on(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 maxAttempts = QUEUE_CONFIGS.webhooks.maxAttempts;
|
||||||
const isFinalAttempt = attempt >= maxAttempts;
|
const isFinalAttempt = attempt >= maxAttempts;
|
||||||
|
|
||||||
|
const { createAuditLog } = await import('@/lib/audit');
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// 6a. Record success
|
// 6a. Record success
|
||||||
await db
|
await db
|
||||||
@@ -214,6 +216,17 @@ export const webhooksWorker = new Worker(
|
|||||||
.where(eq(webhookDeliveries.id, deliveryId));
|
.where(eq(webhookDeliveries.id, deliveryId));
|
||||||
|
|
||||||
logger.info({ webhookId, deliveryId, event }, 'Webhook delivered successfully');
|
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) {
|
} else if (!success && isFinalAttempt) {
|
||||||
// 6b. Final failure → dead_letter + system alert
|
// 6b. Final failure → dead_letter + system alert
|
||||||
await db
|
await db
|
||||||
@@ -231,6 +244,17 @@ export const webhooksWorker = new Worker(
|
|||||||
'Webhook delivery permanently failed - dead_letter',
|
'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
|
// Notify all super admins
|
||||||
try {
|
try {
|
||||||
const superAdmins = await db
|
const superAdmins = await db
|
||||||
@@ -272,6 +296,17 @@ export const webhooksWorker = new Worker(
|
|||||||
})
|
})
|
||||||
.where(eq(webhookDeliveries.id, deliveryId));
|
.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(
|
throw new Error(
|
||||||
`Webhook delivery attempt ${attempt} failed. Status: ${responseStatus ?? 'network error'}. Retrying...`,
|
`Webhook delivery attempt ${attempt} failed. Status: ${responseStatus ?? 'network error'}. Retrying...`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface AuditSearchOptions {
|
|||||||
entityType?: string;
|
entityType?: string;
|
||||||
/** Filter by exact entity id (e.g. paste a uuid into search). */
|
/** Filter by exact entity id (e.g. paste a uuid into search). */
|
||||||
entityId?: string;
|
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. */
|
/** Inclusive date range. */
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: 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.action) conds.push(eq(auditLogs.action, options.action));
|
||||||
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
|
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
|
||||||
if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId));
|
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.from) conds.push(gte(auditLogs.createdAt, options.from));
|
||||||
if (options.to) conds.push(lte(auditLogs.createdAt, options.to));
|
if (options.to) conds.push(lte(auditLogs.createdAt, options.to));
|
||||||
if (options.q) {
|
if (options.q) {
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export async function redeliverWebhookDelivery(
|
|||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
action: 'send',
|
action: 'webhook_retried',
|
||||||
entityType: 'webhook_delivery',
|
entityType: 'webhook_delivery',
|
||||||
entityId: next!.id,
|
entityId: next!.id,
|
||||||
metadata: { redeliveredFrom: deliveryId, originalStatus: source.status },
|
metadata: { redeliveredFrom: deliveryId, originalStatus: source.status },
|
||||||
|
|||||||
Reference in New Issue
Block a user