diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php index 63dcca6982..19e916dc10 100644 --- a/app/Http/Controllers/Api/AuditEventsController.php +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -63,6 +63,7 @@ class AuditEventsController extends Controller 'actor_token_id', 'actor_token_name', 'metadata', + 'changes', 'ip_address', 'user_agent', ])) diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index be8a584e26..62c2a16d82 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -34,6 +34,7 @@ class AuditEvent extends Model 'resource_name', 'description', 'metadata', + 'changes', 'ip_address', 'user_agent', 'created_at', @@ -43,6 +44,7 @@ class AuditEvent extends Model { return [ 'metadata' => 'array', + 'changes' => 'encrypted:array', 'created_at' => 'datetime', ]; } @@ -85,9 +87,27 @@ class AuditEvent extends Model * @param array $context */ public static function record(string $event, array $context = [], string $level = 'info'): void + { + self::recordWithChanges($event, $context, level: $level); + } + + /** + * @param array $context + * @param array $changes + */ + public static function recordModelMutation(string $event, array $context, array $changes): void + { + self::recordWithChanges($event, $context, $changes); + } + + /** + * @param array $context + * @param array|null $changes + */ + private static function recordWithChanges(string $event, array $context, ?array $changes = null, string $level = 'info'): void { try { - $attributes = self::attributesFor($event, $context, $level); + $attributes = self::attributesFor($event, $context, $changes, $level); DB::afterCommit(function () use ($attributes): void { defer(function () use ($attributes): void { @@ -113,7 +133,7 @@ class AuditEvent extends Model * @param array $context * @return array */ - private static function attributesFor(string $event, array $context, string $level): array + private static function attributesFor(string $event, array $context, ?array $changes, string $level): array { $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') ?? (array_key_exists('team_id', $context) @@ -152,7 +172,8 @@ class AuditEvent extends Model 'resource_name' => $resourceName, 'description' => data_get($context, 'audit_description') ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), - 'metadata' => self::redact($context), + 'metadata' => self::redact(Arr::except($context, ['audit_changes'])), + 'changes' => $changes, 'ip_address' => app()->bound('request') ? request()->ip() : null, 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, ]; @@ -204,7 +225,7 @@ class AuditEvent extends Model return $key ? data_get($context, $key) : null; } - private static function redact(mixed $value, ?string $key = null): mixed + public static function redact(mixed $value, ?string $key = null): mixed { if ($key !== null && self::isSensitiveKey($key)) { return '[REDACTED]'; diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index d0b08cd033..2038103bba 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Models\AuditEvent; use App\Models\PersonalAccessToken; use App\Models\Team; use Closure; @@ -82,12 +83,53 @@ trait Auditable $resourceType = Str::snake(class_basename($this)); $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; - auditLog("{$source}.{$resourceType}.{$action}", [ + AuditEvent::recordModelMutation("{$source}.{$resourceType}.{$action}", [ 'team_id' => $teamId, "{$resourceType}_uuid" => $this->getAttribute('uuid'), "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), 'changed_fields' => $changedFields, - ]); + ], $this->auditChanges( + $action, + $action === 'updated' ? $changedFields : array_keys($this->getAttributes()), + )); + } + + /** + * @param array $fields + * @return array + */ + private function auditChanges(string $action, array $fields): array + { + return collect($fields) + ->reject(fn (string $field): bool => $this->isExcludedAuditField($field)) + ->mapWithKeys(fn (string $field): array => [ + $field => [ + 'old' => $action === 'created' ? null : AuditEvent::redact($this->getOriginal($field)), + 'new' => $action === 'deleted' ? null : AuditEvent::redact($this->getAttribute($field)), + ], + ]) + ->all(); + } + + private function isExcludedAuditField(string $field): bool + { + $cast = $this->getCasts()[$field] ?? null; + + return in_array($field, [ + $this->getKeyName(), + 'uuid', + 'created_at', + 'updated_at', + 'deleted_at', + 'order', + 'status', + ...$this->getHidden(), + ...($this->auditExclude ?? []), + ], true) + || str_ends_with($field, '_id') + || str_ends_with($field, '_at') + || preg_match('/password|secret|token|private_key|signature|credential|api_key|access_key|authorization|cookie/i', $field) + || (is_string($cast) && ($cast === 'encrypted' || str_starts_with($cast, 'encrypted:'))); } public function withoutAuditLogging(Closure $callback): mixed diff --git a/database/migrations/2026_08_24_171229_add_changes_to_audit_events_table.php b/database/migrations/2026_08_24_171229_add_changes_to_audit_events_table.php new file mode 100644 index 0000000000..ead6470398 --- /dev/null +++ b/database/migrations/2026_08_24_171229_add_changes_to_audit_events_table.php @@ -0,0 +1,28 @@ +text('changes')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('audit_events', function (Blueprint $table) { + $table->dropColumn('changes'); + }); + } +}; diff --git a/resources/views/livewire/team/audit-log.blade.php b/resources/views/livewire/team/audit-log.blade.php index b81de272c5..d3fd0a9fe7 100644 --- a/resources/views/livewire/team/audit-log.blade.php +++ b/resources/views/livewire/team/audit-log.blade.php @@ -44,8 +44,9 @@ Time @foreach ($events as $event) -
+
+
{{ $event->actor_name ?: Str::headline($event->actor_type) }} @@ -69,6 +70,13 @@
{{ $event->event }}
+ @if (filled($event->changes)) + + @endif
@@ -82,6 +90,24 @@ {{ $event->created_at->diffForHumans() }}
+ @if (filled($event->changes)) +
+
+ Field + Previous value + New value +
+ @foreach ($event->changes as $field => $change) +
+ {{ Str::headline($field) }} + {{ is_array($change['old']) ? json_encode($change['old'], JSON_UNESCAPED_SLASHES) : (is_bool($change['old']) ? ($change['old'] ? 'True' : 'False') : ($change['old'] ?? 'None')) }} + {{ is_array($change['new']) ? json_encode($change['new'], JSON_UNESCAPED_SLASHES) : (is_bool($change['new']) ? ($change['new'] ? 'True' : 'False') : ($change['new'] ?? 'None')) }} +
+ @endforeach +
+ @endif +
@endforeach
diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php index b30526a580..8bae3fa71e 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -33,6 +33,7 @@ use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Logout; use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Once; @@ -171,6 +172,70 @@ test('auditable models record authenticated create update and delete actions', f ])->and($events[1]->metadata['changed_fields'])->toBe(['name']); }); +test('auditable models store encrypted changes for create update and delete actions', function () { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Website project', + 'description' => 'Original description', + ]); + $project->update(['description' => 'Updated description']); + $project->delete(); + + $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get(); + + expect($events[0]->changes)->toMatchArray([ + 'name' => ['old' => null, 'new' => 'Website project'], + 'description' => ['old' => null, 'new' => 'Original description'], + ])->and($events[0]->changes)->not->toHaveKeys(['id', 'uuid', 'team_id', 'created_at', 'updated_at']) + ->and($events[1]->changes)->toBe([ + 'description' => ['old' => 'Original description', 'new' => 'Updated description'], + ])->and($events[2]->changes)->toMatchArray([ + 'name' => ['old' => 'Website project', 'new' => null], + 'description' => ['old' => 'Updated description', 'new' => null], + ]); + + $storedChanges = DB::table('audit_events')->where('id', $events[1]->id)->value('changes'); + + expect($storedChanges)->not->toContain('Original description') + ->and($storedChanges)->not->toContain('Updated description'); +}); + +test('auditable models exclude hidden and encrypted attributes from changes', function () { + SharedEnvironmentVariable::query()->create([ + 'team_id' => $this->team->id, + 'key' => 'API_TOKEN', + 'value' => 'super-secret', + 'comment' => 'Used by the API', + ]); + + $event = AuditEvent::query()->where('resource_type', 'shared_environment_variable')->sole(); + + expect($event->changes)->toMatchArray([ + 'key' => ['old' => null, 'new' => 'API_TOKEN'], + 'comment' => ['old' => null, 'new' => 'Used by the API'], + ])->and($event->changes)->not->toHaveKey('value') + ->and(DB::table('audit_events')->where('id', $event->id)->value('changes')) + ->not->toContain('super-secret'); +}); + +test('auditable models exclude timestamp attributes from changes', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + AuditEvent::query()->delete(); + + StandalonePostgresql::query()->create([ + 'uuid' => fake()->uuid(), + 'name' => 'Timestamp test database', + 'environment_id' => $environment->id, + 'destination_type' => Server::class, + 'destination_id' => 0, + 'postgres_password' => 'password', + 'last_restart_at' => now(), + ]); + + expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('last_restart_at'); +}); + test('deleting a project dispatches deleted events for its environments', function () { $project = Project::factory()->create(['team_id' => $this->team->id]); $environment = $project->environments()->sole(); @@ -634,6 +699,37 @@ test('audit log page only shows events for the current team', function () { ->assertDontSee('Private app deleted'); }); +test('audit log expands encrypted model changes for team admins and owners', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'description' => 'Website updated', + 'changes' => [ + 'name' => ['old' => 'Website', 'new' => 'Store'], + ], + ]); + + Livewire::test(AuditLog::class) + ->assertSee('View changes') + ->assertSee('Name') + ->assertSee('Website') + ->assertSee('Store'); +}); + +test('manual audit events do not store model changes', function () { + auditLog('ui.application.restarted', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + 'audit_changes' => [ + 'name' => ['old' => 'Injected', 'new' => 'Value'], + ], + ]); + + $event = AuditEvent::query()->sole(); + + expect($event->changes)->toBeNull() + ->and($event->metadata)->not->toHaveKey('audit_changes'); +}); + test('audit log is available under team settings', function () { $this->get('/team/audit-log') ->assertSuccessful() @@ -669,6 +765,7 @@ test('team admins can query only their team audit events through the api', funct 'actor_email' => 'owner@example.com', 'actor_token_name' => 'production token', 'metadata' => ['changed_fields' => ['name']], + 'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']], 'ip_address' => '192.0.2.1', 'user_agent' => 'Sensitive user agent', ]); @@ -694,6 +791,7 @@ test('team admins can query only their team audit events through the api', funct ->assertJsonMissingPath('data.0.actor_token_id') ->assertJsonMissingPath('data.0.actor_token_name') ->assertJsonMissingPath('data.0.metadata') + ->assertJsonMissingPath('data.0.changes') ->assertJsonMissingPath('data.0.ip_address') ->assertJsonMissingPath('data.0.user_agent'); }); @@ -710,6 +808,7 @@ test('team admins with sensitive read access can query full audit event details' 'actor_email' => 'owner@example.com', 'actor_token_name' => 'production token', 'metadata' => ['changed_fields' => ['name']], + 'changes' => ['name' => ['old' => 'Old project', 'new' => 'New project']], 'ip_address' => '192.0.2.1', 'user_agent' => 'Sensitive user agent', ]); @@ -720,6 +819,8 @@ test('team admins with sensitive read access can query full audit event details' ->assertJsonPath('data.0.actor_email', 'owner@example.com') ->assertJsonPath('data.0.actor_token_name', 'production token') ->assertJsonPath('data.0.metadata.changed_fields.0', 'name') + ->assertJsonPath('data.0.changes.name.old', 'Old project') + ->assertJsonPath('data.0.changes.name.new', 'New project') ->assertJsonPath('data.0.ip_address', '192.0.2.1') ->assertJsonPath('data.0.user_agent', 'Sensitive user agent'); });