fix(audit): harden audit logging and admin controls

Log audit failures, redact invitation emails, exclude noisy model fields, and gate audit-log filters and pagination by admin access.
This commit is contained in:
Andras Bacsai
2026-08-24 09:13:20 +02:00
parent c1f29beff1
commit d74352f423
15 changed files with 275 additions and 66 deletions
+1
View File
@@ -183,6 +183,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -81,6 +81,11 @@ class ResourceOperations extends Component
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
}
$uuid = new_public_id();
$server = $new_destination->server;
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
auditLog('ui.resource.clone_started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
@@ -89,11 +94,6 @@ class ResourceOperations extends Component
'destination_uuid' => $new_destination->uuid,
'environment_id' => $new_environment->id,
]);
$uuid = new_public_id();
$server = $new_destination->server;
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, [
+1 -1
View File
@@ -19,7 +19,7 @@ class AuditLog extends Component
public int $perPage = 25;
public function mount(): void
public function boot(): void
{
abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403);
}
+12 -3
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
@@ -60,11 +61,19 @@ class AuditEvent extends Model
defer(function () use ($attributes): void {
try {
self::query()->create($attributes);
} catch (Throwable) {
} catch (Throwable $exception) {
Log::warning('Audit event persistence failed', [
'event' => $attributes['event'],
'exception' => $exception::class,
]);
}
})->always();
});
} catch (Throwable) {
} catch (Throwable $exception) {
Log::warning('Audit event preparation failed', [
'event' => $event,
'exception' => $exception::class,
]);
}
}
@@ -154,7 +163,7 @@ class AuditEvent extends Model
private static function redact(mixed $value, ?string $key = null): mixed
{
if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential/i', $key)) {
if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email/i', $key)) {
return '[REDACTED]';
}
+3 -1
View File
@@ -64,7 +64,9 @@ class Project extends BaseModel
]);
});
static::deleting(function ($project) {
$project->environments()->delete();
foreach ($project->environments()->get() as $environment) {
$environment->delete();
}
$project->settings()->delete();
$shared_variables = $project->environment_variables();
foreach ($shared_variables as $shared_variable) {
+2
View File
@@ -16,6 +16,8 @@ class StandaloneClickhouse extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
'name',
+2
View File
@@ -16,6 +16,8 @@ class StandaloneRedis extends BaseModel
{
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
protected array $auditExclude = ['last_online_at'];
protected $fillable = [
'uuid',
'name',
+5 -2
View File
@@ -87,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
}
// Transfer instance-wide sources to root team so they remain available
GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
$systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get()
->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get());
foreach ($systemWideSources as $source) {
$source->update(['team_id' => 0]);
}
// Delete non-instance-wide sources owned by this team
$teamSources = GithubApp::where('team_id', $team->id)->get()
+6 -1
View File
@@ -29,7 +29,12 @@ trait Auditable
$changedFields = $action === 'updated'
? collect(array_keys($this->getChanges()))
->reject(fn (string $field): bool => in_array($field, ['updated_at', 'order', 'status'], true))
->reject(fn (string $field): bool => in_array($field, [
'updated_at',
'order',
'status',
...($this->auditExclude ?? []),
], true))
->values()
->all()
: [];
+1 -2
View File
@@ -84,9 +84,8 @@ function queue_application_deployment(Application $application, string $deployme
'only_this_server' => $only_this_server,
]);
if (auth()->check() && ! $is_webhook && ! $is_api) {
if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) {
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,
@@ -3,8 +3,15 @@
'livewire' => false,
'options' => [10, 25, 50, 100],
'storageKey' => null,
'canGate' => null,
'canResource' => null,
])
@php
$disabled = $canGate && $canResource
&& ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource);
@endphp
<div class="mb-0! flex h-7 items-center gap-1.5 text-[11px] text-neutral-500 dark:text-fg-dim"
x-data="{
selectedPageSize: @js((int) $options[0]),
@@ -36,6 +43,7 @@
<x-table.dropdown panel-class="min-w-24!">
<x-slot:trigger>
<button type="button" aria-label="Items per page" aria-haspopup="listbox" :aria-expanded="open"
@disabled($disabled)
class="inline-flex h-7! w-12! items-center justify-between border-0 px-1 text-[11px]! leading-none! tabular-nums text-neutral-500 transition-colors hover:text-black dark:text-fg-dim dark:hover:text-fg">
<span x-text="selectedPageSize"></span>
<x-reicon name="chevron-down" class="size-3 text-neutral-400 dark:text-fg-faint" />
@@ -43,6 +51,7 @@
</x-slot:trigger>
@foreach ($options as $option)
<button type="button" class="listbox-option" role="option"
@disabled($disabled)
:aria-selected="selectedPageSize === {{ (int) $option }}"
x-on:click="applyPageSize({{ (int) $option }})">
<span>{{ $option }}</span>
@@ -50,6 +59,7 @@
</button>
@endforeach
<button type="button" class="listbox-option" role="option"
@disabled($disabled)
x-on:click="customizingPageSize = true; $nextTick(() => $refs.customPageSize.focus())">
Custom…
</button>
@@ -58,6 +68,6 @@
<input x-cloak x-show="customizingPageSize" x-ref="customPageSize" x-model.number="customPageSize"
x-on:keydown.enter.prevent="applyPageSize(customPageSize)" x-on:keydown.escape.prevent="customizingPageSize = false"
x-on:blur="if (customizingPageSize) applyPageSize(customPageSize)" type="number" min="1" max="100" inputmode="numeric"
aria-label="Custom items per page"
aria-label="Custom items per page" @disabled($disabled)
class="mb-0! h-7! w-14! rounded-md! border-neutral-200! bg-transparent! px-1.5! py-0! text-[11px]! tabular-nums shadow-none! focus:border-neutral-300! focus:ring-0! dark:border-white/[0.08]! dark:text-fg-dim!" />
</div>
@@ -18,7 +18,7 @@
</div>
<div class="grid grid-cols-2 gap-2 sm:flex">
<div class="sm:w-36">
<x-forms.listbox id="action" live :options="[
<x-forms.listbox id="action" live canGate="viewAdmin" :canResource="currentTeam()" :options="[
['value' => 'all', 'label' => 'All actions'],
['value' => 'created', 'label' => 'Created'],
['value' => 'updated', 'label' => 'Updated'],
@@ -35,7 +35,7 @@
]" />
</div>
<div class="sm:w-36">
<x-forms.listbox id="source" live :options="[
<x-forms.listbox id="source" live canGate="viewAdmin" :canResource="currentTeam()" :options="[
['value' => 'all', 'label' => 'All sources'],
['value' => 'ui', 'label' => 'Web UI'],
['value' => 'api', 'label' => 'API'],
@@ -103,7 +103,8 @@
: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-page-size-select model="perPage" livewire storage-key="coolify.page-size.audit-log"
canGate="viewAdmin" :canResource="currentTeam()" />
</x-slot:pageSize>
</x-table-pagination>
@else
+182 -42
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Kernel;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Livewire\Team\AuditLog;
use App\Livewire\Team\Index as TeamIndex;
@@ -9,6 +10,8 @@ use App\Models\ApplicationDeploymentQueue;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -26,6 +29,7 @@ use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use App\Traits\Auditable;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
@@ -94,11 +98,46 @@ test('http kernel invokes deferred callbacks', function () {
});
test('audit persistence failures do not fail the action', function () {
Schema::drop('audit_events');
Schema::rename('audit_events', 'unavailable_audit_events');
auditLog('ui.project.updated', ['team_id' => $this->team->id]);
try {
expect(fn () => auditLog('ui.project.updated', ['team_id' => $this->team->id]))
->not->toThrow(Throwable::class);
expect(true)->toBeTrue();
Log::shouldHaveReceived('warning')->once()->with(
'Audit event persistence failed',
Mockery::on(fn (array $context): bool => $context === [
'event' => 'ui.project.updated',
'exception' => QueryException::class,
]),
);
} finally {
Schema::rename('unavailable_audit_events', 'audit_events');
}
});
test('audit preparation failures log sanitized diagnostics without failing the action', function () {
$resourceName = new class
{
public function __toString(): string
{
throw new RuntimeException('sensitive audit metadata');
}
};
expect(fn () => auditLog('ui.project.updated', [
'team_id' => $this->team->id,
'project_name' => $resourceName,
'secret' => 'must not be logged',
]))->not->toThrow(Throwable::class);
Log::shouldHaveReceived('warning')->once()->with(
'Audit event preparation failed',
Mockery::on(fn (array $context): bool => $context === [
'event' => 'ui.project.updated',
'exception' => RuntimeException::class,
]),
);
});
test('audit log persists a structured event for the current team', function () {
@@ -138,6 +177,49 @@ test('auditable models record authenticated create update and delete actions', f
])->and($events[1]->metadata['changed_fields'])->toBe(['name']);
});
test('deleting a project dispatches deleted events for its environments', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = $project->environments()->sole();
AuditEvent::query()->delete();
$project->delete();
expect(AuditEvent::query()
->where('event', 'ui.environment.deleted')
->where('resource_uuid', $environment->uuid)
->exists())->toBeTrue();
});
test('deleting a team dispatches updated events for transferred system-wide sources', function () {
Team::factory()->create(['id' => 0]);
$githubApp = GithubApp::query()->create([
'name' => 'System GitHub source',
'team_id' => $this->team->id,
'is_system_wide' => true,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
]);
$gitlabApp = GitlabApp::query()->create([
'name' => 'System GitLab source',
'team_id' => $this->team->id,
'is_system_wide' => true,
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
]);
AuditEvent::query()->delete();
$this->team->delete();
expect(AuditEvent::query()
->where('event', 'ui.github_app.updated')
->where('resource_uuid', $githubApp->uuid)
->exists())->toBeTrue()
->and(AuditEvent::query()
->where('event', 'ui.gitlab_app.updated')
->where('resource_uuid', $gitlabApp->uuid)
->exists())->toBeTrue();
});
test('auditable model mutations succeed when audit persistence fails', function () {
Schema::rename('audit_events', 'unavailable_audit_events');
@@ -373,6 +455,39 @@ test('team resource models opt in to automatic auditing', function (string $mode
StandaloneClickhouse::class,
]);
test('status-only database updates do not record last online audit changes', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
foreach ([StandaloneClickhouse::class, StandaloneRedis::class] as $model) {
$attributes = [
'uuid' => fake()->uuid(),
'name' => 'Status test database',
'status' => 'exited',
'environment_id' => $environment->id,
'destination_type' => Server::class,
'destination_id' => 0,
];
if ($model === StandaloneClickhouse::class) {
$attributes['clickhouse_admin_password'] = 'password';
}
$database = $model::create($attributes);
AuditEvent::query()->delete();
$database->update(['status' => 'running']);
expect(AuditEvent::query()->count())->toBe(0);
$database->update(['name' => 'Renamed status test database']);
$event = AuditEvent::query()->sole();
expect($event->metadata['changed_fields'])->toBe(['name']);
AuditEvent::query()->delete();
}
});
test('audit log redacts sensitive metadata', function () {
auditLog('api.application.updated', [
'team_id' => $this->team->id,
@@ -388,6 +503,23 @@ test('audit log redacts sensitive metadata', function () {
->and($metadata['nested']['safe'])->toBe('visible');
});
test('team invitation audit logs redact the invitation email', function () {
auditLog('ui.team_invitation.created', [
'team_id' => $this->team->id,
'invitation_uuid' => 'invitation-123',
'invitation_email' => 'invitee@example.com',
'role' => 'member',
'via' => 'email',
]);
$metadata = AuditEvent::query()->sole()->metadata;
expect($metadata['invitation_email'])->toBe('[REDACTED]')
->and($metadata['invitation_uuid'])->toBe('invitation-123')
->and($metadata['role'])->toBe('member')
->and($metadata['via'])->toBe('email');
});
test('audit log page only shows events for the current team', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
@@ -419,6 +551,15 @@ test('team members cannot view the audit log page', function () {
$this->get('/team/audit-log')->assertForbidden();
});
test('demoted team admins cannot make subsequent audit log requests', function () {
$component = Livewire::test(AuditLog::class);
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
auth()->setUser($this->user->fresh());
$component->set('search', 'deployment')->assertStatus(403);
});
test('team admins can query only their team audit events through the api', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
@@ -463,41 +604,31 @@ test('team members cannot query audit events through the api', function () {
});
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']");
Livewire::test(AuditLog::class)
->assertSee('All sources')
->assertSee('Web UI')
->assertSee('API')
->assertSee('MCP')
->assertSee('Webhook')
->assertDontSee('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('resource clone audit starts only after the destination server capability check', function () {
$source = file_get_contents(app_path('Livewire/Project/Shared/ResourceOperations.php'));
expect(strpos($source, "auditLog('ui.resource.clone_started'"))
->toBeGreaterThan(strpos($source, 'if (! $server->canHostResources())'));
});
test('pull and restart records the service restart audit event after starting the service', function () {
$method = new ReflectionMethod(Heading::class, 'pullAndRestartEvent');
$source = file($method->getFileName());
$methodSource = implode('', array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
expect($methodSource)
->toContain("auditServiceAction('ui.service.restarted')")
->and(strpos($methodSource, 'StartService::run'))->toBeLessThan(strpos($methodSource, 'auditServiceAction'));
});
test('critical operational events persist with their source action and actor', function (string $event) {
auditLog($event, [
@@ -547,17 +678,26 @@ test('critical operational events persist with their source action and actor', f
]);
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'));
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Visible Actor',
]);
expect($view)->toContain('overflow-x-auto')
->toContain('min-w-[760px]')
->not->toContain('hidden lg:block">Actor');
Livewire::test(AuditLog::class)
->assertSeeHtml('class="overflow-x-auto"')
->assertSeeHtml('min-w-[760px]')
->assertSee('Actor')
->assertSee('Visible Actor');
});
test('audit log displays source abbreviations in uppercase', function () {
$view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php'));
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'source' => 'cli',
]);
expect($view)->toContain('Str::upper($event->source)');
Livewire::test(AuditLog::class)
->assertSee('CLI');
});
test('audit log page filters events by search and action', function () {
@@ -3,6 +3,7 @@
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
@@ -61,18 +62,39 @@ describe('queue_application_deployment commit resolution', function () {
});
test('uses the deployed application team for the audit event', function () {
$actorTeam = Team::factory()->create();
$user = User::factory()->create();
$this->team->members()->attach($user, ['role' => 'owner']);
$actorTeam->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,
$event = AuditEvent::query()->where('event', 'ui.application.deployed')->sole();
expect($event->team_id)->toBe($this->team->id)
->and($event->team_id)->not->toBe($actorTeam->id)
->and($event->resource_uuid)->toBe($application->uuid)
->and($event->metadata)->not->toHaveKey('team_id');
});
test('records only the rollback audit event when a user queues a rollback', function () {
$user = User::factory()->create();
$this->team->members()->attach($user, ['role' => 'owner']);
$this->actingAs($user);
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
AuditEvent::query()->delete();
queue_application_deployment(
application: $application,
deployment_uuid: 'audit-rollback-uuid',
commit: 'previous-commit',
rollback: true,
);
expect(AuditEvent::query()->pluck('event')->all())->toBe([
'ui.application.rollback',
]);
});
@@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
it('renders the shared page size selector', function () {
$html = Blade::render('<x-page-size-select model="perPage" livewire storage-key="tests.page-size" />');
@@ -30,6 +31,18 @@ it('renders the shared page size selector', function () {
->toContain('max="100"');
});
it('disables page size controls when the gate denies access', function () {
Gate::define('view-audit-log-test', fn (): bool => false);
$html = Blade::render(<<<'BLADE'
<x-page-size-select model="perPage" livewire
canGate="view-audit-log-test" :canResource="new stdClass" />
BLADE);
expect($html)->toMatch('/<button[^>]*aria-label="Items per page"[^>]*\sdisabled(?:[=\s>])/')
->toMatch('/<input[^>]*aria-label="Custom items per page"[^>]*\sdisabled(?:[=\s>])/');
});
it('positions table dropdown panels outside overflowing containers', function () {
$html = Blade::render(<<<'BLADE'
<x-table.dropdown>