feat: add verifiable audit evidence bundles

This commit is contained in:
2026-08-20 18:00:13 +02:00
parent f07d12fa52
commit 7b43816b22
19 changed files with 2164 additions and 10 deletions
+41
View File
@@ -45,6 +45,30 @@ export type AuditAdminDeltaResponse = AuditAdminListResponse & {
full: boolean;
};
export type EvidenceBundleExportRequest = {
scope: "tenant" | "system" | "all";
tenant_id?: string | null;
record_ids?: string[];
max_records?: number;
sign?: boolean;
};
export type EvidenceBundleResponse = {
id: string;
scope: "tenant" | "system" | "all";
tenant_id?: string | null;
status: "pending" | "ready" | "failed";
bundle_sha256?: string | null;
record_count: number;
reference_count: number;
generated_at?: string | null;
download_url?: string | null;
};
export type EvidenceBundleDownloadResponse = {
bundle: Record<string, unknown>;
};
function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
@@ -72,3 +96,20 @@ export function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOption
export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`);
}
export function exportAuditEvidenceBundle(
settings: ApiSettings,
payload: EvidenceBundleExportRequest
): Promise<EvidenceBundleResponse> {
return apiFetch(settings, "/api/v1/admin/audit/evidence-bundles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function downloadAuditEvidenceBundle(
settings: ApiSettings,
bundleId: string
): Promise<EvidenceBundleDownloadResponse> {
return apiFetch(settings, `/api/v1/admin/audit/evidence-bundles/${bundleId}/download`);
}
+57 -1
View File
@@ -9,6 +9,7 @@ import {
Dialog,
DocumentationHelpLink,
formatAdminDateTime as formatDateTime,
hasScope,
i18nMessage,
mergeDeltaRows,
TableActionGroup,
@@ -18,7 +19,14 @@ import {
type DataGridColumn,
type DataGridQueryState
} from "@govoplan/core-webui";
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit";
import {
downloadAuditEvidenceBundle,
exportAuditEvidenceBundle,
fetchAdminAudit,
fetchAdminAuditDelta,
type AuditAdminItem,
type AuditSortBy
} from "../../api/audit";
type Props = {
settings: ApiSettings;
@@ -38,6 +46,8 @@ const I18N = {
close: "i18n:govoplan-audit.close.f1a20804",
details: "i18n:govoplan-audit.details.f1a20805",
eventDetails: "i18n:govoplan-audit.audit_event_details.f1a20806",
exportEvidence: "i18n:govoplan-audit.export_page_evidence.f1a20822",
exportingEvidence: "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823",
inspect: "i18n:govoplan-audit.inspect_audit_event.f1a20807",
loading: "i18n:govoplan-audit.audit_evidence_is_loading.f1a20808",
noDetails: "i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809",
@@ -74,7 +84,12 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const [exporting, setExporting] = useState(false);
const tenantId = (auth.active_tenant ?? auth.tenant).id;
const canExport = hasScope(
auth,
systemMode ? "audit:system_evidence:export" : "audit:evidence:export"
);
const load = useCallback(async () => {
setLoading(true);
@@ -144,6 +159,29 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
});
}, []);
const exportPageEvidence = useCallback(async () => {
setExporting(true);
setError("");
try {
const created = await exportAuditEvidenceBundle(settings, {
scope: systemMode ? "system" : "tenant",
tenant_id: systemMode ? null : tenantId,
record_ids: items.map((item) => item.id),
max_records: Math.max(1, items.length),
sign: false
});
const downloaded = await downloadAuditEvidenceBundle(settings, created.id);
downloadJson(
downloaded.bundle,
`govoplan-audit-evidence-${created.id}.json`
);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setExporting(false);
}
}, [items, settings, systemMode, tenantId]);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: I18N.time, width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
{ id: "actor", header: I18N.actor, width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System", render: (row) => row.actor_email || I18N.system },
@@ -175,6 +213,14 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
error={error}
actions={(
<>
{canExport && (
<Button
onClick={() => { void exportPageEvidence(); }}
disabled={loading || exporting || items.length === 0}
disabledReason={exporting ? I18N.exportingEvidence : undefined}>
{I18N.exportEvidence}
</Button>
)}
<DocumentationHelpLink
reference={{
topicId: "audit.read-authorized-evidence",
@@ -243,6 +289,16 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }:
);
}
function downloadJson(value: unknown, filename: string): void {
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function auditDetailRows(details: Record<string, unknown>): AuditDetailRow[] {
return Object.entries(details)
.sort(([left], [right]) => left.localeCompare(right))
+6 -2
View File
@@ -22,7 +22,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-audit.tenant_context.f1a20818": "Tenant context",
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Tenant-level administrative history for the active tenant, showing {value0}-{value1} of {value2}.",
"i18n:govoplan-audit.time.f1a20820": "Time",
"i18n:govoplan-audit.value.f1a20821": "Value"
"i18n:govoplan-audit.value.f1a20821": "Value",
"i18n:govoplan-audit.export_page_evidence.f1a20822": "Export page evidence",
"i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Audit evidence export is in progress."
},
de: {
"i18n:govoplan-audit.action.f1a20801": "Aktion",
@@ -45,6 +47,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-audit.tenant_context.f1a20818": "Mandantenkontext",
"i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Administrative Historie des aktiven Mandanten, angezeigt werden {value0}-{value1} von {value2}.",
"i18n:govoplan-audit.time.f1a20820": "Zeit",
"i18n:govoplan-audit.value.f1a20821": "Wert"
"i18n:govoplan-audit.value.f1a20821": "Wert",
"i18n:govoplan-audit.export_page_evidence.f1a20822": "Seitennachweise exportieren",
"i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Der Export der Auditnachweise läuft."
}
};