Files
opencrm/frontend/src/pages/settings/AuditLogs.tsx
T
duffyduckandClaude Opus 5 23505afc05 audit:export gatete nichts - Export hing an audit:read
Bei der Gegenprobe zur neuen Rolle "Gegenbuch" gefunden: Ein Konto mit
ausschliesslich audit:read bekam auf GET /audit-logs/export eine 200. Die
Berechtigung audit:export stand im Katalog und in der Rollenverwaltung -
und wurde nirgends geprueft.

Der Unterschied ist nicht kosmetisch. Blaettern zeigt 50 Zeilen; der
Export liefert in einem Zug das gesamte Protokoll inklusive changesBefore
und changesAfter, also der vollstaendigen Vorher/Nachher-Datensaetze,
dazu resourceLabel mit Klartextnamen, IP-Adressen und User-Agents. Live
nachgewiesen auf Staging: 43 Eintraege mit gefuellter resourceLabel
allein fuer resourceType=Customer.

Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs Personendaten
exportieren - das Konto, dessen Passwort im Klartext in der .env auf der
Notar-Maschine liegt, und dem README und Rollenname "nur Pruefwerte
lesen" zusichern.

/audit-logs/export verlangt jetzt audit:export. Betroffen ist genau eine
Rolle: Gegenbuch, und zwar gewollt. Die DSGVO-Rolle traegt audit:*
vollstaendig und behaelt den Export.

In der Oberflaeche erscheinen JSON- und CSV-Knopf nur noch mit
audit:export - sonst stuenden dort Knoepfe, die zuverlaessig 403 liefern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:56:53 +02:00

512 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
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 = [
{ value: '', label: 'Alle Aktionen' },
{ value: 'CREATE', label: 'Erstellt' },
{ value: 'READ', label: 'Gelesen' },
{ value: 'UPDATE', label: 'Aktualisiert' },
{ value: 'DELETE', label: 'Gelöscht' },
{ value: 'EXPORT', label: 'Exportiert' },
{ value: 'ANONYMIZE', label: 'Anonymisiert' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
{ value: 'TOKEN_REFRESH', label: 'Sitzung verlängert' },
];
const SENSITIVITY_OPTIONS = [
{ value: '', label: 'Alle Stufen' },
{ value: 'LOW', label: 'Niedrig' },
{ value: 'MEDIUM', label: 'Mittel' },
{ value: 'HIGH', label: 'Hoch' },
{ value: 'CRITICAL', label: 'Kritisch' },
];
const RESOURCE_OPTIONS = [
{ value: '', label: 'Alle Ressourcen' },
{ value: 'Customer', label: 'Kunden' },
{ value: 'Contract', label: 'Verträge' },
{ value: 'User', label: 'Benutzer' },
{ value: 'BankCard', label: 'Bankdaten' },
{ value: 'IdentityDocument', label: 'Ausweise' },
{ value: 'Address', label: 'Adressen' },
{ value: 'Meter', label: 'Zähler' },
{ value: 'ContractTask', label: 'Aufgaben' },
{ value: 'Authentication', label: 'Anmeldung' },
{ value: 'CustomerConsent', label: 'Einwilligungen' },
{ value: 'GDPR', label: 'DSGVO / Vollmacht' },
{ value: 'EmailProviderConfig', label: 'E-Mail-Provider' },
{ value: 'AppSetting', label: 'Einstellungen' },
];
function formatDate(date: string): string {
return new Date(date).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getActionColor(action: AuditAction): string {
switch (action) {
case 'CREATE': return 'bg-green-100 text-green-800';
case 'READ': return 'bg-blue-100 text-blue-800';
case 'UPDATE': return 'bg-yellow-100 text-yellow-800';
case 'DELETE': return 'bg-red-100 text-red-800';
case 'EXPORT': return 'bg-purple-100 text-purple-800';
case 'ANONYMIZE': return 'bg-orange-100 text-orange-800';
case 'LOGIN': return 'bg-teal-100 text-teal-800';
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
case 'TOKEN_REFRESH': return 'bg-slate-50 text-slate-500';
default: return 'bg-gray-100 text-gray-800';
}
}
function getSensitivityIcon(sensitivity: AuditSensitivity) {
switch (sensitivity) {
case 'LOW': return <Shield className="w-4 h-4 text-gray-400" />;
case 'MEDIUM': return <Shield className="w-4 h-4 text-blue-500" />;
case 'HIGH': return <ShieldAlert className="w-4 h-4 text-orange-500" />;
case 'CRITICAL': return <ShieldAlert className="w-4 h-4 text-red-500" />;
default: return <Shield className="w-4 h-4 text-gray-400" />;
}
}
const RESOURCE_TYPE_LABELS: Record<string, string> = {
Customer: 'Kunde',
Contract: 'Vertrag',
BankCard: 'Bankverbindung',
IdentityDocument: 'Ausweis',
Address: 'Adresse',
Meter: 'Zähler',
MeterReading: 'Zählerstand',
User: 'Benutzer',
CustomerConsent: 'Einwilligung',
RepresentativeAuthorization: 'Vollmacht',
ContractTask: 'Aufgabe',
EnergyContractDetails: 'Strom/Gas-Details',
EmailProviderConfig: 'E-Mail-Provider',
AppSetting: 'Einstellung',
GDPR: 'Datenschutz',
Authentication: 'Anmeldung',
};
const FIELD_LABELS: Record<string, string> = {
firstName: 'Vorname', lastName: 'Nachname', email: 'E-Mail', phone: 'Telefon', mobile: 'Mobil',
salutation: 'Anrede', companyName: 'Firma', type: 'Typ', notes: 'Notizen',
street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ', city: 'Stadt', country: 'Land',
meterNumber: 'Zählernummer', location: 'Standort', tariffModel: 'Tarifmodell',
isActive: 'Aktiv', isDefault: 'Standard',
status: 'Status', source: 'Quelle',
contractNumber: 'Vertragsnummer', startDate: 'Vertragsbeginn', endDate: 'Vertragsende',
portalUsername: 'Portal-Benutzername', portalEnabled: 'Portal aktiv',
basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus',
name: 'Name', domain: 'Domain', apiUrl: 'API-URL',
documentNumber: 'Dokumentennummer', expiryDate: 'Ablaufdatum',
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', accountHolder: 'Kontoinhaber',
isGranted: 'Vollmacht erteilt', documentPath: 'Dokument-Pfad',
value: 'Zählerstand', valueNt: 'NT-Zählerstand', readingDate: 'Ablesedatum',
privacyPolicyPath: 'Datenschutz-PDF',
};
function formatValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
try { return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return value; }
}
return String(value);
}
function renderChangeDiff(before: Record<string, unknown>, after: Record<string, unknown>) {
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
const changes: { field: string; from: unknown; to: unknown }[] = [];
for (const key of allKeys) {
// Technische Felder überspringen
if (['id', 'createdAt', 'updatedAt', 'passwordEncrypted', 'portalPasswordEncrypted', 'portalPasswordHash', 'systemEmailPasswordEncrypted'].includes(key)) continue;
const fromVal = before[key];
const toVal = after[key];
if (JSON.stringify(fromVal) !== JSON.stringify(toVal)) {
changes.push({ field: key, from: fromVal, to: toVal });
}
}
if (changes.length === 0) {
return <p className="text-sm text-gray-500 italic">Keine sichtbaren Änderungen</p>;
}
return changes.map(({ field, from, to }) => (
<div key={field} className="flex items-start gap-3 p-2 bg-gray-50 rounded text-sm">
<span className="font-medium text-gray-700 min-w-[150px]">
{FIELD_LABELS[field] || field}
</span>
<span className="text-red-600 line-through">{formatValue(from)}</span>
<span className="text-gray-400"></span>
<span className="text-green-700 font-medium">{formatValue(to)}</span>
</div>
));
}
interface DetailModalProps {
log: AuditLog;
onClose: () => void;
}
function DetailModal({ log, onClose }: DetailModalProps) {
const parseChanges = (changes: string | undefined): Record<string, unknown> | null => {
if (!changes) return null;
try {
return JSON.parse(changes);
} catch {
return null;
}
};
const before = parseChanges(log.changesBefore);
const after = parseChanges(log.changesAfter);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Audit-Log Details</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<dt className="text-sm text-gray-500">Zeitpunkt</dt>
<dd className="font-medium">{formatDate(log.createdAt)}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Benutzer</dt>
<dd className="font-medium">{log.userEmail}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Aktion</dt>
<dd><span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>{log.action}</span></dd>
</div>
<div>
<dt className="text-sm text-gray-500">Ressource</dt>
<dd className="font-medium">{RESOURCE_TYPE_LABELS[log.resourceType] || log.resourceType} {log.resourceId && `#${log.resourceId}`}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Endpoint</dt>
<dd className="font-mono text-sm">{log.httpMethod} {log.endpoint}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">IP-Adresse</dt>
<dd className="font-mono text-sm">{log.ipAddress}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Sensitivität</dt>
<dd className="flex items-center gap-2">{getSensitivityIcon(log.sensitivity)} {log.sensitivity}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Dauer</dt>
<dd>{log.durationMs ? `${log.durationMs}ms` : '-'}</dd>
</div>
{log.resourceLabel && (
<div className="col-span-2">
<dt className="text-sm text-gray-500">Ressource-Bezeichnung</dt>
<dd className="font-medium">{log.resourceLabel}</dd>
</div>
)}
</div>
{log.changesEncrypted && (
<div className="mb-4 p-3 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
Die Änderungsdaten sind verschlüsselt und können nur mit dem Encryption-Key eingesehen werden.
</div>
)}
{(before || after) && !log.changesEncrypted && (
<div className="border-t pt-4">
<h3 className="font-medium mb-3">Änderungen</h3>
{before && after ? (
<div className="space-y-2">
{renderChangeDiff(before, after)}
</div>
) : (
<div className="grid grid-cols-2 gap-4">
{before && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Vorher</h4>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(before, null, 2)}
</pre>
</div>
)}
{after && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Daten</h4>
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(after, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)}
<div className="flex justify-end mt-6 pt-4 border-t">
<Button variant="secondary" onClick={onClose}>Schließen</Button>
</div>
</div>
</div>
</div>
);
}
export default function AuditLogs() {
const navigate = useNavigate();
// Export haengt an `audit:export`, Lesen an `audit:read` ein Konto darf
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
const { hasPermission } = useAuth();
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<AuditLogSearchParams>({
page: 1,
limit: 50,
});
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const { data: logsData, isLoading, refetch } = useQuery({
queryKey: ['audit-logs', { ...filters, page }],
queryFn: () => auditLogApi.search({ ...filters, page }),
});
const logs = logsData?.data || [];
const pagination = logsData?.pagination;
const handleFilterChange = (key: keyof AuditLogSearchParams, value: string) => {
setFilters(prev => ({ ...prev, [key]: value || undefined }));
setPage(1);
};
const handleExport = async (format: 'json' | 'csv') => {
try {
if (format === 'csv') {
// CSV direkt als Download mit kurzlebigem Download-Token (60s),
// damit das langlebige Access-JWT nicht in Logs/Referer/History landet.
const downloadToken = await authApi.getDownloadToken();
const params = new URLSearchParams();
params.set('format', 'csv');
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
// nur fuenf wer im Suchfeld eingrenzte und dann CSV klickte, bekam
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
// vollstaendig ueber die gefilterte Menge.
for (const [schluessel, wert] of Object.entries(filters)) {
if (schluessel === 'page' || schluessel === 'limit') continue;
if (wert === undefined || wert === null || wert === '') continue;
params.set(schluessel, String(wert));
}
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
} else {
const result = await auditLogApi.export({ ...filters, format });
const blob = new Blob(
[JSON.stringify(result.data, null, 2)],
{ type: 'application/json' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch (error) {
alert(`Export fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
}
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
</div>
<AuditIntegrityCard />
{/* Filter */}
<Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Input
placeholder="Suche..."
value={filters.search || ''}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-full"
/>
<Select
value={filters.action || ''}
onChange={(e) => handleFilterChange('action', e.target.value)}
options={ACTION_OPTIONS}
/>
<Select
value={filters.resourceType || ''}
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
options={RESOURCE_OPTIONS}
/>
<Select
value={filters.sensitivity || ''}
onChange={(e) => handleFilterChange('sensitivity', e.target.value as AuditSensitivity)}
options={SENSITIVITY_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<Input
type="date"
label="Von"
value={filters.startDate || ''}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
/>
<Input
type="date"
label="Bis"
value={filters.endDate || ''}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
/>
<div></div>
<div className="flex gap-2 justify-end items-end">
<Button variant="secondary" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren
</Button>
{hasPermission('audit:export') && (
<Button variant="secondary" onClick={() => handleExport('json')}>
<Download className="w-4 h-4 mr-2" />
JSON
</Button>
)}
{hasPermission('audit:export') && (
<Button variant="secondary" onClick={() => handleExport('csv')}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
)}
</div>
</div>
</Card>
{/* Tabelle */}
<Card>
{isLoading ? (
<div className="text-center py-8">Laden...</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-gray-500">Keine Audit-Logs gefunden.</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4">Zeitpunkt</th>
<th className="text-left py-3 px-4">Benutzer</th>
<th className="text-left py-3 px-4">Aktion</th>
<th className="text-left py-3 px-4">Ressource</th>
<th className="text-left py-3 px-4">IP</th>
<th className="text-center py-3 px-4"></th>
<th className="text-center py-3 px-4"></th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 whitespace-nowrap">{formatDate(log.createdAt)}</td>
<td className="py-3 px-4">
<div className="truncate max-w-[200px]" title={log.userEmail}>
{log.userEmail}
</div>
{log.userRole && <div className="text-xs text-gray-500">{log.userRole}</div>}
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getActionColor(log.action)}`}>
{log.action}
</span>
</td>
<td className="py-3 px-4">
{log.resourceLabel ? (
<div className="text-sm truncate max-w-[300px]" title={log.resourceLabel}>
{log.resourceLabel}
</div>
) : (
<div className="text-sm text-gray-500">{log.resourceType}</div>
)}
</td>
<td className="py-3 px-4 font-mono text-xs">{log.ipAddress}</td>
<td className="py-3 px-4 text-center">{getSensitivityIcon(log.sensitivity)}</td>
<td className="py-3 px-4 text-center">
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(log)}>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-gray-500">
Seite {pagination.page} von {pagination.totalPages} ({pagination.total} Einträge)
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => Math.min(pagination.totalPages, p + 1))}
disabled={page === pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
{/* Detail Modal */}
{selectedLog && (
<DetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
)}
</div>
);
}