diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php index bb51b563f5..1c8922c8d5 100644 --- a/app/Services/ServerTransfer/ServerTransferMigrator.php +++ b/app/Services/ServerTransfer/ServerTransferMigrator.php @@ -598,7 +598,7 @@ class ServerTransferMigrator $token = trim(substr($token, 7)); } if ($token === '') { - throw new RuntimeException('A target instance API token is required (root or write + create servers).'); + throw new RuntimeException('A target instance API token is required (root or write + deploy).'); } return $token; @@ -697,7 +697,7 @@ class ServerTransferMigrator $gates = [ [404, 'the target instance supports server transfer', 'The target instance does not support server transfer. Upgrade it to Coolify '.config('constants.coolify.version').' or newer.'], [401, 'the target instance accepts the API token', 'The target instance does not accept the API token. Create a new token on the target instance and paste it here.'], - [403, 'the API token can create servers on the target instance', 'Use a token with the write permission from an admin or owner. Target response: '.$message], + [403, 'the API token can create servers on the target instance', 'Use a token with the root permission, or the write and deploy permissions, from an admin or owner. Target response: '.$message], ]; foreach ($gates as [$failStatus, $label, $failMessage]) { if ($status === $failStatus) { diff --git a/resources/views/livewire/server/transfer.blade.php b/resources/views/livewire/server/transfer.blade.php index a28757a968..900e04f8be 100644 --- a/resources/views/livewire/server/transfer.blade.php +++ b/resources/views/livewire/server/transfer.blade.php @@ -94,7 +94,7 @@ helper="Base URL of the other Coolify instance (no trailing path). It must use https." /> + helper="Create a token on the target with root (or write + deploy). It is only used for this request." />
middleware(['api.ability:deploy']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); - Route::get('/servers/import/check', [ServerTransferController::class, 'check'])->middleware(['api.ability:write']); + Route::get('/servers/import/check', [ServerTransferController::class, 'check'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/servers/{uuid}/transfer', [ServerTransferController::class, 'status'])->middleware(['api.ability:read,write']); Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php index 134802cd09..c1d9aa536a 100644 --- a/tests/Feature/Api/ServerTransferApiTest.php +++ b/tests/Feature/Api/ServerTransferApiTest.php @@ -46,6 +46,7 @@ beforeEach(function () { $this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken; $this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken; $this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken; + $this->importToken = $this->user->createToken('transfer-import', ['read', 'write', 'deploy'])->plainTextToken; $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server = Server::factory()->create([ @@ -394,7 +395,7 @@ describe('GET /api/v1/servers/import/check', function () { $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); - $this->withHeaders(transferHeaders($this->writeToken)) + $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertOk() ->assertJsonPath('checks', fn ($checks) => collect($checks)->every(fn ($check) => $check['ok'] === true) @@ -408,14 +409,14 @@ describe('GET /api/v1/servers/import/check', function () { $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); fclose($listener); - $this->withHeaders(transferHeaders($this->writeToken)) + $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot reach the server at 127.0.0.1')); }); test('rejects a target that is not an IP address or hostname before it opens a socket', function (string $ip) { - $this->withHeaders(transferHeaders($this->writeToken)) + $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip)) ->assertStatus(422) ->assertJsonValidationErrors('ip') @@ -423,7 +424,7 @@ describe('GET /api/v1/servers/import/check', function () { })->with(['tcp://127.0.0.1', 'unix:///var/run/docker.sock', 'ssl://example.com']); test('rejects another transfer bundle version', function () { - $this->withHeaders(transferHeaders($this->writeToken)) + $this->withHeaders(transferHeaders($this->importToken)) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&schema_version=999') ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'Upgrade both instances')); @@ -434,7 +435,7 @@ describe('GET /api/v1/servers/import/check', function () { $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); $this->server->forceFill(['ip' => '127.0.0.1'])->save(); - $checks = $this->withHeaders(transferHeaders($this->writeToken)) + $checks = $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") ->assertStatus(422) ->assertJsonPath('message', fn ($message) => str_contains($message, 'already exists on this instance')) @@ -455,6 +456,12 @@ describe('GET /api/v1/servers/import/check', function () { ->assertForbidden(); }); + test('rejects a token without the deploy permission that the import needs', function () { + $this->withHeaders(transferHeaders($this->writeToken)) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') + ->assertForbidden(); + }); + test('rejects an invalid token', function () { $this->withHeaders(transferHeaders('1|not-a-real-token')) ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') @@ -467,7 +474,7 @@ test('the import check lists the system-wide git sources that this instance does $listener = stream_socket_server('tcp://127.0.0.1:0'); $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); - $this->withHeaders(transferHeaders($this->writeToken)) + $this->withHeaders(transferHeaders($this->importToken)) ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}&system_github_apps[]={$present->uuid}&system_github_apps[]=missing-uuid&system_gitlab_apps[]=other-missing") ->assertOk() ->assertJsonPath('missing_system_sources.github', ['missing-uuid']) diff --git a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php index 1abdd22c38..914b416170 100644 --- a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php @@ -351,7 +351,7 @@ test('start checks the target before it queues the transfer', function (int $sta && $request->hasHeader('Authorization', 'Bearer wrong-token')); })->with([ 'invalid token' => [401, 'does not accept the API token'], - 'token without write permission' => [403, 'write permission'], + 'token without the write and deploy permissions' => [403, 'write and deploy permissions'], 'conflict on the target' => [422, 'already exists on this instance'], 'target without server transfer' => [404, 'does not support server transfer'], ]);