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:
@@ -17,6 +17,9 @@ interface AuditEntry {
|
||||
newValue: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | 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) {
|
||||
</span>
|
||||
</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">
|
||||
<ListCardMeta icon={<Clock className="h-3 w-3" />}>
|
||||
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
|
||||
</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>
|
||||
|
||||
{/* Action badge + changed-fields chips */}
|
||||
|
||||
@@ -32,6 +32,9 @@ interface AuditEntry {
|
||||
newValue: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | 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<string, string> = {
|
||||
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<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 = [
|
||||
@@ -91,6 +121,8 @@ export function AuditLogList() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [entityType, setEntityType] = 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 [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 }) => (
|
||||
<Badge
|
||||
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||
>
|
||||
{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',
|
||||
@@ -236,7 +294,18 @@ export function AuditLogList() {
|
||||
}
|
||||
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">
|
||||
<Label className="text-xs">Action</Label>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="w-36" data-testid="audit-action">
|
||||
<SelectTrigger className="w-44" data-testid="audit-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -300,7 +369,52 @@ export function AuditLogList() {
|
||||
<SelectItem value="merge">Merge</SelectItem>
|
||||
<SelectItem value="revert">Revert</SelectItem>
|
||||
<SelectItem value="login">Login</SelectItem>
|
||||
<SelectItem value="logout">Logout</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>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user