From e22e07aae09f7533a4c30eb2595c25b40c30c0c8 Mon Sep 17 00:00:00 2001 From: notCharles Date: Sat, 9 May 2026 12:45:46 -0400 Subject: [PATCH 1/4] Add CURD --- app/Filament/Admin/Pages/Settings.php | 76 +++++++++- .../ActivityLogs/ActivityLogResource.php | 84 +++++++++++ .../ActivityLogs/Pages/ListActivityLogs.php | 85 +++++++++++ .../Admin/Resources/Users/UserResource.php | 28 ++++ app/Models/ActivityLog.php | 2 +- app/Models/Role.php | 4 + app/Models/User.php | 12 ++ app/Observers/AdminActivityObserver.php | 141 ++++++++++++++++++ app/Providers/AppServiceProvider.php | 15 ++ 9 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php create mode 100644 app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php create mode 100644 app/Observers/AdminActivityObserver.php 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..a179471647 --- /dev/null +++ b/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php @@ -0,0 +1,84 @@ +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..712e7b4f51 --- /dev/null +++ b/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php @@ -0,0 +1,85 @@ +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/Role.php b/app/Models/Role.php index b2bbbec96f..ad2b242fe8 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -70,6 +70,9 @@ class Role extends BaseRole 'panelLog' => [ 'view', ], + 'adminAuditLog' => [ + 'view', + ], 'plugin' => [ 'viewList', 'create', @@ -82,6 +85,7 @@ class Role extends BaseRole 'health' => TablerIcon::Heart, 'activityLog' => TablerIcon::Stack, 'panelLog' => TablerIcon::FileInfo, + 'adminAuditLog' => TablerIcon::ShieldSearch, ]; /** @var array> */ diff --git a/app/Models/User.php b/app/Models/User.php index ca94f77014..6dd2a0c8e6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -289,6 +289,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. diff --git a/app/Observers/AdminActivityObserver.php b/app/Observers/AdminActivityObserver.php new file mode 100644 index 0000000000..3250ed16e6 --- /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 + */ + 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 . ':' . get_class($model) . ':' . $model->getKey(); + if (isset(static::$logged[$key])) { + return; + } + static::$logged[$key] = true; + + $log = Activity::event($event) + ->actor($actor) + ->subject($model); + + foreach ($properties as $propKey => $propValue) { + $log->property($propKey, $propValue); + } + + $log->log(); + } + + // ------------------------------------------------------------------------- + // User events + // ------------------------------------------------------------------------- + + public function userCreated(User $user): void + { + $this->log('admin:user.create', $user, ['name' => $user->username]); + } + + public function userUpdated(User $user): void + { + $this->log('admin:user.update', $user, ['name' => $user->username]); + } + + public function userDeleted(User $user): void + { + $this->log('admin:user.delete', $user, ['name' => $user->username]); + } + + // ------------------------------------------------------------------------- + // Server events + // ------------------------------------------------------------------------- + + public function serverCreated(Server $server): void + { + $this->log('admin:server.create', $server, ['name' => $server->name]); + } + + public function serverUpdated(Server $server): void + { + $this->log('admin:server.update', $server, ['name' => $server->name]); + } + + public function serverDeleted(Server $server): void + { + $this->log('admin:server.delete', $server, ['name' => $server->name]); + } + + // ------------------------------------------------------------------------- + // Node events + // ------------------------------------------------------------------------- + + public function nodeCreated(Node $node): void + { + $this->log('admin:node.create', $node, ['name' => $node->name]); + } + + public function nodeUpdated(Node $node): void + { + $this->log('admin:node.update', $node, ['name' => $node->name]); + } + + public function nodeDeleted(Node $node): void + { + $this->log('admin:node.delete', $node, ['name' => $node->name]); + } + + // ------------------------------------------------------------------------- + // Egg events + // ------------------------------------------------------------------------- + + public function eggCreated(Egg $egg): void + { + $this->log('admin:egg.create', $egg, ['name' => $egg->name]); + } + + public function eggUpdated(Egg $egg): void + { + $this->log('admin:egg.update', $egg, ['name' => $egg->name]); + } + + public function eggDeleted(Egg $egg): void + { + $this->log('admin:egg.delete', $egg, ['name' => $egg->name]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6d3f89cf3e..d50ed5820f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -23,6 +23,7 @@ use App\Models\Task; use App\Models\User; use App\Models\UserSSHKey; +use App\Observers\AdminActivityObserver; use App\Services\Helpers\PluginService; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; @@ -109,6 +110,20 @@ public function boot( ]); } + $observer = new AdminActivityObserver(); + User::created(fn (User $user) => $observer->userCreated($user)); + User::updated(fn (User $user) => $observer->userUpdated($user)); + User::deleted(fn (User $user) => $observer->userDeleted($user)); + Server::created(fn (Server $server) => $observer->serverCreated($server)); + Server::updated(fn (Server $server) => $observer->serverUpdated($server)); + Server::deleted(fn (Server $server) => $observer->serverDeleted($server)); + Node::created(fn (Node $node) => $observer->nodeCreated($node)); + Node::updated(fn (Node $node) => $observer->nodeUpdated($node)); + Node::deleted(fn (Node $node) => $observer->nodeDeleted($node)); + Egg::created(fn (Egg $egg) => $observer->eggCreated($egg)); + Egg::updated(fn (Egg $egg) => $observer->eggUpdated($egg)); + Egg::deleted(fn (Egg $egg) => $observer->eggDeleted($egg)); + Gate::before(fn (User $user, $ability) => $user->isRootAdmin() ? true : null); AboutCommand::add('Pelican', [ From 4e56b857d1907635cb6dc16e1339af2665ff6872 Mon Sep 17 00:00:00 2001 From: notCharles Date: Sat, 9 May 2026 12:49:30 -0400 Subject: [PATCH 2/4] pint --- .../Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php b/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php index 712e7b4f51..dc7bcb2aff 100644 --- a/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php +++ b/app/Filament/Admin/Resources/ActivityLogs/Pages/ListActivityLogs.php @@ -6,7 +6,6 @@ use App\Filament\Admin\Resources\ActivityLogs\ActivityLogResource; use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Models\ActivityLog; -use App\Models\Server; use App\Models\User; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Columns\TextColumn; From 396d71c4a5fe31d94933007ef81c7f92e2f61a8f Mon Sep 17 00:00:00 2001 From: notCharles Date: Mon, 11 May 2026 06:38:53 -0400 Subject: [PATCH 3/4] Updates + stan --- .../ActivityLogs/ActivityLogResource.php | 7 +- app/Observers/AdminActivityObserver.php | 87 +++++++++++++++++-- app/Providers/AppServiceProvider.php | 4 + lang/en/activity.php | 30 +++++++ 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php b/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php index a179471647..6a2477644e 100644 --- a/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php +++ b/app/Filament/Admin/Resources/ActivityLogs/ActivityLogResource.php @@ -9,6 +9,7 @@ use BackedEnum; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Resource; +use Illuminate\Database\Eloquent\Model; class ActivityLogResource extends Resource { @@ -45,17 +46,17 @@ public static function canCreate(): bool return false; } - public static function canEdit($record): bool + public static function canEdit(Model $record): bool { return false; } - public static function canDelete($record): bool + public static function canDelete(Model $record): bool { return false; } - public static function canView($record): bool + public static function canView(Model $record): bool { return false; } diff --git a/app/Observers/AdminActivityObserver.php b/app/Observers/AdminActivityObserver.php index 3250ed16e6..5a48a345b4 100644 --- a/app/Observers/AdminActivityObserver.php +++ b/app/Observers/AdminActivityObserver.php @@ -5,6 +5,7 @@ use App\Facades\Activity; use App\Models\Egg; use App\Models\Node; +use App\Models\Role; use App\Models\Server; use App\Models\User; use Filament\Facades\Filament; @@ -33,6 +34,8 @@ private function isAdminPanel(): bool * @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 { @@ -47,10 +50,10 @@ private function log(string $event, Model $model, array $properties = []): void // Deduplicate identical events for the same record within a single request. $key = $event . ':' . get_class($model) . ':' . $model->getKey(); - if (isset(static::$logged[$key])) { + if (isset(self::$logged[$key])) { return; } - static::$logged[$key] = true; + self::$logged[$key] = true; $log = Activity::event($event) ->actor($actor) @@ -62,7 +65,6 @@ private function log(string $event, Model $model, array $properties = []): void $log->log(); } - // ------------------------------------------------------------------------- // User events // ------------------------------------------------------------------------- @@ -74,7 +76,13 @@ public function userCreated(User $user): void public function userUpdated(User $user): void { - $this->log('admin:user.update', $user, ['name' => $user->username]); + $changedFields = $this->changedFieldsFor($user); + + $this->log('admin:user.update', $user, [ + 'name' => empty($changedFields) ? $user->username : sprintf('%s (%s)', $user->username, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); } public function userDeleted(User $user): void @@ -93,7 +101,13 @@ public function serverCreated(Server $server): void public function serverUpdated(Server $server): void { - $this->log('admin:server.update', $server, ['name' => $server->name]); + $changedFields = $this->changedFieldsFor($server); + + $this->log('admin:server.update', $server, [ + 'name' => empty($changedFields) ? $server->name : sprintf('%s (%s)', $server->name, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); } public function serverDeleted(Server $server): void @@ -112,7 +126,13 @@ public function nodeCreated(Node $node): void public function nodeUpdated(Node $node): void { - $this->log('admin:node.update', $node, ['name' => $node->name]); + $changedFields = $this->changedFieldsFor($node); + + $this->log('admin:node.update', $node, [ + 'name' => empty($changedFields) ? $node->name : sprintf('%s (%s)', $node->name, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); } public function nodeDeleted(Node $node): void @@ -131,11 +151,64 @@ public function eggCreated(Egg $egg): void public function eggUpdated(Egg $egg): void { - $this->log('admin:egg.update', $egg, ['name' => $egg->name]); + $changedFields = $this->changedFieldsFor($egg); + + $this->log('admin:egg.update', $egg, [ + 'name' => empty($changedFields) ? $egg->name : sprintf('%s (%s)', $egg->name, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); } public function eggDeleted(Egg $egg): void { $this->log('admin:egg.delete', $egg, ['name' => $egg->name]); } + + // ------------------------------------------------------------------------- + // Role events + // ------------------------------------------------------------------------- + + public function roleCreated(Role $role): void + { + $this->log('admin:role.create', $role, ['name' => $role->name]); + } + + public function roleUpdated(Role $role): void + { + $changedFields = $this->changedFieldsFor($role); + + $this->log('admin:role.update', $role, [ + 'name' => empty($changedFields) ? $role->name : sprintf('%s (%s)', $role->name, implode(', ', $changedFields)), + 'count' => count($changedFields), + 'changes' => implode(', ', $changedFields), + ]); + } + + public function roleDeleted(Role $role): void + { + $this->log('admin:role.delete', $role, ['name' => $role->name]); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * 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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d50ed5820f..7fa11df89d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,6 +18,7 @@ use App\Models\Egg; use App\Models\EggVariable; use App\Models\Node; +use App\Models\Role; use App\Models\Schedule; use App\Models\Server; use App\Models\Task; @@ -123,6 +124,9 @@ public function boot( Egg::created(fn (Egg $egg) => $observer->eggCreated($egg)); Egg::updated(fn (Egg $egg) => $observer->eggUpdated($egg)); Egg::deleted(fn (Egg $egg) => $observer->eggDeleted($egg)); + Role::created(fn (Role $role) => $observer->roleCreated($role)); + Role::updated(fn (Role $role) => $observer->roleUpdated($role)); + Role::deleted(fn (Role $role) => $observer->roleDeleted($role)); Gate::before(fn (User $user, $ability) => $user->isRootAdmin() ? true : null); 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', + ], + ], ]; From 90c50da83bc2b621f9b29379495fecb8ea6a4a01 Mon Sep 17 00:00:00 2001 From: notCharles Date: Tue, 12 May 2026 10:06:33 -0400 Subject: [PATCH 4/4] wip --- app/Eloquent/BackupQueryBuilder.php | 3 +- app/Models/Egg.php | 2 + app/Models/Node.php | 2 + app/Models/Role.php | 2 + app/Models/Server.php | 2 + app/Models/User.php | 7 ++ app/Observers/AdminActivityObserver.php | 133 ++++++------------------ app/Providers/AppServiceProvider.php | 19 ---- app/Traits/HasAdminActivityLogging.php | 26 +++++ lang/en/admin/log.php | 12 +++ 10 files changed, 85 insertions(+), 123 deletions(-) create mode 100644 app/Traits/HasAdminActivityLogging.php 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/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 ad2b242fe8..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'; 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 6dd2a0c8e6..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; } @@ -538,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 index 5a48a345b4..cde55e82cc 100644 --- a/app/Observers/AdminActivityObserver.php +++ b/app/Observers/AdminActivityObserver.php @@ -3,13 +3,9 @@ namespace App\Observers; use App\Facades\Activity; -use App\Models\Egg; -use App\Models\Node; -use App\Models\Role; -use App\Models\Server; -use App\Models\User; use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class AdminActivityObserver { @@ -49,7 +45,7 @@ private function log(string $event, Model $model, array $properties = []): void } // Deduplicate identical events for the same record within a single request. - $key = $event . ':' . get_class($model) . ':' . $model->getKey(); + $key = $event . ':' . $model::class . ':' . $model->getKey(); if (isset(self::$logged[$key])) { return; } @@ -65,135 +61,66 @@ private function log(string $event, Model $model, array $properties = []): void $log->log(); } - // ------------------------------------------------------------------------- - // User events - // ------------------------------------------------------------------------- - public function userCreated(User $user): void + public function created(Model $model): void { - $this->log('admin:user.create', $user, ['name' => $user->username]); - } - - public function userUpdated(User $user): void - { - $changedFields = $this->changedFieldsFor($user); - - $this->log('admin:user.update', $user, [ - 'name' => empty($changedFields) ? $user->username : sprintf('%s (%s)', $user->username, implode(', ', $changedFields)), - 'count' => count($changedFields), - 'changes' => implode(', ', $changedFields), + $this->log($this->eventFor($model, 'create'), $model, [ + 'name' => $this->displayNameFor($model), ]); } - public function userDeleted(User $user): void - { - $this->log('admin:user.delete', $user, ['name' => $user->username]); - } - - // ------------------------------------------------------------------------- - // Server events - // ------------------------------------------------------------------------- - - public function serverCreated(Server $server): void - { - $this->log('admin:server.create', $server, ['name' => $server->name]); - } - - public function serverUpdated(Server $server): void + public function updated(Model $model): void { - $changedFields = $this->changedFieldsFor($server); + $changedFields = $this->changedFieldsFor($model); + $name = $this->displayNameFor($model); - $this->log('admin:server.update', $server, [ - 'name' => empty($changedFields) ? $server->name : sprintf('%s (%s)', $server->name, implode(', ', $changedFields)), + $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 serverDeleted(Server $server): void - { - $this->log('admin:server.delete', $server, ['name' => $server->name]); - } - - // ------------------------------------------------------------------------- - // Node events - // ------------------------------------------------------------------------- - - public function nodeCreated(Node $node): void - { - $this->log('admin:node.create', $node, ['name' => $node->name]); - } - - public function nodeUpdated(Node $node): void + public function deleted(Model $model): void { - $changedFields = $this->changedFieldsFor($node); - - $this->log('admin:node.update', $node, [ - 'name' => empty($changedFields) ? $node->name : sprintf('%s (%s)', $node->name, implode(', ', $changedFields)), - 'count' => count($changedFields), - 'changes' => implode(', ', $changedFields), + $this->log($this->eventFor($model, 'delete'), $model, [ + 'name' => $this->displayNameFor($model), ]); } - public function nodeDeleted(Node $node): void - { - $this->log('admin:node.delete', $node, ['name' => $node->name]); - } - // ------------------------------------------------------------------------- - // Egg events + // Helpers // ------------------------------------------------------------------------- - public function eggCreated(Egg $egg): void + private function eventFor(Model $model, string $action): string { - $this->log('admin:egg.create', $egg, ['name' => $egg->name]); + return sprintf('admin:%s.%s', $this->resourceNameFor($model), $action); } - public function eggUpdated(Egg $egg): void + private function resourceNameFor(Model $model): string { - $changedFields = $this->changedFieldsFor($egg); - - $this->log('admin:egg.update', $egg, [ - 'name' => empty($changedFields) ? $egg->name : sprintf('%s (%s)', $egg->name, implode(', ', $changedFields)), - 'count' => count($changedFields), - 'changes' => implode(', ', $changedFields), - ]); - } + $constant = $model::class . '::RESOURCE_NAME'; - public function eggDeleted(Egg $egg): void - { - $this->log('admin:egg.delete', $egg, ['name' => $egg->name]); - } + if (defined($constant)) { + $value = constant($constant); - // ------------------------------------------------------------------------- - // Role events - // ------------------------------------------------------------------------- + if (is_string($value) && $value !== '') { + return $value; + } + } - public function roleCreated(Role $role): void - { - $this->log('admin:role.create', $role, ['name' => $role->name]); + return Str::of(class_basename($model))->snake()->toString(); } - public function roleUpdated(Role $role): void + private function displayNameFor(Model $model): string { - $changedFields = $this->changedFieldsFor($role); - - $this->log('admin:role.update', $role, [ - 'name' => empty($changedFields) ? $role->name : sprintf('%s (%s)', $role->name, implode(', ', $changedFields)), - 'count' => count($changedFields), - 'changes' => implode(', ', $changedFields), - ]); - } + if (method_exists($model, 'getAdminActivityName')) { + return $model->getAdminActivityName(); + } - public function roleDeleted(Role $role): void - { - $this->log('admin:role.delete', $role, ['name' => $role->name]); + return (string) $model->getKey(); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - /** * Returns the sorted list of attribute names that changed on the given model, * excluding internal timestamps. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7fa11df89d..6d3f89cf3e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,13 +18,11 @@ use App\Models\Egg; use App\Models\EggVariable; use App\Models\Node; -use App\Models\Role; use App\Models\Schedule; use App\Models\Server; use App\Models\Task; use App\Models\User; use App\Models\UserSSHKey; -use App\Observers\AdminActivityObserver; use App\Services\Helpers\PluginService; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; @@ -111,23 +109,6 @@ public function boot( ]); } - $observer = new AdminActivityObserver(); - User::created(fn (User $user) => $observer->userCreated($user)); - User::updated(fn (User $user) => $observer->userUpdated($user)); - User::deleted(fn (User $user) => $observer->userDeleted($user)); - Server::created(fn (Server $server) => $observer->serverCreated($server)); - Server::updated(fn (Server $server) => $observer->serverUpdated($server)); - Server::deleted(fn (Server $server) => $observer->serverDeleted($server)); - Node::created(fn (Node $node) => $observer->nodeCreated($node)); - Node::updated(fn (Node $node) => $observer->nodeUpdated($node)); - Node::deleted(fn (Node $node) => $observer->nodeDeleted($node)); - Egg::created(fn (Egg $egg) => $observer->eggCreated($egg)); - Egg::updated(fn (Egg $egg) => $observer->eggUpdated($egg)); - Egg::deleted(fn (Egg $egg) => $observer->eggDeleted($egg)); - Role::created(fn (Role $role) => $observer->roleCreated($role)); - Role::updated(fn (Role $role) => $observer->roleUpdated($role)); - Role::deleted(fn (Role $role) => $observer->roleDeleted($role)); - Gate::before(fn (User $user, $ability) => $user->isRootAdmin() ? true : null); AboutCommand::add('Pelican', [ 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/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?',