From c1f29beff148712cdd21f691a8af6355b4b2e121 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:56:21 +0200 Subject: [PATCH] feat(audit): expose team events and log integration actions Add an admin-only audit-events API endpoint and restrict audit-log UI access. Record integration token and secret manager changes, key access, and references in audit events. --- .../Controllers/Api/AuditEventsController.php | 44 +++++++++++++++ .../Project/Shared/SecretManagerLinks.php | 28 ++++++++++ .../Security/IntegrationTokenEditor.php | 10 ++++ .../Security/IntegrationTokenForm.php | 9 +++- app/Livewire/Security/IntegrationTokens.php | 9 ++++ app/Livewire/Team/AuditLog.php | 5 ++ .../components/team/settings-layout.blade.php | 4 +- routes/api.php | 2 + tests/Feature/AuditEventsTest.php | 53 +++++++++++++++++++ tests/Feature/SecretManagerApiTest.php | 13 +++++ .../SecretManagerLinksComponentTest.php | 23 ++++++++ .../Security/IntegrationTokenFormTest.php | 24 +++++++++ 12 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/AuditEventsController.php diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php new file mode 100644 index 0000000000..741aa8be3b --- /dev/null +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -0,0 +1,44 @@ +user()->isAdminOfTeam($teamId)) { + return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403); + } + + $perPage = max(1, min(100, $request->integer('per_page', 25))); + $search = trim((string) $request->query('search', '')); + $events = AuditEvent::query() + ->where('team_id', $teamId) + ->when($request->filled('source'), fn ($query) => $query->where('source', $request->string('source')->toString())) + ->when($request->filled('action'), fn ($query) => $query->where('action', $request->string('action')->toString())) + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('event', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->orWhere('actor_email', 'like', "%{$search}%"); + }); + }) + ->latest('created_at') + ->latest('id') + ->paginate($perPage); + + return response()->json($events); + } +} diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php index 0e9866ab94..c0641b56c5 100644 --- a/app/Livewire/Project/Shared/SecretManagerLinks.php +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -116,6 +116,10 @@ class SecretManagerLinks extends Component 'integration_token_id' => $token->id, 'settings' => $settings ?: null, ]); + $this->auditSecretManagerAction('source_updated', [ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + ]); $this->resetKeys(); $this->loadData(); @@ -143,6 +147,7 @@ class SecretManagerLinks extends Component $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); $this->link->update(['settings' => $settings ?: null]); + $this->auditSecretManagerAction('settings_updated'); $this->resetKeys(); $this->loadData(); $this->dispatch('success', 'Secret manager settings saved.'); @@ -155,7 +160,12 @@ class SecretManagerLinks extends Component { try { $this->authorize('update', $this->resource); + $token = $this->link?->integrationToken; $this->resource->secretManagerLink()->delete(); + $this->auditSecretManagerAction('source_removed', [ + 'integration_token_uuid' => $token?->uuid, + 'provider' => $token?->provider, + ]); $this->link = null; $this->integration_token_uuid = ''; $this->settings = []; @@ -181,6 +191,7 @@ class SecretManagerLinks extends Component sort($keys); $this->keys = $keys; $this->keysLoaded = true; + $this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]); } catch (\Throwable $e) { $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage()); } @@ -205,6 +216,7 @@ class SecretManagerLinks extends Component 'key' => $key, 'value' => '{{vault.'.$key.'}}', ]); + $this->auditSecretManagerAction('reference_created', ['secret_key' => $key]); $this->dispatch('refreshEnvs'); $this->dispatch('success', "Added {$key} as {{vault.{$key}}}."); @@ -223,6 +235,10 @@ class SecretManagerLinks extends Component } $imported = $this->link->importMissingReferences(); + $this->auditSecretManagerAction('references_imported', [ + 'key_count' => count($imported), + 'secret_keys' => $imported, + ]); $this->dispatch('refreshEnvs'); $this->dispatch('success', $imported === [] @@ -240,6 +256,18 @@ class SecretManagerLinks extends Component $this->search = ''; } + /** @param array $context */ + private function auditSecretManagerAction(string $action, array $context = []): void + { + $resourceType = str(class_basename($this->resource))->snake()->value(); + + auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([ + 'team_id' => $this->resource->team()?->id, + "{$resourceType}_uuid" => $this->resource->uuid, + "{$resourceType}_name" => $this->resource->name, + ], $context)); + } + public function getFilteredKeysProperty(): array { if (blank($this->search)) { diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 8c00027e4b..2a33591822 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -128,8 +128,18 @@ class IntegrationTokenEditor extends Component return; } + $uuid = $this->integrationToken->uuid; + $name = $this->integrationToken->name; + $provider = $this->integrationToken->provider; $this->integrationToken->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $uuid, + 'integration_token_name' => $name, + 'provider' => $provider, + ]); + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); $this->dispatch('close-modal'); $this->dispatch('success', 'Integration token deleted successfully.'); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index d83c6eef7e..26ecce1e14 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -91,7 +91,7 @@ class IntegrationTokenForm extends Component return; } - IntegrationToken::query()->create([ + $integrationToken = IntegrationToken::query()->create([ 'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'], @@ -100,6 +100,13 @@ class IntegrationTokenForm extends Component 'team_id' => currentTeam()->id, ]); + auditLog('ui.integration_token.created', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $integrationToken->uuid, + 'integration_token_name' => $integrationToken->name, + 'provider' => $integrationToken->provider, + ]); + $this->reset(['name', 'token']); $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index c0b6541cc3..71805fb841 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -36,7 +36,16 @@ class IntegrationTokens extends Component return; } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $provider = $token->provider; $token->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $tokenUuid, + 'integration_token_name' => $tokenName, + 'provider' => $provider, + ]); $this->loadTokens(); $this->dispatch('success', 'Integration token deleted successfully.'); } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php index 7dddcfcfb5..b1421b9c72 100644 --- a/app/Livewire/Team/AuditLog.php +++ b/app/Livewire/Team/AuditLog.php @@ -19,6 +19,11 @@ class AuditLog extends Component public int $perPage = 25; + public function mount(): void + { + abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403); + } + public function updatedSearch(): void { $this->resetPage(); diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php index 80bf767273..3b6db03c3a 100644 --- a/resources/views/components/team/settings-layout.blade.php +++ b/resources/views/components/team/settings-layout.blade.php @@ -12,12 +12,12 @@ 'active' => request()->routeIs('team.member.index'), 'icon' => 'teams', ], - [ + auth()->user()->isAdminOfTeam(currentTeam()->id) ? [ 'label' => 'Audit log', 'route' => 'team.audit-log', 'active' => request()->routeIs('team.audit-log'), 'icon' => 'time-back', - ], + ] : null, isInstanceAdmin() ? [ 'label' => 'Admin View', 'route' => 'team.admin-view', diff --git a/routes/api.php b/routes/api.php index c0ffbd3bb4..5c89a4f8e5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\ApplicationsController; use App\Http\Controllers\Api\ApplicationSecretManagerController; +use App\Http\Controllers\Api\AuditEventsController; use App\Http\Controllers\Api\CloudInitScriptsController; use App\Http\Controllers\Api\CloudProviderTokensController; use App\Http\Controllers\Api\DatabasesController; @@ -66,6 +67,7 @@ Route::group([ ], function () { Route::get('/version', [OtherController::class, 'version'])->middleware(['api.ability:read']); + Route::get('/audit-events', [AuditEventsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/teams', [TeamController::class, 'teams'])->middleware(['api.ability:read']); // Token's team diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index e3fb8395df..a4fc505516 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -409,6 +409,59 @@ test('audit log is available under team settings', function () { ->assertSeeLivewire(AuditLog::class); }); +test('team members cannot view the audit log page', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->get('/team/audit-log')->assertForbidden(); +}); + +test('team admins can query only their team audit events through the api', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'event' => 'api.project.updated', + 'source' => 'api', + 'action' => 'updated', + 'description' => 'Visible event', + ]); + AuditEvent::factory()->create([ + 'team_id' => Team::factory()->create()->id, + 'event' => 'api.project.updated', + 'source' => 'api', + 'action' => 'updated', + 'description' => 'Other team event', + ]); + + $token = $this->user->createToken('audit-read', ['read']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + auth()->logout(); + auth()->forgetGuards(); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/audit-events?source=api&action=updated') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.description', 'Visible event'); +}); + +test('team members cannot query audit events through the api', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + $token = $member->createToken('audit-read', ['read']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + auth()->logout(); + auth()->forgetGuards(); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/audit-events') + ->assertForbidden(); +}); + test('audit source filter omits the unused system source', function () { $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); diff --git a/tests/Feature/SecretManagerApiTest.php b/tests/Feature/SecretManagerApiTest.php index 42c728f331..22c7ca1faf 100644 --- a/tests/Feature/SecretManagerApiTest.php +++ b/tests/Feature/SecretManagerApiTest.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); beforeEach(function () { + $this->withoutDefer(); config(['app.maintenance.driver' => 'file']); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0, 'is_api_enabled' => true])); @@ -56,6 +57,12 @@ test('a secret manager integration token can be created through the api', functi expect($token->team_id)->toBe($this->team->id) ->and($token->capabilities)->toBe(['secrets']); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'api.integration_token.created', + 'resource_uuid' => $token->uuid, + ]); }); test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) { @@ -103,4 +110,10 @@ test('an application can be configured to use a secret manager through the api', expect($link->integration_token_id)->toBe($token->id) ->and($link->settings)->toBe(['project' => 'website', 'config' => 'production']); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'api.application.secret_manager.updated', + 'resource_uuid' => $this->application->uuid, + ]); }); diff --git a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php index dc02122154..28a8707b43 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Shared\EnvironmentVariable\Show; use App\Livewire\Project\Shared\SecretManagerLinks; use App\Models\Application; +use App\Models\AuditEvent; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\IntegrationToken; @@ -18,6 +19,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { + $this->withoutDefer(); if (! InstanceSettings::query()->whereKey(0)->exists()) { $settings = new InstanceSettings; $settings->id = 0; @@ -60,6 +62,11 @@ test('selecting a token in the dropdown saves the source automatically', functio 'resourceable_id' => $this->application->id, 'integration_token_id' => $this->token->id, ]); + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.secret_manager.source_updated', + 'resource_uuid' => $this->application->uuid, + ]); }); test('service account settings are required and save automatically on blur', function () { @@ -163,6 +170,10 @@ test('browse keys shows key names only and search filters them', function () { expect($component->get('keys'))->toBe(['API_KEY', 'DB_PASSWORD']); + $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.keys_viewed')->sole(); + expect($auditEvent->metadata['key_count'])->toBe(2) + ->and($auditEvent->metadata)->not->toHaveKey('keys'); + $component->set('search', 'db_pass') ->assertSee('DB_PASSWORD') ->assertDontSee('API_KEY'); @@ -206,6 +217,9 @@ test('add reference creates a variable with a secret reference value', function $created = $this->application->environment_variables()->where('key', 'DB_PASSWORD')->firstOrFail(); expect($created->value)->toBe('{{vault.DB_PASSWORD}}'); + + $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.reference_created')->sole(); + expect($auditEvent->metadata['secret_key'])->toBe('[REDACTED]'); }); test('import all creates references for missing keys and skips existing ones', function () { @@ -228,6 +242,10 @@ test('import all creates references for missing keys and skips existing ones', f ->toBe('{{vault.NEW_KEY}}') ->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value) ->toBe('local'); + + $auditEvent = AuditEvent::query()->where('event', 'ui.application.secret_manager.references_imported')->sole(); + expect($auditEvent->metadata['key_count'])->toBe(1) + ->and($auditEvent->metadata['secret_keys'])->toBe('[REDACTED]'); }); test('the source can be removed', function () { @@ -238,6 +256,11 @@ test('the source can be removed', function () { ->assertDispatched('success'); $this->assertDatabaseCount('secret_manager_links', 0); + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.secret_manager.source_removed', + 'resource_uuid' => $this->application->uuid, + ]); }); test('members without update permission cannot save a source', function () { diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 4777ebd332..2bffb5ae22 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -3,6 +3,7 @@ use App\Livewire\Security\IntegrationTokenEditor; use App\Livewire\Security\IntegrationTokenForm; use App\Livewire\Security\IntegrationTokens; +use App\Models\AuditEvent; use App\Models\InstanceSettings; use App\Models\IntegrationToken; use App\Models\Team; @@ -15,6 +16,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { + $this->withoutDefer(); if (! InstanceSettings::query()->whereKey(0)->exists()) { $settings = new InstanceSettings; $settings->id = 0; @@ -60,12 +62,34 @@ test('a cloudflare dns token is validated with read only requests before it is s 'provider' => 'cloudflare', 'name' => 'Production DNS', ]); + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.integration_token.created', + 'resource_name' => 'Production DNS', + ]); Http::assertSentCount(3); Http::assertSent(fn ($request) => $request->method() === 'GET' && $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1'); }); +test('deleting an integration token is audited without storing its value', function () { + $token = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'doppler', + 'name' => 'Production secrets', + 'token' => 'dp.st.super-secret', + 'capabilities' => ['secrets'], + ]); + + Livewire::test(IntegrationTokens::class)->call('deleteToken', $token->id); + + $auditEvent = AuditEvent::query()->where('event', 'ui.integration_token.deleted')->sole(); + + expect($auditEvent->resource_uuid)->toBe($token->uuid) + ->and(json_encode($auditEvent->metadata))->not->toContain('dp.st.super-secret'); +}); + test('a cloudflare token is not saved when scope validation fails', function () { Http::fake([ 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([