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:
Andras Bacsai
2026-08-24 07:56:21 +02:00
parent 8726746241
commit c1f29beff1
12 changed files with 221 additions and 3 deletions
+53
View File
@@ -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'));
+13
View File
@@ -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([