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
@@ -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.');
}
+5
View File
@@ -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
View File
@@ -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
+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([