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) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:11:13 +02:00
co-authored by Claude Opus 5
parent ecaeae48d4
commit 7af6b7591b
4 changed files with 232 additions and 1 deletions
+28
View File
@@ -97,6 +97,34 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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) - [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26)
- **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die - **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die
CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken** CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken**
@@ -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<Ampel, { box: string; titel: string; text: string }> = {
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 <ShieldCheck className={`${c} text-green-600`} />;
if (ampel === 'befund') return <ShieldAlert className={`${c} text-red-600`} />;
if (ampel === 'hinweis') return <ShieldAlert className={`${c} text-yellow-600`} />;
return <ShieldQuestion className={`${c} text-gray-500`} />;
}
const AMPEL_TITEL: Record<Ampel, string> = {
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<IntegrityResult['backlogSealStatus'], string> = {
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 (
<div className="flex items-baseline gap-2 text-sm">
<span className={`font-medium ${ton}`}>{label}:</span>
<span className="text-gray-600">{ids.length}</span>
<span className="text-gray-400 font-mono text-xs break-all">({gekuerzt})</span>
</div>
);
}
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 (
<Card
className="mb-6"
title="Unversehrtheit des Protokolls"
actions={
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
{isFetching
? <Loader2 className="w-4 h-4 mr-2 animate-spin" />
: <RefreshCw className="w-4 h-4 mr-2" />}
Neu prüfen
</Button>
}
>
{isFetching && !ergebnis && (
<div className="flex items-center gap-3 text-gray-500">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Die Kette wird geprüft </span>
</div>
)}
{isError && (
<div className="flex items-start gap-4 p-4 rounded-lg border bg-gray-50 border-gray-200">
<ShieldQuestion className="w-7 h-7 shrink-0 text-gray-500" />
<div>
<p className="font-medium text-gray-900">Prüfung nicht möglich</p>
<p className="text-sm text-gray-700 mt-1">
{error instanceof Error ? error.message : 'Unbekannter Fehler'}
</p>
<p className="text-sm text-gray-600 mt-2">
Das ist <strong>keine</strong> Entwarnung der Zustand der Kette ist damit
schlicht unbekannt.
</p>
</div>
</div>
)}
{ergebnis && (() => {
const ampel = ampelFuer(ergebnis);
const stil = AMPEL_STIL[ampel];
return (
<>
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
<AmpelIcon ampel={ampel} />
<div className="min-w-0">
<p className={`font-medium ${stil.titel}`}>{AMPEL_TITEL[ampel]}</p>
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
<p className="text-sm text-gray-500 mt-2">
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
{' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]}
{ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`}
</p>
</div>
</div>
<button
type="button"
onClick={() => setOffen(o => !o)}
className="mt-3 flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
{offen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Einzelheiten
</button>
{offen && (
<div className="mt-3 space-y-2 border-t pt-3">
<Zahl label="Nachträglich verändert" ids={ergebnis.tamperedEntries} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge verändert" ids={ergebnis.backlogTampered} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge entfernt" ids={ergebnis.backlogMissing} ton="text-red-700" />
<Zahl label="Verkettung unterbrochen" ids={ergebnis.chainGaps} ton="text-yellow-700" />
<Zahl label="davon ohne dokumentierte Löschung" ids={ergebnis.unexplainedGaps} ton="text-yellow-700" />
<Zahl label="davon vom Siegel beglaubigt" ids={ergebnis.attestedGaps} ton="text-gray-700" />
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
{ergebnis.tamperedEntries.length === 0 &&
ergebnis.chainGaps.length === 0 &&
ergebnis.unverifiableEntries.length === 0 && (
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
)}
<p className="text-xs text-gray-500 pt-2">
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
mehr zu ihrer Prüfsumme das ist ernst.
{' '}<strong>Verkettung unterbrochen</strong> heißt: die Zeilen selbst sind
unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben.
{' '}<strong>Beglaubigt</strong> sind Lücken, die beim Versiegeln des Altbestands
bereits bestanden und im signierten Siegel festgehalten sind.
</p>
</div>
)}
</>
);
})()}
</Card>
);
}
@@ -7,6 +7,7 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input'; import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select'; import Select from '../../components/ui/Select';
import AuditIntegrityCard from './AuditIntegrityCard';
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
const ACTION_OPTIONS = [ const ACTION_OPTIONS = [
@@ -341,6 +342,8 @@ export default function AuditLogs() {
<h1 className="text-2xl font-bold">Audit-Protokoll</h1> <h1 className="text-2xl font-bold">Audit-Protokoll</h1>
</div> </div>
<AuditIntegrityCard />
{/* Filter */} {/* Filter */}
<Card className="mb-6"> <Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
+25 -1
View File
@@ -1734,6 +1734,30 @@ export interface AuditLogSearchParams {
search?: string; 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 = { export const auditLogApi = {
search: async (params?: AuditLogSearchParams) => { search: async (params?: AuditLogSearchParams) => {
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params }); const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
@@ -1752,7 +1776,7 @@ export const auditLogApi = {
return res.data; return res.data;
}, },
verifyIntegrity: async () => { verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; attestedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify'); const res = await api.post<ApiResponse<IntegrityResult>>('/audit-logs/verify');
return res.data; return res.data;
}, },
rehash: async () => { rehash: async () => {