From c8cdfacfed5a4f79628864562f8f00aeb9819731 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 13:40:27 -0700 Subject: [PATCH] Compliance > Audit log: the audit log in the console A new Management > Compliance section, with an Audit log page over inbuxa:AuditEvent (inbuxa-drafts/specs/audit-hold-lock.md, AU-9 to AU-11): - newest first, 50 to a page, with who acted and how they signed in, what happened, to what, and the outcome (done, refused, or unfinished when the server stopped before a change finished); - filters for words, a date range, what happened and the outcome; - a record's detail: address, reason, details, and each setting's before and after, with secrets shown only as changed; - Export: CSV or JSON Lines of what the filters match, built by the server, with an optional reason; the toast gives the file's SHA-256; - Check for tampering: rechecks every node's chain and names any break; - retention, shown in the header and editable (90 days or more). Export needs sysAuditExport; retention and the tamper check are for server administrators (sysAuditSettingsUpdate). The menu link shows for whoever holds sysAuditGet. The list fetches again after the page's own actions, since they are recorded too. Tests: filter building, record normalizing and labels. --- src/components/layout/MainContent.tsx | 8 + src/features/audit/AuditLogPage.tsx | 737 ++++++++++++++++++++++++++ src/features/audit/auditLog.test.ts | 71 +++ src/features/audit/auditLog.ts | 329 ++++++++++++ src/features/audit/format.ts | 61 +++ src/features/audit/labels.ts | 60 +++ src/lib/layout.ts | 5 + 7 files changed, 1271 insertions(+) create mode 100644 src/features/audit/AuditLogPage.tsx create mode 100644 src/features/audit/auditLog.test.ts create mode 100644 src/features/audit/auditLog.ts create mode 100644 src/features/audit/format.ts create mode 100644 src/features/audit/labels.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 6bf4c32..c3b5d32 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -56,6 +56,11 @@ const LocalAiPage = lazyFeature( () => import('@/features/ai/LocalAiPage'), (m) => m.LocalAiPage, ); +// inbuxa: Management › Compliance › Audit Log (audit-hold-lock spec, AU-9). +const AuditLogPage = lazyFeature( + () => import('@/features/audit/AuditLogPage'), + (m) => m.AuditLogPage, +); const TenantLegacyProtocols = lazyFeature( () => import('@/features/hardening/TenantLegacyProtocols'), (m) => m.TenantLegacyProtocols, @@ -120,6 +125,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LocalAi') { return ; } + if (componentName === 'AuditLog') { + return ; + } return (
Unknown component: {componentName} diff --git a/src/features/audit/AuditLogPage.tsx b/src/features/audit/AuditLogPage.tsx new file mode 100644 index 0000000..fecd07b --- /dev/null +++ b/src/features/audit/AuditLogPage.tsx @@ -0,0 +1,737 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Management › Compliance › Audit Log (audit-hold-lock spec, AU-9 to + * AU-11). Who changed what, who signed in as an administrator, and who + * reached another account's data; filtered, paged newest first, exported as + * a file the server builds, and checked against tampering. + * + * Records can't be edited or deleted from here or anywhere else: that is the + * point of them. A tenant administrator sees its own tenant's records; the + * server decides that, not this page. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronLeft, ChevronRight, Download, Loader2, RotateCcw, Search, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { + AUDIT_ACTIONS, + AuditUnavailable, + EMPTY_FILTER, + PAGE_SIZE, + exportEvents, + fetchKeepForDays, + queryEvents, + updateKeepForDays, + verifyChains, + type AuditAction, + type AuditChange, + type AuditEvent, + type AuditFilterInput, + type AuditOutcome, + type Verification, +} from './auditLog'; +import { formatValue, targetKindLabel, targetName } from './format'; +import { actionLabel, viaLabel } from './labels'; + +/** What was last loaded, and for which filter and page. */ +type Load = + | { kind: 'loading' } + | { kind: 'ready'; key: string; events: AuditEvent[]; total: number } + | { kind: 'error'; key: string; message: string }; + +const ANY = '__any__'; + +export function AuditLogPage() { + const { t } = useTranslation(); + const canExport = useAccountStore((s) => s.hasPermission('sysAuditExport')); + // Retention and verification are the server's: tenant administrators hold neither + const canManage = useAccountStore((s) => s.hasPermission('sysAuditSettingsUpdate')); + + const [draft, setDraft] = useState(EMPTY_FILTER); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [position, setPosition] = useState(0); + // Bumped to fetch again: Show, Clear, and after this page's own actions, + // which are recorded too + const [fetches, setFetches] = useState(0); + const refetch = useCallback(() => setFetches((n) => n + 1), []); + const [load, setLoad] = useState({ kind: 'loading' }); + const [selected, setSelected] = useState(null); + const [exporting, setExporting] = useState(false); + const [verification, setVerification] = useState(null); + const [verifying, setVerifying] = useState(false); + const [keepForDays, setKeepForDays] = useState(null); + const [editingRetention, setEditingRetention] = useState(false); + + // A page that belongs to another filter or position shows as loading + const key = JSON.stringify([filter, position, fetches]); + const shown: Load = load.kind !== 'loading' && load.key === key ? load : { kind: 'loading' }; + + useEffect(() => { + const controller = new AbortController(); + queryEvents(filter, position, controller.signal) + .then((page) => { + if (!controller.signal.aborted) setLoad({ kind: 'ready', key, events: page.events, total: page.total }); + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return; + setLoad({ + kind: 'error', + key, + message: + e instanceof AuditUnavailable + ? t('audit.unavailable', 'This server does not keep an audit log.') + : e instanceof Error + ? e.message + : String(e), + }); + }); + return () => controller.abort(); + }, [filter, position, key, t]); + + useEffect(() => { + fetchKeepForDays() + .then(setKeepForDays) + .catch(() => setKeepForDays(null)); + }, []); + + const apply = useCallback(() => { + setPosition(0); + setFilter(draft); + refetch(); + }, [draft, refetch]); + + const clear = useCallback(() => { + setDraft(EMPTY_FILTER); + setPosition(0); + setFilter(EMPTY_FILTER); + refetch(); + }, [refetch]); + + const verify = useCallback(async () => { + setVerifying(true); + try { + setVerification(await verifyChains()); + refetch(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('audit.verifyFailed', 'The check could not run'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setVerifying(false); + } + }, [refetch, t]); + + const filtered = useMemo(() => JSON.stringify(filter) !== JSON.stringify(EMPTY_FILTER), [filter]); + + return ( +
+ + {t( + 'audit.subtitle', + 'Every change administrators and the server made, administrator sign-ins, and access to other accounts. Records can’t be edited or deleted.', + )}{' '} + {keepForDays !== null && ( + + {t('audit.keptFor', 'Kept for {{days}} days.', { days: keepForDays })}{' '} + {canManage && ( + + )} + + )} + + } + actions={ + <> + {canManage && ( + + )} + {canExport && ( + + )} + + } + /> + + + + {shown.kind === 'loading' && } + {shown.kind === 'error' && ( +
{shown.message}
+ )} + {shown.kind === 'ready' && ( + <> + + setPosition(Math.max(0, position - PAGE_SIZE))} + onNext={() => setPosition(position + PAGE_SIZE)} + /> + + )} + + setSelected(null)} /> + {exporting && ( + { + setExporting(false); + refetch(); + }} + /> + )} + setVerification(null)} /> + {editingRetention && keepForDays !== null && ( + setEditingRetention(false)} + onSaved={(days) => { + setKeepForDays(days); + setEditingRetention(false); + refetch(); + }} + /> + )} +
+ ); +} + +function OutcomeBadge({ outcome }: { outcome: AuditOutcome }) { + const { t } = useTranslation(); + switch (outcome.status) { + case 'success': + return {t('audit.outcome.success', 'Done')}; + case 'refused': + return ( + + {t('audit.outcome.refused', 'Refused')} + + ); + case 'pending': + return ( + + {t('audit.outcome.pending', 'Unfinished')} + + ); + } +} + +function Filters({ + draft, + setDraft, + onApply, + onClear, +}: { + draft: AuditFilterInput; + setDraft: (f: AuditFilterInput) => void; + onApply: () => void; + onClear: () => void; +}) { + const { t } = useTranslation(); + const set = (key: K, value: AuditFilterInput[K]) => + setDraft({ ...draft, [key]: value }); + return ( +
{ + e.preventDefault(); + onApply(); + }} + > + + + + + +
+ + +
+
+ ); +} + +function EventTable({ + events, + onSelect, + filtered, +}: { + events: AuditEvent[]; + onSelect: (e: AuditEvent) => void; + filtered: boolean; +}) { + const { t } = useTranslation(); + if (events.length === 0) { + return ( +
+ {filtered + ? t('audit.noneMatching', 'No records match these filters.') + : t('audit.none', 'Nothing has been recorded yet.')} +
+ ); + } + return ( +
+ + + + + + + + + + + + {events.map((event) => ( + onSelect(event)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') onSelect(event); + }} + > + + + + + + + ))} + +
{t('audit.col.when', 'When')}{t('audit.col.who', 'Who')}{t('audit.col.what', 'What')}{t('audit.col.target', 'To')}{t('audit.col.outcome', 'Outcome')}
+ {new Date(event.at).toLocaleString()} + +
+ {event.actor.name} +
+ {event.via &&
{viaLabel(t, event.via)}
} +
{actionLabel(t, event.action)} + {targetKindLabel(event.target.kind)}{' '} + {targetName(event.target)} + + +
+
+ ); +} + +function Pager({ + position, + count, + total, + onPrevious, + onNext, +}: { + position: number; + count: number; + total: number; + onPrevious: () => void; + onNext: () => void; +}) { + const { t } = useTranslation(); + if (total === 0) return null; + return ( +
+ + {t('audit.showing', 'Showing {{from}}–{{to}} of {{total}}', { + from: position + 1, + to: position + count, + total, + })} + +
+ + +
+
+ ); +} + +function EventDetail({ event, onClose }: { event: AuditEvent | null; onClose: () => void }) { + const { t } = useTranslation(); + return ( + !open && onClose()}> + + {event && ( + <> + + + {actionLabel(t, event.action)} · {targetKindLabel(event.target.kind)}{' '} + {targetName(event.target)} + + {new Date(event.at).toLocaleString()} + +
+
{t('audit.detail.who', 'Who')}
+
+ {event.actor.name} + {event.via && · {viaLabel(t, event.via)}} +
+ {event.remoteIp && ( + <> +
{t('audit.detail.from', 'From')}
+
{event.remoteIp}
+ + )} +
{t('audit.detail.outcome', 'Outcome')}
+
+ + {event.outcome.status === 'refused' && ( + + {event.outcome.description ?? event.outcome.error} + + )} +
+ {event.reason && ( + <> +
{t('audit.detail.reason', 'Reason given')}
+
{event.reason}
+ + )} + {event.details && ( + <> +
{t('audit.detail.details', 'Details')}
+
{event.details}
+ + )} +
{t('audit.detail.record', 'Record')}
+
+ {event.id} · {t('audit.detail.node', 'node {{node}}', { node: event.node })} +
+
+ {event.changes.length > 0 && } + + )} +
+
+ ); +} + +function ChangeTable({ changes }: { changes: AuditChange[] }) { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + + {changes.map((change) => ( + + + {change.redacted ? ( + + ) : ( + <> + + + + )} + + ))} + +
{t('audit.change.field', 'Setting')}{t('audit.change.before', 'Before')}{t('audit.change.after', 'After')}
{change.field} + {t('audit.change.secret', 'Changed. Secrets are never recorded.')} + + {formatValue(change.before)} + {formatValue(change.after)}
+
+ ); +} + +function ExportDialog({ filter, onClose }: { filter: AuditFilterInput; onClose: () => void }) { + const { t } = useTranslation(); + const [format, setFormat] = useState<'csv' | 'jsonl'>('csv'); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + + const run = async () => { + setBusy(true); + try { + const result = await exportEvents(filter, format, reason); + toast({ + title: t('audit.exported', 'Exported {{count}} records', { count: result.count }), + description: ( + + {t('audit.exportedHash', 'SHA-256 {{hash}}', { hash: result.sha256 })} + + ), + }); + onClose(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('audit.exportFailed', 'Nothing was exported'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + {t('audit.exportTitle', 'Export the audit log')} + + {t( + 'audit.exportBody', + 'The records the filters above match, up to 100,000, newest first. Each line carries its chain hash, and the file ends with a manifest, so whoever receives it can check it. The export itself is recorded.', + )} + + +
+ +