mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -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:
@@ -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