diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 7402fb5d35..d9080ea1df 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3631,6 +3631,7 @@ class ApplicationsController extends Controller } $this->authorize('update', $application); + $settingsBefore = $application->settings->getAttributes(); if ($request->has('redact_all_env_values_in_logs') || $request->has('include_coolify_default_variables')) { $this->authorize('manageEnvironment', $application); @@ -4092,12 +4093,18 @@ class ApplicationsController extends Controller $dnsCleanup->queueReleaseOfRemovedHostnames($application, $previousDnsHostnames, (int) $teamId); app(ResourceDnsRecords::class)->queueAutomaticRecords($application, $previousDnsHostnames); - auditLog('api.application.updated', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), - ]); + $changedSettings = collect($application->settings->getAttributes()) + ->reject(fn (mixed $value, string $field): bool => $field === 'updated_at' || ($settingsBefore[$field] ?? null) == $value) + ->keys(); + $changedFields = [...auditSavedFields($application), ...$changedSettings]; + if ($changedFields !== []) { + auditLog('api.application.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'changed_fields' => $changedFields, + ]); + } if ($instantDeploy) { $deployment_uuid = new_public_id(); @@ -4339,15 +4346,20 @@ class ApplicationsController extends Controller $env->value = $request->value; } $env->fill($request->only(['is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'])); - $env->save(); + // One audit row with the application context, instead of a second row from the model. + $env->withoutAuditLogging(fn () => $env->save()); + $changedFields = auditSavedFields($env); - auditLog('api.application.env_updated', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'env_uuid' => $env->uuid, - 'env_key' => $env->key, - 'is_preview' => (bool) $is_preview, - ]); + if ($changedFields !== []) { + auditLog('api.application.env_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'is_preview' => (bool) $is_preview, + 'changed_fields' => $changedFields, + ]); + } return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 96ee88209a..7bb35ade98 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4016,14 +4016,19 @@ class DatabasesController extends Controller if ($request->has('comment')) { $env->comment = $request->comment; } - $env->save(); + // One audit row with the database context, instead of a second row from the model. + $env->withoutAuditLogging(fn () => $env->save()); + $changedFields = auditSavedFields($env); - auditLog('api.database.env_updated', [ - 'team_id' => $teamId, - 'database_uuid' => $database->uuid, - 'env_uuid' => $env->uuid, - 'env_key' => $env->key, - ]); + if ($changedFields !== []) { + auditLog('api.database.env_updated', [ + 'team_id' => $teamId, + 'database_uuid' => $database->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'changed_fields' => $changedFields, + ]); + } return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index cd52dc0aff..b1c9adc3dc 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1584,14 +1584,19 @@ class ServicesController extends Controller if ($request->has('comment')) { $env->comment = $request->comment; } - $env->save(); + // One audit row with the service context, instead of a second row from the model. + $env->withoutAuditLogging(fn () => $env->save()); + $changedFields = auditSavedFields($env); - auditLog('api.service.env_updated', [ - 'team_id' => $teamId, - 'service_uuid' => $service->uuid, - 'env_uuid' => $env->uuid, - 'env_key' => $env->key, - ]); + if ($changedFields !== []) { + auditLog('api.service.env_updated', [ + 'team_id' => $teamId, + 'service_uuid' => $service->uuid, + 'env_uuid' => $env->uuid, + 'env_key' => $env->key, + 'changed_fields' => $changedFields, + ]); + } return response()->json($this->removeSensitiveData($env))->setStatusCode(201); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 23ca1881fe..5e67f0110e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -329,6 +329,7 @@ class Application extends BaseModel $application->manual_webhook_secret_gitlab ??= Str::random(40); $application->manual_webhook_secret_bitbucket ??= Str::random(40); $application->manual_webhook_secret_gitea ??= Str::random(40); + $application->compose_parsing_version = self::$parserVersion; }); static::addGlobalScope('withRelations', function ($builder) { $builder->withCount([ @@ -426,8 +427,6 @@ class Application extends BaseModel ApplicationSetting::create([ 'application_id' => $application->id, ]); - $application->compose_parsing_version = self::$parserVersion; - $application->save(); // Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications if ($application->build_pack === 'nixpacks') { diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 3020119781..f483ba8b19 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -123,7 +123,7 @@ class AuditEvent extends Model */ public static function recordModelMutation(string $event, array $context, array $changes): void { - self::recordWithChanges($event, $context, $changes); + self::recordWithChanges($event, $context, $changes ?: null); } /** @@ -161,6 +161,11 @@ class AuditEvent extends Model */ private static function attributesFor(string $event, array $context, ?array $changes, string $level): array { + // API tokens store team_id as a string. Keep it an integer, so JSON filters on metadata work. + if (is_string($context['team_id'] ?? null) && ctype_digit($context['team_id'])) { + $context['team_id'] = (int) $context['team_id']; + } + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') ?? (array_key_exists('team_id', $context) ? $context['team_id'] diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index ef4b1a4a5d..37b6989e1e 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -48,6 +48,9 @@ class EnvironmentVariable extends BaseModel { use Auditable; + /** The key is in the metadata of the row, and the flags of a new variable have little audit value. */ + protected bool $auditCreatedChanges = false; + /** * Flags that a "# coolify:" line of the developer view can set, with their defaults. * @@ -140,6 +143,9 @@ class EnvironmentVariable extends BaseModel protected static function booted() { + static::creating(function (ModelsEnvironmentVariable $environment_variable) { + $environment_variable->version = config('constants.coolify.version'); + }); static::created(function (ModelsEnvironmentVariable $environment_variable) { if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) { $found = ModelsEnvironmentVariable::where('key', $environment_variable->key) @@ -152,7 +158,8 @@ class EnvironmentVariable extends BaseModel $application = Application::find($environment_variable->resourceable_id); // A compose preview generates its own magic secrets on the first pull request parse. if ($application && ! ($application->build_pack === 'dockercompose' && $environment_variable->isGeneratedMagicVariable())) { - ModelsEnvironmentVariable::create([ + // The preview copy is part of the same change, so it gets no audit rows of its own. + $preview = new ModelsEnvironmentVariable([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, 'is_multiline' => $environment_variable->is_multiline ?? false, @@ -166,12 +173,10 @@ class EnvironmentVariable extends BaseModel 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, ]); + $preview->withoutAuditLogging(fn () => $preview->save()); } } } - $environment_variable->update([ - 'version' => config('constants.coolify.version'), - ]); }); static::saving(function (ModelsEnvironmentVariable $environmentVariable) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 2b696ff1e8..26f5b81d46 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -100,10 +100,7 @@ class Service extends BaseModel if (blank($service->name)) { $service->name = 'service-'.new_public_id(); } - }); - static::created(function ($service) { $service->compose_parsing_version = self::$parserVersion; - $service->save(); }); static::saving(function ($service) { if ($service->isDirty('docker_compose_raw')) { diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php index 52cd033bcb..21475fc9c9 100644 --- a/app/Traits/Auditable.php +++ b/app/Traits/Auditable.php @@ -90,7 +90,7 @@ trait Auditable "{$resourceType}_uuid" => $this->getAttribute('uuid'), "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), 'changed_fields' => $changedFields, - ], $this->auditChanges( + ], $action === 'created' && ($this->auditCreatedChanges ?? true) === false ? [] : $this->auditChanges( $action, $action === 'updated' ? $changedFields : array_keys($this->getAttributes()), )); diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index d6c83c8e54..79c1c10cf1 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -71,6 +71,19 @@ if (! function_exists('auditChangedFields')) { } } +if (! function_exists('auditSavedFields')) { + /** + * Names of the attributes that the last save() really changed, without timestamps. + * Call it after save(), so changes made by saving hooks are included. + * + * @return array + */ + function auditSavedFields(Model $model): array + { + return array_values(array_diff(array_keys($model->getChanges()), ['created_at', 'updated_at'])); + } +} + if (! function_exists('auditFailureDeduplicationWindow')) { /** * Seconds during which repeated unauthenticated or permission failures from one source are recorded once. diff --git a/database/migrations/2026_11_05_224732_drop_unused_indexes_from_audit_events_table.php b/database/migrations/2026_11_05_224732_drop_unused_indexes_from_audit_events_table.php new file mode 100644 index 0000000000..9458e15af0 --- /dev/null +++ b/database/migrations/2026_11_05_224732_drop_unused_indexes_from_audit_events_table.php @@ -0,0 +1,31 @@ +dropIndex('audit_events_level_index'); + } + if (Schema::hasIndex('audit_events', ['team_id', 'resource_type', 'resource_uuid', 'created_at'])) { + $table->dropIndex(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + } + }); + } + + public function down(): void + { + Schema::table('audit_events', function (Blueprint $table) { + $table->index('level'); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + }); + } +};