fix(api): require deploy ability to run scheduled tasks (#12194)

This commit is contained in:
Andras Bacsai
2026-10-18 23:27:05 +02:00
committed by GitHub
parent aae27629d4
commit 8ab10c66de
5 changed files with 287 additions and 21 deletions
@@ -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'),
]
)]
+24 -6
View File
@@ -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"
}
+18 -6
View File
@@ -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:
+2 -2
View File
@@ -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([
@@ -0,0 +1,194 @@
<?php
use App\Jobs\ScheduledTaskJob;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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');
});
});