mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
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.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditEventsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> $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)) {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user