mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
feat(audit): add team activity tracking and audit log
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -156,6 +156,11 @@ class Heading extends Component
|
||||
|
||||
$this->dispatch('info', 'Gracefully stopping application.<br/>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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApplicationFactory> */
|
||||
use HasFactory;
|
||||
use Auditable, HasFactory;
|
||||
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-1
@@ -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',
|
||||
|
||||
+2
-1
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\AuditEvent;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
* Write a security-relevant audit entry to the dedicated `audit` log channel.
|
||||
*
|
||||
* Never include secrets (private keys, passwords, tokens, webhook secrets,
|
||||
* signature header values, env-var values) in $context.
|
||||
* Queue an audit event for persistence after the response.
|
||||
*
|
||||
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
|
||||
* @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
|
||||
{
|
||||
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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
'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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,16 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user