diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index a5c8b4411f..fd8d14b5eb 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -33,6 +33,20 @@ class ScheduledTasksController extends Controller return request()->attributes->get('can_read_sensitive', false) === true; } + /** + * The scheduler runs an enabled task's command in the resource's containers, so the token needs the deploy permission. + */ + private function enabledTaskForbiddenResponse(Request $request, bool $enabled): ?JsonResponse + { + if (! $enabled || $request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { + return null; + } + + return response()->json([ + 'message' => 'Missing required permissions: deploy. An enabled scheduled task runs commands in the resource containers, so it needs a token with the deploy permission.', + ], 403); + } + private function resolveApplication(Request $request, int $teamId): ?Application { return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); @@ -96,6 +110,11 @@ class ScheduledTasksController extends Controller ], 422); } + $enabled = $request->has('enabled') ? $request->boolean('enabled') : true; + if ($response = $this->enabledTaskForbiddenResponse($request, $enabled)) { + return $response; + } + $teamId = getTeamIdFromToken(); $task = new ScheduledTask; @@ -104,7 +123,7 @@ class ScheduledTasksController extends Controller $task->frequency = $request->frequency; $task->container = $request->container; $task->timeout = $request->has('timeout') ? $request->timeout : 300; - $task->enabled = $request->has('enabled') ? $request->enabled : true; + $task->enabled = $enabled; $task->team_id = $teamId; if ($resource instanceof Application) { @@ -177,6 +196,11 @@ class ScheduledTasksController extends Controller return response()->json(['message' => 'Scheduled task not found.'], 404); } + $enabled = $request->has('enabled') ? $request->boolean('enabled') : $task->enabled; + if ($response = $this->enabledTaskForbiddenResponse($request, $enabled)) { + return $response; + } + $task->update($request->only($allowedFields)); auditLog('api.scheduled_task.updated', [ @@ -351,7 +375,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), ) @@ -375,6 +399,10 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability for an enabled task.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -438,7 +466,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), ) @@ -462,6 +490,10 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability for an enabled task.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -714,7 +746,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), ) @@ -738,6 +770,10 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability for an enabled task.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -801,7 +837,7 @@ class ScheduledTasksController extends Controller 'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'], 'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'], 'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300, 'maximum' => 36000], - 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true], + 'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.', 'default' => true], ], ), ) @@ -825,6 +861,10 @@ class ScheduledTasksController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability for an enabled task.', + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -986,7 +1026,7 @@ class ScheduledTasksController extends Controller #[OA\Post( summary: 'Execute Task', - description: 'Queue immediate execution of a scheduled task for an application.', + description: 'Queue immediate execution of a scheduled task for an application. Requires the deploy ability.', path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', operationId: 'execute-scheduled-task-by-application-uuid', security: [['bearerAuth' => []]], @@ -998,6 +1038,7 @@ class ScheduledTasksController extends Controller responses: [ new OA\Response(response: 200, description: 'Scheduled task execution queued.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy ability.'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] @@ -1018,7 +1059,7 @@ class ScheduledTasksController extends Controller #[OA\Post( summary: 'Execute Task', - description: 'Queue immediate execution of a scheduled task for a service.', + description: 'Queue immediate execution of a scheduled task for a service. Requires the deploy ability.', path: '/services/{uuid}/scheduled-tasks/{task_uuid}/execute', operationId: 'execute-scheduled-task-by-service-uuid', security: [['bearerAuth' => []]], @@ -1030,6 +1071,7 @@ class ScheduledTasksController extends Controller responses: [ new OA\Response(response: 200, description: 'Scheduled task execution queued.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy ability.'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] diff --git a/openapi.json b/openapi.json index 4377d7f8de..051395cdf8 100644 --- a/openapi.json +++ b/openapi.json @@ -14642,7 +14642,7 @@ }, "enabled": { "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", + "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", "default": true } }, @@ -14668,6 +14668,9 @@ "404": { "$ref": "#\/components\/responses\/404" }, + "403": { + "description": "The token needs the deploy ability for an enabled task." + }, "422": { "$ref": "#\/components\/responses\/422" } @@ -14796,7 +14799,7 @@ }, "enabled": { "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", + "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", "default": true } }, @@ -14822,6 +14825,9 @@ "404": { "$ref": "#\/components\/responses\/404" }, + "403": { + "description": "The token needs the deploy ability for an enabled task." + }, "422": { "$ref": "#\/components\/responses\/422" } @@ -14990,7 +14996,7 @@ }, "enabled": { "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", + "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", "default": true } }, @@ -15016,6 +15022,9 @@ "404": { "$ref": "#\/components\/responses\/404" }, + "403": { + "description": "The token needs the deploy ability for an enabled task." + }, "422": { "$ref": "#\/components\/responses\/422" } @@ -15144,7 +15153,7 @@ }, "enabled": { "type": "boolean", - "description": "The flag to indicate if the scheduled task is enabled.", + "description": "The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.", "default": true } }, @@ -15170,6 +15179,9 @@ "404": { "$ref": "#\/components\/responses\/404" }, + "403": { + "description": "The token needs the deploy ability for an enabled task." + }, "422": { "$ref": "#\/components\/responses\/422" } @@ -15243,7 +15255,7 @@ "Scheduled Tasks" ], "summary": "Execute Task", - "description": "Queue immediate execution of a scheduled task for an application.", + "description": "Queue immediate execution of a scheduled task for an application. Requires the deploy ability.", "operationId": "execute-scheduled-task-by-application-uuid", "parameters": [ { @@ -15272,6 +15284,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability." + }, "404": { "$ref": "#\/components\/responses\/404" } @@ -15289,7 +15304,7 @@ "Scheduled Tasks" ], "summary": "Execute Task", - "description": "Queue immediate execution of a scheduled task for a service.", + "description": "Queue immediate execution of a scheduled task for a service. Requires the deploy ability.", "operationId": "execute-scheduled-task-by-service-uuid", "parameters": [ { @@ -15318,6 +15333,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability." + }, "404": { "$ref": "#\/components\/responses\/404" } diff --git a/openapi.yaml b/openapi.yaml index a3e7803460..4b806a33a9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9335,7 +9335,7 @@ paths: maximum: 36000 enabled: type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' + description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' default: true type: object responses: @@ -9349,6 +9349,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the deploy ability for an enabled task.' '422': $ref: '#/components/responses/422' security: @@ -9440,7 +9442,7 @@ paths: maximum: 36000 enabled: type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' + description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' default: true type: object responses: @@ -9454,6 +9456,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the deploy ability for an enabled task.' '422': $ref: '#/components/responses/422' security: @@ -9573,7 +9577,7 @@ paths: maximum: 36000 enabled: type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' + description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' default: true type: object responses: @@ -9587,6 +9591,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the deploy ability for an enabled task.' '422': $ref: '#/components/responses/422' security: @@ -9678,7 +9684,7 @@ paths: maximum: 36000 enabled: type: boolean - description: 'The flag to indicate if the scheduled task is enabled.' + description: 'The flag to indicate if the scheduled task is enabled. An enabled task requires the deploy ability.' default: true type: object responses: @@ -9692,6 +9698,8 @@ paths: $ref: '#/components/responses/401' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the deploy ability for an enabled task.' '422': $ref: '#/components/responses/422' security: @@ -9740,7 +9748,7 @@ paths: tags: - 'Scheduled Tasks' summary: 'Execute Task' - description: 'Queue immediate execution of a scheduled task for an application.' + description: 'Queue immediate execution of a scheduled task for an application. Requires the deploy ability.' operationId: execute-scheduled-task-by-application-uuid parameters: - @@ -9762,6 +9770,8 @@ paths: description: 'Scheduled task execution queued.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability.' '404': $ref: '#/components/responses/404' security: @@ -9772,7 +9782,7 @@ paths: tags: - 'Scheduled Tasks' summary: 'Execute Task' - description: 'Queue immediate execution of a scheduled task for a service.' + description: 'Queue immediate execution of a scheduled task for a service. Requires the deploy ability.' operationId: execute-scheduled-task-by-service-uuid parameters: - @@ -9794,6 +9804,8 @@ paths: description: 'Scheduled task execution queued.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability.' '404': $ref: '#/components/responses/404' security: diff --git a/routes/api.php b/routes/api.php index b0eba12a6b..e211e5e913 100644 --- a/routes/api.php +++ b/routes/api.php @@ -438,14 +438,14 @@ Route::group([ Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); - Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); + Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); - Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); + Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:deploy']); }); Route::group([ diff --git a/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php b/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php new file mode 100644 index 0000000000..0f571fb49e --- /dev/null +++ b/tests/Feature/Api/ScheduledTaskDeployAbilityTest.php @@ -0,0 +1,194 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->service = Service::factory()->create([ + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + Queue::fake(); +}); + +function scheduledTaskAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'scheduled-task-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +describe('execute', function () { + test('write-only token cannot execute an application or service task', function () { + $headers = scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write']); + $applicationTask = ScheduledTask::factory()->create(['application_id' => $this->application->id, 'team_id' => $this->team->id]); + $serviceTask = ScheduledTask::factory()->create(['service_id' => $this->service->id, 'team_id' => $this->team->id]); + + $this->withHeaders($headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$applicationTask->uuid}/execute") + ->assertForbidden(); + $this->withHeaders($headers) + ->postJson("/api/v1/services/{$this->service->uuid}/scheduled-tasks/{$serviceTask->uuid}/execute") + ->assertForbidden(); + + Queue::assertNotPushed(ScheduledTaskJob::class); + }); + + test('deploy token can execute an application or service task', function () { + $headers = scheduledTaskAbilityHeaders($this->user, $this->team->id, ['deploy']); + $applicationTask = ScheduledTask::factory()->create(['application_id' => $this->application->id, 'team_id' => $this->team->id]); + $serviceTask = ScheduledTask::factory()->create(['service_id' => $this->service->id, 'team_id' => $this->team->id]); + + $this->withHeaders($headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$applicationTask->uuid}/execute") + ->assertOk(); + $this->withHeaders($headers) + ->postJson("/api/v1/services/{$this->service->uuid}/scheduled-tasks/{$serviceTask->uuid}/execute") + ->assertOk(); + + Queue::assertPushed(ScheduledTaskJob::class, 2); + }); +}); + +describe('create', function () { + test('write-only token cannot create an enabled task', function (array $payload) { + $headers = scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write']); + $task = ['name' => 'nightly', 'command' => 'id', 'frequency' => '0 0 1 1 *'] + $payload; + + $this->withHeaders($headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks", $task) + ->assertForbidden(); + $this->withHeaders($headers) + ->postJson("/api/v1/services/{$this->service->uuid}/scheduled-tasks", $task) + ->assertForbidden(); + + expect(ScheduledTask::count())->toBe(0); + })->with([ + 'enabled by default' => [[]], + 'enabled explicitly' => [['enabled' => true]], + ]); + + test('write-only token can create a disabled task', function () { + $this->withHeaders(scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks", [ + 'name' => 'draft', + 'command' => 'id', + 'frequency' => '0 0 1 1 *', + 'enabled' => false, + ]) + ->assertCreated() + ->assertJsonPath('enabled', false); + }); + + test('write and deploy token can create an enabled task', function () { + $this->withHeaders(scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks", [ + 'name' => 'nightly', + 'command' => 'id', + 'frequency' => '0 0 1 1 *', + ]) + ->assertCreated() + ->assertJsonPath('enabled', true); + }); +}); + +describe('update', function () { + test('write-only token cannot change an enabled task', function () { + $task = ScheduledTask::factory()->create([ + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'command' => 'echo safe', + 'enabled' => true, + ]); + + $this->withHeaders(scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}", ['command' => 'id']) + ->assertForbidden(); + + expect($task->fresh()->command)->toBe('echo safe'); + }); + + test('write-only token cannot enable a disabled task', function () { + $task = ScheduledTask::factory()->create([ + 'service_id' => $this->service->id, + 'team_id' => $this->team->id, + 'enabled' => false, + ]); + + $this->withHeaders(scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/services/{$this->service->uuid}/scheduled-tasks/{$task->uuid}", ['enabled' => true]) + ->assertForbidden(); + + expect($task->fresh()->enabled)->toBeFalse(); + }); + + test('write-only token can change or disable a task that stays disabled', function () { + $task = ScheduledTask::factory()->create([ + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'enabled' => true, + ]); + $headers = scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write']); + + $this->withHeaders($headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}", ['enabled' => false]) + ->assertOk(); + $this->withHeaders($headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}", ['command' => 'id']) + ->assertOk(); + + expect($task->fresh())->enabled->toBeFalse()->command->toBe('id'); + }); + + test('write and deploy token can change an enabled task', function () { + $task = ScheduledTask::factory()->create([ + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'enabled' => true, + ]); + + $this->withHeaders(scheduledTaskAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}", ['command' => 'id']) + ->assertOk(); + + expect($task->fresh()->command)->toBe('id'); + }); +});