From 7af6b7591b63e24d2e6d98f21155045b9677de10 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 26 Aug 2026 09:11:13 +0200 Subject: [PATCH] Integritaetsstatus des Audit-Protokolls in der Oberflaeche Der Zustand der Hash-Kette war bisher nur per POST /api/audit-logs/verify einsehbar - also praktisch nur fuer das Gegenbuch und fuer jemanden mit curl. Jetzt steht er oben auf Einstellungen -> Audit-Protokoll. Vier Zustaende statt gruen/rot: unversehrt; Befund; unversehrt aber ungeschuetzter Altbestand (kein_siegel); nicht vollstaendig pruefbar (signierte Zeilen ohne AUDIT_HMAC_KEY). Die beiden mittleren sind bewusst nicht gruen - ein Protokoll mit unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand unbemerkt aenderbar, und ein Protokoll, das mangels Schluessel nicht pruefbar ist, ist schlicht ungeprueft. Beides als "alles in Ordnung" zu zeigen waere genau die Klasse Fehler, die diese Runde behandelt hat. Schlaegt die Pruefung selbst fehl, steht dort ausdruecklich, dass das keine Entwarnung ist. Aufklappbare Einzelheiten trennen die unterschiedlich schweren Kategorien: nachtraeglich veraendert (ernst) / Verkettung unterbrochen / davon ohne dokumentierte Loeschung / davon vom Siegel beglaubigt / ohne Schluessel nicht pruefbar - mit Erklaerung im Klartext. Die Pruefung liest die gesamte Kette; sie laeuft daher einmal beim Oeffnen der Seite und wird 5 Minuten wiederverwendet. Bewusst read-only: kein Siegel- oder Rehash-Knopf, denn diese Eingriffe verlangen audit:admin und eine ausdrueckliche Bestaetigung und gehoeren nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt. Co-Authored-By: Claude Opus 5 (1M context) --- docs/todo.md | 28 +++ .../src/pages/settings/AuditIntegrityCard.tsx | 176 ++++++++++++++++++ frontend/src/pages/settings/AuditLogs.tsx | 3 + frontend/src/services/api.ts | 26 ++- 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/settings/AuditIntegrityCard.tsx diff --git a/docs/todo.md b/docs/todo.md index 94ab0c6e..79f723ab 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **👁️ Integritätsstatus in der Oberfläche (Einstellungen → Audit-Protokoll)** (2026-08-26) + - Bisher war der Zustand der Hash-Kette nur per `POST /api/audit-logs/verify` + einsehbar – also praktisch nur für das Gegenbuch und für jemanden mit + `curl`. Jetzt steht er oben auf der Audit-Seite. + - Vier Zustände statt „grün/rot": **unversehrt**, **Befund**, **unversehrt + aber ungeschützter Altbestand** (`kein_siegel`), **nicht vollständig + prüfbar** (signierte Zeilen ohne `AUDIT_HMAC_KEY`). + - Die beiden mittleren Zustände sind bewusst nicht grün. Ein Protokoll mit + unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand + unbemerkt änderbar; ein Protokoll, das mangels Schlüssel nicht prüfbar ist, + ist schlicht ungeprüft. Beides als „alles in Ordnung" zu zeigen wäre + genau die Klasse Fehler, die diese ganze Runde behandelt hat. + - Schlägt die Prüfung selbst fehl, steht dort ausdrücklich: *„Das ist keine + Entwarnung – der Zustand der Kette ist damit schlicht unbekannt."* + - Aufklappbare Einzelheiten trennen die Kategorien, die nicht gleich schwer + wiegen: nachträglich verändert (ernst) / Verkettung unterbrochen / + davon ohne dokumentierte Löschung / davon vom Siegel beglaubigt / + ohne Schlüssel nicht prüfbar – mit einer Erklärung im Klartext darunter. + - Die Prüfung liest die **gesamte** Kette. Sie läuft deshalb einmal beim + Öffnen der Seite und wird 5 Minuten wiederverwendet; „Neu prüfen" erzwingt + einen frischen Lauf. + - Bewusst **read-only**: kein Siegel- oder Rehash-Knopf. Diese Eingriffe + verlangen `audit:admin` und eine ausdrückliche Bestätigung; sie gehören + nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt. + - Dateien: `frontend/src/pages/settings/AuditIntegrityCard.tsx` (neu), + `frontend/src/pages/settings/AuditLogs.tsx`, `frontend/src/services/api.ts` + (Typ `IntegrityResult` ausgelagert) + - [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26) - **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken** diff --git a/frontend/src/pages/settings/AuditIntegrityCard.tsx b/frontend/src/pages/settings/AuditIntegrityCard.tsx new file mode 100644 index 00000000..9c4f046c --- /dev/null +++ b/frontend/src/pages/settings/AuditIntegrityCard.tsx @@ -0,0 +1,176 @@ +import { useQuery } from '@tanstack/react-query'; +import { auditLogApi, IntegrityResult } from '../../services/api'; +import Card from '../../components/ui/Card'; +import Button from '../../components/ui/Button'; +import { + ShieldCheck, ShieldAlert, ShieldQuestion, RefreshCw, Loader2, ChevronDown, ChevronUp, +} from 'lucide-react'; +import { useState } from 'react'; + +/** + * Statuskarte fuer die Unversehrtheit des Audit-Protokolls. + * + * Die Prueflast steigt mit der Groesse des Protokolls (die Pruefung liest die + * gesamte Kette). Deshalb laeuft sie beim Oeffnen der Seite EINMAL und wird + * fuenf Minuten lang wiederverwendet; wer sofort neu pruefen will, nutzt den + * Knopf. + */ + +type Ampel = 'ok' | 'befund' | 'hinweis' | 'unbekannt'; + +function ampelFuer(r: IntegrityResult): Ampel { + if (!r.valid) return 'befund'; + // Gueltig, aber nicht vollstaendig pruefbar: kein gruenes Licht vortaeuschen. + if (r.unverifiableEntries.length > 0) return 'unbekannt'; + if (r.backlogSealStatus === 'kein_siegel') return 'hinweis'; + return 'ok'; +} + +const AMPEL_STIL: Record = { + ok: { box: 'bg-green-50 border-green-200', titel: 'text-green-900', text: 'text-green-800' }, + befund: { box: 'bg-red-50 border-red-200', titel: 'text-red-900', text: 'text-red-800' }, + hinweis: { box: 'bg-yellow-50 border-yellow-200',titel: 'text-yellow-900', text: 'text-yellow-800' }, + unbekannt: { box: 'bg-gray-50 border-gray-200', titel: 'text-gray-900', text: 'text-gray-700' }, +}; + +function AmpelIcon({ ampel }: { ampel: Ampel }) { + const c = 'w-7 h-7 shrink-0'; + if (ampel === 'ok') return ; + if (ampel === 'befund') return ; + if (ampel === 'hinweis') return ; + return ; +} + +const AMPEL_TITEL: Record = { + ok: 'Protokoll unversehrt', + befund: 'Befund – das gehört angesehen', + hinweis: 'Unversehrt, aber ungeschützter Altbestand', + unbekannt: 'Nicht vollständig prüfbar', +}; + +const SIEGEL_TEXT: Record = { + intakt: 'versiegelt und intakt', + kein_siegel: 'nicht versiegelt', + nicht_noetig: 'nicht nötig (alles signiert)', + gebrochen: 'GEBROCHEN', + entfernt: 'ENTFERNT', +}; + +/** Zeigt eine Zahl nur, wenn sie ungleich null ist – sonst bleibt es ruhig. */ +function Zahl({ label, ids, ton }: { label: string; ids: number[]; ton: string }) { + if (ids.length === 0) return null; + const gekuerzt = ids.length > 12 ? `${ids.slice(0, 12).join(', ')} … (+${ids.length - 12})` : ids.join(', '); + return ( +
+ {label}: + {ids.length} + ({gekuerzt}) +
+ ); +} + +export default function AuditIntegrityCard() { + const [offen, setOffen] = useState(false); + const { data, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['audit-integrity'], + queryFn: () => auditLogApi.verifyIntegrity(), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }); + + const ergebnis = data?.data; + + return ( + refetch()} disabled={isFetching}> + {isFetching + ? + : } + Neu prüfen + + } + > + {isFetching && !ergebnis && ( +
+ + Die Kette wird geprüft … +
+ )} + + {isError && ( +
+ +
+

Prüfung nicht möglich

+

+ {error instanceof Error ? error.message : 'Unbekannter Fehler'} +

+

+ Das ist keine Entwarnung – der Zustand der Kette ist damit + schlicht unbekannt. +

+
+
+ )} + + {ergebnis && (() => { + const ampel = ampelFuer(ergebnis); + const stil = AMPEL_STIL[ampel]; + return ( + <> +
+ +
+

{AMPEL_TITEL[ampel]}

+

{ergebnis.message}

+

+ {ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft · + {' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]} + {ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`} +

+
+
+ + + + {offen && ( +
+ + + + + + + + {ergebnis.tamperedEntries.length === 0 && + ergebnis.chainGaps.length === 0 && + ergebnis.unverifiableEntries.length === 0 && ( +

Nichts zu berichten.

+ )} +

+ Verändert heißt: der Inhalt einer bestehenden Zeile passt nicht + mehr zu ihrer Prüfsumme – das ist ernst. + {' '}Verkettung unterbrochen heißt: die Zeilen selbst sind + unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben. + {' '}Beglaubigt sind Lücken, die beim Versiegeln des Altbestands + bereits bestanden und im signierten Siegel festgehalten sind. +

+
+ )} + + ); + })()} +
+ ); +} diff --git a/frontend/src/pages/settings/AuditLogs.tsx b/frontend/src/pages/settings/AuditLogs.tsx index a0779dc0..0b9748f5 100644 --- a/frontend/src/pages/settings/AuditLogs.tsx +++ b/frontend/src/pages/settings/AuditLogs.tsx @@ -7,6 +7,7 @@ import Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; +import AuditIntegrityCard from './AuditIntegrityCard'; import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react'; const ACTION_OPTIONS = [ @@ -341,6 +342,8 @@ export default function AuditLogs() {

Audit-Protokoll

+ + {/* Filter */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d4239303..5bfb9fca 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1734,6 +1734,30 @@ export interface AuditLogSearchParams { search?: string; } +/** Ergebnis der Integritaetspruefung des Audit-Protokolls. */ +export interface IntegrityResult { + valid: boolean; + checkedCount: number; + /** Noch offene Beanstandungen – nur hieran haengt `valid`. */ + invalidEntries: number[]; + /** ERNST: Inhalt einer bestehenden Zeile wurde nachtraeglich veraendert. */ + tamperedEntries: number[]; + /** Verkettung unterbrochen; die Zeilen selbst koennen unversehrt sein. */ + chainGaps: number[]; + /** Luecken ohne protokolliertes Loeschungs-Manifest. */ + unexplainedGaps: number[]; + /** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */ + attestedGaps: number[]; + /** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */ + unverifiableEntries: number[]; + backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig'; + backlogTampered: number[]; + backlogMissing: number[]; + backlogSealCount: number; + tampered: boolean; + message: string; +} + export const auditLogApi = { search: async (params?: AuditLogSearchParams) => { const res = await api.get>('/audit-logs', { params }); @@ -1752,7 +1776,7 @@ export const auditLogApi = { return res.data; }, verifyIntegrity: async () => { - const res = await api.post>('/audit-logs/verify'); + const res = await api.post>('/audit-logs/verify'); return res.data; }, rehash: async () => {