diff --git a/app/Models/ActivityEvent.php b/app/Models/ActivityEvent.php index 276bf3b1..bf33c882 100644 --- a/app/Models/ActivityEvent.php +++ b/app/Models/ActivityEvent.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\ActivityType; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,6 +15,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; */ class ActivityEvent extends Model { + use MassPrunable; + public $timestamps = false; protected $fillable = [ @@ -50,4 +54,14 @@ class ActivityEvent extends Model { return $this->morphTo(); } + + /** + * Einträge nach der Löschfrist (config monitoring.retention, 395 Tage). + * + * @return Builder + */ + public function prunable(): Builder + { + return static::query()->where('occurred_at', '<', now()->subDays((int) config('monitoring.retention.activity_events_days'))); + } } diff --git a/app/Models/ApiUsageLog.php b/app/Models/ApiUsageLog.php index d6d9f07c..de7cf87c 100644 --- a/app/Models/ApiUsageLog.php +++ b/app/Models/ApiUsageLog.php @@ -2,12 +2,16 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Sanctum\PersonalAccessToken; class ApiUsageLog extends Model { + use MassPrunable; + public $timestamps = false; protected $fillable = [ @@ -45,4 +49,14 @@ class ApiUsageLog extends Model { return $this->belongsTo(PersonalAccessToken::class); } + + /** + * Zugriffe nach der Löschfrist (config monitoring.retention, 90 Tage). + * + * @return Builder + */ + public function prunable(): Builder + { + return static::query()->where('requested_at', '<', now()->subDays((int) config('monitoring.retention.api_usage_logs_days'))); + } } diff --git a/config/monitoring.php b/config/monitoring.php index 4d72102b..15aede5f 100644 --- a/config/monitoring.php +++ b/config/monitoring.php @@ -82,6 +82,23 @@ return [ 'warn_per_day' => (int) env('MONITORING_FAILED_LOGIN_WARN_PER_DAY', 1000), ], + /* + |-------------------------------------------------------------------------- + | Löschfristen der Protokolle (Roadmap 4.2) + |-------------------------------------------------------------------------- + | + | Aktivitäts-Verlauf (activity_events: Anmeldungen, Statuswechsel, + | Admin-Aktionen) wie die Nutzungsstatistik in DSE 5a nach 395 Tagen; + | API-Zugriffsprotokoll (api_usage_logs, mit IP und User-Agent) nach + | 90 Tagen. Entfernt täglich um 04:05 über `model:prune`. + | + */ + + 'retention' => [ + 'activity_events_days' => (int) env('MONITORING_ACTIVITY_RETENTION_DAYS', 395), + 'api_usage_logs_days' => (int) env('MONITORING_API_USAGE_RETENTION_DAYS', 90), + ], + 'scheduler' => [ 'heartbeat_warn_minutes' => 3, 'heartbeat_critical_minutes' => 10, diff --git a/routes/console.php b/routes/console.php index 6eca4848..924ae438 100644 --- a/routes/console.php +++ b/routes/console.php @@ -21,6 +21,8 @@ use App\Console\Commands\PurgeRedactionSnapshots; use App\Console\Commands\RefreshMarketData; use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Console\Commands\SweepOrphanPressReleaseOriginals; +use App\Models\ActivityEvent; +use App\Models\ApiUsageLog; use App\Models\ContactMessage; use App\Models\FailedLoginTally; use App\Support\OperationalAlert; @@ -259,13 +261,15 @@ Schedule::command(MonitoringHeartbeat::class) ->onFailure($alertOnFailure('monitoring:heartbeat')); // Tageszähler fehlgeschlagener Anmeldungen nach der Löschfrist entfernen -// (täglich 04:05, config monitoring.failed_logins.retention_days) und -// Spam-Quarantäne des Kontaktformulars nach 30 Tagen (Spamschutz B3). -Schedule::command('model:prune', ['--model' => [FailedLoginTally::class, ContactMessage::class]]) +// (täglich 04:05, config monitoring.failed_logins.retention_days), +// Spam-Quarantäne des Kontaktformulars nach 30 Tagen (Spamschutz B3), +// Aktivitäts-Verlauf nach 395 und API-Zugriffsprotokoll nach 90 Tagen +// (config monitoring.retention, Roadmap 4.2). +Schedule::command('model:prune', ['--model' => [FailedLoginTally::class, ContactMessage::class, ActivityEvent::class, ApiUsageLog::class]]) ->dailyAt('04:05') ->withoutOverlapping(30) ->runInBackground() - ->onFailure($alertOnFailure('model:prune failed_login_tallies, contact_messages')); + ->onFailure($alertOnFailure('model:prune failed_login_tallies, contact_messages, activity_events, api_usage_logs')); // Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin // sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar. diff --git a/tests/Feature/Monitoring/LogRetentionTest.php b/tests/Feature/Monitoring/LogRetentionTest.php new file mode 100644 index 00000000..d1f459e2 --- /dev/null +++ b/tests/Feature/Monitoring/LogRetentionTest.php @@ -0,0 +1,74 @@ + ActivityType::UserLoggedIn, + 'occurred_at' => now()->subDays($daysAgo), + 'description' => 'Anmeldung', + ]); +} + +function apiUsageLogAt(int $daysAgo): ApiUsageLog +{ + return ApiUsageLog::create([ + 'method' => 'GET', + 'path' => 'api/v1/press-releases', + 'status_code' => 200, + 'ip_address' => '203.0.113.7', + 'requested_at' => now()->subDays($daysAgo), + ]); +} + +test('activity events are pruned after 395 days', function () { + /** @var TestCase $this */ + $old = activityEventAt(396); + $recent = activityEventAt(394); + + $this->artisan('model:prune', ['--model' => [ActivityEvent::class]])->assertSuccessful(); + + $this->assertModelMissing($old); + $this->assertModelExists($recent); +}); + +test('api usage logs are pruned after 90 days', function () { + /** @var TestCase $this */ + $old = apiUsageLogAt(91); + $recent = apiUsageLogAt(89); + + $this->artisan('model:prune', ['--model' => [ApiUsageLog::class]])->assertSuccessful(); + + $this->assertModelMissing($old); + $this->assertModelExists($recent); +}); + +test('the retention periods follow the configuration', function () { + /** @var TestCase $this */ + config()->set('monitoring.retention.activity_events_days', 30); + config()->set('monitoring.retention.api_usage_logs_days', 7); + $event = activityEventAt(31); + $log = apiUsageLogAt(8); + + $this->artisan('model:prune', ['--model' => [ActivityEvent::class, ApiUsageLog::class]])->assertSuccessful(); + + $this->assertModelMissing($event); + $this->assertModelMissing($log); +}); + +test('the daily prune run covers both logs', function () { + $command = collect(app(Schedule::class)->events()) + ->map(fn ($event) => (string) $event->command) + ->first(fn (string $command) => str_contains($command, 'model:prune')); + + expect($command)->toContain('ActivityEvent')->toContain('ApiUsageLog'); +});