From 40e5fd8521a5e5706bc27e288f1b35faca0c3f49 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:18:14 +0200 Subject: [PATCH] feat(audit): add team activity tracking and audit log --- app/Console/Commands/CleanupDatabase.php | 7 + .../Api/ApplicationsController.php | 8 - .../Controllers/Api/ProjectController.php | 21 - app/Http/Kernel.php | 2 + .../Project/Application/DeploymentNavbar.php | 1 - app/Livewire/Project/Application/Heading.php | 5 + app/Livewire/Project/Application/Previews.php | 6 + app/Livewire/Project/CloneMe.php | 8 + app/Livewire/Project/Database/BackupEdit.php | 21 +- app/Livewire/Project/Database/BackupNow.php | 7 + app/Livewire/Project/Database/Heading.php | 12 + app/Livewire/Project/Database/ImportForm.php | 13 + app/Livewire/Project/Service/Heading.php | 12 + app/Livewire/Project/Shared/Destination.php | 7 + .../Project/Shared/ResourceOperations.php | 8 + .../Project/Shared/ScheduledTask/Show.php | 7 + .../Project/Shared/Storages/VolumeBackups.php | 6 + app/Livewire/Security/ApiTokens.php | 11 + app/Livewire/Server/DockerCleanup.php | 7 + app/Livewire/Server/Navbar.php | 16 + app/Livewire/Server/TransferImport.php | 9 + app/Livewire/Team/AuditLog.php | 70 ++ app/Livewire/Team/Invitations.php | 7 + app/Livewire/Team/InviteLink.php | 7 + app/Livewire/Team/Member.php | 20 + app/Models/Application.php | 7 +- app/Models/ApplicationDeploymentQueue.php | 39 ++ app/Models/AuditEvent.php | 171 +++++ app/Models/Environment.php | 3 +- app/Models/EnvironmentVariable.php | 3 + app/Models/GithubApp.php | 3 + app/Models/GitlabApp.php | 3 + app/Models/PrivateKey.php | 3 +- app/Models/Project.php | 3 +- app/Models/S3Storage.php | 3 +- app/Models/Server.php | 3 +- app/Models/Service.php | 3 +- app/Models/SharedEnvironmentVariable.php | 3 + app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Models/Tag.php | 3 +- app/Models/Team.php | 3 +- app/Traits/Auditable.php | 82 +++ bootstrap/helpers/applications.php | 10 + bootstrap/helpers/audit.php | 48 +- config/logging.php | 7 - database/factories/AuditEventFactory.php | 29 + ...08_20_000000_create_audit_events_table.php | 43 ++ .../components/team/settings-layout.blade.php | 6 + .../views/livewire/team/audit-log.blade.php | 116 ++++ routes/web.php | 2 + tests/Feature/AuditEventsTest.php | 614 ++++++++++++++++++ tests/Feature/Proxy/RestartProxyTest.php | 21 + .../QueueApplicationDeploymentCommitTest.php | 34 + 60 files changed, 1486 insertions(+), 101 deletions(-) create mode 100644 app/Livewire/Team/AuditLog.php create mode 100644 app/Models/AuditEvent.php create mode 100644 app/Traits/Auditable.php create mode 100644 database/factories/AuditEventFactory.php create mode 100644 database/migrations/2026_08_20_000000_create_audit_events_table.php create mode 100644 resources/views/livewire/team/audit-log.blade.php create mode 100644 tests/Feature/AuditEventsTest.php diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Models\AuditEvent; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -49,6 +50,12 @@ class CleanupDatabase extends Command $activity_log->delete(); } + $count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count(); + echo "Delete $count entries from audit_events.\n"; + if ($this->option('yes')) { + AuditEvent::pruneExpired(); + } + // Cleanup application_deployment_queues table $application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10); $count = $application_deployment_queues->count(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..b47db0e26f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5630,14 +5630,6 @@ class ApplicationsController extends Controller return response()->json(['message' => $result['message']], 200); } - auditLog('api.application.rollback', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid, - 'commit' => $commit, - ]); - return response()->json([ 'message' => 'Rollback deployment queued.', 'deployment_uuid' => $deployment_uuid, diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index eb137c5349..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -271,12 +271,6 @@ class ProjectController extends Controller 'team_id' => $teamId, ]); - auditLog('api.project.created', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - ]); - return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -396,13 +390,6 @@ class ProjectController extends Controller $project->update($request->only($allowedFields)); - auditLog('api.project.updated', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), - ]); - return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -482,16 +469,8 @@ class ProjectController extends Controller return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } - $projectUuid = $project->uuid; - $projectName = $project->name; $project->delete(); - auditLog('api.project.deleted', [ - 'team_id' => $teamId, - 'project_uuid' => $projectUuid, - 'project_name' => $projectName, - ]); - return response()->json(['message' => 'Project deleted.']); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\SetCacheHeaders; @@ -59,6 +60,7 @@ class Kernel extends HttpKernel ValidatePostSize::class, TrimStrings::class, ConvertEmptyStringsToNull::class, + InvokeDeferredCallbacks::class, ]; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -104,7 +104,6 @@ class DeploymentNavbar extends Component $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); - try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.It could take a while depending on the application.'); StopApplication::dispatch($this->application, false, $this->docker_cleanup); + auditLog('ui.application.stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b40..3944bbe09d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -377,6 +377,12 @@ class Previews extends Component ApplicationPreview::where('application_id', $this->application->id) ->where('pull_request_id', $pull_request_id) ->update(['status' => 'exited']); + auditLog('ui.application.preview_stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'pull_request_id' => $pull_request_id, + ]); ServiceStatusChanged::dispatch($this->application->environment->project->team->id); GetContainersStatus::run($server); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -102,6 +102,14 @@ class CloneMe extends Component if (! $selectedDestination) { throw new \Exception('Destination not found.'); } + auditLog('ui.project.clone_started', [ + 'team_id' => $this->project->team_id, + 'project_uuid' => $this->project->uuid, + 'project_name' => $this->project->name, + 'clone_type' => $type, + 'new_name' => $this->newName, + 'destination_uuid' => $selectedDestination->uuid, + ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); if ($foundProject) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 2c04f5ba9b..608d153ebc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -207,10 +207,18 @@ class BackupEdit extends Component } } + $database = $this->backup->database; + $backupUuid = $this->backup->uuid; $this->backup->delete(); + auditLog('ui.database.backup_schedule_deleted', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $backupUuid, + ]); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; + if ($database->getMorphClass() === ServiceDatabase::class) { + $serviceDatabase = $database; return redirect()->route('project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], @@ -238,9 +246,14 @@ class BackupEdit extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); - $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); if ($database instanceof ServiceDatabase) { return redirect()->route('project.service.database.backup.executions', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..e45c797d1e 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -18,6 +18,13 @@ class BackupNow extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); + $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f227021..4b34c5e4ee 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -83,6 +83,7 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); + $this->auditDatabaseAction('ui.database.stopped'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } @@ -94,6 +95,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -107,6 +109,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.started'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -122,4 +125,13 @@ class Heading extends Component ], ]); } + + private function auditDatabaseAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index ccd3435106..d6d713d801 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -510,6 +510,12 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.import_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 'file', + ]); } } catch (\Throwable $e) { handleError($e, $this); @@ -768,6 +774,13 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.restore_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 's3', + 'storage_id' => $this->s3StorageId, + ]); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 34bb46ff19..9390fe2a58 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -113,6 +113,7 @@ class Heading extends Component try { $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); + $this->auditServiceAction('ui.service.started'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -146,6 +147,7 @@ class Heading extends Component try { $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); + $this->auditServiceAction('ui.service.stopped'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -162,6 +164,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -196,6 +199,15 @@ class Heading extends Component $this->authorize($ability, $this->service); } + private function auditServiceAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->service->team()?->id, + 'service_uuid' => $this->service->uuid, + 'service_name' => $this->service->name, + ]); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -64,6 +64,13 @@ class Destination extends Component $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); + auditLog('ui.application.destination_stopped', [ + 'team_id' => $this->resource->team()?->id, + 'application_uuid' => $this->resource->uuid, + 'application_name' => $this->resource->name, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); $this->refreshServers(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..1389b583b2 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -81,6 +81,14 @@ class ResourceOperations extends Component if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } + auditLog('ui.resource.clone_started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'resource_type' => class_basename($this->resource), + 'destination_uuid' => $new_destination->uuid, + 'environment_id' => $new_environment->id, + ]); $uuid = new_public_id(); $server = $new_destination->server; if (! $server->canHostResources()) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 11df001531..14777724e5 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -184,6 +184,13 @@ class Show extends Component $this->authorize('update', $this->resource); $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); + auditLog('ui.scheduled_task.executed', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'scheduled_task_uuid' => $this->task->uuid, + 'scheduled_task_name' => $this->task->name, + ]); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..ef7b36ff72 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -204,6 +204,12 @@ class VolumeBackups extends Component } VolumeBackupJob::dispatch($this->backup); + auditLog('ui.volume_backup.started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Storage backup queued.'); return redirect()->route($this->routeName('executions'), $this->routeParameters()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5a978ac84f..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -140,6 +140,12 @@ class ApiTokens extends Component ]); $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); + auditLog('ui.api_token.created', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $this->description, + 'abilities' => array_values($this->permissions), + 'expires_at' => $expiresAt?->toIso8601String(), + ]); $this->getTokens(); // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. session()->flash('token', $token->plainTextToken); @@ -156,7 +162,12 @@ class ApiTokens extends Component ->where('id', $id) ->firstOrFail(); $this->authorize('delete', $token); + $tokenName = $token->name; $token->delete(); + auditLog('ui.api_token.revoked', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $tokenName, + ]); $this->getTokens(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 12d111d219..24acdecad1 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -134,6 +134,13 @@ class DockerCleanup extends Component try { $this->authorize('update', $this->server); DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); + auditLog('ui.server.docker_cleanup_started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'delete_unused_volumes' => $this->deleteUnusedVolumes, + 'delete_unused_networks' => $this->deleteUnusedNetworks, + ]); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -101,6 +101,11 @@ class Navbar extends Component // Always use background job for all servers RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); } catch (\Throwable $e) { $this->restartInitiated = false; @@ -125,6 +130,11 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); $activity = StartProxy::run($this->server, force: true); + auditLog('ui.proxy.started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -136,6 +146,12 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); StopProxy::dispatch($this->server, $forceStop); + auditLog('ui.proxy.stopped', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'force' => $forceStop, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -123,6 +123,15 @@ class TransferImport extends Component $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + if (! $dryRun) { + auditLog('ui.server.imported', [ + 'team_id' => $teamId, + 'server_uuid' => $this->importedServerUuid, + 'claimed' => (bool) data_get($result, 'claimed'), + 'adopt_mode' => $this->adoptMode, + ]); + } + if ($dryRun) { $this->dispatch('success', 'Dry run completed — nothing was written.'); } elseif (data_get($result, 'claimed')) { diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..7dddcfcfb5 --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,70 @@ +resetPage(); + } + + public function updatedAction(): void + { + $this->resetPage(); + } + + public function updatedSource(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(10, min(100, $this->perPage)); + $this->resetPage(); + } + + public function render(): View + { + $search = trim($this->search); + $teamId = currentTeam()->id; + $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin(); + $events = AuditEvent::query() + ->where(function ($query) use ($canViewInstanceEvents, $teamId): void { + $query->where('team_id', $teamId) + ->when($canViewInstanceEvents, fn ($query) => $query->orWhereNull('team_id')); + }) + ->when($this->action !== 'all', fn ($query) => $query->where('action', $this->action)) + ->when($this->source !== 'all', fn ($query) => $query->where('source', $this->source)) + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->orWhere('actor_email', 'like', "%{$search}%") + ->orWhere('event', 'like', "%{$search}%"); + }); + }) + ->latest('created_at') + ->latest('id') + ->paginate($this->perPage); + + return view('livewire.team.audit-log', ['events' => $events]); + } +} diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8ecafc417c..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -22,6 +22,8 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); + $invitationEmail = $invitation->email; + $invitationUuid = $invitation->uuid; DB::transaction(function () use ($invitation): void { $user = User::whereEmail($invitation->email)->first(); if (filled($user)) { @@ -30,6 +32,11 @@ class Invitations extends Component $invitation->delete(); }); + auditLog('ui.team_invitation.revoked', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitationUuid, + 'invitation_email' => $invitationEmail, + ]); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -103,6 +103,13 @@ class InviteLink extends Component 'link' => $link, 'via' => $sendEmail ? 'email' : 'link', ]); + auditLog('ui.team_invitation.created', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitation->uuid, + 'invitation_email' => $invitation->email, + 'role' => $invitation->role, + 'via' => $invitation->via, + ]); if ($sendEmail) { $mail = new MailMessage; $mail->view('emails.invitation-link', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 38c932c39d..d99fd2eb1b 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -30,6 +30,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -50,6 +51,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -70,6 +72,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -90,6 +93,12 @@ class Member extends Component $this->member->teams()->detach($teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + auditLog('ui.team_member.removed', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + ]); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); @@ -103,4 +112,15 @@ class Member extends Component { return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } + + private function auditRoleUpdate(int $teamId, Role $role): void + { + auditLog('ui.team_member.role_updated', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + 'role' => $role->value, + ]); + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..824e58a154 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,6 +7,7 @@ use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -121,10 +122,10 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ - use HasFactory; + use Auditable, HasFactory; + + use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; +use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -44,6 +45,44 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + protected static function booted(): void + { + static::created(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() || ! $deployment->rollback) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.application.rollback", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + 'commit' => $deployment->commit, + ]); + }); + + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() + || ! $deployment->wasChanged('status') + || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.deployment.cancelled", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + }); + } + protected $fillable = [ 'application_id', 'deployment_uuid', diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..8ef1a3dbac --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,171 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + /** + * @param array $context + */ + public static function record(string $event, array $context = []): void + { + try { + $attributes = self::attributesFor($event, $context); + + if ($attributes === null) { + return; + } + + DB::afterCommit(function () use ($attributes): void { + defer(function () use ($attributes): void { + try { + self::query()->create($attributes); + } catch (Throwable) { + } + })->always(); + }); + } catch (Throwable) { + } + } + + /** + * @param array $context + * @return array + */ + private static function attributesFor(string $event, array $context): array + { + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') + ?? data_get($context, 'team_id') + ?? currentTeam()?->id + ?? self::teamIdFromContext($context); + + $parts = explode('.', $event); + $source = $parts[0] ?? 'system'; + $resourceType = $parts[1] ?? null; + $action = end($parts) ?: 'event'; + $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); + $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $actorType = match (true) { + in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, + $token !== null => 'api_token', + $user !== null => 'user', + default => 'system', + }; + + return [ + 'team_id' => $teamId, + 'event' => $event, + 'source' => $source, + 'action' => $action, + 'actor_type' => $actorType, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + 'actor_token_id' => $token?->id, + 'actor_token_name' => $token?->name, + 'resource_type' => $resourceType, + 'resource_uuid' => $resourceUuid, + 'resource_name' => $resourceName, + 'description' => data_get($context, 'audit_description') + ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), + 'metadata' => self::redact($context), + 'ip_address' => app()->bound('request') ? request()->ip() : null, + 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, + ]; + } + + /** + * @param array $context + */ + private static function teamIdFromContext(array $context): ?int + { + $applicationUuid = data_get($context, 'application_uuid'); + if (! is_string($applicationUuid) || $applicationUuid === '') { + return null; + } + + return Application::query() + ->where('uuid', $applicationUuid) + ->first()?->team()?->id; + } + + public static function pruneExpired(): int + { + return self::query() + ->where('created_at', '<', now()->subDays(90)) + ->delete(); + } + + /** + * @param array $context + */ + private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed + { + if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { + return data_get($context, $preferredKey); + } + + $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); + + return $key ? data_get($context, $key) : null; + } + + private static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential/i', $key)) { + return '[REDACTED]'; + } + + if (! is_array($value)) { + return $value; + } + + return collect($value) + ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ + $itemKey => self::redact($item, (string) $itemKey), + ]) + ->all(); + } +} diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,8 +22,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..f4872e5c14 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -34,6 +35,8 @@ use OpenApi\Attributes as OA; )] class EnvironmentVariable extends BaseModel { + use Auditable; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; protected $attributes = [ diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 564fbcf6a4..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + use Auditable; + public function delete(): ?bool { return DB::transaction(fn () => parent::delete()); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index c6c2b84095..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\Crypt; class GitlabApp extends BaseModel { + use Auditable; + protected $fillable = [ 'name', 'organization', diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasFactory, HasSafeStringAttribute, WithRateLimiting; + use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..677af58666 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,8 +21,8 @@ use OpenApi\Attributes as OA; )] class Project extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { - use HasFactory, HasSafeStringAttribute; + use Auditable, HasFactory, HasSafeStringAttribute; private const CONNECTION_TIMEOUT_SECONDS = 15; diff --git a/app/Models/Server.php b/app/Models/Server.php index f7a4bf20c0..b6e1b92d99 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..16d5673a86 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -43,7 +44,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model { + use Auditable; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..e3e1c249f3 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..9b0ec923b2 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..7b1b20b2fe 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..7ac68aa597 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..33fb862164 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..ec3c7b5795 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..92796aea6a 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..f9877a16c7 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..b42f7daf29 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..433ea13a23 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,82 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, ['updated_at', 'order', 'status'], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..b6c24e0b32 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,16 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'team_id' => $application->team()?->id, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..f3a268f00a --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['team_id', 'created_at']); + $table->index(['team_id', 'action', 'created_at']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php index e9a5fbd9b0..80bf767273 100644 --- a/resources/views/components/team/settings-layout.blade.php +++ b/resources/views/components/team/settings-layout.blade.php @@ -12,6 +12,12 @@ 'active' => request()->routeIs('team.member.index'), 'icon' => 'teams', ], + [ + 'label' => 'Audit log', + 'route' => 'team.audit-log', + 'active' => request()->routeIs('team.audit-log'), + 'icon' => 'time-back', + ], isInstanceAdmin() ? [ 'label' => 'Admin View', 'route' => 'team.admin-view', diff --git a/resources/views/livewire/team/audit-log.blade.php b/resources/views/livewire/team/audit-log.blade.php new file mode 100644 index 0000000000..2dcb544785 --- /dev/null +++ b/resources/views/livewire/team/audit-log.blade.php @@ -0,0 +1,116 @@ + + + Team Audit Log | Coolify + + + + + + + + + + + + + + + + + + + + + @if ($events->isNotEmpty()) + + + + Actor + Activity + Source + Time + + @foreach ($events as $event) + + + + {{ $event->actor_name ?: Str::headline($event->actor_type) }} + + @if ($event->actor_email) + + {{ $event->actor_email }} + + @endif + @if ($event->actor_token_name) + + Token: {{ $event->actor_token_name }} + + @endif + + + + {{ $event->description }} + + + {{ $event->event }} + + + + + {{ Str::upper($event->source) }} + + {{ Str::headline($event->action) }} + + + {{ $event->created_at->diffForHumans() }} + + + @endforeach + + + + + + + + + @else + + @endif + + + + diff --git a/routes/web.php b/routes/web.php index c81c46b92d..3aa32dc661 100644 --- a/routes/web.php +++ b/routes/web.php @@ -97,6 +97,7 @@ use App\Livewire\Subscription\Index as SubscriptionIndex; use App\Livewire\Subscription\Show as SubscriptionShow; use App\Livewire\Tags\Show as TagsShow; use App\Livewire\Team\AdminView as TeamAdminView; +use App\Livewire\Team\AuditLog as TeamAuditLog; use App\Livewire\Team\DangerZone as TeamDangerZone; use App\Livewire\Team\Index as TeamIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex; @@ -206,6 +207,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::prefix('team')->group(function () { Route::get('/', TeamIndex::class)->name('team.index'); Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); }); diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php new file mode 100644 index 0000000000..e3fb8395df --- /dev/null +++ b/tests/Feature/AuditEventsTest.php @@ -0,0 +1,614 @@ +withoutDefer(); + + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Log::spy(); +}); + +test('audit inserts are deferred until after the response', function () { + $this->withDefer(); + + auditLog('ui.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'project_name' => 'Website', + ]); + + expect(AuditEvent::query()->count())->toBe(0); + + defer()->invoke(); + + expect(AuditEvent::query()->count())->toBe(1); +}); + +test('multiple audit inserts in one request are all deferred', function () { + $this->withDefer(); + + auditLog('ui.application.deployed', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + ]); + auditLog('ui.application.updated', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + ]); + + defer()->invoke(); + + expect(AuditEvent::query()->pluck('event')->all())->toBe([ + 'ui.application.deployed', + 'ui.application.updated', + ]); +}); + +test('http kernel invokes deferred callbacks', function () { + $kernel = app(Kernel::class); + $middleware = (new ReflectionClass($kernel))->getProperty('middleware')->getValue($kernel); + + expect($middleware)->toContain(InvokeDeferredCallbacks::class); +}); + +test('audit persistence failures do not fail the action', function () { + Schema::drop('audit_events'); + + auditLog('ui.project.updated', ['team_id' => $this->team->id]); + + expect(true)->toBeTrue(); +}); + +test('audit log persists a structured event for the current team', function () { + auditLog('ui.application.updated', [ + 'application_uuid' => 'app-123', + 'application_name' => 'Website', + 'changed' => ['name'], + ]); + + $event = AuditEvent::query()->sole(); + + expect($event->team_id)->toBe($this->team->id) + ->and($event->actor_id)->toBe($this->user->id) + ->and($event->actor_email)->toBe($this->user->email) + ->and($event->source)->toBe('ui') + ->and($event->action)->toBe('updated') + ->and($event->resource_type)->toBe('application') + ->and($event->resource_uuid)->toBe('app-123') + ->and($event->resource_name)->toBe('Website') + ->and($event->metadata['changed'])->toBe(['name']); +}); + +test('auditable models record authenticated create update and delete actions', function () { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Website project', + ]); + $project->update(['name' => 'Renamed project']); + $project->delete(); + + $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get(); + + expect($events->pluck('event')->all())->toBe([ + 'ui.project.created', + 'ui.project.updated', + 'ui.project.deleted', + ])->and($events[1]->metadata['changed_fields'])->toBe(['name']); +}); + +test('auditable model mutations succeed when audit persistence fails', function () { + Schema::rename('audit_events', 'unavailable_audit_events'); + + try { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Persisted project', + ]); + } finally { + Schema::rename('unavailable_audit_events', 'audit_events'); + } + + expect($project->exists)->toBeTrue() + ->and(Project::query()->whereKey($project->id)->exists())->toBeTrue(); +}); + +test('repeated events for the same resource are each persisted', function () { + auditLog('api.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'changed_fields' => ['name'], + ]); + auditLog('api.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'changed_fields' => ['description'], + ]); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events[0]->metadata['changed_fields'])->toBe(['name']) + ->and($events[1]->metadata['changed_fields'])->toBe(['description']); +}); + +test('automatic and explicit auditing both preserve their events', function () { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Website project', + ]); + + auditLog('ui.project.created', [ + 'team_id' => $this->team->id, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + 'audit_description' => 'Project created through the API', + 'request_field' => 'preserved', + ]); + + $events = AuditEvent::query()->where('event', 'ui.project.created')->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events[1]->description)->toBe('Project created through the API') + ->and($events[1]->metadata['request_field'])->toBe('preserved'); +}); + +test('auditable models ignore unauthenticated mutations', function () { + auth()->logout(); + + Project::factory()->create(['team_id' => $this->team->id]); + + expect(AuditEvent::query()->count())->toBe(0); +}); + +test('webhook audits resolve the team from the application', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + auth()->logout(); + session()->forget('currentTeam'); + + auditLog('webhook.deployment.queued', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'webhook.deployment.queued', + 'resource_uuid' => $application->uuid, + ]); +}); + +test('unauthenticated webhook failures without a team are preserved', function () { + auth()->logout(); + session()->forget('currentTeam'); + + auditLogWebhookFailure('sentinel', 'token_missing'); + auditLogWebhookFailure('stripe', 'invalid_signature'); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('event')->all())->toBe([ + 'webhook.sentinel.signature_failed', + 'webhook.stripe.signature_failed', + ]) + ->and($events->pluck('team_id')->all())->toBe([null, null]); +}); + +test('early Sentinel and Stripe rejections persist unscoped audit events', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized(); + + config(['subscription.stripe_webhook_secret' => 'whsec_test']); + $this->withHeader('Stripe-Signature', 'invalid') + ->call('POST', '/webhooks/payments/stripe/events', [], [], [], [], '{}') + ->assertBadRequest(); + + expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([ + 'webhook.sentinel.signature_failed', + 'webhook.stripe.signature_failed', + ])->and(AuditEvent::query()->whereNotNull('team_id')->doesntExist())->toBeTrue(); +}); + +test('unscoped audit events are only visible to the instance team', function () { + AuditEvent::factory()->create([ + 'team_id' => null, + 'description' => 'Unscoped security failure', + ]); + + Livewire::test(AuditLog::class) + ->assertDontSee('Unscoped security failure'); + + $instanceTeam = Team::factory()->create(['id' => 0]); + $instanceTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->user->unsetRelation('teams'); + session(['currentTeam' => $instanceTeam]); + + Livewire::test(AuditLog::class) + ->assertSee('Unscoped security failure'); +}); + +test('auditable models identify personal access token mutations as api events', function () { + $newToken = $this->user->createToken('audit-api'); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->actingAs($this->user->withAccessToken($newToken->accessToken->fresh())); + + Project::factory()->create(['team_id' => $this->team->id]); + + expect(AuditEvent::query()->where('resource_type', 'project')->firstOrFail()->event) + ->toBe('api.project.created'); +}); + +test('API audit events identify the responsible access token', function () { + $firstToken = $this->user->createToken('first-token'); + $firstToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $secondToken = $this->user->createToken('second-token'); + $secondToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + + foreach ([$firstToken->accessToken->fresh(), $secondToken->accessToken->fresh()] as $token) { + $this->actingAs($this->user->withAccessToken($token)); + auditLog('api.project.updated', ['team_id' => $this->team->id]); + } + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events->pluck('actor_token_id')->all())->toBe([ + $firstToken->accessToken->id, + $secondToken->accessToken->id, + ])->and($events->pluck('actor_token_name')->all())->toBe([ + 'first-token', + 'second-token', + ]); + + Livewire::test(AuditLog::class) + ->assertSee('Token: first-token') + ->assertSee('Token: second-token'); +}); + +test('API model mutations produce one audit event', function () { + $this->withoutExceptionHandling(); + $token = $this->user->createToken('audit-api', ['root']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + auth()->logout(); + auth()->forgetGuards(); + + $response = $this->withToken($token->plainTextToken)->postJson('/api/v1/projects', [ + 'name' => 'Single API audit event', + ]); + + $response->assertCreated(); + + expect(AuditEvent::query() + ->where('event', 'api.project.created') + ->where('resource_uuid', $response->json('uuid')) + ->count())->toBe(1); +}); + +test('deployment queue records rollback and cancellation operations', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'rollback-deployment', + 'commit' => 'abc123', + 'rollback' => true, + 'status' => 'queued', + ]); + + $deployment->update(['status' => 'cancelled-by-user']); + + expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([ + 'ui.application.rollback', + 'ui.deployment.cancelled', + ]); +}); + +test('team resource models opt in to automatic auditing', function (string $model) { + expect(class_uses_recursive($model))->toContain(Auditable::class); +})->with([ + Application::class, + Service::class, + Server::class, + Project::class, + Environment::class, + EnvironmentVariable::class, + SharedEnvironmentVariable::class, + PrivateKey::class, + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneRedis::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, +]); + +test('audit log redacts sensitive metadata', function () { + auditLog('api.application.updated', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + 'token' => 'secret-token', + 'nested' => ['password' => 'secret-password', 'safe' => 'visible'], + ]); + + $metadata = AuditEvent::query()->sole()->metadata; + + expect($metadata['token'])->toBe('[REDACTED]') + ->and($metadata['nested']['password'])->toBe('[REDACTED]') + ->and($metadata['nested']['safe'])->toBe('visible'); +}); + +test('audit log page only shows events for the current team', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'description' => 'Website created', + ]); + AuditEvent::factory()->create([ + 'team_id' => Team::factory()->create()->id, + 'description' => 'Private app deleted', + ]); + + Livewire::test(AuditLog::class) + ->assertSee('Website created') + ->assertDontSee('Private app deleted'); +}); + +test('audit log is available under team settings', function () { + $this->get('/team/audit-log') + ->assertSuccessful() + ->assertSeeLivewire(AuditLog::class); +}); + +test('audit source filter omits the unused system source', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->not->toContain("['value' => 'system', 'label' => 'System']"); +}); + +test('critical UI operations have explicit audit events', function (string $path, string $event) { + expect(file_get_contents(base_path($path)))->toContain("'{$event}'"); +})->with([ + ['app/Livewire/Project/Application/Heading.php', 'ui.application.stopped'], + ['app/Livewire/Project/Application/Previews.php', 'ui.application.preview_stopped'], + ['app/Livewire/Project/Shared/Destination.php', 'ui.application.destination_stopped'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.started'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.stopped'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.restarted'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.started'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.stopped'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.restarted'], + ['app/Livewire/Server/Navbar.php', 'ui.proxy.stopped'], + ['app/Livewire/Server/Navbar.php', 'ui.proxy.restarted'], + ['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_started'], + ['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_schedule_deleted'], + ['app/Livewire/Project/Database/ImportForm.php', 'ui.database.import_started'], + ['app/Livewire/Project/Database/ImportForm.php', 'ui.database.restore_started'], + ['app/Livewire/Project/Shared/ScheduledTask/Show.php', 'ui.scheduled_task.executed'], + ['app/Livewire/Security/ApiTokens.php', 'ui.api_token.created'], + ['app/Livewire/Security/ApiTokens.php', 'ui.api_token.revoked'], + ['app/Livewire/Team/Member.php', 'ui.team_member.role_updated'], + ['app/Livewire/Team/Member.php', 'ui.team_member.removed'], + ['app/Livewire/Team/InviteLink.php', 'ui.team_invitation.created'], + ['app/Livewire/Team/Invitations.php', 'ui.team_invitation.revoked'], + ['app/Livewire/Server/DockerCleanup.php', 'ui.server.docker_cleanup_started'], + ['app/Livewire/Server/TransferImport.php', 'ui.server.imported'], + ['app/Livewire/Project/CloneMe.php', 'ui.project.clone_started'], + ['app/Livewire/Project/Shared/ResourceOperations.php', 'ui.resource.clone_started'], +]); + +test('critical operational events persist with their source action and actor', function (string $event) { + auditLog($event, [ + 'team_id' => $this->team->id, + 'resource_uuid' => 'resource-123', + 'resource_name' => 'Test resource', + ]); + + $auditEvent = AuditEvent::query()->sole(); + + expect($auditEvent->event)->toBe($event) + ->and($auditEvent->source)->toBe(str($event)->before('.')->value()) + ->and($auditEvent->action)->toBe(str($event)->afterLast('.')->value()) + ->and($auditEvent->actor_email)->toBe($this->user->email); +})->with([ + 'ui.application.stopped', + 'ui.application.preview_stopped', + 'ui.application.destination_stopped', + 'ui.application.rollback', + 'ui.deployment.cancelled', + 'ui.service.started', + 'ui.service.stopped', + 'ui.service.restarted', + 'ui.database.started', + 'ui.database.stopped', + 'ui.database.restarted', + 'ui.proxy.stopped', + 'ui.proxy.restarted', + 'ui.database.backup_started', + 'ui.database.backup_schedule_deleted', + 'ui.database.import_started', + 'ui.database.restore_started', + 'ui.scheduled_task.executed', + 'ui.api_token.created', + 'ui.api_token.revoked', + 'ui.team_member.role_updated', + 'ui.team_member.removed', + 'ui.team_invitation.created', + 'ui.team_invitation.revoked', + 'ui.server.docker_cleanup_started', + 'ui.server.imported', + 'ui.project.clone_started', + 'ui.resource.clone_started', + 'api.database.started', + 'api.database.stopped', + 'api.database.restarted', +]); + +test('audit log table keeps actor details visible in a mobile scroll area', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->toContain('overflow-x-auto') + ->toContain('min-w-[760px]') + ->not->toContain('hidden lg:block">Actor'); +}); + +test('audit log displays source abbreviations in uppercase', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->toContain('Str::upper($event->source)'); +}); + +test('audit log page filters events by search and action', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'action' => 'created', + 'description' => 'Website created', + 'resource_name' => 'Website', + ]); + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'event' => 'api.server.deleted', + 'action' => 'deleted', + 'description' => 'Build server deleted', + 'resource_name' => 'Build server', + ]); + + Livewire::test(AuditLog::class) + ->set('search', 'Website') + ->assertSee('Website created') + ->assertDontSee('Build server deleted') + ->set('search', '') + ->set('action', 'deleted') + ->assertDontSee('Website created') + ->assertSee('Build server deleted'); +}); + +test('updating team settings records an audit event', function () { + Livewire::test(TeamIndex::class) + ->set('name', 'Renamed team') + ->call('submit') + ->assertHasNoErrors(); + + $event = AuditEvent::query()->where('action', 'updated')->sole(); + + expect($event->event)->toBe('ui.team.updated') + ->and($event->team_id)->toBe($this->team->id) + ->and($event->resource_name)->toBe('Renamed team'); +}); + +test('updating an environment variable records an event without its value', function () { + $variable = SharedEnvironmentVariable::create([ + 'team_id' => $this->team->id, + 'type' => 'team', + 'key' => 'API_SECRET', + 'value' => 'old-secret', + ]); + + Livewire::test(Show::class, [ + 'env' => $variable, + 'type' => 'team', + ]) + ->call('loadValues') + ->set('value', 'new-secret') + ->call('submit') + ->assertHasNoErrors(); + + $event = AuditEvent::query() + ->where('resource_type', 'shared_environment_variable') + ->where('action', 'updated') + ->sole(); + + expect($event->event)->toBe('ui.shared_environment_variable.updated') + ->and($event->resource_name)->toBe('API_SECRET') + ->and(json_encode($event->metadata))->not->toContain('new-secret'); +}); + +test('creating an application environment variable records an audit event', function () { + $this->withDefer(); + + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + + $application->environment_variables()->create([ + 'key' => 'API_SECRET', + 'value' => 'secret-value', + ]); + + defer()->invoke(); + + $event = AuditEvent::query() + ->where('resource_type', 'environment_variable') + ->where('action', 'created') + ->where('resource_name', 'API_SECRET') + ->firstOrFail(); + + expect($event->team_id)->toBe($this->team->id) + ->and($event->resource_name)->toBe('API_SECRET') + ->and(json_encode($event->metadata))->not->toContain('secret-value'); +}); + +test('database cleanup removes audit events older than 90 days', function () { + $old = AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'created_at' => now()->subDays(91), + ]); + $recent = AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'created_at' => now()->subDays(89), + ]); + + AuditEvent::pruneExpired(); + + expect($old->fresh())->toBeNull() + ->and($recent->fresh())->not->toBeNull(); +}); diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php index 16cddd36ea..0c393c03b5 100644 --- a/tests/Feature/Proxy/RestartProxyTest.php +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -1,16 +1,20 @@ withoutDefer(); InstanceSettings::forceCreate(['id' => 0]); }); @@ -187,3 +191,20 @@ test('start proxy button shows a loading state while proxy startup actions run', ->assertSeeHtml('wire:loading.class="is-loading"') ->assertSeeHtml('wire:target="checkProxy,startProxy"'); }); + +test('starting a proxy records a team audit event', function () { + [$user, $team, $server] = setupProxyUser('admin'); + $activity = Activity::create([ + 'description' => 'proxy start', + 'properties' => ['team_id' => $team->id], + ]); + StartProxy::shouldRun()->andReturn($activity); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test('server.navbar', ['server' => $server]) + ->call('startProxy'); + + expect(AuditEvent::query()->sole()->event)->toBe('ui.proxy.started'); +}); diff --git a/tests/Feature/QueueApplicationDeploymentCommitTest.php b/tests/Feature/QueueApplicationDeploymentCommitTest.php index ac6be5c9e9..6b4c766fb0 100644 --- a/tests/Feature/QueueApplicationDeploymentCommitTest.php +++ b/tests/Feature/QueueApplicationDeploymentCommitTest.php @@ -8,12 +8,14 @@ use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; uses(RefreshDatabase::class); beforeEach(function () { + $this->withoutDefer(); Bus::fake([ApplicationDeploymentJob::class]); $this->team = Team::factory()->create(); @@ -42,6 +44,38 @@ function makeApplication(int $environmentId, int $destinationId, ?string $gitCom } describe('queue_application_deployment commit resolution', function () { + test('records a team audit event when a user queues a deployment', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD'); + + queue_application_deployment($application, 'audit-deploy-uuid'); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.deployed', + 'resource_uuid' => $application->uuid, + ]); + }); + + test('uses the deployed application team for the audit event', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user, ['role' => 'owner']); + $this->actingAs($user); + session()->forget('currentTeam'); + $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD'); + + queue_application_deployment($application, 'resource-team-audit-deploy'); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.deployed', + 'resource_uuid' => $application->uuid, + ]); + }); + test('uses application git_commit_sha when commit parameter omitted', function () { $pinnedSha = 'abc123def456abc123def456abc123def456abc1'; $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);