feat(audit): configurable retention + scheduled clusev:prune-audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
4ced37ac40
commit
bb1ad53030
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue