feat(audit): add team activity tracking and audit log

This commit is contained in:
Andras Bacsai
2026-08-22 13:18:14 +02:00
parent 0bcaa4f51a
commit 40e5fd8521
60 changed files with 1486 additions and 101 deletions
+7
View File
@@ -2,6 +2,7 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\AuditEvent;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
$activity_log->delete(); $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 // 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); $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(); $count = $application_deployment_queues->count();
@@ -5630,14 +5630,6 @@ class ApplicationsController extends Controller
return response()->json(['message' => $result['message']], 200); 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([ return response()->json([
'message' => 'Rollback deployment queued.', 'message' => 'Rollback deployment queued.',
'deployment_uuid' => $deployment_uuid, 'deployment_uuid' => $deployment_uuid,
@@ -271,12 +271,6 @@ class ProjectController extends Controller
'team_id' => $teamId, 'team_id' => $teamId,
]); ]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([ return response()->json([
'uuid' => $project->uuid, 'uuid' => $project->uuid,
])->setStatusCode(201); ])->setStatusCode(201);
@@ -396,13 +390,6 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields)); $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([ return response()->json([
'uuid' => $project->uuid, 'uuid' => $project->uuid,
'name' => $project->name, 'name' => $project->name,
@@ -482,16 +469,8 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
} }
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete(); $project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']); return response()->json(['message' => 'Project deleted.']);
} }
+2
View File
@@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders; use Illuminate\Http\Middleware\SetCacheHeaders;
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
ValidatePostSize::class, ValidatePostSize::class,
TrimStrings::class, TrimStrings::class,
ConvertEmptyStringsToNull::class, ConvertEmptyStringsToNull::class,
InvokeDeferredCallbacks::class,
]; ];
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
$this->application_deployment_queue->update([ $this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]); ]);
try { try {
if ($this->application->settings->is_build_server_enabled) { if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id); $server = Server::ownedByCurrentTeam()->find($build_server_id);
@@ -156,6 +156,11 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.'); $this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup); 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) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -377,6 +377,12 @@ class Previews extends Component
ApplicationPreview::where('application_id', $this->application->id) ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id) ->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']); ->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); ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server); GetContainersStatus::run($server);
+8
View File
@@ -102,6 +102,14 @@ class CloneMe extends Component
if (! $selectedDestination) { if (! $selectedDestination) {
throw new \Exception('Destination not found.'); 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') { if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first(); $foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) { if ($foundProject) {
+17 -4
View File
@@ -207,10 +207,18 @@ class BackupEdit extends Component
} }
} }
$database = $this->backup->database;
$backupUuid = $this->backup->uuid;
$this->backup->delete(); $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) { if ($database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database; $serviceDatabase = $database;
return redirect()->route('project.service.database.backups', [ return redirect()->route('project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'], 'project_uuid' => $this->parameters['project_uuid'],
@@ -238,9 +246,14 @@ class BackupEdit extends Component
$this->authorize('manageBackups', $this->backup->database); $this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup); DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
$database = $this->backup->database; $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) { if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [ return redirect()->route('project.service.database.backup.executions', [
@@ -18,6 +18,13 @@ class BackupNow extends Component
$this->authorize('manageBackups', $this->backup->database); $this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup); 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.'); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
+12
View File
@@ -83,6 +83,7 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping database.'); $this->dispatch('info', 'Gracefully stopping database.');
StopDatabase::dispatch($this->database, false, $this->docker_cleanup); StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
$this->auditDatabaseAction('ui.database.stopped');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
@@ -94,6 +95,7 @@ class Heading extends Component
$this->authorize('manage', $this->database); $this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database); $activity = RestartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -107,6 +109,7 @@ class Heading extends Component
$this->authorize('manage', $this->database); $this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database); $activity = StartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.started');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) { } 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,
]);
}
} }
@@ -510,6 +510,12 @@ EOD;
// Dispatch activity to the monitor and open slide-over // Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore'); $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) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
@@ -768,6 +774,13 @@ EOD;
// Dispatch activity to the monitor and open slide-over // Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore'); $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...'); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->importRunning = false; $this->importRunning = false;
+12
View File
@@ -113,6 +113,7 @@ class Heading extends Component
try { try {
$this->authorizeService('deploy'); $this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true); $activity = StartService::run($this->service, pullLatestImages: true);
$this->auditServiceAction('ui.service.started');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -146,6 +147,7 @@ class Heading extends Component
try { try {
$this->authorizeService('stop'); $this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup); StopService::dispatch($this->service, false, $this->docker_cleanup);
$this->auditServiceAction('ui.service.stopped');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@@ -162,6 +164,7 @@ class Heading extends Component
return; return;
} }
$activity = StartService::run($this->service, stopBeforeStart: true); $activity = StartService::run($this->service, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -196,6 +199,15 @@ class Heading extends Component
$this->authorize($ability, $this->service); $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() public function render()
{ {
return view('livewire.project.service.heading', [ return view('livewire.project.service.heading', [
@@ -64,6 +64,13 @@ class Destination extends Component
$this->authorize('deploy', $this->resource); $this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId); $server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server); 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(); $this->refreshServers();
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -81,6 +81,14 @@ class ResourceOperations extends Component
if (! $new_destination) { if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.'); 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(); $uuid = new_public_id();
$server = $new_destination->server; $server = $new_destination->server;
if (! $server->canHostResources()) { if (! $server->canHostResources()) {
@@ -184,6 +184,13 @@ class Show extends Component
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task); $this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($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.'); $this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e); return handleError($e);
@@ -204,6 +204,12 @@ class VolumeBackups extends Component
} }
VolumeBackupJob::dispatch($this->backup); 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.'); $this->dispatch('success', 'Storage backup queued.');
return redirect()->route($this->routeName('executions'), $this->routeParameters()); return redirect()->route($this->routeName('executions'), $this->routeParameters());
+11
View File
@@ -140,6 +140,12 @@ class ApiTokens extends Component
]); ]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); $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(); $this->getTokens();
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken); session()->flash('token', $token->plainTextToken);
@@ -156,7 +162,12 @@ class ApiTokens extends Component
->where('id', $id) ->where('id', $id)
->firstOrFail(); ->firstOrFail();
$this->authorize('delete', $token); $this->authorize('delete', $token);
$tokenName = $token->name;
$token->delete(); $token->delete();
auditLog('ui.api_token.revoked', [
'team_id' => currentTeam()->id,
'api_token_name' => $tokenName,
]);
$this->getTokens(); $this->getTokens();
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
+7
View File
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
try { try {
$this->authorize('update', $this->server); $this->authorize('update', $this->server);
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); 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.'); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
+16
View File
@@ -101,6 +101,11 @@ class Navbar extends Component
// Always use background job for all servers // Always use background job for all servers
RestartProxyJob::dispatch($this->server); 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) { } catch (\Throwable $e) {
$this->restartInitiated = false; $this->restartInitiated = false;
@@ -125,6 +130,11 @@ class Navbar extends Component
try { try {
$this->authorize('manageProxy', $this->server); $this->authorize('manageProxy', $this->server);
$activity = StartProxy::run($this->server, force: true); $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); $this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -136,6 +146,12 @@ class Navbar extends Component
try { try {
$this->authorize('manageProxy', $this->server); $this->authorize('manageProxy', $this->server);
StopProxy::dispatch($this->server, $forceStop); 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) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
+9
View File
@@ -123,6 +123,15 @@ class TransferImport extends Component
$this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); $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) { if ($dryRun) {
$this->dispatch('success', 'Dry run completed — nothing was written.'); $this->dispatch('success', 'Dry run completed — nothing was written.');
} elseif (data_get($result, 'claimed')) { } elseif (data_get($result, 'claimed')) {
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Team;
use App\Models\AuditEvent;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithPagination;
class AuditLog extends Component
{
use WithPagination;
public string $search = '';
public string $action = 'all';
public string $source = 'all';
public int $perPage = 25;
public function updatedSearch(): void
{
$this->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]);
}
}
+7
View File
@@ -22,6 +22,8 @@ class Invitations extends Component
$this->authorize('manageInvitations', currentTeam()); $this->authorize('manageInvitations', currentTeam());
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$invitationEmail = $invitation->email;
$invitationUuid = $invitation->uuid;
DB::transaction(function () use ($invitation): void { DB::transaction(function () use ($invitation): void {
$user = User::whereEmail($invitation->email)->first(); $user = User::whereEmail($invitation->email)->first();
if (filled($user)) { if (filled($user)) {
@@ -30,6 +32,11 @@ class Invitations extends Component
$invitation->delete(); $invitation->delete();
}); });
auditLog('ui.team_invitation.revoked', [
'team_id' => currentTeam()->id,
'invitation_uuid' => $invitationUuid,
'invitation_email' => $invitationEmail,
]);
$this->refreshInvitations(); $this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.'); $this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) { } catch (\Exception) {
+7
View File
@@ -103,6 +103,13 @@ class InviteLink extends Component
'link' => $link, 'link' => $link,
'via' => $sendEmail ? 'email' : '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) { if ($sendEmail) {
$mail = new MailMessage; $mail = new MailMessage;
$mail->view('emails.invitation-link', [ $mail->view('emails.invitation-link', [
+20
View File
@@ -30,6 +30,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
}); });
$this->auditRoleUpdate($teamId, Role::ADMIN);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -50,6 +51,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
}); });
$this->auditRoleUpdate($teamId, Role::OWNER);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -70,6 +72,7 @@ class Member extends Component
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
}); });
$this->auditRoleUpdate($teamId, Role::MEMBER);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -90,6 +93,12 @@ class Member extends Component
$this->member->teams()->detach($teamId); $this->member->teams()->detach($teamId);
RevokeUserTeamTokens::forUserTeam($this->member, $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 // Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}"); Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}"); 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; 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,
]);
}
} }
+4 -3
View File
@@ -7,6 +7,7 @@ use App\Services\ConfigurationGenerator;
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiff;
use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration; use App\Traits\HasConfiguration;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -121,10 +122,10 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel class Application extends BaseModel
{ {
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
/** @use HasFactory<ApplicationFactory> */ /** @use HasFactory<ApplicationFactory> */
use HasFactory; use Auditable, HasFactory;
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
+39
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Casts\EncryptedArrayCast; use App\Casts\EncryptedArrayCast;
use App\Enums\ApplicationDeploymentStatus;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -44,6 +45,44 @@ use OpenApi\Attributes as OA;
)] )]
class ApplicationDeploymentQueue extends Model 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 = [ protected $fillable = [
'application_id', 'application_id',
'deployment_uuid', 'deployment_uuid',
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class AuditEvent extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'team_id',
'event',
'source',
'action',
'actor_type',
'actor_id',
'actor_name',
'actor_email',
'actor_token_id',
'actor_token_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'metadata',
'ip_address',
'user_agent',
'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
/**
* @param array<string, mixed> $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<string, mixed> $context
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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();
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -21,8 +22,8 @@ use OpenApi\Attributes as OA;
)] )]
class Environment extends BaseModel class Environment extends BaseModel
{ {
use Auditable, HasFactory;
use ClearsGlobalSearchCache; use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute; use HasSafeStringAttribute;
protected $fillable = [ protected $fillable = [
+3
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -34,6 +35,8 @@ use OpenApi\Attributes as OA;
)] )]
class EnvironmentVariable extends BaseModel class EnvironmentVariable extends BaseModel
{ {
use Auditable;
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
protected $attributes = [ protected $attributes = [
+3
View File
@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class GithubApp extends BaseModel class GithubApp extends BaseModel
{ {
use Auditable;
public function delete(): ?bool public function delete(): ?bool
{ {
return DB::transaction(fn () => parent::delete()); return DB::transaction(fn () => parent::delete());
+3
View File
@@ -2,12 +2,15 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
class GitlabApp extends BaseModel class GitlabApp extends BaseModel
{ {
use Auditable;
protected $fillable = [ protected $fillable = [
'name', 'name',
'organization', 'organization',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use DanHarrin\LivewireRateLimiting\WithRateLimiting; use DanHarrin\LivewireRateLimiting\WithRateLimiting;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader;
)] )]
class PrivateKey extends BaseModel class PrivateKey extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute, WithRateLimiting; use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting;
protected $fillable = [ protected $fillable = [
'name', 'name',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -20,8 +21,8 @@ use OpenApi\Attributes as OA;
)] )]
class Project extends BaseModel class Project extends BaseModel
{ {
use Auditable, HasFactory;
use ClearsGlobalSearchCache; use ClearsGlobalSearchCache;
use HasFactory;
use HasSafeStringAttribute; use HasSafeStringAttribute;
protected $fillable = [ protected $fillable = [
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Rules\SafeWebhookUrl; use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName; use App\Rules\ValidS3BucketName;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel class S3Storage extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute; use Auditable, HasFactory, HasSafeStringAttribute;
private const CONNECTION_TIMEOUT_SECONDS = 15; private const CONNECTION_TIMEOUT_SECONDS = 15;
+2 -1
View File
@@ -21,6 +21,7 @@ use App\Services\DigitalOceanService;
use App\Services\HetznerService; use App\Services\HetznerService;
use App\Services\VultrService; use App\Services\VultrService;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
@@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml;
class Server extends BaseModel 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 * Sentinel IP for servers that do not have a real address yet
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -43,7 +44,7 @@ use Symfony\Component\Yaml\Yaml;
)] )]
class Service extends BaseModel class Service extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5'; private static $parserVersion = '5';
+3
View File
@@ -3,11 +3,14 @@
namespace App\Models; namespace App\Models;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model class SharedEnvironmentVariable extends Model
{ {
use Auditable;
protected $fillable = [ protected $fillable = [
// Core identification // Core identification
'key', 'key',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel class StandaloneClickhouse extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel class StandaloneDragonfly extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel class StandaloneKeydb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel class StandaloneMariadb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel class StandaloneMongodb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel class StandaloneMysql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel class StandalonePostgresql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel class StandaloneRedis extends BaseModel
{ {
use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\Auditable;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -18,7 +19,7 @@ use OpenApi\Attributes as OA;
)] )]
class Tag extends BaseModel class Tag extends BaseModel
{ {
use HasSafeStringAttribute; use Auditable, HasSafeStringAttribute;
protected $fillable = [ protected $fillable = [
'name', 'name',
+2 -1
View File
@@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsPushover;
use App\Notifications\Channels\SendsSlack; use App\Notifications\Channels\SendsSlack;
use App\Traits\Auditable;
use App\Traits\HasNotificationSettings; use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -39,7 +40,7 @@ use OpenApi\Attributes as OA;
class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack
{ {
use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable;
protected $fillable = [ protected $fillable = [
'name', 'name',
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Traits;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
trait Auditable
{
public static function bootAuditable(): void
{
static::created(fn (Model $model) => $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;
}
}
+10
View File
@@ -84,6 +84,16 @@ function queue_application_deployment(Application $application, string $deployme
'only_this_server' => $only_this_server, '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) { if ($no_questions_asked) {
$deployment->update([ $deployment->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
+7 -41
View File
@@ -1,13 +1,10 @@
<?php <?php
use Illuminate\Support\Facades\Log; use App\Models\AuditEvent;
if (! function_exists('auditLog')) { if (! function_exists('auditLog')) {
/** /**
* Write a security-relevant audit entry to the dedicated `audit` log channel. * Queue an audit event for persistence after the response.
*
* Never include secrets (private keys, passwords, tokens, webhook secrets,
* signature header values, env-var values) in $context.
* *
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`. * @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
* @param array<string, mixed> $context Identifiers + outcome details. * @param array<string, mixed> $context Identifiers + outcome details.
@@ -16,39 +13,15 @@ if (! function_exists('auditLog')) {
function auditLog(string $event, array $context = [], string $level = 'info'): void function auditLog(string $event, array $context = [], string $level = 'info'): void
{ {
try { try {
$request = app()->bound('request') ? request() : null; AuditEvent::record($event, $context);
$user = auth()->check() ? auth()->user() : null; } catch (Throwable) {
$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) {
}
} }
} }
} }
if (! function_exists('auditLogWebhookFailure')) { 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 function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
{ {
@@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) {
$event = "webhook.{$provider}.signature_failed"; $event = "webhook.{$provider}.signature_failed";
$base = [ $base = [
'event' => $event,
'reason' => $reason, 'reason' => $reason,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'method' => $request?->method(), 'method' => $request?->method(),
'path' => $request?->path(), 'path' => $request?->path(),
'event_header' => $request?->header('X-GitHub-Event') 'event_header' => $request?->header('X-GitHub-Event')
@@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) {
?? $request?->header('X-Event-Key'), ?? $request?->header('X-Event-Key'),
]; ];
Log::channel('audit')->warning($event, array_merge($base, $context)); auditLog($event, array_merge($base, $context), 'warning');
} catch (Throwable $e) { } catch (Throwable) {
try {
Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
} catch (Throwable) {
}
} }
} }
} }
-7
View File
@@ -133,13 +133,6 @@ return [
'days' => 14, '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,
],
], ],
]; ];
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\AuditEvent;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AuditEvent>
*/
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(),
];
}
}
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_events', function (Blueprint $table): void {
$table->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');
}
};
@@ -12,6 +12,12 @@
'active' => request()->routeIs('team.member.index'), 'active' => request()->routeIs('team.member.index'),
'icon' => 'teams', 'icon' => 'teams',
], ],
[
'label' => 'Audit log',
'route' => 'team.audit-log',
'active' => request()->routeIs('team.audit-log'),
'icon' => 'time-back',
],
isInstanceAdmin() ? [ isInstanceAdmin() ? [
'label' => 'Admin View', 'label' => 'Admin View',
'route' => 'team.admin-view', 'route' => 'team.admin-view',
@@ -0,0 +1,116 @@
<div>
<x-slot:title>
Team Audit Log | Coolify
</x-slot>
<x-team.settings-layout>
<div class="application-settings-form">
<x-application.settings-section title="Audit log"
description="Activity from the last 90 days for the current team." flush>
<div
class="flex flex-col gap-2 border-b border-neutral-200 p-3 sm:flex-row sm:items-center dark:border-white/[0.08]">
<div class="relative min-w-0 flex-1 sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input wire:model.live.debounce.300ms="search" type="search"
placeholder="Search activity" aria-label="Search activity"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
</div>
<div class="grid grid-cols-2 gap-2 sm:flex">
<div class="sm:w-36">
<x-forms.listbox id="action" live :options="[
['value' => 'all', 'label' => 'All actions'],
['value' => 'created', 'label' => 'Created'],
['value' => 'updated', 'label' => 'Updated'],
['value' => 'deleted', 'label' => 'Deleted'],
['value' => 'deployed', 'label' => 'Deployed'],
['value' => 'started', 'label' => 'Started'],
['value' => 'stopped', 'label' => 'Stopped'],
['value' => 'restarted', 'label' => 'Restarted'],
['value' => 'cancelled', 'label' => 'Cancelled'],
['value' => 'rollback', 'label' => 'Rollback'],
['value' => 'executed', 'label' => 'Executed'],
['value' => 'revoked', 'label' => 'Revoked'],
['value' => 'imported', 'label' => 'Imported'],
]" />
</div>
<div class="sm:w-36">
<x-forms.listbox id="source" live :options="[
['value' => 'all', 'label' => 'All sources'],
['value' => 'ui', 'label' => 'Web UI'],
['value' => 'api', 'label' => 'API'],
['value' => 'mcp', 'label' => 'MCP'],
['value' => 'webhook', 'label' => 'Webhook'],
]" />
</div>
</div>
</div>
@if ($events->isNotEmpty())
<div class="overflow-x-auto">
<div class="data-table min-w-[760px] transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="search,action,source,setPage,previousPage,nextPage">
<div class="grid grid-cols-[10rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
<span>Actor</span>
<span>Activity</span>
<span>Source</span>
<span class="text-right">Time</span>
</div>
@foreach ($events as $event)
<div wire:key="audit-event-{{ $event->id }}"
class="grid grid-cols-[10rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-3 last:border-b-0 dark:border-white/[0.07]">
<div class="min-w-0">
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
{{ $event->actor_name ?: Str::headline($event->actor_type) }}
</div>
@if ($event->actor_email)
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $event->actor_email }}
</div>
@endif
@if ($event->actor_token_name)
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">
Token: {{ $event->actor_token_name }}
</div>
@endif
</div>
<div class="min-w-0">
<div class="truncate text-[13px] font-medium text-black dark:text-fg">
{{ $event->description }}
</div>
<div class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $event->event }}
</div>
</div>
<div class="flex items-center gap-2 text-[12px] text-neutral-500 dark:text-fg-dim">
<span class="rounded-md bg-neutral-100 px-2 py-1 dark:bg-white/[0.06]">
{{ Str::upper($event->source) }}
</span>
<span>{{ Str::headline($event->action) }}</span>
</div>
<time datetime="{{ $event->created_at->toIso8601String() }}"
title="{{ $event->created_at->toDayDateTimeString() }}"
class="text-right text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $event->created_at->diffForHumans() }}
</time>
</div>
@endforeach
</div>
</div>
<x-table-pagination :from="$events->firstItem() ?? 0" :to="$events->lastItem() ?? 0"
:total="$events->total()" :current-page="$events->currentPage()"
:last-page="$events->lastPage()" wire-target="setPage,previousPage,nextPage"
previous-action="previousPage" next-action="nextPage">
<x-slot:pageSize>
<x-page-size-select model="perPage" livewire storage-key="coolify.page-size.audit-log" />
</x-slot:pageSize>
</x-table-pagination>
@else
<x-empty title="No activity found"
description="Team actions will appear here as they happen." icon-name="time-back" size="sm" />
@endif
</x-application.settings-section>
</div>
</x-team.settings-layout>
</div>
+2
View File
@@ -97,6 +97,7 @@ use App\Livewire\Subscription\Index as SubscriptionIndex;
use App\Livewire\Subscription\Show as SubscriptionShow; use App\Livewire\Subscription\Show as SubscriptionShow;
use App\Livewire\Tags\Show as TagsShow; use App\Livewire\Tags\Show as TagsShow;
use App\Livewire\Team\AdminView as TeamAdminView; 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\DangerZone as TeamDangerZone;
use App\Livewire\Team\Index as TeamIndex; use App\Livewire\Team\Index as TeamIndex;
use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex;
@@ -206,6 +207,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::prefix('team')->group(function () { Route::prefix('team')->group(function () {
Route::get('/', TeamIndex::class)->name('team.index'); Route::get('/', TeamIndex::class)->name('team.index');
Route::get('/members', TeamMemberIndex::class)->name('team.member.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('/admin', TeamAdminView::class)->name('team.admin-view');
Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone');
}); });
+614
View File
@@ -0,0 +1,614 @@
<?php
use App\Http\Kernel;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Team\AuditLog;
use App\Livewire\Team\Index as TeamIndex;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use App\Traits\Auditable;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});
+21
View File
@@ -1,16 +1,20 @@
<?php <?php
use App\Actions\Proxy\StartProxy;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\AuditEvent;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]); 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:loading.class="is-loading"')
->assertSeeHtml('wire:target="checkProxy,startProxy"'); ->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');
});
@@ -8,12 +8,14 @@ use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use App\Models\Team; use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
$this->withoutDefer();
Bus::fake([ApplicationDeploymentJob::class]); Bus::fake([ApplicationDeploymentJob::class]);
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
@@ -42,6 +44,38 @@ function makeApplication(int $environmentId, int $destinationId, ?string $gitCom
} }
describe('queue_application_deployment commit resolution', function () { 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 () { test('uses application git_commit_sha when commit parameter omitted', function () {
$pinnedSha = 'abc123def456abc123def456abc123def456abc1'; $pinnedSha = 'abc123def456abc123def456abc123def456abc1';
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha); $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);