diff --git a/app/Filament/Admin/Pages/Settings.php b/app/Filament/Admin/Pages/Settings.php index 4001baf0d8..662c1cbf9f 100644 --- a/app/Filament/Admin/Pages/Settings.php +++ b/app/Filament/Admin/Pages/Settings.php @@ -6,6 +6,7 @@ use App\Extensions\Avatar\AvatarService; use App\Extensions\Captcha\CaptchaService; use App\Extensions\OAuth\OAuthService; +use App\Facades\Activity; use App\Notifications\MailTested; use App\Traits\EnvironmentWriterTrait; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -14,6 +15,7 @@ use BackedEnum; use BladeUI\Icons\Exceptions\SvgNotFound; use BladeUI\Icons\Factory as IconFactory; +use Dotenv\Dotenv; use Exception; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -44,6 +46,7 @@ use Filament\Support\Enums\Width; use Illuminate\Http\Client\Factory; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Notification as MailNotification; use Illuminate\Support\Str; @@ -979,8 +982,16 @@ public function save(): void return $value; }, $data); + $changes = $this->buildSettingsDiff($data); + $this->writeToEnvironment($data); + if ($changes !== []) { + Activity::event('settings:update') + ->property('changes', $changes) + ->log(); + } + Artisan::call('queue:restart'); $this->redirect($this->getUrl()); @@ -998,6 +1009,46 @@ public function save(): void } } + /** + * Old-to-new pairs for every env key actually changing, secrets masked. + * Snapshotted by parsing the environment file directly, because env() + * returns null for .env-only keys once the configuration is cached. + * + * @param array $data + * @return array + */ + private function buildSettingsDiff(array $data): array + { + $path = App::environmentFilePath(); + $current = is_file($path) ? Dotenv::parse(file_get_contents($path)) : []; + + $changes = []; + + foreach ($data as $key => $value) { + $old = $current[$key] ?? null; + + $old = is_null($old) ? null : (string) $old; + $new = match (true) { + is_null($value) => null, + is_array($value) => implode(',', $value), + default => (string) $value, + }; + + if ($old === $new) { + continue; + } + + if (Str::is(['*SECRET*', '*PASSWORD*', '*TOKEN*', '*_KEY'], $key)) { + $old = is_null($old) || $old === '' ? $old : '********'; + $new = is_null($new) || $new === '' ? $new : '********'; + } + + $changes[$key] = ['old' => $old, 'new' => $new]; + } + + return $changes; + } + /** @return array */ protected function getDefaultHeaderActions(): array { diff --git a/lang/en/activity.php b/lang/en/activity.php index 4667ff5f8a..75b4835741 100644 --- a/lang/en/activity.php +++ b/lang/en/activity.php @@ -38,6 +38,9 @@ 'delete' => 'Disabled two-factor auth', ], ], + 'settings' => [ + 'update' => 'Updated :count panel setting|Updated :count panel settings', + ], 'server' => [ 'console' => [ 'command' => 'Executed ":command" on the server', diff --git a/tests/Filament/Admin/SettingsTest.php b/tests/Filament/Admin/SettingsTest.php index 6f17fd7aec..bec18a19b2 100644 --- a/tests/Filament/Admin/SettingsTest.php +++ b/tests/Filament/Admin/SettingsTest.php @@ -1,8 +1,10 @@ assertForbidden(); }); +it('logs a settings:update event with a redacted diff', function () { + $path = sys_get_temp_dir().'/pelican-settings-test-'.getmypid(); + @mkdir($path); + file_put_contents("$path/.env", ''); + app()->useEnvironmentPath($path); + + try { + livewire(Settings::class) + ->fillForm([ + 'APP_NAME' => 'Audited Panel', + 'FILAMENT_AVATAR_PROVIDER' => 'gravatar', + // smtp keeps the MAIL_PASSWORD field visible so it lands in the diff + 'MAIL_MAILER' => 'smtp', + 'MAIL_HOST' => 'localhost', + 'MAIL_PORT' => 2525, + 'MAIL_SCHEME' => 'smtp', + 'MAIL_PASSWORD' => 'supersecretpw', + 'GUZZLE_CONNECT_TIMEOUT' => 5, + ]) + ->call('save') + ->assertHasNoErrors(); + + $this->assertActivityLogged('settings:update'); + $this->assertActivityActor('settings:update', $this->admin); + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('settings:update')) { + return false; + } + $changes = $e->model->properties['changes']; + + return $changes['APP_NAME']['new'] === 'Audited Panel' + && $changes['MAIL_PASSWORD']['new'] === '********' + && !str_contains(json_encode($e->model->properties), 'supersecretpw'); + }); + } finally { + @unlink("$path/.env"); + @rmdir($path); + } +}); + it('persists saved settings to the environment file', function () { // Point the environment file at a scratch copy so the real .env is untouched. $path = sys_get_temp_dir().'/pelican-settings-test-'.getmypid();