From a16994a88f99a8e2ac1b51751349c46634f41981 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:25 +0200 Subject: [PATCH] fix(api): require deploy permission for proxy and custom log drain changes (#12198) --- .../Api/ServerLogDrainsController.php | 10 +++- .../Controllers/Api/ServerProxyController.php | 9 ++- openapi.json | 6 +- openapi.yaml | 6 +- routes/api.php | 2 +- tests/Feature/Api/ServerProxyApiTest.php | 60 +++++++++++++++++++ tests/Feature/Api/ServerSubsystemsApiTest.php | 52 ++++++++++++++++ 7 files changed, 135 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index e56d6dfb2c..ba1faa787e 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -108,7 +108,7 @@ class ServerLogDrainsController extends Controller #[OA\Patch( summary: 'Update log drain settings', - description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.', + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.', path: '/servers/{uuid}/log-drains', operationId: 'update-server-log-drains', security: [['bearerAuth' => []]], @@ -186,6 +186,14 @@ class ServerLogDrainsController extends Controller } $settings = $server->settings; + + // The custom config is applied on the server, like a deployment. + $customConfigChanged = collect(['logdrain_custom_config', 'logdrain_custom_config_parser']) + ->contains(fn (string $field) => $request->has($field) && (string) $request->input($field) !== (string) $settings->{$field}); + if ($customConfigChanged && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { + return response()->json(['message' => 'Missing required permissions: deploy'], 403); + } + foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { $settings->{$field} = $request->input($field); diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php index 8c7bdc90fe..202eb002d5 100644 --- a/app/Http/Controllers/Api/ServerProxyController.php +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -270,7 +270,7 @@ class ServerProxyController extends Controller #[OA\Put( summary: 'Save server proxy configuration', - description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).', + description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Needs a token with the `write` and `deploy` permissions. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).', path: '/servers/{uuid}/proxy/configuration', operationId: 'save-server-proxy-configuration', security: [['bearerAuth' => []]], @@ -322,6 +322,11 @@ class ServerProxyController extends Controller return $teamId; } + // The proxy compose file is applied on the server, like a deployment. + if (! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { + return response()->json(['message' => 'Missing required permissions: deploy'], 403); + } + $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; @@ -391,7 +396,7 @@ class ServerProxyController extends Controller #[OA\Post( summary: 'Restart server proxy', - description: 'Queue a proxy restart for a team-owned server.', + description: 'Queue a proxy restart for a team-owned server. Needs a token with the `deploy` permission.', path: '/servers/{uuid}/proxy/restart', operationId: 'restart-server-proxy', security: [['bearerAuth' => []]], diff --git a/openapi.json b/openapi.json index 051395cdf8..95e9a7a275 100644 --- a/openapi.json +++ b/openapi.json @@ -16575,7 +16575,7 @@ "Servers" ], "summary": "Update log drain settings", - "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.", + "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.", "operationId": "update-server-log-drains", "parameters": [ { @@ -16878,7 +16878,7 @@ "Servers" ], "summary": "Save server proxy configuration", - "description": "Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).", + "description": "Save the raw proxy Docker Compose configuration for a team-owned server. Needs a token with the `write` and `deploy` permissions. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).", "operationId": "save-server-proxy-configuration", "parameters": [ { @@ -16983,7 +16983,7 @@ "Servers" ], "summary": "Restart server proxy", - "description": "Queue a proxy restart for a team-owned server.", + "description": "Queue a proxy restart for a team-owned server. Needs a token with the `deploy` permission.", "operationId": "restart-server-proxy", "parameters": [ { diff --git a/openapi.yaml b/openapi.yaml index 4b806a33a9..9a8b956142 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10522,7 +10522,7 @@ paths: tags: - Servers summary: 'Update log drain settings' - description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.' + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.' operationId: update-server-log-drains parameters: - @@ -10676,7 +10676,7 @@ paths: tags: - Servers summary: 'Save server proxy configuration' - description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).' + description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Needs a token with the `write` and `deploy` permissions. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).' operationId: save-server-proxy-configuration parameters: - @@ -10729,7 +10729,7 @@ paths: tags: - Servers summary: 'Restart server proxy' - description: 'Queue a proxy restart for a team-owned server.' + description: 'Queue a proxy restart for a team-owned server. Needs a token with the `deploy` permission.' operationId: restart-server-proxy parameters: - diff --git a/routes/api.php b/routes/api.php index e211e5e913..000680b4a5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -202,7 +202,7 @@ Route::group([ Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']); Route::put('/servers/{uuid}/proxy/configuration', [ServerProxyController::class, 'saveConfiguration'])->middleware(['api.ability:write']); - Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:deploy']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php index a78fc81c3e..f27649e19c 100644 --- a/tests/Feature/Api/ServerProxyApiTest.php +++ b/tests/Feature/Api/ServerProxyApiTest.php @@ -268,6 +268,66 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration forbids normal members', fu ->assertForbidden(); }); +test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permission', function () { + $writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']); + $configuration = "services:\n traefik:\n image: traefik:v3.5\n"; + + SaveProxyConfiguration::shouldNotRun(); + + $this->withHeaders(serverProxyApiHeaders($writeOnlyToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode($configuration), + ]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); +}); + +test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () { + $deployToken = serverProxyApiToken($this->user, $this->team, ['write', 'deploy']); + + SaveProxyConfiguration::shouldRun()->once(); + + $this->withHeaders(serverProxyApiHeaders($deployToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"), + ]) + ->assertOk(); +}); + +test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the write permission', function () { + $deployOnlyToken = serverProxyApiToken($this->user, $this->team, ['deploy']); + + SaveProxyConfiguration::shouldNotRun(); + + $this->withHeaders(serverProxyApiHeaders($deployOnlyToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode("services:\n traefik:\n image: traefik:v3.5\n"), + ]) + ->assertForbidden(); +}); + +test('POST /api/v1/servers/{uuid}/proxy/restart requires the deploy permission', function () { + Queue::fake(); + $writeOnlyToken = serverProxyApiToken($this->user, $this->team, ['write']); + + $this->withHeaders(serverProxyApiHeaders($writeOnlyToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") + ->assertForbidden(); + + Queue::assertNotPushed(RestartProxyJob::class); +}); + +test('POST /api/v1/servers/{uuid}/proxy/restart works with the deploy permission', function () { + Queue::fake(); + $deployToken = serverProxyApiToken($this->user, $this->team, ['deploy']); + + $this->withHeaders(serverProxyApiHeaders($deployToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") + ->assertOk(); + + Queue::assertPushed(RestartProxyJob::class); +}); + test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () { Queue::fake(); diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 1f24abd97e..b3a0847a70 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -188,6 +188,58 @@ describe('Log drains API', function () { ->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123'); }); + test('PATCH requires the deploy permission to change the custom log drain config', function (string $field) { + Queue::fake(); + $writeOnlyToken = $this->user->createToken('server-subsystems-write', ['write'])->plainTextToken; + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeOnlyToken, + 'Accept' => 'application/json', + ]) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + $field => "[OUTPUT]\n Name stdout", + ]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); + + $settings = $this->server->settings->fresh(); + expect($settings->{$field})->toBeNull(); + })->with(['logdrain_custom_config', 'logdrain_custom_config_parser']); + + test('PATCH lets a write token enable the custom log drain with its saved config', function () { + Queue::fake(); + $this->server->settings->update(['logdrain_custom_config' => "[OUTPUT]\n Name stdout"]); + $writeOnlyToken = $this->user->createToken('server-subsystems-write', ['write'])->plainTextToken; + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeOnlyToken, + 'Accept' => 'application/json', + ]) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_custom_enabled' => true, + 'logdrain_custom_config' => "[OUTPUT]\n Name stdout", + ]) + ->assertOk(); + + expect($this->server->settings->fresh()->is_logdrain_custom_enabled)->toBeTruthy(); + }); + + test('PATCH changes the custom log drain config with the deploy permission', function () { + Queue::fake(); + $deployToken = $this->user->createToken('server-subsystems-deploy', ['write', 'deploy'])->plainTextToken; + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$deployToken, + 'Accept' => 'application/json', + ]) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'logdrain_custom_config' => "[OUTPUT]\n Name stdout", + ]) + ->assertOk(); + + expect($this->server->settings->fresh()->logdrain_custom_config)->toBe("[OUTPUT]\n Name stdout"); + }); + test('other-team log drains endpoints return 404', function () { $this->withHeaders(serverSubsystemsHeaders()) ->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains")