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..45c8b632fa 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,8 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use JsonSerializable; +use stdClass; use Throwable; class AuditEvent extends Model @@ -17,6 +20,27 @@ class AuditEvent extends Model public const UPDATED_AT = null; + /** + * Raw commands and configurations can contain credentials without sensitive field names. + * + * @var array + */ + private const SENSITIVE_VALUE_FIELDS = [ + 'git_full_url', + 'install_command', 'build_command', 'start_command', + 'health_check_command', 'health_check_response_text', + 'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command', + 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', + 'custom_nginx_configuration', + 'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf', 'redis_conf', 'keydb_conf', + 'internal_db_url', 'external_db_url', 'init_scripts', + 'dockerfile', 'docker_compose', 'docker_compose_raw', 'custom_labels', + 'last_saved_proxy_configuration', + 'environment_variables', 'environment_variables_preview', + 'validation_logs', 'server_metadata', 'logs', + 'configuration_snapshot', 'configuration_diff', 'content', 'file_storage_content', + ]; + protected $fillable = [ 'team_id', 'event', @@ -34,6 +58,7 @@ class AuditEvent extends Model 'resource_name', 'description', 'metadata', + 'changes', 'ip_address', 'user_agent', 'created_at', @@ -43,6 +68,7 @@ class AuditEvent extends Model { return [ 'metadata' => 'array', + 'changes' => 'encrypted:array', 'created_at' => 'datetime', ]; } @@ -85,9 +111,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 +157,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 +196,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,12 +249,26 @@ class AuditEvent extends Model return $key ? data_get($context, $key) : null; } - private static function redact(mixed $value, ?string $key = null): mixed + public static function isSensitiveField(string $field): bool { - if ($key !== null && self::isSensitiveKey($key)) { + return in_array($field, self::SENSITIVE_VALUE_FIELDS, true) + || preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie|license_key/i', $field) === 1; + } + + public static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && (in_array($key, self::SENSITIVE_VALUE_FIELDS, true) || self::isSensitiveKey($key))) { return '[REDACTED]'; } + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } elseif ($value instanceof JsonSerializable) { + $value = $value->jsonSerialize(); + } elseif ($value instanceof stdClass) { + $value = (array) $value; + } + if (! is_array($value)) { return $value; } diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index d0b08cd033..52cd033bcb 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -2,11 +2,14 @@ namespace App\Traits; +use App\Models\AuditEvent; use App\Models\PersonalAccessToken; use App\Models\Team; use Closure; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Throwable; trait Auditable { @@ -82,12 +85,64 @@ 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 + { + try { + 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(); + } catch (Throwable $exception) { + Log::warning('Audit change preparation failed', [ + 'resource_type' => Str::snake(class_basename($this)), + 'action' => $action, + 'exception' => $exception::class, + ]); + + return []; + } + } + + 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', + ...(get_class_vars(static::class)['hidden'] ?? []), + ...$this->getHidden(), + ...($this->auditExclude ?? []), + ], true) + || str_ends_with($field, '_id') + || str_ends_with($field, '_at') + || AuditEvent::isSensitiveField($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..f10de08adc 100644 --- a/tests/Feature/AuditEventsTest.php +++ b/tests/Feature/AuditEventsTest.php @@ -31,13 +31,29 @@ use App\Models\User; use App\Traits\Auditable; use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Logout; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Database\Eloquent\Model; 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; use Livewire\Livewire; +class FailingAuditAttributeCast implements CastsAttributes +{ + public function get(Model $model, string $key, mixed $value, array $attributes): mixed + { + throw new RuntimeException('sensitive attribute value'); + } + + public function set(Model $model, string $key, mixed $value, array $attributes): mixed + { + return $value; + } +} + uses(RefreshDatabase::class); beforeEach(function () { @@ -171,6 +187,216 @@ 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 redact nested secrets in object casts', function () { + Server::flushIdentityMap(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy = [ + 'type' => 'TRAEFIK', + 'credentials' => ['password' => 'old-proxy-password'], + ]; + $server->save(); + AuditEvent::query()->delete(); + + $server->proxy = [ + 'type' => 'TRAEFIK', + 'credentials' => ['password' => 'new-proxy-password'], + ]; + $server->save(); + + $changes = AuditEvent::query()->sole()->changes; + + expect($changes['proxy'])->toBe([ + 'old' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'], + 'new' => ['type' => 'TRAEFIK', 'credentials' => '[REDACTED]'], + ]); +}); + +test('audit changes exclude raw fields that can contain embedded credentials', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + + $application->update([ + 'name' => 'Safe name', + 'git_full_url' => 'https://user:embedded-password@example.com/repo.git', + 'build_command' => 'API_KEY=embedded-key npm run build', + ]); + + expect(AuditEvent::query()->sole()->changes)->toHaveKey('name') + ->not->toHaveKeys(['git_full_url', 'build_command']); +}); + +test('audit changes exclude database configuration credentials on create and delete', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + AuditEvent::query()->delete(); + + $database = StandaloneRedis::query()->create([ + 'name' => 'Redis', + 'environment_id' => $environment->id, + 'destination_type' => Server::class, + 'destination_id' => 0, + 'redis_conf' => 'requirepass embedded-redis-password', + ]); + $database->delete(); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2); + foreach ($events as $event) { + expect($event->changes)->toHaveKey('name')->not->toHaveKey('redis_conf'); + } +}); + +test('audit metadata redacts nested raw configuration credentials', function () { + auditLog('ui.server.updated', [ + 'team_id' => $this->team->id, + 'configuration' => ['last_saved_proxy_configuration' => 'PASSWORD=must-not-be-recorded'], + ]); + + expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([ + 'last_saved_proxy_configuration' => '[REDACTED]', + ]); +}); + +test('audit changes keep model default hidden fields excluded after makeVisible', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + $application->makeVisible(['domain_dns_statuses']); + + $application->update([ + 'name' => 'Updated application', + 'domain_dns_statuses' => ['example.com' => 'private DNS data'], + ]); + + expect(AuditEvent::query()->sole()->changes)->not->toHaveKey('domain_dns_statuses'); +}); + +test('audit changes redact credentials embedded in nested proxy configuration', function () { + Server::flushIdentityMap(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=old-secret']; + $server->save(); + AuditEvent::query()->delete(); + + $server->proxy = ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => 'PASSWORD=new-secret']; + $server->save(); + + expect(AuditEvent::query()->sole()->changes['proxy'])->toBe([ + 'old' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'], + 'new' => ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => '[REDACTED]'], + ]); +}); + +test('audit metadata redacts secrets in serializable objects', function (Closure $configuration) { + auditLog('ui.server.updated', [ + 'team_id' => $this->team->id, + 'configuration' => $configuration(), + ]); + + expect(AuditEvent::query()->sole()->metadata['configuration'])->toBe([ + 'enabled' => true, + 'nested' => ['password' => '[REDACTED]'], + ]); +})->with([ + 'plain object' => fn () => (object) [ + 'enabled' => true, + 'nested' => (object) ['password' => 'must-not-be-recorded'], + ], + 'JSON serializable object' => fn () => new class implements JsonSerializable + { + public function jsonSerialize(): mixed + { + return ['enabled' => true, 'nested' => ['password' => 'must-not-be-recorded']]; + } + }, +]); + +test('audit change preparation failures do not fail model writes', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + AuditEvent::query()->delete(); + $project->mergeCasts(['description' => FailingAuditAttributeCast::class]); + + $project->update(['description' => 'Persist this description']); + + expect(Project::query()->findOrFail($project->id)->description)->toBe('Persist this description') + ->and(AuditEvent::query()->sole()->changes)->toBe([]); + + Log::shouldHaveReceived('warning')->once()->with( + 'Audit change preparation failed', + Mockery::on(fn (array $context): bool => $context['action'] === 'updated' + && $context['exception'] === RuntimeException::class + && array_keys($context) === ['resource_type', 'action', 'exception']), + ); +}); + +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 +860,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 +926,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 +952,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 +969,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 +980,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'); });