diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 7b6b6116d3..58e88ccc51 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Str; use Lorisleiva\Actions\Concerns\AsAction; use Spatie\Activitylog\Contracts\Activity; @@ -54,9 +55,18 @@ class UpdatePackage ]; } - return remote_process($commands, $server, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [ + $updateId = (string) Str::uuid(); + $packages = $all ? [] : [$package]; + $user = auth()->user(); + + return remote_process($commands, $server, type_uuid: $updateId, callEventOnFinish: 'ServerPackagesUpdateFinished', callEventData: [ + 'update_id' => $updateId, 'server_uuid' => $server->uuid, - 'packages' => $all ? [] : [$package], + 'packages' => $packages, + 'package_manager' => $packageManager, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, ]); } catch (\Exception $e) { return [ diff --git a/app/Listeners/CheckPatchesAfterPackageUpdate.php b/app/Listeners/CheckPatchesAfterPackageUpdate.php deleted file mode 100644 index 492f9f5463..0000000000 --- a/app/Listeners/CheckPatchesAfterPackageUpdate.php +++ /dev/null @@ -1,20 +0,0 @@ -where('uuid', $event->data['server_uuid'] ?? null)->first(); - if ($server === null) { - return; - } - - ServerPatchCheckJob::dispatchManual($server); - } -} diff --git a/app/Listeners/FinishServerPackagesUpdate.php b/app/Listeners/FinishServerPackagesUpdate.php new file mode 100644 index 0000000000..142859fafb --- /dev/null +++ b/app/Listeners/FinishServerPackagesUpdate.php @@ -0,0 +1,43 @@ +where('uuid', $event->data['server_uuid'] ?? null)->first(); + if ($server === null) { + return; + } + + $activity = Activity::query()->where('properties->type_uuid', $event->data['update_id'] ?? null)->first(); + $status = data_get($activity, 'properties.status'); + $packages = $event->data['packages'] ?? []; + + auditLog('ui.server.packages_update_finished', [ + 'team_id' => $server->team_id, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'packages' => $packages === [] ? 'all' : $packages, + 'package_manager' => $event->data['package_manager'] ?? null, + 'activity_id' => $activity?->id, + 'result' => $status === ProcessStatus::FINISHED->value ? 'success' : 'failed', + 'exit_code' => data_get($activity, 'properties.exitCode'), + 'actor_id' => $event->data['actor_id'] ?? null, + 'actor_name' => $event->data['actor_name'] ?? null, + 'actor_email' => $event->data['actor_email'] ?? null, + ], $status === ProcessStatus::FINISHED->value ? 'info' : 'error'); + + ServerPatchCheckJob::dispatchManual($server); + } +} diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index 7df4dbd521..45a69ed76c 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -121,7 +121,14 @@ class Patches extends Component { try { $this->authorize('update', $this->server); - RebootServer::run($this->server); + try { + RebootServer::run($this->server); + } catch (\Throwable $e) { + $this->auditPatchAction('reboot_requested', ['result' => 'failed', 'error' => $e->getMessage()], 'error'); + + throw $e; + } + $this->auditPatchAction('reboot_requested', ['result' => 'started']); $this->dispatch('success', 'Reboot started. The server is back in a few minutes, and Coolify then checks it again.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -152,13 +159,26 @@ class Patches extends Component if (! ConfigureAutomaticSecurityUpdates::supportFor($packageManager)) { throw new \Exception('Automatic security updates are supported only on Debian, Ubuntu, and RHEL-family servers. Run "Check for updates" first, so Coolify knows the package manager.'); } - ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time); + $auditContext = ['enabled' => $this->autoSecurityUpdates, 'time' => $time, 'package_manager' => $packageManager]; + try { + ConfigureAutomaticSecurityUpdates::run($this->server, $packageManager, $this->autoSecurityUpdates, $time); + } catch (\Throwable $e) { + $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'failed', 'error' => $e->getMessage()], 'error'); + + throw $e; + } } $settings->patch_check_frequency = $frequency === '' ? null : $frequency; $settings->is_auto_security_updates_enabled = $this->autoSecurityUpdates; $settings->auto_security_updates_time = $time; + $changedFields = auditChangedFields($settings); $settings->save(); + if ($autoUpdatesChanged) { + $this->auditPatchAction('auto_security_updates_updated', [...$auditContext, 'result' => 'success']); + } elseif ($changedFields !== []) { + $this->auditPatchAction('patch_settings_updated', ['changed_fields' => $changedFields]); + } $this->syncSettings(); $this->dispatch('success', 'Patch settings saved.'); } catch (\Throwable $e) { @@ -187,6 +207,19 @@ class Patches extends Component return true; } + /** + * @param array $context + */ + private function auditPatchAction(string $action, array $context, string $level = 'info'): void + { + auditLog("ui.server.{$action}", [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ...$context, + ], $level); + } + /** * A transferred server is an archive: another instance manages the host. */ @@ -215,6 +248,11 @@ class Patches extends Component return; } + $this->auditPatchAction('packages_update_requested', [ + 'packages' => $all ? 'all' : [$package], + 'package_manager' => $packageManager, + 'activity_id' => $activity->id, + ]); $this->dispatch('startupdate'); $this->dispatch('activityMonitor', $activity->id); } diff --git a/tests/Feature/Server/ServerPatchesTest.php b/tests/Feature/Server/ServerPatchesTest.php index 99378339cd..4e2786cde4 100644 --- a/tests/Feature/Server/ServerPatchesTest.php +++ b/tests/Feature/Server/ServerPatchesTest.php @@ -3,11 +3,13 @@ use App\Actions\Server\CheckUpdates; use App\Actions\Server\ConfigureAutomaticSecurityUpdates; use App\Actions\Server\RebootServer; +use App\Actions\Server\UpdatePackage; use App\Events\ServerPackagesUpdateFinished; use App\Events\ServerPatchCheckFinished; use App\Jobs\ServerManagerJob; use App\Jobs\ServerPatchCheckJob; use App\Livewire\Server\Security\Patches; +use App\Models\AuditEvent; use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Server; @@ -22,6 +24,7 @@ use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; +use Spatie\Activitylog\Models\Activity; uses(RefreshDatabase::class); @@ -401,3 +404,71 @@ describe('automatic security updates', function () { ->assertDispatched('error'); }); }); + +describe('audit trail', function () { + beforeEach(function () { + Queue::fake(); + $this->withoutDefer(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $this->server->settings->forceFill(['patch_check_result' => patchesTestResult(), 'patch_checked_at' => now()])->saveQuietly(); + }); + + it('records who requested a package update', function () { + $activity = Activity::query()->create(['log_name' => 'default', 'description' => '', 'properties' => []]); + UpdatePackage::shouldRun()->once()->andReturn($activity); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('updatePackage', 'openssl'); + + $event = AuditEvent::query()->where('event', 'ui.server.packages_update_requested')->sole(); + expect($event->actor_email)->toBe($this->admin->email) + ->and($event->team_id)->toBe($this->team->id) + ->and($event->resource_uuid)->toBe($this->server->uuid) + ->and($event->metadata['packages'])->toBe(['openssl']) + ->and($event->metadata['activity_id'])->toBe($activity->id); + }); + + it('records the result of a finished package update and checks the server again', function (string $status, string $result) { + $activity = Activity::query()->create(['log_name' => 'default', 'description' => '', 'properties' => [ + 'type_uuid' => 'update-1', 'status' => $status, 'exitCode' => $status === 'finished' ? 0 : 100, + ]]); + auth()->logout(); + + ServerPackagesUpdateFinished::dispatch([ + 'update_id' => 'update-1', 'server_uuid' => $this->server->uuid, 'packages' => [], 'package_manager' => 'apt', + 'actor_id' => $this->admin->id, 'actor_name' => $this->admin->name, 'actor_email' => $this->admin->email, + ]); + + $event = AuditEvent::query()->where('event', 'ui.server.packages_update_finished')->sole(); + expect($event->actor_email)->toBe($this->admin->email) + ->and($event->team_id)->toBe($this->team->id) + ->and($event->metadata)->toMatchArray(['packages' => 'all', 'result' => $result, 'activity_id' => $activity->id]); + Queue::assertPushed(ServerPatchCheckJob::class); + })->with([ + ['finished', 'success'], + ['error', 'failed'], + ]); + + it('records a reboot and a failed reboot', function () { + RebootServer::shouldRun()->once(); + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('rebootServer'); + + RebootServer::shouldRun()->once()->andThrow(new RuntimeException('Server is not reachable or not ready.')); + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid])->call('rebootServer'); + + expect(AuditEvent::query()->where('event', 'ui.server.reboot_requested')->orderBy('id')->get()->map(fn ($event) => $event->metadata['result'])->all()) + ->toBe(['started', 'failed']); + }); + + it('records a change of the automatic security updates', function () { + ConfigureAutomaticSecurityUpdates::shouldRun()->once(); + + Livewire::test(Patches::class, ['server_uuid' => $this->server->uuid]) + ->set('autoSecurityUpdates', true) + ->call('submit'); + + $event = AuditEvent::query()->where('event', 'ui.server.auto_security_updates_updated')->sole(); + expect($event->actor_email)->toBe($this->admin->email) + ->and($event->metadata)->toMatchArray(['enabled' => true, 'time' => '03:00', 'package_manager' => 'apt', 'result' => 'success']); + }); +});