diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index c800c8d94f..61bd2bd6f2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -10,6 +10,7 @@ use App\Enums\BuildPackTypes; use App\Enums\HttpBasicAuthHashAlgorithm; use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; +use App\Jobs\ServerStorageSaveJob; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\ApplicationSetting; @@ -5202,7 +5203,7 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'], ], additionalProperties: false, ), @@ -5337,7 +5338,7 @@ class ApplicationsController extends Controller } } - if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) { return $forbidden; } @@ -5367,6 +5368,10 @@ class ApplicationsController extends Controller $storage->save(); + if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) { + ServerStorageSaveJob::dispatch($storage); + } + auditLog('api.application.storage_updated', [ 'team_id' => $teamId, 'application_uuid' => $application->uuid, diff --git a/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php index fe8934cec7..9b862ef18d 100644 --- a/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php +++ b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php @@ -24,15 +24,18 @@ trait RequiresDeployForOutsideHostPaths return $this->deployAbilityForbiddenResponse($request, 'A mount outside the resource directory needs a token with the deploy permission.'); } - private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse + /** + * Coolify writes changed file content on the server immediately, also inside the resource directory. + */ + private function contentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse { - if (! $storage instanceof LocalFileVolume || $storage->is_directory || ! $request->has('content')) { + if (! $storage instanceof LocalFileVolume || $storage->is_directory || $storage->is_host_file || ! $request->has('content')) { return null; } if ((string) $request->input('content') === (string) $storage->content) { return null; } - return $this->outsideHostPathForbiddenResponse($request, $storage); + return $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 45a2ad54f3..41ee9ffb7d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,6 +11,7 @@ use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Jobs\ServerStorageSaveJob; use App\Jobs\VolumeCloneJob; use App\Models\EnvironmentVariable; use App\Models\LocalFileVolume; @@ -4792,7 +4793,7 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'], ], additionalProperties: false, ), @@ -4924,7 +4925,7 @@ class DatabasesController extends Controller } } - if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) { return $forbidden; } @@ -4954,6 +4955,10 @@ class DatabasesController extends Controller $storage->save(); + if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) { + ServerStorageSaveJob::dispatch($storage); + } + auditLog('api.database.storage_updated', [ 'team_id' => $teamId, 'database_uuid' => $database->uuid, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index aa4ea63110..1e5231400a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -7,6 +7,7 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; +use App\Jobs\ServerStorageSaveJob; use App\Models\EnvironmentVariable; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -2793,7 +2794,7 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.'], ], additionalProperties: false, ), @@ -2955,7 +2956,7 @@ class ServicesController extends Controller } } - if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) { + if (! $isReadOnly && ($forbidden = $this->contentChangeForbiddenResponse($request, $storage))) { return $forbidden; } @@ -2985,6 +2986,10 @@ class ServicesController extends Controller $storage->save(); + if ($storage instanceof LocalFileVolume && $storage->wasChanged('content')) { + ServerStorageSaveJob::dispatch($storage); + } + auditLog('api.service.storage_updated', [ 'team_id' => $teamId, 'service_uuid' => $service->uuid, diff --git a/openapi.json b/openapi.json index 7af9db1b47..4ed696c239 100644 --- a/openapi.json +++ b/openapi.json @@ -5449,7 +5449,7 @@ "content": { "type": "string", "nullable": true, - "description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission." + "description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission." } }, "type": "object", @@ -10340,7 +10340,7 @@ "content": { "type": "string", "nullable": true, - "description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission." + "description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission." } }, "type": "object", @@ -21789,7 +21789,7 @@ "content": { "type": "string", "nullable": true, - "description": "The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission." + "description": "The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission." } }, "type": "object", diff --git a/openapi.yaml b/openapi.yaml index 4018755766..f1f4987405 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3586,7 +3586,7 @@ paths: content: type: string nullable: true - description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.' + description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.' type: object additionalProperties: false responses: @@ -6779,7 +6779,7 @@ paths: content: type: string nullable: true - description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.' + description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.' type: object additionalProperties: false responses: @@ -13753,7 +13753,7 @@ paths: content: type: string nullable: true - description: 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.' + description: 'The file content (file only, not allowed for read-only storages). Changing the content needs the deploy permission.' type: object additionalProperties: false responses: diff --git a/tests/Feature/Api/StorageContentDeployAbilityTest.php b/tests/Feature/Api/StorageContentDeployAbilityTest.php index ae76e45dc4..bbf726910c 100644 --- a/tests/Feature/Api/StorageContentDeployAbilityTest.php +++ b/tests/Feature/Api/StorageContentDeployAbilityTest.php @@ -111,3 +111,64 @@ test('write and deploy token creates a file storage with content', function (str expect($resource->fileStorages()->where('mount_path', '/etc/app.conf')->value('content'))->toBe('listen 8080;'); Queue::assertPushed(ServerStorageSaveJob::class); })->with(['applications', 'services', 'databases']); + +function createSavedFileStorage(mixed $resource): LocalFileVolume +{ + $storage = LocalFileVolume::create([ + 'fs_path' => '/data/coolify/app.conf', + 'mount_path' => '/etc/app.conf', + 'content' => 'listen 80;', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + Queue::fake(); + + return $storage; +} + +test('updating file storage content writes the file on the server', function (string $type) { + [$url, , $resource] = $this->targets[$type]; + $storage = createSavedFileStorage($resource); + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 8080;']) + ->assertOk(); + + expect($storage->fresh()->content)->toBe('listen 8080;'); + Queue::assertPushed(ServerStorageSaveJob::class, fn (ServerStorageSaveJob $job) => $job->localFileVolume->is($storage)); +})->with(['applications', 'services', 'databases']); + +test('write-only token cannot change file storage content', function (string $type) { + [$url, , $resource] = $this->targets[$type]; + $storage = createSavedFileStorage($resource); + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 8080;']) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); + + expect($storage->fresh()->content)->toBe('listen 80;'); + Queue::assertNotPushed(ServerStorageSaveJob::class); +})->with(['applications', 'services', 'databases']); + +test('write-only token can update a file storage without changing its content', function (string $type) { + [$url, , $resource] = $this->targets[$type]; + $storage = createSavedFileStorage($resource); + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 80;', 'mount_path' => '/etc/app2.conf']) + ->assertOk(); + + expect($storage->fresh()->mount_path)->toBe('/etc/app2.conf'); +})->with(['applications', 'services', 'databases']); + +test('updating a file storage without changing its content does not write the file', function (string $type) { + [$url, , $resource] = $this->targets[$type]; + $storage = createSavedFileStorage($resource); + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson($url, ['uuid' => $storage->uuid, 'type' => 'file', 'content' => 'listen 80;', 'is_preview_suffix_enabled' => false]) + ->assertOk(); + + Queue::assertNotPushed(ServerStorageSaveJob::class); +})->with(['applications', 'services', 'databases']); diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php index 3f60abf56b..37239aa74d 100644 --- a/tests/Feature/StorageHostPathTest.php +++ b/tests/Feature/StorageHostPathTest.php @@ -436,7 +436,7 @@ test('changing the content of a mount outside the resource directory needs deplo $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])]) ->patchJson($url, ['type' => 'file', 'uuid' => $storage->uuid, 'content' => 'ssh-ed25519 AAAA']) ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.'); + ->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); expect($storage->fresh()->content)->toBe('listen 8080;'); $this->withHeaders(['Authorization' => 'Bearer '.storageHostPathToken($this->admin, ['read', 'write'])])