diff --git a/app/Eloquent/BackupQueryBuilder.php b/app/Eloquent/BackupQueryBuilder.php index 53f10a28f4..7e119cdf83 100644 --- a/app/Eloquent/BackupQueryBuilder.php +++ b/app/Eloquent/BackupQueryBuilder.php @@ -3,9 +3,10 @@ namespace App\Eloquent; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; /** - * @template TModel of \Illuminate\Database\Eloquent\Model + * @template TModel of Model * * @extends Builder */ diff --git a/app/Filament/Admin/Pages/Settings.php b/app/Filament/Admin/Pages/Settings.php index 424b4a7957..4b7a075065 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\Models\Backup; use App\Notifications\MailTested; use App\Traits\EnvironmentWriterTrait; @@ -46,6 +47,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Notification as MailNotification; use Illuminate\Support\Str; +use Livewire\Attributes\Locked; /** * @property Schema $form @@ -75,9 +77,14 @@ class Settings extends Page implements HasSchemas /** @var array|null */ public ?array $data = []; + /** @var array Snapshot of normalized form state captured on mount, used to detect which settings changed before saving. */ + #[Locked] + public array $initialSettings = []; + public function mount(): void { $this->form->fill(); + $this->initialSettings = $this->sanitizeSettingsData($this->form->getState()); } public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void @@ -865,8 +872,8 @@ protected function getFormStatePath(): ?string public function save(): void { try { - $data = $this->form->getState(); - unset($data['ConsoleFonts']); + $data = $this->sanitizeSettingsData($this->form->getState()); + $changedSettings = $this->getChangedSettings($this->initialSettings, $data); $data = array_map(function ($value) { // Convert bools to a string, so they are correctly written to the .env file @@ -886,6 +893,14 @@ public function save(): void Artisan::call('queue:restart'); + if (!empty($changedSettings)) { + Activity::event('admin:settings.update') + ->actor(user()) + ->property('count', count($changedSettings)) + ->property('settings', implode(', ', $changedSettings)) + ->log(); + } + $this->redirect($this->getUrl()); Notification::make() @@ -901,6 +916,63 @@ public function save(): void } } + /** + * @param array $data + * @return array + */ + private function sanitizeSettingsData(array $data): array + { + unset($data['ConsoleFonts']); + + return $data; + } + + /** + * @param array $before + * @param array $after + * @return string[] + */ + private function getChangedSettings(array $before, array $after): array + { + $changed = []; + + foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $key) { + $old = $before[$key] ?? null; + $new = $after[$key] ?? null; + + if ($this->normalizeSettingValue($old) !== $this->normalizeSettingValue($new)) { + $changed[] = $key; + } + } + + sort($changed); + + return $changed; + } + + /** + * Converts a setting value to a normalized string for diffing. + * Handles enums, booleans, arrays (JSON-encoded), and scalar values. + */ + private function normalizeSettingValue(mixed $value): string + { + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_array($value)) { + $json = json_encode($value); + + return $json === false ? '' : $json; + } + + return (string) ($value ?? ''); + } + /** @return array */ protected function getDefaultHeaderActions(): array { diff --git a/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php b/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php new file mode 100644 index 0000000000..6a2477644e --- /dev/null +++ b/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php @@ -0,0 +1,85 @@ +isRootAdmin()) { + return true; + } + + return $user->can('view adminAuditLog') || $user->can('view panelLog'); + } + + /** @return array */ + public static function getDefaultPages(): array + { + return [ + 'index' => ListActivityLogs::route('/'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php b/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php new file mode 100644 index 0000000000..dc7bcb2aff --- /dev/null +++ b/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php @@ -0,0 +1,84 @@ +query( + ActivityLog::query() + ->where('event', 'like', 'admin:%') + ->with(['actor']) + ->latest('timestamp') + ) + ->columns([ + TextColumn::make('actor.username') + ->label(trans('admin/log.table.actor')) + ->state(function (ActivityLog $log): string { + if ($log->actor instanceof User) { + return $log->actor->username; + } + + return trans('admin/log.table.system'); + }) + ->searchable(query: function (Builder $query, string $search): Builder { + $escapedSearch = addcslashes($search, '%_\\\\'); + + return $query->whereHas('actor', fn (Builder $q) => $q->where('username', 'like', "%{$escapedSearch}%")); + }), + TextColumn::make('event') + ->label(trans('admin/log.table.event')) + ->badge() + ->searchable(), + TextColumn::make('description') + ->label(trans('admin/log.table.description')) + ->html() + ->state(fn (ActivityLog $log) => new HtmlString($log->getLabel())) + ->grow(), + TextColumn::make('ip') + ->label(trans('admin/log.table.ip')) + ->visibleFrom('lg') + ->visible(fn () => user()?->can('seeIps activityLog')), + DateTimeColumn::make('timestamp') + ->label(trans('admin/log.table.timestamp')) + ->sortable() + ->since(), + ]) + ->defaultSort('timestamp', 'desc') + ->searchable() + ->filters([ + SelectFilter::make('event') + ->label(trans('admin/log.table.event')) + ->options(fn () => ActivityLog::query() + ->where('event', 'like', 'admin:%') + ->distinct() + ->pluck('event') + ->mapWithKeys(fn (string $event) => [$event => $event]) + ->toArray()) + ->searchable(), + ]) + ->emptyStateHeading(trans('admin/log.empty_audit_log')) + ->emptyStateIcon(TablerIcon::ShieldSearch); + } +} diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 001ef63ca9..7daf5b721c 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -489,6 +489,34 @@ function (User $user, string $token) { ->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())), ]), ]), + Tab::make('admin_log') + ->visible(function (?User $user): bool { + if (!$user) { + return false; + } + + $viewer = user(); + + return $viewer?->isRootAdmin() || $viewer?->can('view adminAuditLog'); + }) + ->disabledOn('create') + ->label(trans('admin/user.tabs.admin_log')) + ->icon(TablerIcon::ShieldSearch) + ->schema([ + Repeater::make('adminLog') + ->hiddenLabel() + ->inlineLabel(false) + ->deletable(false) + ->addable(false) + ->relationship('adminLog', function (Builder $query) { + $query->orderBy('timestamp', 'desc'); + }) + ->schema([ + TextEntry::make('log') + ->hiddenLabel() + ->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())), + ]), + ]), ]; } diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 062ba862ab..136e92d175 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -228,7 +228,7 @@ public function wrapProperties(): array }); $keys = $properties->keys()->filter(fn ($key) => Str::endsWith($key, '_count'))->values(); - if ($keys->containsOneItem()) { + if ($keys->hasSole()) { $properties = $properties->merge(['count' => $properties->get($keys[0])])->except([$keys[0]]); } diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 9be069fae0..221a6418e0 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -6,6 +6,7 @@ use App\Exceptions\Service\Egg\HasChildrenException; use App\Exceptions\Service\HasActiveServersException; use App\Models\Traits\HasIcon; +use App\Traits\HasAdminActivityLogging; use App\Traits\HasValidation; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -93,6 +94,7 @@ */ class Egg extends Model implements Validatable { + use HasAdminActivityLogging; use HasFactory; use HasIcon; use HasValidation; diff --git a/app/Models/Node.php b/app/Models/Node.php index 3edf89d9d1..1be3e843d4 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -5,6 +5,7 @@ use App\Contracts\Validatable; use App\Exceptions\Service\HasActiveServersException; use App\Repositories\Daemon\DaemonSystemRepository; +use App\Traits\HasAdminActivityLogging; use App\Traits\HasValidation; use Exception; use Illuminate\Database\Eloquent\Collection; @@ -95,6 +96,7 @@ */ class Node extends Model implements Validatable { + use HasAdminActivityLogging; use HasFactory; use HasValidation; use Notifiable; diff --git a/app/Models/Role.php b/app/Models/Role.php index b2bbbec96f..c05291b8ff 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -5,6 +5,7 @@ use App\Enums\RolePermissionModels; use App\Enums\RolePermissionPrefixes; use App\Enums\TablerIcon; +use App\Traits\HasAdminActivityLogging; use BackedEnum; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,6 +42,7 @@ */ class Role extends BaseRole { + use HasAdminActivityLogging; use HasFactory; public const RESOURCE_NAME = 'role'; @@ -70,6 +72,9 @@ class Role extends BaseRole 'panelLog' => [ 'view', ], + 'adminAuditLog' => [ + 'view', + ], 'plugin' => [ 'viewList', 'create', @@ -82,6 +87,7 @@ class Role extends BaseRole 'health' => TablerIcon::Heart, 'activityLog' => TablerIcon::Stack, 'panelLog' => TablerIcon::FileInfo, + 'adminAuditLog' => TablerIcon::ShieldSearch, ]; /** @var array> */ diff --git a/app/Models/Server.php b/app/Models/Server.php index 073a39be20..61fb4f506d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -10,6 +10,7 @@ use App\Models\Traits\HasIcon; use App\Repositories\Daemon\DaemonServerRepository; use App\Services\Subusers\SubuserDeletionService; +use App\Traits\HasAdminActivityLogging; use App\Traits\HasValidation; use Carbon\CarbonInterface; use Exception; @@ -129,6 +130,7 @@ */ class Server extends Model implements HasAvatar, Validatable { + use HasAdminActivityLogging; use HasFactory; use HasIcon; use HasValidation; diff --git a/app/Models/User.php b/app/Models/User.php index ca94f77014..eddc549cb5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,6 +9,7 @@ use App\Exceptions\DisplayException; use App\Extensions\Avatar\AvatarService; use App\Models\Traits\HasAccessTokens; +use App\Traits\HasAdminActivityLogging; use App\Traits\HasValidation; use BackedEnum; use DateTimeZone; @@ -119,6 +120,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac use Authorizable { can as protected canned; } use CanResetPassword; use HasAccessTokens; + use HasAdminActivityLogging; use HasFactory; use HasRoles; use HasValidation { getRules as getValidationRules; } @@ -289,6 +291,18 @@ public function activity(): MorphToMany return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects'); } + /** + * Returns activity logs where this user is the actor and the event is an admin action. + * + * @return HasMany + */ + public function adminLog(): HasMany + { + return $this->hasMany(ActivityLog::class, 'actor_id') + ->where('actor_type', 'user') + ->where('event', 'like', 'admin:%'); + } + /** * Returns all the servers that a user can access. * Either because they are an admin or because they are the owner/ a subuser of the server. @@ -526,4 +540,9 @@ public function toggleEmailAuthentication(bool $condition): void { $this->update(['mfa_email_enabled' => $condition]); } + + public function getAdminActivityName(): string + { + return $this->username; + } } diff --git a/app/Observers/AdminActivityObserver.php b/app/Observers/AdminActivityObserver.php new file mode 100644 index 0000000000..cde55e82cc --- /dev/null +++ b/app/Observers/AdminActivityObserver.php @@ -0,0 +1,141 @@ + + */ + private static array $logged = []; + + /** + * Determines if the current request is being handled by the admin panel. + */ + private function isAdminPanel(): bool + { + return Filament::getCurrentPanel()?->getId() === 'admin'; + } + + /** + * Logs an admin activity event for the given model, deduplicating within a single request. + * + * @param string $event The event name (e.g. 'admin:user.create') + * @param Model $model The model being acted upon + * @param array $properties Additional properties to log + * + * @throws \Throwable + */ + private function log(string $event, Model $model, array $properties = []): void + { + if (!$this->isAdminPanel()) { + return; + } + + $actor = user(); + if (!$actor) { + return; + } + + // Deduplicate identical events for the same record within a single request. + $key = $event . ':' . $model::class . ':' . $model->getKey(); + if (isset(self::$logged[$key])) { + return; + } + self::$logged[$key] = true; + + $log = Activity::event($event) + ->actor($actor) + ->subject($model); + + foreach ($properties as $propKey => $propValue) { + $log->property($propKey, $propValue); + } + + $log->log(); + } + + public function created(Model $model): void + { + $this->log($this->eventFor($model, 'create'), $model, [ + 'name' => $this->displayNameFor($model), + ]); + } + + public function updated(Model $model): void + { + $changedFields = $this->changedFieldsFor($model); + $name = $this->displayNameFor($model); + + $this->log($this->eventFor($model, 'update'), $model, [ + 'name' => empty($changedFields) ? $name : sprintf('%s (%s)', $name, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); + } + + public function deleted(Model $model): void + { + $this->log($this->eventFor($model, 'delete'), $model, [ + 'name' => $this->displayNameFor($model), + ]); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function eventFor(Model $model, string $action): string + { + return sprintf('admin:%s.%s', $this->resourceNameFor($model), $action); + } + + private function resourceNameFor(Model $model): string + { + $constant = $model::class . '::RESOURCE_NAME'; + + if (defined($constant)) { + $value = constant($constant); + + if (is_string($value) && $value !== '') { + return $value; + } + } + + return Str::of(class_basename($model))->snake()->toString(); + } + + private function displayNameFor(Model $model): string + { + if (method_exists($model, 'getAdminActivityName')) { + return $model->getAdminActivityName(); + } + + return (string) $model->getKey(); + } + + /** + * Returns the sorted list of attribute names that changed on the given model, + * excluding internal timestamps. + * + * @return string[] + */ + private function changedFieldsFor(Model $model): array + { + $fields = collect(array_keys($model->getChanges())) + ->reject(fn (string $field) => $field === 'updated_at') + ->values() + ->all(); + + sort($fields); + + return $fields; + } +} diff --git a/app/Traits/HasAdminActivityLogging.php b/app/Traits/HasAdminActivityLogging.php new file mode 100644 index 0000000000..50d441cdbf --- /dev/null +++ b/app/Traits/HasAdminActivityLogging.php @@ -0,0 +1,26 @@ + $observer->created($model)); + static::updated(fn ($model) => $observer->updated($model)); + static::deleted(fn ($model) => $observer->deleted($model)); + } + + public function getAdminActivityName(): string + { + if (isset($this->attributes['name'])) { + return (string) $this->attributes['name']; + } + + return (string) $this->getKey(); + } +} diff --git a/lang/en/activity.php b/lang/en/activity.php index 4667ff5f8a..7baf18594a 100644 --- a/lang/en/activity.php +++ b/lang/en/activity.php @@ -125,4 +125,34 @@ ], 'crashed' => 'Server crashed', ], + 'admin' => [ + 'settings' => [ + 'update' => 'Updated panel settings (:count): :settings', + ], + 'user' => [ + 'create' => 'Created user :name', + 'update' => 'Updated user :name', + 'delete' => 'Deleted user :name', + ], + 'server' => [ + 'create' => 'Created server :name', + 'update' => 'Updated server :name', + 'delete' => 'Deleted server :name', + ], + 'node' => [ + 'create' => 'Created node :name', + 'update' => 'Updated node :name', + 'delete' => 'Deleted node :name', + ], + 'egg' => [ + 'create' => 'Created egg :name', + 'update' => 'Updated egg :name', + 'delete' => 'Deleted egg :name', + ], + 'role' => [ + 'create' => 'Created role :name', + 'update' => 'Updated role :name', + 'delete' => 'Deleted role :name', + ], + ], ]; diff --git a/lang/en/admin/log.php b/lang/en/admin/log.php index 4c1a131595..41c3d0d891 100644 --- a/lang/en/admin/log.php +++ b/lang/en/admin/log.php @@ -2,6 +2,9 @@ return [ 'empty_table' => 'Yay! No Errors!', + 'empty_audit_log' => 'No admin actions have been logged yet.', + 'model_label' => 'Admin Audit Log', + 'model_label_plural' => 'Admin Audit Logs', 'total_logs' => 'Total Logs', 'error' => 'Error', 'warning' => 'Warning', @@ -10,6 +13,15 @@ 'debug' => 'Debug', 'navigation' => [ 'panel_logs' => 'Panel Logs', + 'admin_audit_log' => 'Admin Audit', + ], + 'table' => [ + 'actor' => 'Actor', + 'event' => 'Event', + 'description' => 'Description', + 'ip' => 'IP Address', + 'timestamp' => 'Time', + 'system' => 'System', ], 'actions' => [ 'upload_logs' => 'Upload Logs?',