Compliance > Audit log: the audit log in the console #26
No files matched your search
@@ -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 <LocalAiPage />;
|
||||
}
|
||||
if (componentName === 'AuditLog') {
|
||||
return <AuditLogPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown component: {componentName}
|
||||
|
||||
@@ -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<AuditFilterInput>(EMPTY_FILTER);
|
||||
const [filter, setFilter] = useState<AuditFilterInput>(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<Load>({ kind: 'loading' });
|
||||
const [selected, setSelected] = useState<AuditEvent | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [verification, setVerification] = useState<Verification | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [keepForDays, setKeepForDays] = useState<number | null>(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 (
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
<PageHeader
|
||||
icon="shield-check"
|
||||
title={t('audit.title', 'Audit log')}
|
||||
subtitle={
|
||||
<>
|
||||
{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 && (
|
||||
<span>
|
||||
{t('audit.keptFor', 'Kept for {{days}} days.', { days: keepForDays })}{' '}
|
||||
{canManage && (
|
||||
<button type="button" className="underline" onClick={() => setEditingRetention(true)}>
|
||||
{t('audit.change', 'Change')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{canManage && (
|
||||
<Button variant="outline" onClick={() => void verify()} disabled={verifying}>
|
||||
{verifying ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('audit.verify', 'Check for tampering')}
|
||||
</Button>
|
||||
)}
|
||||
{canExport && (
|
||||
<Button onClick={() => setExporting(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('audit.export', 'Export…')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Filters draft={draft} setDraft={setDraft} onApply={apply} onClear={clear} />
|
||||
|
||||
{shown.kind === 'loading' && <LoadingFallback />}
|
||||
{shown.kind === 'error' && (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{shown.message}</div>
|
||||
)}
|
||||
{shown.kind === 'ready' && (
|
||||
<>
|
||||
<EventTable events={shown.events} onSelect={setSelected} filtered={filtered} />
|
||||
<Pager
|
||||
position={position}
|
||||
count={shown.events.length}
|
||||
total={shown.total}
|
||||
onPrevious={() => setPosition(Math.max(0, position - PAGE_SIZE))}
|
||||
onNext={() => setPosition(position + PAGE_SIZE)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EventDetail event={selected} onClose={() => setSelected(null)} />
|
||||
{exporting && (
|
||||
<ExportDialog
|
||||
filter={filter}
|
||||
onClose={() => {
|
||||
setExporting(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<VerificationDialog result={verification} onClose={() => setVerification(null)} />
|
||||
{editingRetention && keepForDays !== null && (
|
||||
<RetentionDialog
|
||||
days={keepForDays}
|
||||
onClose={() => setEditingRetention(false)}
|
||||
onSaved={(days) => {
|
||||
setKeepForDays(days);
|
||||
setEditingRetention(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: AuditOutcome }) {
|
||||
const { t } = useTranslation();
|
||||
switch (outcome.status) {
|
||||
case 'success':
|
||||
return <Badge variant="secondary">{t('audit.outcome.success', 'Done')}</Badge>;
|
||||
case 'refused':
|
||||
return (
|
||||
<Badge variant="destructive" title={outcome.description ?? outcome.error}>
|
||||
{t('audit.outcome.refused', 'Refused')}
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="outline" title={t('audit.outcome.pendingHint', 'The server stopped before it finished.')}>
|
||||
{t('audit.outcome.pending', 'Unfinished')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Filters({
|
||||
draft,
|
||||
setDraft,
|
||||
onApply,
|
||||
onClear,
|
||||
}: {
|
||||
draft: AuditFilterInput;
|
||||
setDraft: (f: AuditFilterInput) => void;
|
||||
onApply: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const set = <K extends keyof AuditFilterInput>(key: K, value: AuditFilterInput[K]) =>
|
||||
setDraft({ ...draft, [key]: value });
|
||||
return (
|
||||
<form
|
||||
className="grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onApply();
|
||||
}}
|
||||
>
|
||||
<label className="space-y-1 text-sm lg:col-span-2">
|
||||
<span className="text-muted-foreground">{t('audit.filter.text', 'Search')}</span>
|
||||
<Input
|
||||
value={draft.text}
|
||||
placeholder={t('audit.filter.textHint', 'Name, address, object, reason…')}
|
||||
onChange={(e) => set('text', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('audit.filter.from', 'From')}</span>
|
||||
<Input type="date" value={draft.from} onChange={(e) => set('from', e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('audit.filter.to', 'To')}</span>
|
||||
<Input type="date" value={draft.to} onChange={(e) => set('to', e.target.value)} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('audit.filter.action', 'What happened')}</span>
|
||||
<Select value={draft.action || ANY} onValueChange={(v) => set('action', v === ANY ? '' : (v as AuditAction))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t('audit.filter.any', 'Anything')}</SelectItem>
|
||||
{AUDIT_ACTIONS.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{actionLabel(t, a)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('audit.filter.outcome', 'Outcome')}</span>
|
||||
<Select
|
||||
value={draft.outcome || ANY}
|
||||
onValueChange={(v) => set('outcome', v === ANY ? '' : (v as AuditFilterInput['outcome']))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY}>{t('audit.filter.any', 'Anything')}</SelectItem>
|
||||
<SelectItem value="success">{t('audit.outcome.success', 'Done')}</SelectItem>
|
||||
<SelectItem value="refused">{t('audit.outcome.refused', 'Refused')}</SelectItem>
|
||||
<SelectItem value="pending">{t('audit.outcome.pending', 'Unfinished')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-6">
|
||||
<Button type="submit" size="sm">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{t('audit.filter.apply', 'Show')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('audit.filter.clear', 'Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EventTable({
|
||||
events,
|
||||
onSelect,
|
||||
filtered,
|
||||
}: {
|
||||
events: AuditEvent[];
|
||||
onSelect: (e: AuditEvent) => void;
|
||||
filtered: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{filtered
|
||||
? t('audit.noneMatching', 'No records match these filters.')
|
||||
: t('audit.none', 'Nothing has been recorded yet.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">{t('audit.col.when', 'When')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('audit.col.who', 'Who')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('audit.col.what', 'What')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('audit.col.target', 'To')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('audit.col.outcome', 'Outcome')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event) => (
|
||||
<tr
|
||||
key={event.id}
|
||||
className="cursor-pointer border-t hover:bg-muted/40"
|
||||
onClick={() => onSelect(event)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSelect(event);
|
||||
}}
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-2 tabular-nums text-muted-foreground">
|
||||
{new Date(event.at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className={cn(event.actor.accountId ? 'font-medium' : 'italic text-muted-foreground')}>
|
||||
{event.actor.name}
|
||||
</div>
|
||||
{event.via && <div className="text-xs text-muted-foreground">{viaLabel(t, event.via)}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2">{actionLabel(t, event.action)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="text-muted-foreground">{targetKindLabel(event.target.kind)}</span>{' '}
|
||||
{targetName(event.target)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<OutcomeBadge outcome={event.outcome} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t('audit.showing', 'Showing {{from}}–{{to}} of {{total}}', {
|
||||
from: position + 1,
|
||||
to: position + count,
|
||||
total,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={position === 0} onClick={onPrevious}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('audit.previous', 'Newer')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={position + count >= total} onClick={onNext}>
|
||||
{t('audit.next', 'Older')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetail({ event, onClose }: { event: AuditEvent | null; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{event && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{actionLabel(t, event.action)} · {targetKindLabel(event.target.kind)}{' '}
|
||||
{targetName(event.target)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{new Date(event.at).toLocaleString()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
<dt className="text-muted-foreground">{t('audit.detail.who', 'Who')}</dt>
|
||||
<dd>
|
||||
{event.actor.name}
|
||||
{event.via && <span className="text-muted-foreground"> · {viaLabel(t, event.via)}</span>}
|
||||
</dd>
|
||||
{event.remoteIp && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('audit.detail.from', 'From')}</dt>
|
||||
<dd className="font-mono">{event.remoteIp}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('audit.detail.outcome', 'Outcome')}</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<OutcomeBadge outcome={event.outcome} />
|
||||
{event.outcome.status === 'refused' && (
|
||||
<span className="text-muted-foreground">
|
||||
{event.outcome.description ?? event.outcome.error}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
{event.reason && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('audit.detail.reason', 'Reason given')}</dt>
|
||||
<dd>{event.reason}</dd>
|
||||
</>
|
||||
)}
|
||||
{event.details && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('audit.detail.details', 'Details')}</dt>
|
||||
<dd className="break-words">{event.details}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">{t('audit.detail.record', 'Record')}</dt>
|
||||
<dd className="font-mono text-xs text-muted-foreground">
|
||||
{event.id} · {t('audit.detail.node', 'node {{node}}', { node: event.node })}
|
||||
</dd>
|
||||
</dl>
|
||||
{event.changes.length > 0 && <ChangeTable changes={event.changes} />}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeTable({ changes }: { changes: AuditChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-h-80 overflow-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 font-medium">{t('audit.change.field', 'Setting')}</th>
|
||||
<th className="px-3 py-1.5 font-medium">{t('audit.change.before', 'Before')}</th>
|
||||
<th className="px-3 py-1.5 font-medium">{t('audit.change.after', 'After')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((change) => (
|
||||
<tr key={change.field} className="border-t align-top">
|
||||
<td className="px-3 py-1.5 font-mono text-xs">{change.field}</td>
|
||||
{change.redacted ? (
|
||||
<td colSpan={2} className="px-3 py-1.5 italic text-muted-foreground">
|
||||
{t('audit.change.secret', 'Changed. Secrets are never recorded.')}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="break-all px-3 py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{formatValue(change.before)}
|
||||
</td>
|
||||
<td className="break-all px-3 py-1.5 font-mono text-xs">{formatValue(change.after)}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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: (
|
||||
<span className="break-all font-mono text-xs">
|
||||
{t('audit.exportedHash', 'SHA-256 {{hash}}', { hash: result.sha256 })}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
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 (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('audit.exportTitle', 'Export the audit log')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-muted-foreground">{t('audit.format', 'Format')}</span>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as 'csv' | 'jsonl')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">{t('audit.formatCsv', 'CSV (spreadsheets)')}</SelectItem>
|
||||
<SelectItem value="jsonl">{t('audit.formatJsonl', 'JSON Lines (tools, SIEM)')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-muted-foreground">{t('audit.reason', 'Reason (optional)')}</span>
|
||||
<Textarea
|
||||
value={reason}
|
||||
maxLength={500}
|
||||
placeholder={t('audit.reasonHint', 'Ticket, request or case this is for')}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('audit.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void run()} disabled={busy}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('audit.exportGo', 'Export')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationDialog({ result, onClose }: { result: Verification | null; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={!!result} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
{result && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className={cn(!result.verified && 'text-destructive')}>
|
||||
{result.verified
|
||||
? t('audit.verified', 'No tampering found')
|
||||
: t('audit.broken', 'The audit log was changed')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result.verified
|
||||
? t(
|
||||
'audit.verifiedBody',
|
||||
'Every record on every node follows from the one before it. This catches edits to the store; it can’t stop someone with root on the server rewriting everything, so forward records to a system your mail administrators don’t control as well.',
|
||||
)
|
||||
: t(
|
||||
'audit.brokenBody',
|
||||
'At least one record doesn’t follow from the one before it. Keep the server as it is and compare against an export or a forwarded copy.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{result.chains.map((chain) => (
|
||||
<li key={chain.node} className="rounded-lg border p-3">
|
||||
<div className="font-medium">
|
||||
{t('audit.chainNode', 'Node {{node}}', { node: chain.node })}:{' '}
|
||||
{chain.brokenAt
|
||||
? t('audit.chainBroken', 'broken at record {{at}}', { at: chain.brokenAt })
|
||||
: t('audit.chainOk', '{{count}} entries verified', { count: chain.entries })}
|
||||
</div>
|
||||
{chain.reason && <div className="text-muted-foreground">{chain.reason}</div>}
|
||||
{chain.unfinished > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
{t('audit.chainUnfinished', '{{count}} changes never recorded an outcome.', {
|
||||
count: chain.unfinished,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionDialog({
|
||||
days,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
days: number;
|
||||
onClose: () => void;
|
||||
onSaved: (days: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(String(days));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const parsed = Number(value);
|
||||
const valid = Number.isInteger(parsed) && parsed >= 90;
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateKeepForDays(parsed);
|
||||
onSaved(parsed);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('audit.retentionFailed', 'Not saved'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('audit.retentionTitle', 'How long records are kept')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'audit.retentionBody',
|
||||
'Older records are removed once a day. At least 90 days; the default is two years. This change is recorded too.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('audit.retentionDays', 'Days')}</span>
|
||||
<Input type="number" min={90} value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('audit.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('audit.save', 'Save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EMPTY_FILTER, normalize, toServerFilter } from './auditLog';
|
||||
import { formatValue, targetKindLabel, targetName } from './format';
|
||||
|
||||
describe('toServerFilter', () => {
|
||||
it('sends nothing for an empty filter', () => {
|
||||
expect(toServerFilter(EMPTY_FILTER)).toEqual({});
|
||||
});
|
||||
|
||||
it('turns local days into a half-open UTC range covering both days', () => {
|
||||
const filter = toServerFilter({ ...EMPTY_FILTER, from: '2026-09-01', to: '2026-09-02' });
|
||||
const after = new Date(filter.after!);
|
||||
const before = new Date(filter.before!);
|
||||
expect(after.getTime()).toBe(new Date(2026, 8, 1).getTime());
|
||||
expect(before.getTime()).toBe(new Date(2026, 8, 3).getTime());
|
||||
expect(filter.after).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
});
|
||||
|
||||
it('passes the other conditions through, trimmed', () => {
|
||||
expect(
|
||||
toServerFilter({
|
||||
...EMPTY_FILTER,
|
||||
action: 'signIn',
|
||||
outcome: 'refused',
|
||||
targetKind: ' x:Domain ',
|
||||
text: ' admin ',
|
||||
}),
|
||||
).toEqual({ action: 'signIn', outcome: 'refused', targetKind: 'x:Domain', text: 'admin' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalize', () => {
|
||||
it('fills what the server leaves out', () => {
|
||||
const event = normalize({ id: 'a', action: 'signIn' });
|
||||
expect(event.changes).toEqual([]);
|
||||
expect(event.via).toBeNull();
|
||||
expect(event.reason).toBeNull();
|
||||
expect(event.outcome).toEqual({ status: 'pending' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('format', () => {
|
||||
it('names object kinds in words', () => {
|
||||
expect(targetKindLabel('x:Domain')).toBe('Domain');
|
||||
expect(targetKindLabel('x:DkimSignature')).toBe('DKIM signature');
|
||||
expect(targetKindLabel('x:BlockedIp')).toBe('Blocked IP');
|
||||
expect(targetKindLabel('inbuxa:AuditSettings')).toBe('Audit settings');
|
||||
expect(targetKindLabel('x:OAuthClient')).toBe('OAuth client');
|
||||
expect(targetKindLabel('account')).toBe('Account');
|
||||
expect(targetKindLabel('inbuxa:AuditEvent')).toBe('Audit log');
|
||||
});
|
||||
|
||||
it('never names a target by a singleton id', () => {
|
||||
expect(targetName({ id: 'singleton' })).toBe('');
|
||||
expect(targetName({ id: 'b', name: 'example.com' })).toBe('example.com');
|
||||
expect(targetName({ id: 'b' })).toBe('b');
|
||||
});
|
||||
|
||||
it('shows values plainly', () => {
|
||||
expect(formatValue(undefined)).toBe('—');
|
||||
expect(formatValue('text')).toBe('text');
|
||||
expect(formatValue(false)).toBe('false');
|
||||
expect(formatValue({ '@type': 'Manual' })).toBe('{"@type":"Manual"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: the audit log over JMAP (audit-hold-lock spec, AU-9 to AU-11):
|
||||
* `inbuxa:AuditEvent`, `inbuxa:AuditSettings`, `inbuxa:AuditExport` and
|
||||
* `inbuxa:AuditVerification`. This module is the wire; the page draws from it.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
|
||||
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
export type AuditAction =
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'destroy'
|
||||
| 'signIn'
|
||||
| 'signInFailed'
|
||||
| 'accountAccess'
|
||||
| 'blobAccess'
|
||||
| 'export'
|
||||
| 'verify';
|
||||
|
||||
export const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'create',
|
||||
'update',
|
||||
'destroy',
|
||||
'signIn',
|
||||
'signInFailed',
|
||||
'accountAccess',
|
||||
'blobAccess',
|
||||
'export',
|
||||
'verify',
|
||||
];
|
||||
|
||||
export interface AuditActor {
|
||||
accountId?: string;
|
||||
name: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export type AuditVia =
|
||||
| { kind: 'password' }
|
||||
| { kind: 'appPassword'; id: number }
|
||||
| { kind: 'apiKey'; id: number }
|
||||
| { kind: 'oauth'; client: string }
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'master'; accountId?: string; name: string }
|
||||
| { kind: 'recovery' };
|
||||
|
||||
export interface AuditTarget {
|
||||
kind: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
accountId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface AuditChange {
|
||||
field: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export type AuditOutcome =
|
||||
| { status: 'success'; createdId?: string }
|
||||
| { status: 'refused'; error: string; description?: string }
|
||||
| { status: 'pending' };
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
/** RFC 3339, to the millisecond. */
|
||||
at: string;
|
||||
node: number;
|
||||
actor: AuditActor;
|
||||
via: AuditVia | null;
|
||||
remoteIp: string | null;
|
||||
action: AuditAction;
|
||||
target: AuditTarget;
|
||||
changes: AuditChange[];
|
||||
details: string | null;
|
||||
reason: string | null;
|
||||
outcome: AuditOutcome;
|
||||
}
|
||||
|
||||
/** What the page filters by. Empty strings mean "any". */
|
||||
export interface AuditFilterInput {
|
||||
/** `yyyy-mm-dd`, local day, inclusive. */
|
||||
from: string;
|
||||
/** `yyyy-mm-dd`, local day, inclusive. */
|
||||
to: string;
|
||||
action: AuditAction | '';
|
||||
outcome: 'success' | 'refused' | 'pending' | '';
|
||||
targetKind: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTER: AuditFilterInput = {
|
||||
from: '',
|
||||
to: '',
|
||||
action: '',
|
||||
outcome: '',
|
||||
targetKind: '',
|
||||
text: '',
|
||||
};
|
||||
|
||||
/** The server's filter object: every condition must hold. */
|
||||
export function toServerFilter(input: AuditFilterInput): Record<string, string> {
|
||||
const filter: Record<string, string> = {};
|
||||
if (input.from) filter.after = localDayStart(input.from).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
if (input.to) {
|
||||
const end = localDayStart(input.to);
|
||||
end.setDate(end.getDate() + 1);
|
||||
filter.before = end.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
if (input.action) filter.action = input.action;
|
||||
if (input.outcome) filter.outcome = input.outcome;
|
||||
if (input.targetKind.trim()) filter.targetKind = input.targetKind.trim();
|
||||
if (input.text.trim()) filter.text = input.text.trim();
|
||||
return filter;
|
||||
}
|
||||
|
||||
function localDayStart(day: string): Date {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
return new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||
}
|
||||
|
||||
function methodError(name: string | undefined, result: unknown): Error {
|
||||
const r = result as { type?: string; description?: string } | undefined;
|
||||
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') {
|
||||
return new AuditUnavailable(r.type);
|
||||
}
|
||||
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
|
||||
}
|
||||
|
||||
/** The server doesn't offer the audit log (an older server, or not inbuxa). */
|
||||
export class AuditUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`Audit log unavailable: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditPage {
|
||||
events: AuditEvent[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One page of events, newest first. */
|
||||
export async function queryEvents(
|
||||
input: AuditFilterInput,
|
||||
position: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuditPage> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[
|
||||
[
|
||||
'inbuxa:AuditEvent/query',
|
||||
{ accountId, filter: toServerFilter(input), position, limit: PAGE_SIZE, calculateTotal: true },
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'inbuxa:AuditEvent/get',
|
||||
{ accountId, '#ids': { resultOf: 'q', name: 'inbuxa:AuditEvent/query', path: '/ids' } },
|
||||
'g',
|
||||
],
|
||||
],
|
||||
signal,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [qName, qResult] = responses[0] ?? [];
|
||||
if (qName !== 'inbuxa:AuditEvent/query') throw methodError(qName, qResult);
|
||||
const [gName, gResult] = responses[1] ?? [];
|
||||
if (gName !== 'inbuxa:AuditEvent/get') throw methodError(gName, gResult);
|
||||
const ids = (qResult as { ids?: string[] }).ids ?? [];
|
||||
const byId = new Map(((gResult as { list?: AuditEvent[] }).list ?? []).map((e) => [e.id, normalize(e)]));
|
||||
return {
|
||||
// Keep the query's order: newest first
|
||||
events: ids.map((id) => byId.get(id)).filter((e): e is AuditEvent => !!e),
|
||||
total: (qResult as { total?: number }).total ?? ids.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fills what the server leaves out when it has nothing to say. */
|
||||
export function normalize(raw: Partial<AuditEvent> & { id: string }): AuditEvent {
|
||||
return {
|
||||
id: raw.id,
|
||||
at: raw.at ?? '',
|
||||
node: raw.node ?? 0,
|
||||
actor: raw.actor ?? { name: '' },
|
||||
via: raw.via ?? null,
|
||||
remoteIp: raw.remoteIp ?? null,
|
||||
action: (raw.action ?? 'update') as AuditAction,
|
||||
target: raw.target ?? { kind: '' },
|
||||
changes: raw.changes ?? [],
|
||||
details: raw.details ?? null,
|
||||
reason: raw.reason ?? null,
|
||||
outcome: raw.outcome ?? { status: 'pending' },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKeepForDays(): Promise<number> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AuditSettings/get', { accountId, ids: null }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== 'inbuxa:AuditSettings/get') throw methodError(name, result);
|
||||
return (result as { list?: { keepForDays?: number }[] }).list?.[0]?.keepForDays ?? 730;
|
||||
}
|
||||
|
||||
export async function updateKeepForDays(days: number): Promise<void> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AuditSettings/set', { accountId, update: { singleton: { keepForDays: days } } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== 'inbuxa:AuditSettings/set') throw methodError(name, result);
|
||||
const failed = (result as { notUpdated?: Record<string, { description?: string; type?: string }> }).notUpdated
|
||||
?.singleton;
|
||||
if (failed) throw new Error(failed.description ?? failed.type ?? 'Not saved');
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
count: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** Asks the server for a file of the matching records, and saves it (AU-11). */
|
||||
export async function exportEvents(
|
||||
input: AuditFilterInput,
|
||||
format: 'csv' | 'jsonl',
|
||||
reason: string,
|
||||
): Promise<ExportResult> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const create: Record<string, unknown> = { format, filter: toServerFilter(input) };
|
||||
if (reason.trim()) create.reason = reason.trim();
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AuditExport/set', { accountId, create: { x: create } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== 'inbuxa:AuditExport/set') throw methodError(name, result);
|
||||
const r = result as {
|
||||
created?: { x?: { blobId: string; count: number; sha256: string } };
|
||||
notCreated?: { x?: { description?: string; type?: string } };
|
||||
};
|
||||
const created = r.created?.x;
|
||||
if (!created) throw new Error(r.notCreated?.x?.description ?? r.notCreated?.x?.type ?? 'Export failed');
|
||||
|
||||
const fileName = `audit-${new Date().toISOString().slice(0, 10)}.${format === 'csv' ? 'csv' : 'jsonl'}`;
|
||||
const type = format === 'csv' ? 'text/csv' : 'application/jsonl';
|
||||
const session = await fetchSession();
|
||||
const template =
|
||||
typeof session.downloadUrl === 'string'
|
||||
? session.downloadUrl
|
||||
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
|
||||
const url = template
|
||||
.replace('{accountId}', encodeURIComponent(accountId))
|
||||
.replace('{blobId}', encodeURIComponent(created.blobId))
|
||||
.replace('{name}', encodeURIComponent(fileName))
|
||||
.replace('{type}', encodeURIComponent(type));
|
||||
const response = await apiFetch(pathOf(url));
|
||||
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||
saveBlob(await response.blob(), fileName);
|
||||
return { count: created.count, sha256: created.sha256 };
|
||||
}
|
||||
|
||||
function pathOf(url: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://placeholder');
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 10_000);
|
||||
}
|
||||
|
||||
export interface ChainReport {
|
||||
node: number;
|
||||
entries: number;
|
||||
firstSeq: number;
|
||||
lastSeq: number;
|
||||
brokenAt?: string;
|
||||
reason?: string;
|
||||
unfinished: number;
|
||||
}
|
||||
|
||||
export interface Verification {
|
||||
verified: boolean;
|
||||
chains: ChainReport[];
|
||||
}
|
||||
|
||||
/** Rechecks every node's chain (AU-6). Server administrators only. */
|
||||
export async function verifyChains(): Promise<Verification> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[['inbuxa:AuditVerification/set', { accountId, create: { v: {} } }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== 'inbuxa:AuditVerification/set') throw methodError(name, result);
|
||||
const created = (result as { created?: { v?: Verification } }).created?.v;
|
||||
if (!created) throw new Error('Verification failed');
|
||||
return { verified: created.verified, chains: created.chains ?? [] };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** INBUXA: how audit records read on screen (audit-hold-lock spec, AU-4). */
|
||||
|
||||
/** Words the registry spells in camel case that read as acronyms. */
|
||||
const ACRONYMS: Record<string, string> = {
|
||||
Oauth: 'OAuth',
|
||||
Dkim: 'DKIM',
|
||||
Dmarc: 'DMARC',
|
||||
Dns: 'DNS',
|
||||
Dnsbl: 'DNSBL',
|
||||
Mta: 'MTA',
|
||||
Acme: 'ACME',
|
||||
Ip: 'IP',
|
||||
Ai: 'AI',
|
||||
Llm: 'LLM',
|
||||
Tls: 'TLS',
|
||||
Spf: 'SPF',
|
||||
Arf: 'ARF',
|
||||
Dsn: 'DSN',
|
||||
Http: 'HTTP',
|
||||
Imap: 'IMAP',
|
||||
Jmap: 'JMAP',
|
||||
Oidc: 'OIDC',
|
||||
Sts: 'STS',
|
||||
Api: 'API',
|
||||
Scim: 'SCIM',
|
||||
};
|
||||
|
||||
/** `x:DkimSignature` reads as "DKIM signature"; fork objects lose their prefix too. */
|
||||
export function targetKindLabel(kind: string): string {
|
||||
if (kind === 'account') return 'Account';
|
||||
// Exports and checks act on the log itself
|
||||
if (kind === 'inbuxa:AuditEvent') return 'Audit log';
|
||||
const name = kind.replace(/^(x|inbuxa):/, '').replace('OAuth', 'Oauth');
|
||||
if (!name) return '';
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map((word, i) => ACRONYMS[word] ?? (i === 0 ? word : word.toLowerCase()))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/** One side of a change: absent reads as a dash, text as itself, anything else as JSON. */
|
||||
export function formatValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return '—';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** What a record names its target by: a singleton's id says nothing. */
|
||||
export function targetName(target: { name?: string; id?: string }): string {
|
||||
if (target.name) return target.name;
|
||||
if (target.id && target.id !== 'singleton') return target.id;
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** INBUXA: the audit log's words for actions and sign-in methods. */
|
||||
|
||||
import type { AuditAction, AuditVia } from './auditLog';
|
||||
|
||||
type T = (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function actionLabel(t: T, action: AuditAction): string {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
return t('audit.action.create', 'Created');
|
||||
case 'update':
|
||||
return t('audit.action.update', 'Changed');
|
||||
case 'destroy':
|
||||
return t('audit.action.destroy', 'Deleted');
|
||||
case 'signIn':
|
||||
return t('audit.action.signIn', 'Signed in');
|
||||
case 'signInFailed':
|
||||
return t('audit.action.signInFailed', 'Sign-in failed');
|
||||
case 'accountAccess':
|
||||
return t('audit.action.accountAccess', 'Opened another account');
|
||||
case 'blobAccess':
|
||||
return t('audit.action.blobAccess', 'Read another account’s file');
|
||||
case 'export':
|
||||
return t('audit.action.export', 'Exported the audit log');
|
||||
case 'verify':
|
||||
return t('audit.action.verify', 'Checked for tampering');
|
||||
}
|
||||
}
|
||||
|
||||
export function viaLabel(t: T, via: AuditVia | null): string {
|
||||
if (!via) return '';
|
||||
switch (via.kind) {
|
||||
case 'password':
|
||||
return t('audit.via.password', 'password');
|
||||
case 'appPassword':
|
||||
return t('audit.via.appPassword', 'app password');
|
||||
case 'apiKey':
|
||||
return t('audit.via.apiKey', 'API key');
|
||||
case 'oauth':
|
||||
return t('audit.via.oauth', 'app {{client}}', { client: shortClient(via.client) });
|
||||
case 'directory':
|
||||
return t('audit.via.directory', 'directory token');
|
||||
case 'master':
|
||||
return t('audit.via.master', 'as master user {{name}}', { name: via.name });
|
||||
case 'recovery':
|
||||
return t('audit.via.recovery', 'recovery administrator');
|
||||
}
|
||||
}
|
||||
|
||||
/** First-party client ids are names; registered ones are long tokens. */
|
||||
function shortClient(client: string): string {
|
||||
return client.length > 24 ? `${client.slice(0, 12)}…` : client;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ function checkSpecialLink(
|
||||
return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false };
|
||||
}
|
||||
|
||||
// inbuxa: the audit log, for whoever may read it (AU-9).
|
||||
if (viewName === 'CustomComponent/AuditLog') {
|
||||
return { visible: canGet ? canGet('sysAudit') : true, enterprise: false };
|
||||
}
|
||||
|
||||
if (viewName.startsWith('CustomComponent/')) {
|
||||
return { visible: true, enterprise: false };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user