feat(audit): configurable retention + scheduled clusev:prune-audit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 23:40:17 +02:00
parent 4ced37ac40
commit bb1ad53030
7 changed files with 234 additions and 0 deletions

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\AuditEvent;
use App\Models\Setting;
use Illuminate\Console\Command;
/**
* Enforce the audit-log retention policy: delete audit_events older than the
* configured number of days. The policy lives in the `audit_retention_days`
* setting (managed from the Audit page); empty or 0 means "keep forever".
*
* Scheduled daily in routes/console.php.
*/
class PruneAudit extends Command
{
protected $signature = 'clusev:prune-audit';
protected $description = 'Prune audit_events older than the configured retention window';
public function handle(): int
{
$days = (int) Setting::get('audit_retention_days', '0');
if ($days <= 0) {
$this->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;
}
}

View File

@ -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'));
}
}

View File

@ -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 (13650). Leer lassen für unbegrenzte Aufbewahrung. Ältere Einträge werden täglich gelöscht.',
// Page title
'title' => 'Audit-Log — Clusev',
];

View File

@ -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 (13650). Leave empty to keep entries forever. Older entries are deleted daily.',
// Page title
'title' => 'Audit log — Clusev',
];

View File

@ -8,6 +8,46 @@
<x-status-pill status="online">{{ __('audit.event_count', ['count' => $events->count()]) }}</x-status-pill>
</div>
{{-- Aufbewahrung (Retention) --}}
<x-panel :title="__('audit.retention_title')" :subtitle="__('audit.retention_subtitle')">
<div class="space-y-3">
{{-- Aktuelle Richtlinie --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">
{{ __('audit.retention_label') }}
<span class="font-mono text-accent-text">
@if ($retentionPolicy > 0)
{{ __('audit.retention_days', ['count' => $retentionPolicy]) }}
@else
{{ __('audit.retention_unlimited') }}
@endif
</span>
</p>
</div>
</div>
{{-- Eingabe + Speichern --}}
<div>
<label for="retentionDays" class="mb-1 block text-sm text-ink-2">{{ __('audit.retention_label') }}</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input wire:model="retentionDays" id="retentionDays" type="text" inputmode="numeric"
autocomplete="off" autocapitalize="off" spellcheck="false"
placeholder="{{ __('audit.retention_unlimited') }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:max-w-[12rem]" />
<x-btn variant="secondary" wire:click="saveRetention" wire:loading.attr="disabled" class="shrink-0">
<x-icon name="save" class="h-3.5 w-3.5" /> {{ __('audit.retention_save') }}
</x-btn>
</div>
@error('retentionDays')
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
@enderror
<p class="mt-1.5 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('audit.retention_hint') }}</p>
</div>
</div>
</x-panel>
{{-- Ereignisliste --}}
<x-panel :title="__('audit.panel_title')" :subtitle="__('audit.panel_subtitle')" :padded="false">
<x-slot:actions>

View File

@ -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();

View File

@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use App\Livewire\Audit\Index;
use App\Models\AuditEvent;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
use Tests\TestCase;
class AuditRetentionTest extends TestCase
{
use RefreshDatabase;
/** Seed an audit_events row with an explicit created_at timestamp. */
private function seedEvent(Carbon $createdAt, string $action = 'test.event'): AuditEvent
{
return AuditEvent::forceCreate([
'actor' => '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',
]);
}
}