diff --git a/app/Console/Commands/PruneAudit.php b/app/Console/Commands/PruneAudit.php new file mode 100644 index 0000000..1b64351 --- /dev/null +++ b/app/Console/Commands/PruneAudit.php @@ -0,0 +1,38 @@ +info('Aufbewahrung unbegrenzt — nichts zu tun'); + + return self::SUCCESS; + } + + $deleted = AuditEvent::where('created_at', '<', now()->subDays($days))->delete(); + + $this->info("Aufbewahrung {$days} Tage — {$deleted} Audit-Einträge gelöscht"); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Audit/Index.php b/app/Livewire/Audit/Index.php index bd7e8f4..51ff6ef 100644 --- a/app/Livewire/Audit/Index.php +++ b/app/Livewire/Audit/Index.php @@ -3,7 +3,9 @@ namespace App\Livewire\Audit; use App\Models\AuditEvent; +use App\Models\Setting; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Component; @@ -13,6 +15,52 @@ class Index extends Component /** Freitext-Filter über Akteur / Aktion / Ziel / Server. */ public string $q = ''; + /** + * Aufbewahrungsdauer in Tagen (Formularfeld). Leer/0 = unbegrenzt; ein + * positiver Wert N löscht beim täglichen Prune Einträge älter als N Tage. + */ + public ?string $retentionDays = null; + + public function mount(): void + { + // Empty/0 means "keep forever" — surface that as a blank field, not "0". + $stored = (int) Setting::get('audit_retention_days', '0'); + $this->retentionDays = $stored > 0 ? (string) $stored : null; + } + + /** Persist the retention policy, audit the change and notify the operator. */ + public function saveRetention(): void + { + $validated = $this->validate([ + 'retentionDays' => ['nullable', 'integer', 'min:1', 'max:3650'], + ]); + + $days = $validated['retentionDays'] !== null && $validated['retentionDays'] !== '' + ? (int) $validated['retentionDays'] + : 0; + + Setting::put('audit_retention_days', $days > 0 ? (string) $days : null); + $this->retentionDays = $days > 0 ? (string) $days : null; + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'audit.retention_set', + 'target' => 'audit_retention_days='.$days, + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: __('audit.retention_saved_notify')); + } + + /** Effective retention in days (0 = unlimited) for the policy summary. */ + protected function retentionPolicy(): int + { + return $this->retentionDays !== null && $this->retentionDays !== '' + ? (int) $this->retentionDays + : 0; + } + /** * Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB; * die Filterung läuft im Speicher über die geladenen 50 Einträge. @@ -45,6 +93,7 @@ class Index extends Component { return view('livewire.audit.index', [ 'events' => $this->events(), + 'retentionPolicy' => $this->retentionPolicy(), ])->title(__('audit.title')); } } diff --git a/lang/de/audit.php b/lang/de/audit.php index e8f4cf3..1d57e3c 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -18,6 +18,16 @@ return [ 'empty_filtered' => 'Für „:query“ wurden keine Audit-Einträge gefunden.', 'empty_none' => 'Es liegen noch keine Audit-Einträge vor.', + // Aufbewahrung (Retention) + 'retention_title' => 'Aufbewahrung', + 'retention_subtitle' => 'Audit-Log automatisch ausdünnen', + 'retention_label' => 'Aufbewahrung', + 'retention_unlimited' => 'unbegrenzt', + 'retention_days' => ':count Tage', + 'retention_save' => 'Speichern', + 'retention_saved_notify' => 'Aufbewahrungsrichtlinie gespeichert.', + 'retention_hint' => 'Anzahl der Tage (1–3650). Leer lassen für unbegrenzte Aufbewahrung. Ältere Einträge werden täglich gelöscht.', + // Page title 'title' => 'Audit-Log — Clusev', ]; diff --git a/lang/en/audit.php b/lang/en/audit.php index 87b9735..9302954 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -18,6 +18,16 @@ return [ 'empty_filtered' => 'No audit entries found for “:query”.', 'empty_none' => 'There are no audit entries yet.', + // Retention + 'retention_title' => 'Retention', + 'retention_subtitle' => 'Automatically prune the audit log', + 'retention_label' => 'Retention', + 'retention_unlimited' => 'unlimited', + 'retention_days' => ':count days', + 'retention_save' => 'Save', + 'retention_saved_notify' => 'Retention policy saved.', + 'retention_hint' => 'Number of days (1–3650). Leave empty to keep entries forever. Older entries are deleted daily.', + // Page title 'title' => 'Audit log — Clusev', ]; diff --git a/resources/views/livewire/audit/index.blade.php b/resources/views/livewire/audit/index.blade.php index a14e93b..8c8138d 100644 --- a/resources/views/livewire/audit/index.blade.php +++ b/resources/views/livewire/audit/index.blade.php @@ -8,6 +8,46 @@ {{ __('audit.event_count', ['count' => $events->count()]) }} + {{-- Aufbewahrung (Retention) --}} + +
+ {{-- Aktuelle Richtlinie --}} +
+ +
+

+ {{ __('audit.retention_label') }} + + @if ($retentionPolicy > 0) + {{ __('audit.retention_days', ['count' => $retentionPolicy]) }} + @else + {{ __('audit.retention_unlimited') }} + @endif + +

+
+
+ + {{-- Eingabe + Speichern --}} +
+ +
+ + + {{ __('audit.retention_save') }} + +
+ @error('retentionDays') +

{{ $message }}

+ @enderror +

{{ __('audit.retention_hint') }}

+
+
+
+ {{-- Ereignisliste --}} diff --git a/routes/console.php b/routes/console.php index 3c9adf1..1122c93 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,13 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Enforce the audit-log retention policy daily. The command is a no-op when +// `audit_retention_days` is empty/0 (keep forever); otherwise it deletes +// audit_events older than the configured number of days. +Schedule::command('clusev:prune-audit')->daily(); diff --git a/tests/Feature/AuditRetentionTest.php b/tests/Feature/AuditRetentionTest.php new file mode 100644 index 0000000..901a571 --- /dev/null +++ b/tests/Feature/AuditRetentionTest.php @@ -0,0 +1,81 @@ + 'tester', + 'action' => $action, + 'target' => null, + 'ip' => '127.0.0.1', + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } + + public function test_prune_deletes_events_older_than_retention_and_keeps_newer(): void + { + Setting::put('audit_retention_days', '30'); + + $old = $this->seedEvent(now()->subDays(45), 'old.event'); + $recent = $this->seedEvent(now()->subDays(5), 'recent.event'); + + $this->artisan('clusev:prune-audit')->assertExitCode(0); + + $this->assertDatabaseMissing('audit_events', ['id' => $old->id]); + $this->assertDatabaseHas('audit_events', ['id' => $recent->id]); + } + + public function test_prune_with_zero_retention_deletes_nothing(): void + { + Setting::put('audit_retention_days', '0'); + + $old = $this->seedEvent(now()->subDays(1000), 'ancient.event'); + + $this->artisan('clusev:prune-audit')->assertExitCode(0); + + $this->assertDatabaseHas('audit_events', ['id' => $old->id]); + } + + public function test_prune_with_empty_retention_deletes_nothing(): void + { + // No setting persisted at all → treated as unlimited. + $old = $this->seedEvent(now()->subDays(1000), 'ancient.event'); + + $this->artisan('clusev:prune-audit')->assertExitCode(0); + + $this->assertDatabaseHas('audit_events', ['id' => $old->id]); + } + + public function test_audit_component_saves_retention_and_writes_audit_event(): void + { + $this->actingAs(User::factory()->create(['must_change_password' => false])); + + Livewire::test(Index::class) + ->set('retentionDays', '30') + ->call('saveRetention') + ->assertHasNoErrors(); + + $this->assertSame('30', Setting::get('audit_retention_days')); + + $this->assertDatabaseHas('audit_events', [ + 'action' => 'audit.retention_set', + 'target' => 'audit_retention_days=30', + ]); + } +}