From 40b31b4a57091bbfcbc2937016155fda96f00d6e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:19:02 +0200 Subject: [PATCH] Align API deployment permissions --- .../Api/ApplicationsController.php | 9 + .../Controllers/Api/DatabasesController.php | 4 + .../Controllers/Api/ServicesController.php | 9 + .../Feature/ApiDeploymentPermissionsTest.php | 275 ++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 tests/Feature/ApiDeploymentPermissionsTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4d055e87cb..02baa6c492 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1213,6 +1213,10 @@ class ApplicationsController extends Controller $this->authorize('create', Application::class); + if ($request->instant_deploy) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + } + $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; @@ -3157,6 +3161,11 @@ class ApplicationsController extends Controller $this->authorize('update', $application); + if ($request->instant_deploy) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + $this->authorize('deploy', $application); + } + $server = $application->destination->server; $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6019ee2c78..b836876d81 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1856,6 +1856,10 @@ class DatabasesController extends Controller // Use a generic authorization for database creation - using PostgreSQL as representative model $this->authorize('create', StandalonePostgresql::class); + if ($request->boolean('instant_deploy')) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + } + $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 8e51043617..53abc72cce 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -368,6 +368,10 @@ class ServicesController extends Controller $this->authorize('create', Service::class); + if ($request->boolean('instant_deploy')) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + } + $return = validateIncomingRequest($request); if ($return instanceof JsonResponse) { return $return; @@ -1182,6 +1186,11 @@ class ServicesController extends Controller $this->authorize('update', $service); + if ($request->boolean('instant_deploy')) { + abort_unless($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root'), 403, 'Missing required permissions: deploy'); + $this->authorize('deploy', $service); + } + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ diff --git a/tests/Feature/ApiDeploymentPermissionsTest.php b/tests/Feature/ApiDeploymentPermissionsTest.php new file mode 100644 index 0000000000..fd1c1bb632 --- /dev/null +++ b/tests/Feature/ApiDeploymentPermissionsTest.php @@ -0,0 +1,275 @@ + 'file']); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'original-name', + ]); + + Queue::fake(); +}); + +function instantDeployHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'instant-deploy-scope-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +test('write-only token can update an application without deployment', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['name' => 'edited-name']) + ->assertOk(); + + expect($this->application->fresh()->name)->toBe('edited-name'); + expect(ApplicationDeploymentQueue::count())->toBe(0); + Queue::assertNotPushed(ApplicationDeploymentJob::class); +}); + +test('write-only token cannot deploy through application update or partially save changes', function () { + $originalIsSpa = $this->application->settings->is_spa; + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'name' => 'edited-name', + 'post_deployment_command' => 'echo should-not-run', + 'is_spa' => ! $originalIsSpa, + 'instant_deploy' => true, + ])->assertForbidden(); + + $application = $this->application->fresh(); + expect($application->name)->toBe('original-name') + ->and($application->post_deployment_command)->toBeNull() + ->and($application->settings->is_spa)->toBe($originalIsSpa); + expect(ApplicationDeploymentQueue::count())->toBe(0); + Queue::assertNotPushed(ApplicationDeploymentJob::class); +}); + +test('write-only token cannot bypass deploy ability with a truthy string flag', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'instant_deploy' => 'false', + ])->assertForbidden(); + + expect(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('write-only token cannot use the explicit start endpoint', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/applications/{$this->application->uuid}/start") + ->assertForbidden(); + + expect(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('write and deploy token can update and queue an application deployment', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'name' => 'edited-name', + 'instant_deploy' => true, + ])->assertOk(); + + expect($this->application->fresh()->name)->toBe('edited-name'); + expect(ApplicationDeploymentQueue::count())->toBe(1); + Queue::assertPushed(ApplicationDeploymentJob::class); +}); + +test('root token can update and queue an application deployment', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['root'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'instant_deploy' => true, + ])->assertOk(); + + expect(ApplicationDeploymentQueue::count())->toBe(1); + Queue::assertPushed(ApplicationDeploymentJob::class); +}); + +test('write-only token cannot create and deploy a docker image application', function () { + $before = Application::count(); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson('/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'alpine', + 'instant_deploy' => true, + ])->assertForbidden(); + + expect(Application::count())->toBe($before); + expect(ApplicationDeploymentQueue::count())->toBe(0); + Queue::assertNotPushed(ApplicationDeploymentJob::class); +}); + +test('write and deploy token can create and queue a docker image application', function () { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson('/api/v1/applications/dockerimage', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'alpine', + 'instant_deploy' => true, + ])->assertCreated(); + + expect(ApplicationDeploymentQueue::count())->toBe(1); + Queue::assertPushed(ApplicationDeploymentJob::class); +}); + +test('all other application create variants reject write-only instant deployment', function (string $path) { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson($path, ['instant_deploy' => true]) + ->assertForbidden(); + + expect(ApplicationDeploymentQueue::count())->toBe(0); +})->with([ + '/api/v1/applications/public', + '/api/v1/applications/private-github-app', + '/api/v1/applications/private-deploy-key', + '/api/v1/applications/dockerfile', +]); + +test('write-only token cannot create and deploy a service', function () { + $before = Service::count(); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson('/api/v1/services', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"), + 'instant_deploy' => true, + ])->assertForbidden(); + + expect(Service::count())->toBe($before); +}); + +test('write and deploy token can create and start a service', function () { + $before = Service::count(); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson('/api/v1/services', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_compose_raw' => base64_encode("services:\n web:\n image: nginx:alpine\n"), + 'instant_deploy' => true, + ])->assertCreated(); + + expect(Service::count())->toBe($before + 1); +}); + +test('write-only token cannot update and deploy a service', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'original-service', + ]); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'name' => 'edited-service', + 'instant_deploy' => true, + ])->assertForbidden(); + + expect($service->fresh()->name)->toBe('original-service'); +}); + +test('write and deploy token can update and start a service', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'original-service', + ]); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'name' => 'edited-service', + 'instant_deploy' => true, + ])->assertOk(); + + expect($service->fresh()->name)->toBe('edited-service'); +}); + +test('write-only token cannot create and deploy a database', function () { + $before = StandalonePostgresql::count(); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson('/api/v1/databases/postgresql', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'instant_deploy' => true, + ])->assertForbidden(); + + expect(StandalonePostgresql::count())->toBe($before); +}); + +test('write and deploy token can create and start a database', function () { + $before = StandalonePostgresql::count(); + + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson('/api/v1/databases/postgresql', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'instant_deploy' => true, + ])->assertCreated(); + + expect(StandalonePostgresql::count())->toBe($before + 1); +}); + +test('all other database create variants reject write-only instant deployment', function (string $path) { + $this->withHeaders(instantDeployHeaders($this->user, $this->team->id, ['write'])) + ->postJson($path, ['instant_deploy' => true]) + ->assertForbidden(); +})->with([ + '/api/v1/databases/mysql', + '/api/v1/databases/mariadb', + '/api/v1/databases/mongodb', + '/api/v1/databases/redis', + '/api/v1/databases/clickhouse', + '/api/v1/databases/dragonfly', + '/api/v1/databases/keydb', +]);