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 () => {