fix(api): require deploy permission for proxy and custom log drain changes (#12198)

This commit is contained in:
Andras Bacsai
2026-10-18 23:35:25 +02:00
committed by GitHub
parent 5227693885
commit a16994a88f
7 changed files with 135 additions and 10 deletions
@@ -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);
@@ -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' => []]],
+3 -3
View File
@@ -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": [
{
+3 -3
View File
@@ -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:
-
+1 -1
View File
@@ -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']);
+60
View File
@@ -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();
@@ -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")