diff --git a/.ai/lessons.md b/.ai/lessons.md index 3ce9a99b08..c8e986bd99 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -25,6 +25,8 @@ - Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result. - When the request covers more than one interface or resource type, inventory and verify each supported path. - When a change alters how stored data reaches running containers, simulate the upgrade on the dev VM: start resources on the old code, then migrate and restart them on the new code, and compare the results. +- Horizon workers in a dev instance keep the code they loaded. Run `./scripts/dev exec php artisan horizon:terminate` after a code change and before a live test of queued jobs. +- To prove that a server side effect is blocked, check the server itself after the request. A model hook or job can be one of several write paths (for example `ServerStorageSaveJob` and `ServerFilesFromServerJob` both write file storages). ## Preserve product scope - Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect. diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index ba3886d21c..b43e199555 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -273,11 +273,12 @@ class CleanupDocker implements ShouldBeUnique // Add application repo exclusion if there are applications if ($applicationImageRepos->isNotEmpty()) { - $grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:@].+'"; + // The repository names come from application data, so quote the pattern for the shell. + $grepCommands .= ' | grep -v -E '.escapeshellarg("^({$appExcludePatterns})[_:@].+"); } // Add infrastructure image exclusion (matches any registry prefix) - $grepCommands .= " | grep -v -E '{$infraExcludePattern}'"; + $grepCommands .= ' | grep -v -E '.escapeshellarg($infraExcludePattern); $commands[] = "docker images --digests --format '{{if eq .Tag \"\"}}{{.Repository}}@{{.Digest}}{{else}}{{.Repository}}:{{.Tag}}{{end}}' | ". $grepCommands.' | '. diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 9fd3eecbc8..11bf160f57 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -29,6 +29,7 @@ class StartLogDrain if ($type !== 'none') { StopLogDrain::run($server); } + $serverName = $this->fluentBitValue((string) $server->name); try { if ($type === 'none') { return 'No log drain is enabled.'; @@ -54,7 +55,7 @@ class StartLogDrain [FILTER] Name modify Match * - Set coolify.server_name {$server->name} + Set coolify.server_name {$serverName} Rename COOLIFY_APP_NAME coolify.app_name Rename COOLIFY_PROJECT_NAME coolify.project_name Rename COOLIFY_SERVER_IP coolify.server_ip @@ -109,7 +110,7 @@ class StartLogDrain [FILTER] Name modify Match * - Set coolify.server_name {$server->name} + Set coolify.server_name {$serverName} Rename COOLIFY_APP_NAME coolify.app_name Rename COOLIFY_PROJECT_NAME coolify.project_name Rename COOLIFY_SERVER_IP coolify.server_ip @@ -219,6 +220,14 @@ Files: } } + /** + * Fluent Bit reads its config line by line, so a value with a line break could add sections. + */ + private function fluentBitValue(string $value): string + { + return trim(preg_replace('/\s*[\x00-\x1F\x7F]+\s*/', ' ', $value)); + } + private function logDrainNetworkConnectCommands(Server $server): array { if (! $server->isLogDrainEnabled()) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 28f82b083a..3dac22efb3 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -39,6 +39,7 @@ use Spatie\Url\Url; class ApplicationsController extends Controller { use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployAbility; use Concerns\RequiresDeployForOutsideHostPaths; private const APPLICATION_SETTING_FIELDS = [ @@ -143,6 +144,21 @@ class ApplicationsController extends Controller return serializeApiResponse($application); } + /** + * Setting or changing a manual webhook secret needs the deploy ability. Sending the stored value + * again or clearing it does not. + */ + private function manualWebhookSecretForbiddenResponse(Request $request, ?Application $application = null): ?JsonResponse + { + foreach (['manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea'] as $field) { + if ($request->filled($field) && $request->input($field) !== $application?->{$field}) { + return $this->deployAbilityForbiddenResponse($request, 'A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.'); + } + } + + return null; + } + private function applicationSettingsFromRequest(Request $request): array { $settings = []; @@ -394,10 +410,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], // 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], @@ -478,6 +494,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -591,10 +611,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], @@ -674,6 +694,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -787,10 +811,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], @@ -870,6 +894,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -970,10 +998,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], @@ -1036,6 +1064,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -1133,10 +1165,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], @@ -1199,6 +1231,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -1422,6 +1458,9 @@ class ApplicationsController extends Controller if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) { return $internalNameValidation; } + if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request)) { + return $forbidden; + } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], @@ -2940,7 +2979,7 @@ class ApplicationsController extends Controller #[OA\Delete( summary: 'Delete', - description: 'Delete application by UUID.', + description: 'Delete application by UUID. Needs a token with the write and deploy permissions.', path: '/applications/{uuid}', operationId: 'delete-application-by-uuid', security: [ @@ -2990,6 +3029,7 @@ class ApplicationsController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), ] )] public function delete_by_uuid(Request $request) @@ -3109,10 +3149,10 @@ class ApplicationsController extends Controller 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], - 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github.'], - 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab.'], - 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket.'], - 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], @@ -3189,6 +3229,10 @@ class ApplicationsController extends Controller response: 401, ref: '#/components/responses/401', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), new OA\Response( response: 400, ref: '#/components/responses/400', @@ -3359,6 +3403,9 @@ class ApplicationsController extends Controller 'errors' => $errors, ], 422); } + if ($forbidden = $this->manualWebhookSecretForbiddenResponse($request, $application)) { + return $forbidden; + } $githubApp = null; if ($request->has('github_app_uuid')) { @@ -5334,7 +5381,7 @@ class ApplicationsController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], @@ -5352,7 +5399,7 @@ class ApplicationsController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), - new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -5553,6 +5600,13 @@ class ApplicationsController extends Controller ], 422); } + if ($request->filled('content')) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, @@ -5664,7 +5718,7 @@ class ApplicationsController extends Controller #[OA\Delete( summary: 'Delete Preview Deployment', - description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.', + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record. Needs a token with the write and deploy permissions.', path: '/applications/{uuid}/previews/{pull_request_id}', operationId: 'delete-preview-deployment-by-pull-request-id', security: [ @@ -5695,6 +5749,7 @@ class ApplicationsController extends Controller new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), ] )] public function delete_preview_by_pull_request_id(Request $request): JsonResponse @@ -5898,7 +5953,7 @@ class ApplicationsController extends Controller properties: [ new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the destination to clone into.'), new OA\Property(property: 'name', type: 'string', nullable: true, description: 'Optional name for the cloned application.'), - new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data.'), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'), ] ) ), @@ -5915,6 +5970,7 @@ class ApplicationsController extends Controller ), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -5957,6 +6013,10 @@ class ApplicationsController extends Controller $this->authorize('update', $application); + if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { + return $response; + } + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php index cd86224ef7..35f731b7af 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesDatabaseImportsApi.php @@ -20,6 +20,8 @@ use Spatie\Activitylog\Models\Activity; trait HandlesDatabaseImportsApi { + use RequiresDeployAbility; + protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse { $this->authorize('uploadBackup', $resource); @@ -82,11 +84,8 @@ trait HandlesDatabaseImportsApi return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); } - // A server import reads any absolute path on the host, so it needs the same permission as host mounts outside the resource directory. - if ($payload['source'] === 'server' && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { - return response()->json([ - 'message' => 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.', - ], 403); + if ($response = $this->deployAbilityForbiddenResponse($request, 'An import runs the dump in the database container, so it needs a token with the deploy permission.')) { + return $response; } try { diff --git a/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php b/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php new file mode 100644 index 0000000000..28ccd598ed --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/RequiresDeployAbility.php @@ -0,0 +1,28 @@ +user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { + return null; + } + + return response()->json([ + 'message' => "Missing required permissions: deploy. {$reason}", + ], 403); + } +} diff --git a/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php index 90d3a136fa..fe8934cec7 100644 --- a/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php +++ b/app/Http/Controllers/Api/Concerns/RequiresDeployForOutsideHostPaths.php @@ -13,18 +13,15 @@ use Illuminate\Http\Request; */ trait RequiresDeployForOutsideHostPaths { + use RequiresDeployAbility; + private function outsideHostPathForbiddenResponse(Request $request, LocalFileVolume $storage): ?JsonResponse { if ($storage->is_host_file || ! $storage->isOutsideResourceDirectory()) { return null; } - if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { - return null; - } - return response()->json([ - 'message' => 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.', - ], 403); + return $this->deployAbilityForbiddenResponse($request, 'A mount outside the resource directory needs a token with the deploy permission.'); } private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b01bbfa5ed..cee12701d5 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -40,6 +40,7 @@ class DatabasesController extends Controller { use Concerns\HandlesDatabaseImportsApi; use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployAbility; use Concerns\RequiresDeployForOutsideHostPaths; #[OA\Post( @@ -71,6 +72,7 @@ class DatabasesController extends Controller path: '/databases/{uuid}/imports', operationId: 'create-database-import', summary: 'Import database backup', + description: 'Restore a backup into the database. Every import source requires the deploy ability.', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Databases'], @@ -79,6 +81,7 @@ class DatabasesController extends Controller ], responses: [ new OA\Response(response: 202, description: 'Import queued'), + new OA\Response(response: 403, description: 'The token needs the deploy ability.'), new OA\Response(response: 409, description: 'Another start, restart or import of this database is already in progress.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -474,8 +477,8 @@ class DatabasesController extends Controller 'name' => ['type' => 'string', 'description' => 'Name of the database'], 'description' => ['type' => 'string', 'description' => 'Description of the database'], 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], - 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], - 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], + 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'], + 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database (1024-65535)'], 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit of the database'], 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit of the database'], @@ -535,6 +538,10 @@ class DatabasesController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to change is_public or to use instant_deploy.', + ), new OA\Response( response: 404, ref: '#/components/responses/404', @@ -563,7 +570,7 @@ class DatabasesController extends Controller 'description' => 'string|nullable', 'image' => 'string', 'is_public' => 'boolean', - 'public_port' => 'numeric|nullable', + 'public_port' => 'numeric|nullable|between:1024,65535', 'public_port_timeout' => 'integer|nullable|min:1', 'limits_memory' => 'string', 'limits_memory_swap' => 'string', @@ -596,6 +603,13 @@ class DatabasesController extends Controller $this->authorize('manage', $database); } + if ($request->has('is_public') && (bool) $request->is_public !== (bool) $database->is_public) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + if ($request->is_public && $request->public_port) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { return response()->json(['message' => 'Public port already used by another database.'], 400); @@ -905,7 +919,7 @@ class DatabasesController extends Controller 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], - 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation. Needs a token with the deploy permission.'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'], @@ -938,6 +952,10 @@ class DatabasesController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to use backup_now.', + ), new OA\Response( response: 404, ref: '#/components/responses/404', @@ -1000,6 +1018,13 @@ class DatabasesController extends Controller $this->authorize('manageBackups', $database); + if ($request->backup_now) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that runs now copies the database on the server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + if (! $database->isBackupSolutionAvailable()) { return response()->json([ 'message' => 'Scheduled backups are not supported for this database type.', @@ -1159,7 +1184,7 @@ class DatabasesController extends Controller properties: [ 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], - 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not. Needs a token with the deploy permission.'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], @@ -1189,6 +1214,10 @@ class DatabasesController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 403, + description: 'The token needs the deploy permission to use backup_now.', + ), new OA\Response( response: 404, ref: '#/components/responses/404', @@ -1254,6 +1283,13 @@ class DatabasesController extends Controller $this->authorize('update', $database); + if ($request->backup_now) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that runs now copies the database on the server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + // Validate frequency is a valid cron expression if ($request->filled('frequency')) { $isValid = validate_cron_expression($request->frequency); @@ -2776,7 +2812,7 @@ class DatabasesController extends Controller #[OA\Delete( summary: 'Delete', - description: 'Delete database by UUID.', + description: 'Delete database by UUID. Needs a token with the write and deploy permissions.', path: '/databases/{uuid}', operationId: 'delete-database-by-uuid', security: [ @@ -2826,6 +2862,7 @@ class DatabasesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), ] )] public function delete_by_uuid(Request $request) @@ -4361,7 +4398,7 @@ class DatabasesController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], @@ -4379,7 +4416,7 @@ class DatabasesController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), - new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -4576,6 +4613,13 @@ class DatabasesController extends Controller ], 422); } + if ($request->filled('content')) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, @@ -5062,7 +5106,7 @@ class DatabasesController extends Controller properties: [ new OA\Property(property: 'destination_uuid', type: 'string'), new OA\Property(property: 'name', type: 'string', nullable: true), - new OA\Property(property: 'clone_volumes', type: 'boolean', default: false), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'), ] ) ), @@ -5070,6 +5114,7 @@ class DatabasesController extends Controller new OA\Response(response: 201, description: 'Database cloned.'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -5112,6 +5157,10 @@ class DatabasesController extends Controller $this->authorize('update', $database); + if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { + return $response; + } + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index 83ec65f1f8..9991df2793 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -14,6 +14,11 @@ use OpenApi\Attributes as OA; class DestinationsController extends Controller { + /** + * Docker's built-in networks. A destination must not use them. + */ + private const RESERVED_NETWORK_NAMES = ['bridge', 'host', 'none']; + private function transform(StandaloneDocker|SwarmDocker $destination): array { return [ @@ -159,7 +164,7 @@ class DestinationsController extends Controller #[OA\Post( summary: 'Create destination', - description: 'Create a Docker network destination on a server owned by the authenticated team.', + description: 'Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker\'s built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network.', path: '/servers/{server_uuid}/destinations', operationId: 'create-server-destination', security: [['bearerAuth' => []]], @@ -173,7 +178,7 @@ class DestinationsController extends Controller required: ['network'], properties: [ new OA\Property(property: 'name', type: 'string', maxLength: 255), - new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'), + new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', description: 'Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.'), new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), ], type: 'object', @@ -188,7 +193,7 @@ class DestinationsController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 409, description: 'A destination with this network already exists.'), - new OA\Response(response: 422, ref: '#/components/responses/422'), + new OA\Response(response: 422, description: 'Validation failed, the network is reserved or already exists on the server, or the server could not be checked.'), ], )] public function create(Request $request, string $server_uuid): JsonResponse @@ -224,6 +229,13 @@ class DestinationsController extends Controller return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); } + if (in_array(strtolower($request->input('network')), self::RESERVED_NETWORK_NAMES, true)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['network' => ['The network name is reserved by Docker. Use a different name.']], + ], 422); + } + $expectedType = $server->isSwarm() ? 'swarm' : 'standalone'; $type = $request->input('type', $expectedType); if ($type !== $expectedType) { @@ -240,6 +252,11 @@ class DestinationsController extends Controller return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); } + // Creating a standalone destination connects the proxy to the network, so it must not be an existing network. + if ($class === StandaloneDocker::class && ($response = $this->existingServerNetworkResponse($server, $request->input('network')))) { + return $response; + } + try { $destination = $class::create([ 'name' => $name, @@ -265,6 +282,27 @@ class DestinationsController extends Controller return response()->json($this->transform($destination->load('server:id,uuid')), 201); } + private function existingServerNetworkResponse(Server $server, string $network): ?JsonResponse + { + try { + $output = instant_remote_process(["docker network ls --format '{{.Name}}'"], $server); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Could not read the Docker networks on the server. Check that the server is reachable and try again.', + ], 422); + } + + $existingNetworks = array_map('trim', explode("\n", (string) $output)); + if (in_array($network, $existingNetworks, true)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['network' => ['A Docker network with this name already exists on the server. Use a new network name.']], + ], 422); + } + + return null; + } + private function isUniqueConstraintViolation(QueryException $exception): bool { $sqlState = $exception->errorInfo[0] ?? null; diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php index 249f34f04c..427a805278 100644 --- a/app/Http/Controllers/Api/DigitalOceanController.php +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -21,6 +22,8 @@ use OpenApi\Attributes as OA; class DigitalOceanController extends Controller { + use RequiresDeployAbility; + private function getCloudProviderTokenUuid(Request $request): ?string { return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id; @@ -198,11 +201,13 @@ class DigitalOceanController extends Controller path: '/servers/digitalocean', operationId: 'create-digitalocean-server', summary: 'Create a server on DigitalOcean', + description: 'Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.', security: [['bearerAuth' => []]], tags: ['DigitalOcean'], responses: [ new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response(response: 422, description: 'Validation failed.'), new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'), ] @@ -264,6 +269,13 @@ class DigitalOceanController extends Controller ], 422); } + if (! empty($request->cloud_init_script) || ! empty($request->digitalocean_ssh_key_ids)) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $team = Team::find($teamId); if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index d432c90811..ca0ec99116 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -20,6 +21,8 @@ use OpenApi\Attributes as OA; class HetznerController extends Controller { + use RequiresDeployAbility; + /** * Get cloud provider token UUID from request. * Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id. @@ -652,7 +655,7 @@ class HetznerController extends Controller #[OA\Post( summary: 'Create Hetzner Server', - description: 'Create a new server on Hetzner and register it in Coolify.', + description: 'Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.', path: '/servers/hetzner', operationId: 'create-hetzner-server', security: [ @@ -678,10 +681,10 @@ class HetznerController extends Controller 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], 'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'], - 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], + 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs. Needs a token with the deploy permission.'], 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], - 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], + 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional). Needs a token with the deploy permission.'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], ], ), @@ -712,6 +715,7 @@ class HetznerController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response( response: 404, ref: '#/components/responses/404', @@ -793,6 +797,13 @@ class HetznerController extends Controller ], 422); } + if (! empty($request->cloud_init_script) || ! empty($request->hetzner_ssh_key_ids)) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + // Check server limit $team = Team::find($teamId); if (Team::serverLimitReached($team)) { diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index fd8d14b5eb..bc14c11600 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Jobs\ScheduledTaskJob; use App\Models\Application; @@ -13,6 +14,8 @@ use OpenApi\Attributes as OA; class ScheduledTasksController extends Controller { + use RequiresDeployAbility; + private function removeSensitiveData($task) { $task->makeHidden([ @@ -38,13 +41,11 @@ class ScheduledTasksController extends Controller */ private function enabledTaskForbiddenResponse(Request $request, bool $enabled): ?JsonResponse { - if (! $enabled || $request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) { + if (! $enabled) { 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); + return $this->deployAbilityForbiddenResponse($request, 'An enabled scheduled task runs commands in the resource containers, so it needs a token with the deploy permission.'); } private function resolveApplication(Request $request, int $teamId): ?Application diff --git a/app/Http/Controllers/Api/ServerDockerCleanupController.php b/app/Http/Controllers/Api/ServerDockerCleanupController.php index 2c0a15da2f..b70acd3605 100644 --- a/app/Http/Controllers/Api/ServerDockerCleanupController.php +++ b/app/Http/Controllers/Api/ServerDockerCleanupController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Jobs\DockerCleanupJob; use App\Models\Server; @@ -11,6 +12,8 @@ use OpenApi\Attributes as OA; class ServerDockerCleanupController extends Controller { + use RequiresDeployAbility; + private const ALLOWED_FIELDS = [ 'docker_cleanup_frequency', 'docker_cleanup_threshold', @@ -89,7 +92,7 @@ class ServerDockerCleanupController extends Controller #[OA\Patch( summary: 'Update Docker cleanup settings', - description: 'Update Docker cleanup settings for a server owned by the authenticated team.', + description: 'Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.', path: '/servers/{uuid}/docker-cleanup', operationId: 'update-server-docker-cleanup', security: [['bearerAuth' => []]], @@ -129,6 +132,7 @@ class ServerDockerCleanupController extends Controller ), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Turning on `delete_unused_volumes` needs the deploy permission.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], @@ -181,6 +185,11 @@ class ServerDockerCleanupController extends Controller ], 422); } + $turnsOnVolumeDeletion = $request->boolean('delete_unused_volumes') && ! $server->settings->delete_unused_volumes; + if ($turnsOnVolumeDeletion && ($response = $this->deployAbilityForbiddenResponse($request, 'Deleting unused volumes removes data on the server, so it needs a token with the deploy permission.'))) { + return $response; + } + $settings = $server->settings; foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { @@ -201,7 +210,7 @@ class ServerDockerCleanupController extends Controller #[OA\Post( summary: 'Run Docker cleanup', - description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.', + description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.', path: '/servers/{uuid}/docker-cleanup/run', operationId: 'run-server-docker-cleanup', security: [['bearerAuth' => []]], @@ -232,6 +241,7 @@ class ServerDockerCleanupController extends Controller ), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], diff --git a/app/Http/Controllers/Api/ServerDockerRegistriesController.php b/app/Http/Controllers/Api/ServerDockerRegistriesController.php index ce25596262..2923335dcd 100644 --- a/app/Http/Controllers/Api/ServerDockerRegistriesController.php +++ b/app/Http/Controllers/Api/ServerDockerRegistriesController.php @@ -125,7 +125,7 @@ class ServerDockerRegistriesController extends Controller #[OA\Post( summary: 'Log in to a registry on a server', - description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.', + description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.', path: '/servers/{uuid}/registries', operationId: 'login-server-registry', security: [['bearerAuth' => []]], @@ -158,6 +158,7 @@ class ServerDockerRegistriesController extends Controller ), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index 1c46881307..8084070b82 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -109,7 +109,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. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.', + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.', path: '/servers/{uuid}/log-drains', operationId: 'update-server-log-drains', security: [['bearerAuth' => []]], @@ -138,7 +138,7 @@ class ServerLogDrainsController extends Controller new OA\Response(response: 200, description: 'Updated log drain settings.'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), - new OA\Response(response: 403, description: 'Missing the deploy permission for a custom log drain change.'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], @@ -162,14 +162,6 @@ class ServerLogDrainsController extends Controller $this->authorize('update', $server); - // 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) - && (! is_string($request->input($field) ?? '') || (string) $request->input($field) !== (string) $server->settings->{$field})); - if ($customConfigChanged && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { - return response()->json(['message' => 'Missing required permissions: deploy'], 403); - } - $validator = customApiValidator($request->all(), [ 'is_logdrain_newrelic_enabled' => 'boolean', 'logdrain_newrelic_license_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php index 202eb002d5..9861831123 100644 --- a/app/Http/Controllers/Api/ServerProxyController.php +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Jobs\RestartProxyJob; use App\Models\Server; @@ -15,6 +16,8 @@ use OpenApi\Attributes as OA; class ServerProxyController extends Controller { + use RequiresDeployAbility; + private function teamIdOrAbort(): int|JsonResponse { $teamId = getTeamIdFromToken(); @@ -118,7 +121,7 @@ class ServerProxyController extends Controller #[OA\Patch( summary: 'Update server proxy', - description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.', + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.', path: '/servers/{uuid}/proxy', operationId: 'update-server-proxy', security: [['bearerAuth' => []]], @@ -135,7 +138,7 @@ class ServerProxyController extends Controller new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'), - new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'), + new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.'), ] ) ), @@ -158,6 +161,7 @@ class ServerProxyController extends Controller ), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'The token needs the deploy permission to change the proxy type.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], @@ -213,6 +217,12 @@ class ServerProxyController extends Controller } } + $proxyTypeChanged = $request->has('proxy_type') && filled($request->proxy_type) + && ! str($request->proxy_type)->lower()->exactly(str($server->proxyType())->lower()); + if ($proxyTypeChanged && ($response = $this->deployAbilityForbiddenResponse($request, 'Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'))) { + return $response; + } + $changedFields = array_values(array_intersect($allowedFields, array_keys($request->all()))); $redirectChanged = false; @@ -242,7 +252,7 @@ class ServerProxyController extends Controller $server->settings->save(); } - if ($request->has('proxy_type') && filled($request->proxy_type)) { + if ($proxyTypeChanged) { $server->changeProxy($request->proxy_type, async: true); $server->refresh(); } @@ -322,9 +332,8 @@ 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); + if ($response = $this->deployAbilityForbiddenResponse($request, 'Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.')) { + return $response; } $return = validateIncomingRequest($request); diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index 04076c7ce7..7dedfa250c 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -130,7 +130,7 @@ class ServerSentinelController extends Controller #[OA\Patch( summary: 'Update Sentinel settings', - description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.', + description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.', path: '/servers/{uuid}/sentinel', operationId: 'update-server-sentinel', security: [['bearerAuth' => []]], @@ -165,6 +165,7 @@ class ServerSentinelController extends Controller new OA\Response(response: 200, description: 'Updated Sentinel settings.'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php index f43189abf8..cd68d39a1a 100644 --- a/app/Http/Controllers/Api/ServerTransferController.php +++ b/app/Http/Controllers/Api/ServerTransferController.php @@ -184,7 +184,7 @@ class ServerTransferController extends Controller #[OA\Post( summary: 'Import server transfer bundle', - description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.', + description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it. Needs a token with the write and deploy permissions.', path: '/servers/import', operationId: 'import-server-transfer-bundle', security: [['bearerAuth' => []]], @@ -205,6 +205,7 @@ class ServerTransferController extends Controller new OA\Response(response: 200, description: 'Dry-run result'), new OA\Response(response: 201, description: 'Imported'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 422, description: 'Validation failed'), ] )] diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 69622d8255..fec376109b 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -7,6 +7,7 @@ use App\Actions\Server\ValidateServer; use App\Enums\ProxyStatus; use App\Enums\ProxyTypes; use App\Enums\ServerRole; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Jobs\ValidateAndInstallServerJob; @@ -27,6 +28,13 @@ use Stringable; class ServersController extends Controller { + use RequiresDeployAbility; + + /** + * The server name goes into one-line config files on the server (for example the log drain config), so it must not contain control characters such as new lines. + */ + private const SERVER_NAME_CONTROL_CHARACTERS_PATTERN = '/[\x00-\x1F\x7F]/'; + private function removeSensitiveDataFromSettings($settings) { if (request()->attributes->get('can_read_sensitive', false) === true) { @@ -459,7 +467,7 @@ class ServersController extends Controller schema: new OA\Schema( type: 'object', properties: [ - 'name' => ['type' => 'string', 'example' => 'My Server', 'description' => 'The name of the server.'], + 'name' => ['type' => 'string', 'example' => 'My Server', 'description' => 'The name of the server. Must not contain control characters such as new lines.'], 'description' => ['type' => 'string', 'example' => 'My Server Description', 'description' => 'The description of the server.'], 'ip' => ['type' => 'string', 'example' => '127.0.0.1', 'description' => 'The IP of the server.'], 'port' => ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'], @@ -521,7 +529,7 @@ class ServersController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255', + 'name' => ['string', 'max:255', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN], 'description' => 'string|nullable', 'ip' => ['string', 'required', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', @@ -533,6 +541,7 @@ class ServersController extends Controller 'proxy_type' => 'string|nullable', ], [ ...ValidationPatterns::serverUsernameMessages(), + 'name.not_regex' => 'The name must not contain control characters such as new lines.', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -636,7 +645,7 @@ class ServersController extends Controller #[OA\Patch( summary: 'Update', - description: 'Update Server.', + description: 'Update Server. Changing proxy_type needs a token with the write and deploy permissions.', path: '/servers/{uuid}', operationId: 'update-server-by-uuid', security: [ @@ -654,7 +663,7 @@ class ServersController extends Controller schema: new OA\Schema( type: 'object', properties: [ - 'name' => ['type' => 'string', 'description' => 'The name of the server.'], + 'name' => ['type' => 'string', 'description' => 'The name of the server. Must not contain control characters such as new lines.'], 'description' => ['type' => 'string', 'description' => 'The description of the server.'], 'ip' => ['type' => 'string', 'description' => 'The IP of the server.'], 'port' => ['type' => 'integer', 'description' => 'The port of the server.'], @@ -663,7 +672,7 @@ class ServersController extends Controller 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'], 'is_build_server' => ['type' => 'boolean', 'deprecated' => true, 'description' => 'Deprecated: use server_role instead. true sets server_role to build, false changes a build server to both and leaves other roles unchanged. Must not conflict with server_role.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], - 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], + 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type. Changing it starts the new proxy on the server and needs the deploy permission.'], 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], 'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'], 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], @@ -676,6 +685,7 @@ class ServersController extends Controller ), ), responses: [ + new OA\Response(response: 403, description: 'The token needs the deploy permission to change the proxy type.'), new OA\Response( response: 201, description: 'Server updated.', @@ -719,7 +729,7 @@ class ServersController extends Controller return $return; } $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255|nullable', + 'name' => ['string', 'max:255', 'nullable', 'not_regex:'.self::SERVER_NAME_CONTROL_CHARACTERS_PATTERN], 'description' => 'string|nullable', 'ip' => ['string', 'nullable', new ValidServerIp], 'port' => 'integer|nullable|between:1,65535', @@ -739,6 +749,7 @@ class ServersController extends Controller 'is_terminal_enabled' => 'boolean|nullable', ], [ ...ValidationPatterns::serverUsernameMessages(), + 'name.not_regex' => 'The name must not contain control characters such as new lines.', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -822,6 +833,11 @@ class ServersController extends Controller ], 422); } + $proxyTypeChanged = $request->proxy_type && ! str($request->proxy_type)->lower()->exactly(str($server->proxyType())->lower()); + if ($proxyTypeChanged && ($response = $this->deployAbilityForbiddenResponse($request, 'Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'))) { + return $response; + } + $server->update($updateFields); if ($serverRole !== null) { $server->settings()->update([ @@ -841,7 +857,7 @@ class ServersController extends Controller $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } - if ($request->proxy_type) { + if ($proxyTypeChanged) { $server->changeProxy($request->proxy_type, async: true); } @@ -863,7 +879,7 @@ class ServersController extends Controller #[OA\Delete( summary: 'Delete', - description: 'Delete server by UUID.', + description: 'Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.', path: '/servers/{uuid}', operationId: 'delete-server-by-uuid', security: [ @@ -883,19 +899,20 @@ class ServersController extends Controller new OA\Parameter( name: 'force', in: 'query', - description: 'Also delete all resources on the server.', + description: 'Also delete all resources on the server. Needs the deploy permission.', required: false, schema: new OA\Schema(type: 'boolean', default: false) ), new OA\Parameter( name: 'delete_from_provider', in: 'query', - description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.', + description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.', required: false, schema: new OA\Schema(type: 'boolean', default: false) ), ], responses: [ + new OA\Response(response: 403, description: 'The token needs the deploy permission to delete the resources or the cloud server.'), new OA\Response( response: 200, description: 'Server deleted.', @@ -946,6 +963,10 @@ class ServersController extends Controller $this->authorize('delete', $server); $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN); + $deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if (is_null($deleteFromProvider)) { + return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422); + } if ($server->definedResources()->count() > 0 && ! $force) { return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); @@ -954,10 +975,6 @@ class ServersController extends Controller return response()->json(['message' => 'Local server cannot be deleted.'], 400); } - $deleteFromProvider = filter_var($request->query('delete_from_provider', false), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if (is_null($deleteFromProvider)) { - return response()->json(['message' => 'delete_from_provider must be a boolean.'], 422); - } if ($deleteFromProvider) { $linkedProviders = array_keys(array_filter([ 'hetzner' => $server->hetzner_server_id, @@ -974,6 +991,10 @@ class ServersController extends Controller } } + if (($force || $deleteFromProvider) && ($response = $this->deployAbilityForbiddenResponse($request, 'Deleting the resources or the cloud server needs a token with the deploy permission.'))) { + return $response; + } + if ($force) { foreach ($server->definedResources() as $resource) { DeleteResourceJob::dispatch($resource); @@ -1010,7 +1031,7 @@ class ServersController extends Controller #[OA\Post( summary: 'Validate', - description: 'Validate server by UUID.', + description: 'Validate server by UUID. With install=true, needs a token with the write and deploy permissions.', path: '/servers/{uuid}/validate', operationId: 'validate-server-by-uuid', security: [ @@ -1026,7 +1047,7 @@ class ServersController extends Controller properties: [ new OA\Property( property: 'install', - description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.', + description: 'Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.', type: 'boolean', default: false, ), @@ -1034,6 +1055,7 @@ class ServersController extends Controller ), ), responses: [ + new OA\Response(response: 403, description: 'The token needs the deploy permission to install the server.'), new OA\Response( response: 201, description: 'Server validation started.', @@ -1100,6 +1122,9 @@ class ServersController extends Controller } $install = $request->boolean('install', false); + if ($install && ($response = $this->deployAbilityForbiddenResponse($request, 'Installing the server runs commands on it and starts the proxy, so it needs a token with the deploy permission.'))) { + return $response; + } if ($install) { ValidateAndInstallServerJob::dispatch($server); } else { diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 795778ffb0..6b92bedbaa 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -44,6 +44,7 @@ class ServiceDatabasesController extends Controller path: '/services/{uuid}/databases/{database_uuid}/imports', operationId: 'create-service-database-import', summary: 'Import service database backup', + description: 'Restore a backup into the service database. Every import source requires the deploy ability.', requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')), security: [['bearerAuth' => []]], tags: ['Service databases'], @@ -53,6 +54,7 @@ class ServiceDatabasesController extends Controller ], responses: [ new OA\Response(response: 202, description: 'Import queued'), + new OA\Response(response: 403, description: 'The token needs the deploy ability.'), new OA\Response(response: 409, description: 'Another start, restart or import of this database is already in progress.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -234,7 +236,7 @@ class ServiceDatabasesController extends Controller new OA\Property(property: 'image', type: 'string'), new OA\Property(property: 'exclude_from_status', type: 'boolean'), new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'), - new OA\Property(property: 'is_public', type: 'boolean'), + new OA\Property(property: 'is_public', type: 'boolean', description: 'Changing it starts or stops the database proxy, so it needs a token with the deploy permission.'), new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535), new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1), ], @@ -245,6 +247,7 @@ class ServiceDatabasesController extends Controller new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy permission to change is_public.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -312,6 +315,13 @@ class ServiceDatabasesController extends Controller ], 422); } + if (array_key_exists('is_public', $payload) && (bool) $payload['is_public'] !== (bool) $serviceDatabase->is_public) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $server = $serviceDatabase->service->destination->server; if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) { return response()->json([ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 601c9b582b..53cd599cf2 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -30,6 +30,7 @@ use OpenApi\Attributes as OA; class ServicesController extends Controller { use Concerns\HandlesTagsApi; + use Concerns\RequiresDeployAbility; use Concerns\RequiresDeployForOutsideHostPaths; protected function findTaggableResource(string $uuid, int|string $teamId): mixed @@ -997,7 +998,7 @@ class ServicesController extends Controller #[OA\Delete( summary: 'Delete', - description: 'Delete service by UUID.', + description: 'Delete service by UUID. Needs a token with the write and deploy permissions.', path: '/services/{uuid}', operationId: 'delete-service-by-uuid', security: [ @@ -1040,6 +1041,7 @@ class ServicesController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), ] )] public function delete_by_uuid(Request $request) @@ -2477,7 +2479,7 @@ class ServicesController extends Controller 'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], + 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional). Needs the deploy ability when set.'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'], ], @@ -2495,7 +2497,7 @@ class ServicesController extends Controller new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 404, ref: '#/components/responses/404'), - new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] )] @@ -2701,6 +2703,13 @@ class ServicesController extends Controller ], 422); } + if ($request->filled('content')) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $storage = LocalFileVolume::create([ 'fs_path' => $fsPath, 'mount_path' => $mountPath, @@ -3238,7 +3247,7 @@ class ServicesController extends Controller properties: [ new OA\Property(property: 'destination_uuid', type: 'string'), new OA\Property(property: 'name', type: 'string', nullable: true), - new OA\Property(property: 'clone_volumes', type: 'boolean', default: false), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data. Needs the deploy ability.'), ] ) ), @@ -3246,6 +3255,7 @@ class ServicesController extends Controller new OA\Response(response: 201, description: 'Service cloned.'), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy ability to clone volume data.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ] @@ -3288,6 +3298,10 @@ class ServicesController extends Controller $this->authorize('update', $service); + if ($request->boolean('clone_volumes') && $response = $this->deployAbilityForbiddenResponse($request, 'Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.')) { + return $response; + } + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e3b48a367a..97161d3111 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Shared\DeleteScheduledVolumeBackup; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Jobs\VolumeBackupJob; use App\Models\Application; @@ -26,7 +27,7 @@ use RuntimeException; new OA\Property(property: 'enabled', type: 'boolean', default: true), new OA\Property(property: 'save_s3', type: 'boolean', default: false), new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false), - new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false), + new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false, description: 'Stop the resource containers during the backup. Needs a token with the deploy permission.'), new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true), new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), @@ -67,9 +68,11 @@ use RuntimeException; )] class VolumeBackupsController extends Controller { + use RequiresDeployAbility; + #[OA\Put( summary: 'Set application storage backup schedule', - description: 'Create or replace the backup schedule for an application persistent volume or directory storage.', + description: 'Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.', path: '/applications/{uuid}/storages/{storage_uuid}/backups', operationId: 'set-application-storage-backup-schedule', security: [['bearerAuth' => []]], @@ -84,14 +87,14 @@ class VolumeBackupsController extends Controller new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), - new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], )] #[OA\Put( summary: 'Set database storage backup schedule', - description: 'Create or replace the backup schedule for a database persistent volume or directory storage.', + description: 'Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.', path: '/databases/{uuid}/storages/{storage_uuid}/backups', operationId: 'set-database-storage-backup-schedule', security: [['bearerAuth' => []]], @@ -106,14 +109,14 @@ class VolumeBackupsController extends Controller new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), - new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], )] #[OA\Put( summary: 'Set service storage backup schedule', - description: 'Create or replace the backup schedule for a service persistent volume or directory storage.', + description: 'Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.', path: '/services/{uuid}/storages/{storage_uuid}/backups', operationId: 'set-service-storage-backup-schedule', security: [['bearerAuth' => []]], @@ -128,7 +131,7 @@ class VolumeBackupsController extends Controller new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')), new OA\Response(response: 400, ref: '#/components/responses/400'), new OA\Response(response: 401, ref: '#/components/responses/401'), - new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 403, description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], @@ -160,6 +163,13 @@ class VolumeBackupsController extends Controller $this->authorize('update', $resource); + if ($request->boolean('stop_during_backup')) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A backup that stops the containers needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $storage = $this->findStorage($resource, $request->route('storage_uuid')); if (! $storage) { return response()->json(['message' => 'Storage not found.'], 404); @@ -458,7 +468,7 @@ class VolumeBackupsController extends Controller #[OA\Post( summary: 'Run application storage backup', - description: 'Queue an immediate volume backup for an application storage that has a schedule.', + description: 'Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.', path: '/applications/{uuid}/storages/{storage_uuid}/backups/run', operationId: 'run-application-storage-backup', security: [['bearerAuth' => []]], @@ -470,12 +480,13 @@ class VolumeBackupsController extends Controller responses: [ new OA\Response(response: 200, description: 'Storage backup queued.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] #[OA\Post( summary: 'Run database storage backup', - description: 'Queue an immediate volume backup for a database storage that has a schedule.', + description: 'Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.', path: '/databases/{uuid}/storages/{storage_uuid}/backups/run', operationId: 'run-database-storage-backup', security: [['bearerAuth' => []]], @@ -487,12 +498,13 @@ class VolumeBackupsController extends Controller responses: [ new OA\Response(response: 200, description: 'Storage backup queued.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] #[OA\Post( summary: 'Run service storage backup', - description: 'Queue an immediate volume backup for a service storage that has a schedule.', + description: 'Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.', path: '/services/{uuid}/storages/{storage_uuid}/backups/run', operationId: 'run-service-storage-backup', security: [['bearerAuth' => []]], @@ -504,6 +516,7 @@ class VolumeBackupsController extends Controller responses: [ new OA\Response(response: 200, description: 'Storage backup queued.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 879e887517..31aa2b4bac 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; +use App\Http\Controllers\Api\Concerns\RequiresDeployAbility; use App\Http\Controllers\Controller; use App\Models\CloudProviderToken; use App\Models\PrivateKey; @@ -21,6 +22,8 @@ use OpenApi\Attributes as OA; class VultrController extends Controller { + use RequiresDeployAbility; + private function getCloudProviderTokenUuid(Request $request): ?string { return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id; @@ -177,7 +180,7 @@ class VultrController extends Controller #[OA\Post( summary: 'Create Vultr Server', - description: 'Create a Vultr instance and link it as a Coolify server.', + description: 'Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.', path: '/servers/vultr', operationId: 'create-vultr-server', security: [ @@ -187,6 +190,7 @@ class VultrController extends Controller responses: [ new OA\Response(response: 201, description: 'Vultr server created.'), new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'), new OA\Response(response: 422, description: 'Validation failed.'), new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'), ] @@ -247,6 +251,13 @@ class VultrController extends Controller ], 422); } + if (! empty($request->cloud_init_script) || ! empty($request->vultr_ssh_key_ids)) { + $forbidden = $this->deployAbilityForbiddenResponse($request, 'A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.'); + if ($forbidden) { + return $forbidden; + } + } + $team = Team::find($teamId); if (Team::serverLimitReached($team)) { return response()->json(['message' => 'Server limit reached for your subscription.'], 400); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 0a255370e5..549414f1b8 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -103,8 +103,6 @@ class ApiTokens extends Component $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { $this->permissions[] = 'read'; - } elseif ($permissionToUpdate == 'deploy' && in_array('deploy', $this->permissions, true)) { - $this->permissions = ['deploy']; } else { if (count($this->permissions) == 0) { $this->permissions = ['read']; diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 263bd8af45..50d5a51578 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -107,6 +107,11 @@ class LocalFileVolume extends BaseModel if ($fileVolume->is_host_file) { return; } + // A token with only the write permission can save a Compose file. A mount outside the resource + // directory is written when the resource is deployed or started, not when it is saved. + if ($fileVolume->isOutsideResourceDirectory()) { + return; + } ServerStorageSaveJob::dispatch($fileVolume)->afterCommit(); }); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index dddb673ca5..442debc04b 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -210,6 +210,9 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $fsPath = trim((string) $fileVolume->fs_path); $fileLocation = str_starts_with($fsPath, '$') ? $fsPath : $fileVolume->resolvedFsPath($workdir)->value(); $escapedFileLocation = filesystemVolumeShellArgument((string) $fileLocation); + // A token with only the write permission can save a Compose file, so Coolify does not + // write outside the resource directory here. A deployment or start writes those paths. + $isOutsideResourceDirectory = fn (): bool => $fileVolume->isOutsideResourceDirectory(); // Exists and is a file $isFile = instant_remote_process(["test -f {$escapedFileLocation} && echo OK || echo NOK"], $server); // Exists and is a directory @@ -225,7 +228,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli // A configured file must not lose its content because a directory is at its path. // Docker leaves an empty directory when it starts before the file exists: replace it. // A directory with files stays; the start or deployment shows a warning for it. - if (LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') { + if (! $isOutsideResourceDirectory() && LocalFileVolume::remoteFileStates([(string) $fileLocation], $server)[0] === 'empty-directory') { $fileVolume->saveStorageOnServer(); } } elseif ($isDir === 'OK') { @@ -235,6 +238,9 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $fileVolume->save(); } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && $content) { // Does not exists (no dir or file), not flagged as directory, is init, has content + if ($isOutsideResourceDirectory()) { + continue; + } $fileVolume->content = $content; $fileVolume->is_directory = false; $fileVolume->save(); @@ -248,13 +254,17 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $fileVolume->content = null; $fileVolume->is_directory = true; $fileVolume->save(); - instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server); + if (! $isOutsideResourceDirectory()) { + instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server); + } } elseif ($isFile === 'NOK' && $isDir === 'NOK' && ! $fileVolume->is_directory && $isInit && is_null($content)) { // Does not exists (no dir or file), not flagged as directory, is init, has no content => create directory $fileVolume->content = null; $fileVolume->is_directory = true; $fileVolume->save(); - instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server); + if (! $isOutsideResourceDirectory()) { + instant_remote_process(["mkdir -p -- {$escapedFileLocation}"], $server); + } } } } catch (Throwable $e) { diff --git a/openapi.json b/openapi.json index a15dcdde82..28c0ed18db 100644 --- a/openapi.json +++ b/openapi.json @@ -244,7 +244,7 @@ "Applications" ], "summary": "Delete Preview Deployment", - "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record. Needs a token with the write and deploy permissions.", "operationId": "delete-preview-deployment-by-pull-request-id", "parameters": [ { @@ -293,6 +293,9 @@ }, "422": { "$ref": "#\/components\/responses\/422" + }, + "403": { + "description": "The token needs the write and deploy permissions." } }, "security": [ @@ -769,19 +772,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -1040,6 +1043,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -1365,19 +1371,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -1636,6 +1642,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -1961,19 +1970,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -2232,6 +2241,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -2495,19 +2507,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -2718,6 +2730,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -2966,19 +2981,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -3189,6 +3204,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -3305,7 +3323,7 @@ "Applications" ], "summary": "Delete", - "description": "Delete application by UUID.", + "description": "Delete application by UUID. Needs a token with the write and deploy permissions.", "operationId": "delete-application-by-uuid", "parameters": [ { @@ -3383,6 +3401,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "403": { + "description": "The token needs the write and deploy permissions." } }, "security": [ @@ -3624,19 +3645,19 @@ }, "manual_webhook_secret_github": { "type": "string", - "description": "Manual webhook secret for Github." + "description": "Manual webhook secret for Github. Needs the deploy ability." }, "manual_webhook_secret_gitlab": { "type": "string", - "description": "Manual webhook secret for Gitlab." + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." }, "manual_webhook_secret_bitbucket": { "type": "string", - "description": "Manual webhook secret for Bitbucket." + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." }, "manual_webhook_secret_gitea": { "type": "string", - "description": "Manual webhook secret for Gitea." + "description": "Manual webhook secret for Gitea. Needs the deploy ability." }, "redirect": { "type": "string", @@ -3867,6 +3888,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, "400": { "$ref": "#\/components\/responses\/400" }, @@ -4976,7 +5000,7 @@ "content": { "type": "string", "nullable": true, - "description": "File content (file only, optional)." + "description": "File content (file only, optional). Needs the deploy ability when set." }, "is_directory": { "type": "boolean", @@ -5014,7 +5038,7 @@ "$ref": "#\/components\/responses\/404" }, "403": { - "description": "The token needs the deploy permission for a mount outside the resource directory." + "description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content." }, "422": { "$ref": "#\/components\/responses\/422" @@ -5405,7 +5429,7 @@ ] }, "clone_volumes": { - "description": "Whether to clone volume data.", + "description": "Whether to clone volume data. Needs the deploy ability.", "type": "boolean", "default": false } @@ -5441,6 +5465,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to clone volume data." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -6549,6 +6576,7 @@ "Databases" ], "summary": "Import database backup", + "description": "Restore a backup into the database. Every import source requires the deploy ability.", "operationId": "create-database-import", "parameters": [ { @@ -6575,6 +6603,9 @@ "202": { "description": "Import queued" }, + "403": { + "description": "The token needs the deploy ability." + }, "409": { "description": "Another start, restart or import of this database is already in progress." }, @@ -6776,7 +6807,7 @@ }, "backup_now": { "type": "boolean", - "description": "Whether to trigger backup immediately after creation" + "description": "Whether to trigger backup immediately after creation. Needs a token with the deploy permission." }, "database_backup_retention_amount_locally": { "type": "integer", @@ -6848,6 +6879,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the deploy permission to use backup_now." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -6914,7 +6948,7 @@ "Databases" ], "summary": "Delete", - "description": "Delete database by UUID.", + "description": "Delete database by UUID. Needs a token with the write and deploy permissions.", "operationId": "delete-database-by-uuid", "parameters": [ { @@ -6992,6 +7026,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "403": { + "description": "The token needs the write and deploy permissions." } }, "security": [ @@ -7039,11 +7076,11 @@ }, "is_public": { "type": "boolean", - "description": "Is the database public?" + "description": "Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission." }, "public_port": { "type": "integer", - "description": "Public port of the database" + "description": "Public port of the database (1024-65535)" }, "public_port_timeout": { "type": "integer", @@ -7238,6 +7275,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the deploy permission to change is_public or to use instant_deploy." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -7374,7 +7414,7 @@ }, "backup_now": { "type": "boolean", - "description": "Whether to take a backup now or not" + "description": "Whether to take a backup now or not. Needs a token with the deploy permission." }, "enabled": { "type": "boolean", @@ -7443,6 +7483,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the deploy permission to use backup_now." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -9800,7 +9843,7 @@ "content": { "type": "string", "nullable": true, - "description": "File content (file only, optional)." + "description": "File content (file only, optional). Needs the deploy ability when set." }, "is_directory": { "type": "boolean", @@ -9838,7 +9881,7 @@ "$ref": "#\/components\/responses\/404" }, "403": { - "description": "The token needs the deploy permission for a mount outside the resource directory." + "description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content." }, "422": { "$ref": "#\/components\/responses\/422" @@ -10227,6 +10270,7 @@ ] }, "clone_volumes": { + "description": "Whether to clone volume data. Needs the deploy ability.", "type": "boolean", "default": false } @@ -10246,6 +10290,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to clone volume data." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -10690,7 +10737,7 @@ "Destinations" ], "summary": "Create destination", - "description": "Create a Docker network destination on a server owned by the authenticated team.", + "description": "Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker's built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network.", "operationId": "create-server-destination", "parameters": [ { @@ -10717,6 +10764,7 @@ "maxLength": 255 }, "network": { + "description": "Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.", "type": "string", "maxLength": 255, "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" @@ -10755,7 +10803,7 @@ "description": "A destination with this network already exists." }, "422": { - "$ref": "#\/components\/responses\/422" + "description": "Validation failed, the network is reserved or already exists on the server, or the server could not be checked." } }, "security": [ @@ -11103,6 +11151,7 @@ "DigitalOcean" ], "summary": "Create a server on DigitalOcean", + "description": "Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.", "operationId": "create-digitalocean-server", "responses": { "201": { @@ -11111,6 +11160,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy permission for a cloud-init script or extra SSH keys." + }, "422": { "description": "Validation failed." }, @@ -12564,7 +12616,7 @@ "Hetzner" ], "summary": "Create Hetzner Server", - "description": "Create a new server on Hetzner and register it in Coolify.", + "description": "Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.", "operationId": "create-hetzner-server", "requestBody": { "description": "Hetzner server creation parameters", @@ -12635,7 +12687,7 @@ "items": { "type": "integer" }, - "description": "Additional Hetzner SSH key IDs" + "description": "Additional Hetzner SSH key IDs. Needs a token with the deploy permission." }, "hetzner_firewall_ids": { "type": "array", @@ -12653,7 +12705,7 @@ }, "cloud_init_script": { "type": "string", - "description": "Cloud-init YAML script (optional)" + "description": "Cloud-init YAML script (optional). Needs a token with the deploy permission." }, "instant_validate": { "type": "boolean", @@ -12698,6 +12750,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the deploy permission for a cloud-init script or extra SSH keys." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -15913,7 +15968,7 @@ "Servers" ], "summary": "Update Docker cleanup settings", - "description": "Update Docker cleanup settings for a server owned by the authenticated team.", + "description": "Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.", "operationId": "update-server-docker-cleanup", "parameters": [ { @@ -15996,6 +16051,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "Turning on `delete_unused_volumes` needs the deploy permission." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -16016,7 +16074,7 @@ "Servers" ], "summary": "Run Docker cleanup", - "description": "Dispatch a manual Docker cleanup job for a server owned by the authenticated team.", + "description": "Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.", "operationId": "run-server-docker-cleanup", "parameters": [ { @@ -16070,6 +16128,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -16275,7 +16336,7 @@ "Servers" ], "summary": "Log in to a registry on a server", - "description": "Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.", + "description": "Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.", "operationId": "login-server-registry", "parameters": [ { @@ -16341,6 +16402,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -16575,7 +16639,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. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.", + "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.", "operationId": "update-server-log-drains", "parameters": [ { @@ -16638,7 +16702,7 @@ "$ref": "#\/components\/responses\/400" }, "403": { - "description": "Missing the deploy permission for a custom log drain change." + "description": "The token needs the write and deploy permissions." }, "404": { "$ref": "#\/components\/responses\/404" @@ -16751,7 +16815,7 @@ "Servers" ], "summary": "Update server proxy", - "description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.", + "description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.", "operationId": "update-server-proxy", "parameters": [ { @@ -16791,7 +16855,7 @@ ] }, "proxy_type": { - "description": "Proxy type (case-insensitive).", + "description": "Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.", "type": "string", "enum": [ "traefik", @@ -16861,6 +16925,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the deploy permission to change the proxy type." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -17151,7 +17218,7 @@ "Servers" ], "summary": "Update Sentinel settings", - "description": "Update Sentinel settings for a server owned by the authenticated team. Changing token\/metrics timing fields may restart Sentinel.", + "description": "Update Sentinel settings for a server owned by the authenticated team. Changing token\/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.", "operationId": "update-server-sentinel", "parameters": [ { @@ -17254,6 +17321,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -17320,7 +17390,7 @@ "name": { "type": "string", "example": "My Server", - "description": "The name of the server." + "description": "The name of the server. Must not contain control characters such as new lines." }, "description": { "type": "string", @@ -17472,7 +17542,7 @@ "Servers" ], "summary": "Delete", - "description": "Delete server by UUID.", + "description": "Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.", "operationId": "delete-server-by-uuid", "parameters": [ { @@ -17487,7 +17557,7 @@ { "name": "force", "in": "query", - "description": "Also delete all resources on the server.", + "description": "Also delete all resources on the server. Needs the deploy permission.", "required": false, "schema": { "type": "boolean", @@ -17497,7 +17567,7 @@ { "name": "delete_from_provider", "in": "query", - "description": "Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.", + "description": "Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.", "required": false, "schema": { "type": "boolean", @@ -17506,6 +17576,9 @@ } ], "responses": { + "403": { + "description": "The token needs the deploy permission to delete the resources or the cloud server." + }, "200": { "description": "Server deleted.", "content": { @@ -17546,7 +17619,7 @@ "Servers" ], "summary": "Update", - "description": "Update Server.", + "description": "Update Server. Changing proxy_type needs a token with the write and deploy permissions.", "operationId": "update-server-by-uuid", "parameters": [ { @@ -17568,7 +17641,7 @@ "properties": { "name": { "type": "string", - "description": "The name of the server." + "description": "The name of the server. Must not contain control characters such as new lines." }, "description": { "type": "string", @@ -17615,7 +17688,7 @@ "caddy", "none" ], - "description": "The proxy type." + "description": "The proxy type. Changing it starts the new proxy on the server and needs the deploy permission." }, "concurrent_builds": { "type": "integer", @@ -17652,6 +17725,9 @@ } }, "responses": { + "403": { + "description": "The token needs the deploy permission to change the proxy type." + }, "201": { "description": "Server updated.", "content": { @@ -17816,7 +17892,7 @@ "Servers" ], "summary": "Validate", - "description": "Validate server by UUID.", + "description": "Validate server by UUID. With install=true, needs a token with the write and deploy permissions.", "operationId": "validate-server-by-uuid", "parameters": [ { @@ -17836,7 +17912,7 @@ "schema": { "properties": { "install": { - "description": "Install missing prerequisites and Docker. This can restart the Docker daemon.", + "description": "Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.", "type": "boolean", "default": false } @@ -17847,6 +17923,9 @@ } }, "responses": { + "403": { + "description": "The token needs the deploy permission to install the server." + }, "201": { "description": "Server validation started.", "content": { @@ -18563,6 +18642,7 @@ "Service databases" ], "summary": "Import service database backup", + "description": "Restore a backup into the service database. Every import source requires the deploy ability.", "operationId": "create-service-database-import", "parameters": [ { @@ -18598,6 +18678,9 @@ "202": { "description": "Import queued" }, + "403": { + "description": "The token needs the deploy ability." + }, "409": { "description": "Another start, restart or import of this database is already in progress." }, @@ -18823,6 +18906,7 @@ "type": "boolean" }, "is_public": { + "description": "Changing it starts or stops the database proxy, so it needs a token with the deploy permission.", "type": "boolean" }, "public_port": { @@ -18864,6 +18948,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy permission to change is_public." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -19455,7 +19542,7 @@ "Services" ], "summary": "Delete", - "description": "Delete service by UUID.", + "description": "Delete service by UUID. Needs a token with the write and deploy permissions.", "operationId": "delete-service-by-uuid", "parameters": [ { @@ -19543,6 +19630,9 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "403": { + "description": "The token needs the write and deploy permissions." } }, "security": [ @@ -20658,7 +20748,7 @@ "content": { "type": "string", "nullable": true, - "description": "File content (file only, optional)." + "description": "File content (file only, optional). Needs the deploy ability when set." }, "is_directory": { "type": "boolean", @@ -20696,7 +20786,7 @@ "$ref": "#\/components\/responses\/404" }, "403": { - "description": "The token needs the deploy permission for a mount outside the resource directory." + "description": "The token needs the deploy permission for a mount outside the resource directory or for a file storage with content." }, "422": { "$ref": "#\/components\/responses\/422" @@ -21085,6 +21175,7 @@ ] }, "clone_volumes": { + "description": "Whether to clone volume data. Needs the deploy ability.", "type": "boolean", "default": false } @@ -21104,6 +21195,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy ability to clone volume data." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -22310,7 +22404,7 @@ "Applications" ], "summary": "Set application storage backup schedule", - "description": "Create or replace the backup schedule for an application persistent volume or directory storage.", + "description": "Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.", "operationId": "set-application-storage-backup-schedule", "parameters": [ { @@ -22370,7 +22464,7 @@ "$ref": "#\/components\/responses\/401" }, "403": { - "description": "Forbidden." + "description": "Forbidden, or the token needs the deploy permission for stop_during_backup." }, "404": { "$ref": "#\/components\/responses\/404" @@ -22440,7 +22534,7 @@ "Databases" ], "summary": "Set database storage backup schedule", - "description": "Create or replace the backup schedule for a database persistent volume or directory storage.", + "description": "Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.", "operationId": "set-database-storage-backup-schedule", "parameters": [ { @@ -22500,7 +22594,7 @@ "$ref": "#\/components\/responses\/401" }, "403": { - "description": "Forbidden." + "description": "Forbidden, or the token needs the deploy permission for stop_during_backup." }, "404": { "$ref": "#\/components\/responses\/404" @@ -22570,7 +22664,7 @@ "Services" ], "summary": "Set service storage backup schedule", - "description": "Create or replace the backup schedule for a service persistent volume or directory storage.", + "description": "Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.", "operationId": "set-service-storage-backup-schedule", "parameters": [ { @@ -22630,7 +22724,7 @@ "$ref": "#\/components\/responses\/401" }, "403": { - "description": "Forbidden." + "description": "Forbidden, or the token needs the deploy permission for stop_during_backup." }, "404": { "$ref": "#\/components\/responses\/404" @@ -22700,7 +22794,7 @@ "Applications" ], "summary": "Run application storage backup", - "description": "Queue an immediate volume backup for an application storage that has a schedule.", + "description": "Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.", "operationId": "run-application-storage-backup", "parameters": [ { @@ -22727,6 +22821,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" } @@ -22744,7 +22841,7 @@ "Databases" ], "summary": "Run database storage backup", - "description": "Queue an immediate volume backup for a database storage that has a schedule.", + "description": "Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.", "operationId": "run-database-storage-backup", "parameters": [ { @@ -22771,6 +22868,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" } @@ -22788,7 +22888,7 @@ "Services" ], "summary": "Run service storage backup", - "description": "Queue an immediate volume backup for a service storage that has a schedule.", + "description": "Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.", "operationId": "run-service-storage-backup", "parameters": [ { @@ -22815,6 +22915,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the write and deploy permissions." + }, "404": { "$ref": "#\/components\/responses\/404" } @@ -22936,7 +23039,7 @@ "Vultr" ], "summary": "Create Vultr Server", - "description": "Create a Vultr instance and link it as a Coolify server.", + "description": "Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.", "operationId": "create-vultr-server", "responses": { "201": { @@ -22945,6 +23048,9 @@ "401": { "$ref": "#\/components\/responses\/401" }, + "403": { + "description": "The token needs the deploy permission for a cloud-init script or extra SSH keys." + }, "422": { "description": "Validation failed." }, @@ -23268,6 +23374,7 @@ "default": false }, "stop_during_backup": { + "description": "Stop the resource containers during the backup. Needs a token with the deploy permission.", "type": "boolean", "default": false }, diff --git a/openapi.yaml b/openapi.yaml index 2e172706d0..71c6507b97 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -155,7 +155,7 @@ paths: tags: - Applications summary: 'Delete Preview Deployment' - description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.' + description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record. Needs a token with the write and deploy permissions.' operationId: delete-preview-deployment-by-pull-request-id parameters: - @@ -189,6 +189,8 @@ paths: $ref: '#/components/responses/404' '422': $ref: '#/components/responses/422' + '403': + description: 'The token needs the write and deploy permissions.' security: - bearerAuth: [] @@ -503,16 +505,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -684,6 +686,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '409': @@ -893,16 +897,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -1074,6 +1078,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '409': @@ -1283,16 +1289,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -1464,6 +1470,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '409': @@ -1630,16 +1638,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -1791,6 +1799,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '409': @@ -1947,16 +1957,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -2108,6 +2118,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '409': @@ -2158,7 +2170,7 @@ paths: tags: - Applications summary: Delete - description: 'Delete application by UUID.' + description: 'Delete application by UUID. Needs a token with the write and deploy permissions.' operationId: delete-application-by-uuid parameters: - @@ -2215,6 +2227,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the write and deploy permissions.' security: - bearerAuth: [] @@ -2391,16 +2405,16 @@ paths: description: 'Pre deployment command container.' manual_webhook_secret_github: type: string - description: 'Manual webhook secret for Github.' + description: 'Manual webhook secret for Github. Needs the deploy ability.' manual_webhook_secret_gitlab: type: string - description: 'Manual webhook secret for Gitlab.' + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' manual_webhook_secret_bitbucket: type: string - description: 'Manual webhook secret for Bitbucket.' + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' manual_webhook_secret_gitea: type: string - description: 'Manual webhook secret for Gitea.' + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' redirect: type: string nullable: true @@ -2551,6 +2565,8 @@ paths: type: object '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' '400': $ref: '#/components/responses/400' '404': @@ -3232,7 +3248,7 @@ paths: content: type: string nullable: true - description: 'File content (file only, optional).' + description: 'File content (file only, optional). Needs the deploy ability when set.' is_directory: type: boolean description: 'Whether this is a directory mount (file only, default false).' @@ -3255,7 +3271,7 @@ paths: '404': $ref: '#/components/responses/404' '403': - description: 'The token needs the deploy permission for a mount outside the resource directory.' + description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.' '422': $ref: '#/components/responses/422' security: @@ -3516,7 +3532,7 @@ paths: description: 'Optional name for the cloned application.' type: [string, 'null'] clone_volumes: - description: 'Whether to clone volume data.' + description: 'Whether to clone volume data. Needs the deploy ability.' type: boolean default: false type: object @@ -3534,6 +3550,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to clone volume data.' '404': $ref: '#/components/responses/404' '422': @@ -4182,6 +4200,7 @@ paths: tags: - Databases summary: 'Import database backup' + description: 'Restore a backup into the database. Every import source requires the deploy ability.' operationId: create-database-import parameters: - @@ -4200,6 +4219,8 @@ paths: responses: '202': description: 'Import queued' + '403': + description: 'The token needs the deploy ability.' '409': description: 'Another start, restart or import of this database is already in progress.' '422': @@ -4340,7 +4361,7 @@ paths: default: false backup_now: type: boolean - description: 'Whether to trigger backup immediately after creation' + description: 'Whether to trigger backup immediately after creation. Needs a token with the deploy permission.' database_backup_retention_amount_locally: type: integer description: 'Number of backups to retain locally' @@ -4384,6 +4405,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the deploy permission to use backup_now.' '404': $ref: '#/components/responses/404' '422': @@ -4427,7 +4450,7 @@ paths: tags: - Databases summary: Delete - description: 'Delete database by UUID.' + description: 'Delete database by UUID. Needs a token with the write and deploy permissions.' operationId: delete-database-by-uuid parameters: - @@ -4484,6 +4507,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the write and deploy permissions.' security: - bearerAuth: [] @@ -4519,10 +4544,10 @@ paths: description: 'Docker Image of the database' is_public: type: boolean - description: 'Is the database public?' + description: 'Is the database public? Changing it starts or stops the database proxy, so it needs a token with the deploy permission.' public_port: type: integer - description: 'Public port of the database' + description: 'Public port of the database (1024-65535)' public_port_timeout: type: integer description: 'Public port timeout in seconds (default: 3600)' @@ -4666,6 +4691,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the deploy permission to change is_public or to use instant_deploy.' '404': $ref: '#/components/responses/404' '422': @@ -4759,7 +4786,7 @@ paths: description: 'S3 storage UUID' backup_now: type: boolean - description: 'Whether to take a backup now or not' + description: 'Whether to take a backup now or not. Needs a token with the deploy permission.' enabled: type: boolean description: 'Whether the backup is enabled or not' @@ -4807,6 +4834,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the deploy permission to use backup_now.' '404': $ref: '#/components/responses/404' '422': @@ -6388,7 +6417,7 @@ paths: content: type: string nullable: true - description: 'File content (file only, optional).' + description: 'File content (file only, optional). Needs the deploy ability when set.' is_directory: type: boolean description: 'Whether this is a directory mount (file only, default false).' @@ -6411,7 +6440,7 @@ paths: '404': $ref: '#/components/responses/404' '403': - description: 'The token needs the deploy permission for a mount outside the resource directory.' + description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.' '422': $ref: '#/components/responses/422' security: @@ -6670,6 +6699,7 @@ paths: name: type: [string, 'null'] clone_volumes: + description: 'Whether to clone volume data. Needs the deploy ability.' type: boolean default: false type: object @@ -6680,6 +6710,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to clone volume data.' '404': $ref: '#/components/responses/404' '422': @@ -6957,7 +6989,7 @@ paths: tags: - Destinations summary: 'Create destination' - description: 'Create a Docker network destination on a server owned by the authenticated team.' + description: "Create a Docker network destination on a server owned by the authenticated team. The network must be new: Docker's built-in networks (bridge, host, none) and networks that already exist on a standalone server are rejected, because Coolify connects the proxy to the destination network." operationId: create-server-destination parameters: - @@ -6979,6 +7011,7 @@ paths: type: string maxLength: 255 network: + description: 'Name of a new Docker network. Must not be bridge, host, none, or a network that already exists on the server.' type: string maxLength: 255 pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' @@ -7000,7 +7033,7 @@ paths: '409': description: 'A destination with this network already exists.' '422': - $ref: '#/components/responses/422' + description: 'Validation failed, the network is reserved or already exists on the server, or the server could not be checked.' security: - bearerAuth: [] @@ -7230,12 +7263,15 @@ paths: tags: - DigitalOcean summary: 'Create a server on DigitalOcean' + description: 'Create a DigitalOcean droplet and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.' operationId: create-digitalocean-server responses: '201': description: 'DigitalOcean droplet created and linked to a Coolify server.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.' '422': description: 'Validation failed.' '429': @@ -8029,7 +8065,7 @@ paths: tags: - Hetzner summary: 'Create Hetzner Server' - description: 'Create a new server on Hetzner and register it in Coolify.' + description: 'Create a new server on Hetzner and register it in Coolify. A cloud-init script or extra SSH key IDs need a token with the deploy permission.' operationId: create-hetzner-server requestBody: description: 'Hetzner server creation parameters' @@ -8087,7 +8123,7 @@ paths: hetzner_ssh_key_ids: type: array items: { type: integer } - description: 'Additional Hetzner SSH key IDs' + description: 'Additional Hetzner SSH key IDs. Needs a token with the deploy permission.' hetzner_firewall_ids: type: array items: { type: integer } @@ -8098,7 +8134,7 @@ paths: description: 'Existing Hetzner network IDs to attach during server creation' cloud_init_script: type: string - description: 'Cloud-init YAML script (optional)' + description: 'Cloud-init YAML script (optional). Needs a token with the deploy permission.' instant_validate: type: boolean example: false @@ -8119,6 +8155,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.' '404': $ref: '#/components/responses/404' '422': @@ -10167,7 +10205,7 @@ paths: tags: - Servers summary: 'Update Docker cleanup settings' - description: 'Update Docker cleanup settings for a server owned by the authenticated team.' + description: 'Update Docker cleanup settings for a server owned by the authenticated team. Turning on `delete_unused_volumes` needs a token with the `deploy` permission.' operationId: update-server-docker-cleanup parameters: - @@ -10217,6 +10255,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'Turning on `delete_unused_volumes` needs the deploy permission.' '404': $ref: '#/components/responses/404' '422': @@ -10229,7 +10269,7 @@ paths: tags: - Servers summary: 'Run Docker cleanup' - description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.' + description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team. Needs a token with the write and deploy permissions.' operationId: run-server-docker-cleanup parameters: - @@ -10263,6 +10303,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' '422': @@ -10342,7 +10384,7 @@ paths: tags: - Servers summary: 'Log in to a registry on a server' - description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.' + description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login. Needs a token with the write and deploy permissions.' operationId: login-server-registry parameters: - @@ -10386,6 +10428,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' '422': @@ -10522,7 +10566,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. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.' + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Needs a token with the write and deploy permissions. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.' operationId: update-server-log-drains parameters: - @@ -10565,7 +10609,7 @@ paths: '400': $ref: '#/components/responses/400' '403': - description: 'Missing the deploy permission for a custom log drain change.' + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' '422': @@ -10616,7 +10660,7 @@ paths: tags: - Servers summary: 'Update server proxy' - description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.' + description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server. Changing proxy_type needs a token with the `write` and `deploy` permissions.' operationId: update-server-proxy parameters: - @@ -10643,7 +10687,7 @@ paths: description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.' type: [string, 'null'] proxy_type: - description: 'Proxy type (case-insensitive).' + description: 'Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.' type: string enum: [traefik, caddy, nginx, none] type: object @@ -10666,6 +10710,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the deploy permission to change the proxy type.' '404': $ref: '#/components/responses/404' '422': @@ -10812,7 +10858,7 @@ paths: tags: - Servers summary: 'Update Sentinel settings' - description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.' + description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel. Needs a token with the write and deploy permissions.' operationId: update-server-sentinel parameters: - @@ -10879,6 +10925,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' '422': @@ -10925,7 +10973,7 @@ paths: name: type: string example: 'My Server' - description: 'The name of the server.' + description: 'The name of the server. Must not contain control characters such as new lines.' description: type: string example: 'My Server Description' @@ -11020,7 +11068,7 @@ paths: tags: - Servers summary: Delete - description: 'Delete server by UUID.' + description: 'Delete server by UUID. With force or delete_from_provider, needs a token with the write and deploy permissions.' operationId: delete-server-by-uuid parameters: - @@ -11033,7 +11081,7 @@ paths: - name: force in: query - description: 'Also delete all resources on the server.' + description: 'Also delete all resources on the server. Needs the deploy permission.' required: false schema: type: boolean @@ -11041,12 +11089,14 @@ paths: - name: delete_from_provider in: query - description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone.' + description: 'Also delete the server from its cloud provider (Hetzner, Vultr or DigitalOcean). This cannot be undone. Needs the deploy permission.' required: false schema: type: boolean default: false responses: + '403': + description: 'The token needs the deploy permission to delete the resources or the cloud server.' '200': description: 'Server deleted.' content: @@ -11070,7 +11120,7 @@ paths: tags: - Servers summary: Update - description: 'Update Server.' + description: 'Update Server. Changing proxy_type needs a token with the write and deploy permissions.' operationId: update-server-by-uuid parameters: - @@ -11089,7 +11139,7 @@ paths: properties: name: type: string - description: 'The name of the server.' + description: 'The name of the server. Must not contain control characters such as new lines.' description: type: string description: 'The description of the server.' @@ -11119,7 +11169,7 @@ paths: proxy_type: type: string enum: [traefik, caddy, none] - description: 'The proxy type.' + description: 'The proxy type. Changing it starts the new proxy on the server and needs the deploy permission.' concurrent_builds: type: integer description: 'Number of concurrent builds.' @@ -11143,6 +11193,8 @@ paths: description: 'SSH connection timeout in seconds (1-300). Default: 10.' type: object responses: + '403': + description: 'The token needs the deploy permission to change the proxy type.' '201': description: 'Server updated.' content: @@ -11229,7 +11281,7 @@ paths: tags: - Servers summary: Validate - description: 'Validate server by UUID.' + description: 'Validate server by UUID. With install=true, needs a token with the write and deploy permissions.' operationId: validate-server-by-uuid parameters: - @@ -11246,11 +11298,13 @@ paths: schema: properties: install: - description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.' + description: 'Install missing prerequisites and Docker, then start the proxy. This can restart the Docker daemon. Needs the deploy permission.' type: boolean default: false type: object responses: + '403': + description: 'The token needs the deploy permission to install the server.' '201': description: 'Server validation started.' content: @@ -11703,6 +11757,7 @@ paths: tags: - 'Service databases' summary: 'Import service database backup' + description: 'Restore a backup into the service database. Every import source requires the deploy ability.' operationId: create-service-database-import parameters: - @@ -11728,6 +11783,8 @@ paths: responses: '202': description: 'Import queued' + '403': + description: 'The token needs the deploy ability.' '409': description: 'Another start, restart or import of this database is already in progress.' '422': @@ -11879,6 +11936,7 @@ paths: is_log_drain_enabled: type: boolean is_public: + description: 'Changing it starts or stops the database proxy, so it needs a token with the deploy permission.' type: boolean public_port: type: [integer, 'null'] @@ -11900,6 +11958,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy permission to change is_public.' '404': $ref: '#/components/responses/404' '422': @@ -12256,7 +12316,7 @@ paths: tags: - Services summary: Delete - description: 'Delete service by UUID.' + description: 'Delete service by UUID. Needs a token with the write and deploy permissions.' operationId: delete-service-by-uuid parameters: - @@ -12321,6 +12381,8 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '403': + description: 'The token needs the write and deploy permissions.' security: - bearerAuth: [] @@ -12997,7 +13059,7 @@ paths: content: type: string nullable: true - description: 'File content (file only, optional).' + description: 'File content (file only, optional). Needs the deploy ability when set.' is_directory: type: boolean description: 'Whether this is a directory mount (file only, default false).' @@ -13020,7 +13082,7 @@ paths: '404': $ref: '#/components/responses/404' '403': - description: 'The token needs the deploy permission for a mount outside the resource directory.' + description: 'The token needs the deploy permission for a mount outside the resource directory or for a file storage with content.' '422': $ref: '#/components/responses/422' security: @@ -13279,6 +13341,7 @@ paths: name: type: [string, 'null'] clone_volumes: + description: 'Whether to clone volume data. Needs the deploy ability.' type: boolean default: false type: object @@ -13289,6 +13352,8 @@ paths: $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to clone volume data.' '404': $ref: '#/components/responses/404' '422': @@ -14094,7 +14159,7 @@ paths: tags: - Applications summary: 'Set application storage backup schedule' - description: 'Create or replace the backup schedule for an application persistent volume or directory storage.' + description: 'Create or replace the backup schedule for an application persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.' operationId: set-application-storage-backup-schedule parameters: - @@ -14135,7 +14200,7 @@ paths: '401': $ref: '#/components/responses/401' '403': - description: Forbidden. + description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.' '404': $ref: '#/components/responses/404' '422': @@ -14181,7 +14246,7 @@ paths: tags: - Databases summary: 'Set database storage backup schedule' - description: 'Create or replace the backup schedule for a database persistent volume or directory storage.' + description: 'Create or replace the backup schedule for a database persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.' operationId: set-database-storage-backup-schedule parameters: - @@ -14222,7 +14287,7 @@ paths: '401': $ref: '#/components/responses/401' '403': - description: Forbidden. + description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.' '404': $ref: '#/components/responses/404' '422': @@ -14268,7 +14333,7 @@ paths: tags: - Services summary: 'Set service storage backup schedule' - description: 'Create or replace the backup schedule for a service persistent volume or directory storage.' + description: 'Create or replace the backup schedule for a service persistent volume or directory storage. A schedule with stop_during_backup needs a token with the deploy permission.' operationId: set-service-storage-backup-schedule parameters: - @@ -14309,7 +14374,7 @@ paths: '401': $ref: '#/components/responses/401' '403': - description: Forbidden. + description: 'Forbidden, or the token needs the deploy permission for stop_during_backup.' '404': $ref: '#/components/responses/404' '422': @@ -14355,7 +14420,7 @@ paths: tags: - Applications summary: 'Run application storage backup' - description: 'Queue an immediate volume backup for an application storage that has a schedule.' + description: 'Queue an immediate volume backup for an application storage that has a schedule. Needs a token with the write and deploy permissions.' operationId: run-application-storage-backup parameters: - @@ -14375,6 +14440,8 @@ paths: description: 'Storage backup queued.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' security: @@ -14385,7 +14452,7 @@ paths: tags: - Databases summary: 'Run database storage backup' - description: 'Queue an immediate volume backup for a database storage that has a schedule.' + description: 'Queue an immediate volume backup for a database storage that has a schedule. Needs a token with the write and deploy permissions.' operationId: run-database-storage-backup parameters: - @@ -14405,6 +14472,8 @@ paths: description: 'Storage backup queued.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' security: @@ -14415,7 +14484,7 @@ paths: tags: - Services summary: 'Run service storage backup' - description: 'Queue an immediate volume backup for a service storage that has a schedule.' + description: 'Queue an immediate volume backup for a service storage that has a schedule. Needs a token with the write and deploy permissions.' operationId: run-service-storage-backup parameters: - @@ -14435,6 +14504,8 @@ paths: description: 'Storage backup queued.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the write and deploy permissions.' '404': $ref: '#/components/responses/404' security: @@ -14513,13 +14584,15 @@ paths: tags: - Vultr summary: 'Create Vultr Server' - description: 'Create a Vultr instance and link it as a Coolify server.' + description: 'Create a Vultr instance and link it as a Coolify server. A cloud-init script or extra SSH key IDs need a token with the deploy permission.' operationId: create-vultr-server responses: '201': description: 'Vultr server created.' '401': $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.' '422': description: 'Validation failed.' '429': @@ -14748,6 +14821,7 @@ components: type: boolean default: false stop_during_backup: + description: 'Stop the resource containers during the backup. Needs a token with the deploy permission.' type: boolean default: false s3_storage_uuid: diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 2fd7a387c3..96120b855f 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -68,7 +68,7 @@
diff --git a/routes/api.php b/routes/api.php index 000680b4a5..bfbb2d29fa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -169,14 +169,14 @@ Route::group([ // Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel). Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'update'])->middleware(['api.ability:write']); - Route::post('/servers/{uuid}/docker-cleanup/run', [ServerDockerCleanupController::class, 'run'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/docker-cleanup/run', [ServerDockerCleanupController::class, 'run'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/servers/{uuid}/docker-cleanup/executions', [ServerDockerCleanupController::class, 'executions'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'show'])->middleware(['api.ability:read']); - Route::patch('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'update'])->middleware(['api.ability:write']); + Route::patch('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'show'])->middleware(['api.ability:read']); - Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write']); + Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'update'])->middleware(['api.ability:write']); @@ -195,7 +195,7 @@ Route::group([ Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); - Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']); @@ -205,7 +205,7 @@ Route::group([ 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']); + Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']); Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); @@ -249,7 +249,7 @@ Route::group([ Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']); @@ -268,7 +268,7 @@ Route::group([ ->middleware(['api.ability:write']); Route::post('/applications/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) ->defaults('resource_type', 'application') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); @@ -296,7 +296,7 @@ Route::group([ Route::post('/applications/{uuid}/previews', [ApplicationPreviewsController::class, 'store'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/previews/{pull_request_id}', [ApplicationPreviewsController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'update_preview_by_pull_request_id'])->middleware(['api.ability:write']); - Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/previews/{pull_request_id}', [ApplicationsController::class, 'delete_preview_by_pull_request_id'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']); @@ -330,7 +330,7 @@ Route::group([ Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read:sensitive']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); @@ -344,7 +344,7 @@ Route::group([ ->middleware(['api.ability:write']); Route::post('/databases/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) ->defaults('resource_type', 'database') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); @@ -374,7 +374,7 @@ Route::group([ Route::get('/services/{uuid}', [ServicesController::class, 'service_by_uuid'])->middleware(['api.ability:read']); Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); @@ -385,7 +385,7 @@ Route::group([ ->middleware(['api.ability:write']); Route::post('/services/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) ->defaults('resource_type', 'service') - ->middleware(['api.ability:write']); + ->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/CloneVolumesDeployAbilityTest.php b/tests/Feature/Api/CloneVolumesDeployAbilityTest.php new file mode 100644 index 0000000000..7b99280fde --- /dev/null +++ b/tests/Feature/Api/CloneVolumesDeployAbilityTest.php @@ -0,0 +1,105 @@ + 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]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first(); + $destinationAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]; + + $this->resources = [ + 'applications' => Application::factory()->create($destinationAttributes), + 'services' => Service::factory()->create($destinationAttributes + ['server_id' => $server->id]), + 'databases' => StandalonePostgresql::create($destinationAttributes + [ + 'name' => 'source-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + ]), + ]; + + Queue::fake(); + Bus::fake(); +}); + +function cloneAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'clone-volumes-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +test('write-only token cannot clone volume data', function (string $type) { + $resource = $this->resources[$type]; + $countBefore = $resource::query()->count(); + + $this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => true, + ]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Cloning volume data stops and starts the source resource, so it needs a token with the deploy permission.'); + + expect($resource::query()->count())->toBe($countBefore); +})->with(['applications', 'services', 'databases']); + +test('write-only token can clone without volume data', function (string $type) { + $resource = $this->resources[$type]; + $countBefore = $resource::query()->count(); + + $this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'clone_volumes' => false, + ]) + ->assertCreated(); + + expect($resource::query()->count())->toBe($countBefore + 1); +})->with(['applications', 'services', 'databases']); + +test('write and deploy token passes the volume clone check', function (string $type) { + $resource = $this->resources[$type]; + + // An unknown destination stops the request right after the ability check, before any server work. + $this->withHeaders(cloneAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/{$type}/{$resource->uuid}/clone", [ + 'destination_uuid' => 'missing-destination', + 'clone_volumes' => true, + ]) + ->assertNotFound() + ->assertJsonPath('message', 'Destination not found.'); +})->with(['applications', 'services', 'databases']); diff --git a/tests/Feature/Api/CloudInitPublicBackupDeployAbilityTest.php b/tests/Feature/Api/CloudInitPublicBackupDeployAbilityTest.php new file mode 100644 index 0000000000..43d2370da5 --- /dev/null +++ b/tests/Feature/Api/CloudInitPublicBackupDeployAbilityTest.php @@ -0,0 +1,293 @@ +set('cache.default', 'array'); + config()->set('app.maintenance.store', 'array'); + 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]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first(); + $this->destinationAttributes = [ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]; + + $this->database = StandalonePostgresql::create($this->destinationAttributes + [ + 'name' => 'deploy-ability-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'is_public' => false, + ]); + + Queue::fake(); + Http::fake(); +}); + +function deployAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'cloud-init-public-backup-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +dataset('cloud providers', [ + 'hetzner' => ['hetzner', ['location' => 'nbg1', 'server_type' => 'cx11', 'image' => 15512617], 'hetzner_ssh_key_ids', [123]], + 'vultr' => ['vultr', ['region' => 'ewr', 'plan' => 'vc2-1c-1gb', 'os_id' => 2284], 'vultr_ssh_key_ids', ['key-1']], + 'digitalocean' => ['digitalocean', ['region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64'], 'digitalocean_ssh_key_ids', [123]], +]); + +function cloudServerPayload(object $context, string $provider, array $providerFields, ?string $tokenUuid = null): array +{ + $token = CloudProviderToken::create([ + 'team_id' => $context->team->id, + 'provider' => $provider, + 'name' => 'Test token', + 'token' => 'test-api-token', + ]); + $privateKey = PrivateKey::create([ + 'team_id' => $context->team->id, + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + ]); + + return $providerFields + [ + 'cloud_provider_token_uuid' => $tokenUuid ?? $token->uuid, + 'name' => 'deploy-ability-server', + 'private_key_uuid' => $privateKey->uuid, + ]; +} + +test('write-only token cannot create a cloud server with a cloud-init script or extra SSH keys', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds, string $extra) { + $payload = cloudServerPayload($this, $provider, $providerFields); + $payload += $extra === 'cloud_init_script' + ? ['cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n"] + : [$sshKeyField => $sshKeyIds]; + $serverCount = Server::query()->count(); + + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$provider}", $payload) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. A cloud-init script or extra SSH keys run as root on the new server, so it needs a token with the deploy permission.'); + + Http::assertNothingSent(); + expect(Server::query()->count())->toBe($serverCount); +})->with('cloud providers')->with(['cloud_init_script', 'ssh_key_ids']); + +test('write and deploy token passes the cloud-init check', function (string $provider, array $providerFields, string $sshKeyField, array $sshKeyIds) { + // An unknown provider token stops the request after the ability check, before any provider call. + $payload = cloudServerPayload($this, $provider, $providerFields, 'missing-token') + [ + 'cloud_init_script' => "#cloud-config\nruncmd:\n - echo hello\n", + $sshKeyField => $sshKeyIds, + ]; + + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/servers/{$provider}", $payload) + ->assertNotFound(); + + Http::assertNothingSent(); +})->with('cloud providers'); + +test('write-only token can create a cloud server request without cloud-init or extra SSH keys', function (string $provider, array $providerFields) { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$provider}", cloudServerPayload($this, $provider, $providerFields, 'missing-token')) + ->assertNotFound(); +})->with('cloud providers'); + +test('write-only token cannot make a database public', function () { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.'); + + expect((bool) $this->database->fresh()->is_public)->toBeFalse(); + Queue::assertNothingPushed(); +}); + +test('write-only token can send an unchanged is_public value', function () { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => false, 'name' => 'renamed-db']) + ->assertOk(); + + expect($this->database->fresh()->name)->toBe('renamed-db'); +}); + +test('write and deploy token can make a database public', function () { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_public' => true, 'public_port' => 25432]) + ->assertOk(); + + expect((bool) $this->database->fresh()->is_public)->toBeTrue(); +}); + +test('database update rejects a public port outside 1024-65535', function (int $port) { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['public_port' => $port]) + ->assertUnprocessable() + ->assertJsonValidationErrors('public_port'); + + expect($this->database->fresh()->public_port)->toBeNull(); +})->with([80, 70000]); + +test('service database is_public change needs the deploy permission', function (array $abilities, int $status) { + $service = Service::factory()->create($this->destinationAttributes + [ + 'server_id' => $this->server->id, + 'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n", + ]); + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'postgres', + 'service_id' => $service->id, + 'image' => 'postgres:17-alpine', + 'is_public' => false, + ]); + + $response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities)) + ->patchJson("/api/v1/services/{$service->uuid}/databases/{$serviceDatabase->uuid}", ['is_public' => true, 'public_port' => 25433]) + ->assertStatus($status); + + if ($status === 403) { + $response->assertJsonPath('message', 'Missing required permissions: deploy. Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.'); + } + expect((bool) $serviceDatabase->fresh()->is_public)->toBe($status === 200); +})->with([ + 'write only' => [['write'], 403], + 'write and deploy' => [['write', 'deploy'], 200], +]); + +test('backup_now needs the deploy permission when creating a database backup', function (array $abilities, int $status) { + $response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities)) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily', 'backup_now' => true]) + ->assertStatus($status); + + if ($status === 403) { + $response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.'); + expect(ScheduledDatabaseBackup::query()->count())->toBe(0); + Queue::assertNotPushed(DatabaseBackupJob::class); + } else { + Queue::assertPushed(DatabaseBackupJob::class); + } +})->with([ + 'write only' => [['write'], 403], + 'write and deploy' => [['write', 'deploy'], 201], +]); + +test('write-only token can create a database backup schedule without backup_now', function () { + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily']) + ->assertCreated(); + + Queue::assertNotPushed(DatabaseBackupJob::class); +}); + +test('backup_now needs the deploy permission when updating a database backup', function (array $abilities, int $status) { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities)) + ->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", ['frequency' => 'hourly', 'backup_now' => true]) + ->assertStatus($status); + + if ($status === 403) { + $response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that runs now copies the database on the server, so it needs a token with the deploy permission.'); + expect($backup->fresh()->frequency)->toBe('daily'); + Queue::assertNotPushed(DatabaseBackupJob::class); + } else { + expect($backup->fresh()->frequency)->toBe('hourly'); + Queue::assertPushed(DatabaseBackupJob::class); + } +})->with([ + 'write only' => [['write'], 403], + 'write and deploy' => [['write', 'deploy'], 200], +]); + +test('stop_during_backup needs the deploy permission for a volume backup schedule', function (array $abilities, bool $stopDuringBackup, int $status) { + $application = Application::factory()->create($this->destinationAttributes); + $volume = LocalPersistentVolume::create([ + 'name' => 'deploy-ability-volume', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $response = $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, $abilities)) + ->putJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups", [ + 'frequency' => '0 2 * * *', + 'stop_during_backup' => $stopDuringBackup, + ]) + ->assertStatus($status); + + if ($status === 403) { + $response->assertJsonPath('message', 'Missing required permissions: deploy. A backup that stops the containers needs a token with the deploy permission.'); + expect(ScheduledVolumeBackup::query()->count())->toBe(0); + } else { + expect(ScheduledVolumeBackup::query()->sole()->stop_during_backup)->toBe($stopDuringBackup); + } +})->with([ + 'write only, stop' => [['write'], true, 403], + 'write only, no stop' => [['write'], false, 201], + 'write and deploy, stop' => [['write', 'deploy'], true, 201], +]); + +test('write-only token cannot run a volume backup now', function () { + $application = Application::factory()->create($this->destinationAttributes); + $volume = LocalPersistentVolume::create([ + 'name' => 'deploy-ability-volume', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $this->withHeaders(deployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/applications/{$application->uuid}/storages/{$volume->uuid}/backups/run") + ->assertForbidden(); + + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Api/DatabaseImportServerSourceDeployAbilityApiTest.php b/tests/Feature/Api/DatabaseImportServerSourceDeployAbilityApiTest.php index 45095aa56f..0ddaf85ff2 100644 --- a/tests/Feature/Api/DatabaseImportServerSourceDeployAbilityApiTest.php +++ b/tests/Feature/Api/DatabaseImportServerSourceDeployAbilityApiTest.php @@ -57,33 +57,32 @@ function expectServerSourceImportStarts(): void } dataset('server source import variants', ['standalone', 'service']); +dataset('import sources', [ + 'upload' => [fn () => ['source' => 'upload', 'upload_id' => (string) Str::uuid()]], + 's3' => [fn () => ['source' => 's3', 's3_storage_uuid' => (string) Str::uuid(), 'path' => '/backups/backup.sql']], + 'server' => [fn () => ['source' => 'server', 'path' => '/tmp/backup.sql']], +]); -test('a write token cannot import from a server path', function (string $variant) { +test('a write token without deploy cannot start an import from any source', function (string $variant, array $payload) { $action = Mockery::mock(StartDatabaseImport::class); $action->shouldNotReceive('handle'); app()->instance(StartDatabaseImport::class, $action); $this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write'])) - ->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/etc/hostname']) + ->postJson($this->importPaths[$variant], $payload) ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy. An import from a server path needs a token with the deploy permission.'); -})->with('server source import variants'); + ->assertJsonPath('message', 'Missing required permissions: deploy. An import runs the dump in the database container, so it needs a token with the deploy permission.'); -test('a write token with deploy or a root token can import from a server path', function (string $variant, array $abilities) { + expect(Activity::query()->count())->toBe(0); +})->with('server source import variants')->with('import sources'); + +test('a write token with deploy or a root token can start an import from any source', function (string $variant, array $payload, array $abilities) { expectServerSourceImportStarts(); $this->withToken(serverSourceImportToken($this->user, $this->team, $abilities)) - ->postJson($this->importPaths[$variant], ['source' => 'server', 'path' => '/tmp/backup.sql']) + ->postJson($this->importPaths[$variant], $payload) ->assertAccepted(); -})->with('server source import variants')->with([ +})->with('server source import variants')->with('import sources')->with([ 'write and deploy' => [['read', 'write', 'deploy']], 'root' => [['root']], ]); - -test('a write token can still import from an upload without deploy', function () { - expectServerSourceImportStarts(); - - $this->withToken(serverSourceImportToken($this->user, $this->team, ['read', 'write'])) - ->postJson($this->importPaths['standalone'], ['source' => 'upload', 'upload_id' => (string) Str::uuid()]) - ->assertAccepted(); -}); diff --git a/tests/Feature/Api/DeleteResourceDeployAbilityTest.php b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php new file mode 100644 index 0000000000..b6f6bb4c52 --- /dev/null +++ b/tests/Feature/Api/DeleteResourceDeployAbilityTest.php @@ -0,0 +1,123 @@ + 0, 'is_api_enabled' => true]); + Queue::fake(); + Bus::fake(); + + $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(); + $destinationAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $this->resources = [ + 'applications' => Application::factory()->create($destinationAttributes), + 'services' => Service::factory()->create($destinationAttributes + ['server_id' => $server->id]), + 'databases' => StandalonePostgresql::create($destinationAttributes + [ + 'name' => 'delete-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + ]), + ]; + + $this->preview = ApplicationPreview::create([ + 'uuid' => (string) new Cuid2, + 'application_id' => $this->resources['applications']->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repo/pull/42', + 'fqdn' => 'pr-42.example.com', + ]); + + CleanupPreviewDeployment::shouldRun()->andReturn([ + 'cancelled_deployments' => 0, + 'killed_containers' => 0, + 'status' => 'success', + ]); +}); + +function deleteAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'delete-resource-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +test('write-only token cannot delete a resource', function (string $type) { + $resource = $this->resources[$type]; + + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + ->deleteJson("/api/v1/{$type}/{$resource->uuid}") + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); + + expect($resource::query()->whereKey($resource->getKey())->exists())->toBeTrue(); + Queue::assertNotPushed(DeleteResourceJob::class); +})->with(['applications', 'services', 'databases']); + +test('write and deploy token deletes a resource', function (string $type) { + $resource = $this->resources[$type]; + + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->deleteJson("/api/v1/{$type}/{$resource->uuid}") + ->assertOk(); + + expect($resource::query()->whereKey($resource->getKey())->exists())->toBeFalse(); +})->with(['applications', 'services', 'databases']); + +test('write-only token cannot delete a preview deployment', function () { + $application = $this->resources['applications']; + + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write'])) + ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); + + expect($this->preview->fresh()->trashed())->toBeFalse(); +}); + +test('write and deploy token deletes a preview deployment', function () { + $application = $this->resources['applications']; + + $this->withHeaders(deleteAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->deleteJson("/api/v1/applications/{$application->uuid}/previews/42") + ->assertOk(); + + expect($this->preview->fresh()->trashed())->toBeTrue(); +}); diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index 90875a0a2e..6fb87584e6 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -2,6 +2,7 @@ use App\Actions\Destination\RemoveStandaloneDockerNetwork; use App\Models\InstanceSettings; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -11,6 +12,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -21,7 +23,10 @@ beforeEach(function () { 'session.driver' => 'array', 'queue.default' => 'sync', 'app.maintenance.driver' => 'file', + 'constants.ssh.mux_enabled' => false, ]); + // Creating a standalone destination lists the Docker networks on the server first. + Process::fake(); InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( ['id' => 0], @@ -34,7 +39,10 @@ beforeEach(function () { session(['currentTeam' => $this->team]); $this->bearerToken = destinationsApiToken($this->user, $this->team, ['*']); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); }); diff --git a/tests/Feature/Api/ManualWebhookSecretDeployAbilityTest.php b/tests/Feature/Api/ManualWebhookSecretDeployAbilityTest.php new file mode 100644 index 0000000000..737d7f25db --- /dev/null +++ b/tests/Feature/Api/ManualWebhookSecretDeployAbilityTest.php @@ -0,0 +1,112 @@ + 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]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'manual_webhook_secret_github' => 'existing-secret-0123456789', + ]); + + Queue::fake(); +}); + +function manualWebhookSecretAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'manual-webhook-secret-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +describe('update', function () { + test('write-only token cannot set a new manual webhook secret', function (string $field) { + $this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => 'attacker-secret-0123456789']) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. A manual webhook secret lets anyone who knows it start a deployment, so it needs a token with the deploy permission.'); + + $application = $this->application->fresh(); + expect($application->manual_webhook_secret_github)->toBe('existing-secret-0123456789'); + expect($application->{$field})->not->toBe('attacker-secret-0123456789'); + })->with([ + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + ]); + + test('write-only token can update other fields and send the stored secret back', function () { + $headers = manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write']); + + $this->withHeaders($headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['description' => 'changed']) + ->assertOk(); + $this->withHeaders($headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'description' => 'changed again', + 'manual_webhook_secret_github' => 'existing-secret-0123456789', + ]) + ->assertOk(); + + expect($this->application->fresh()) + ->description->toBe('changed again') + ->manual_webhook_secret_github->toBe('existing-secret-0123456789'); + }); + + test('write and deploy token can set a new manual webhook secret', function () { + $this->withHeaders(manualWebhookSecretAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['manual_webhook_secret_gitlab' => 'new-gitlab-secret-0123456789']) + ->assertOk(); + + expect($this->application->fresh()->manual_webhook_secret_gitlab)->toBe('new-gitlab-secret-0123456789'); + }); +}); + +describe('create', function () { + test('write-only token cannot create an application with a manual webhook secret', function () { + $this->withHeaders(manualWebhookSecretAbilityHeaders($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', + 'ports_exposes' => '80', + 'manual_webhook_secret_github' => 'attacker-secret-0123456789', + ]) + ->assertForbidden() + ->assertJsonPath('message', fn (string $message) => str_contains($message, 'manual webhook secret')); + + expect(Application::count())->toBe(1); + }); +}); diff --git a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php index 190b759488..82fe41ffca 100644 --- a/tests/Feature/Api/ServerDeleteFromProviderApiTest.php +++ b/tests/Feature/Api/ServerDeleteFromProviderApiTest.php @@ -20,7 +20,7 @@ beforeEach(function () { $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $newToken = $this->user->createToken('write-token', ['write']); + $newToken = $this->user->createToken('write-token', ['write', 'deploy']); $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->token = $newToken->plainTextToken; }); diff --git a/tests/Feature/Api/ServerDeployAbilityTest.php b/tests/Feature/Api/ServerDeployAbilityTest.php new file mode 100644 index 0000000000..085b3bd506 --- /dev/null +++ b/tests/Feature/Api/ServerDeployAbilityTest.php @@ -0,0 +1,237 @@ + 'array', + 'session.driver' => 'array', + 'app.maintenance.driver' => 'file', + 'constants.ssh.mux_enabled' => false, + ]); + Queue::fake(); + Bus::fake(); + + 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]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'original-name', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $this->server->save(); +}); + +function serverDeployAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'server-deploy-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +describe('server name', function () { + test('create rejects a name with control characters', function (string $name) { + $countBefore = Server::query()->count(); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson('/api/v1/servers', [ + 'name' => $name, + 'ip' => '10.0.0.55', + 'private_key_uuid' => $this->server->privateKey->uuid, + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.name.0', 'The name must not contain control characters such as new lines.'); + + expect(Server::query()->count())->toBe($countBefore); + })->with(['new line' => "bad\nname", 'carriage return' => "bad\rname", 'tab' => "bad\tname", 'null byte' => "bad\0name"]); + + test('update rejects a name with a new line', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/servers/{$this->server->uuid}", ['name' => "bad\n[OUTPUT]\nname"]) + ->assertUnprocessable() + ->assertJsonPath('errors.name.0', 'The name must not contain control characters such as new lines.'); + + expect($this->server->fresh()->name)->toBe('original-name'); + }); + + test('update accepts a one-line name', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->patchJson("/api/v1/servers/{$this->server->uuid}", ['name' => 'My Server (prod)']) + ->assertCreated(); + + expect($this->server->fresh()->name)->toBe('My Server (prod)'); + }); +}); + +describe('proxy type', function () { + test('write-only token cannot change the proxy type', function (string $method, string $path) { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'caddy']) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Changing the proxy type starts the new proxy on the server, so it needs a token with the deploy permission.'); + + expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::TRAEFIK->value); + })->with([ + 'server update' => ['PATCH', '/api/v1/servers/{uuid}'], + 'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'], + ]); + + test('write-only token can send the current proxy type', function (string $method, string $path) { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'traefik']) + ->assertSuccessful(); + + expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::TRAEFIK->value); + })->with([ + 'server update' => ['PATCH', '/api/v1/servers/{uuid}'], + 'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'], + ]); + + test('write and deploy token can change the proxy type', function (string $method, string $path) { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->json($method, str_replace('{uuid}', $this->server->uuid, $path), ['proxy_type' => 'caddy']) + ->assertSuccessful(); + + expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value); + })->with([ + 'server update' => ['PATCH', '/api/v1/servers/{uuid}'], + 'proxy update' => ['PATCH', '/api/v1/servers/{uuid}/proxy'], + ]); +}); + +describe('validate with install', function () { + test('write-only token cannot install the server', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Installing the server runs commands on it and starts the proxy, so it needs a token with the deploy permission.'); + + Bus::assertNotDispatched(ValidateAndInstallServerJob::class); + }); + + test('write-only token can validate without install', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/validate") + ->assertCreated() + ->assertJsonPath('message', 'Validation started.'); + + Bus::assertNotDispatched(ValidateAndInstallServerJob::class); + }); + + test('write and deploy token can install the server', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]) + ->assertCreated() + ->assertJsonPath('message', 'Validation and installation started.'); + + Bus::assertDispatched(ValidateAndInstallServerJob::class); + }); +}); + +describe('delete', function () { + test('write-only token cannot delete with force or delete_from_provider', function (string $query) { + CloudProviderToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'hetzner']); + $this->server->update(['hetzner_server_id' => 123]); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->deleteJson("/api/v1/servers/{$this->server->uuid}?{$query}") + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Deleting the resources or the cloud server needs a token with the deploy permission.'); + + expect(Server::query()->whereKey($this->server->id)->exists())->toBeTrue(); + })->with(['force=true', 'delete_from_provider=true']); + + test('write-only token can delete an empty server', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->deleteJson("/api/v1/servers/{$this->server->uuid}") + ->assertOk(); + + expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse(); + }); + + test('write and deploy token can delete with force', function () { + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") + ->assertOk(); + + expect(Server::query()->whereKey($this->server->id)->exists())->toBeFalse(); + }); +}); + +describe('destination create', function () { + test('rejects Docker built-in network names', function (string $network) { + Process::fake(); + $countBefore = StandaloneDocker::query()->count(); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => $network]) + ->assertUnprocessable() + ->assertJsonPath('errors.network.0', 'The network name is reserved by Docker. Use a different name.'); + + expect(StandaloneDocker::query()->count())->toBe($countBefore); + Process::assertNothingRan(); + })->with(['bridge', 'host', 'none', 'Bridge']); + + test('rejects a network that already exists on the server', function () { + Process::fake(fn () => Process::result(output: "bridge\nhost\nnone\ncoolify\nprivate-db-net\n")); + $countBefore = StandaloneDocker::query()->count(); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'private-db-net']) + ->assertUnprocessable() + ->assertJsonPath('errors.network.0', 'A Docker network with this name already exists on the server. Use a new network name.'); + + expect(StandaloneDocker::query()->count())->toBe($countBefore); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker network ls')); + }); + + test('rejects the destination when the server networks cannot be read', function () { + Process::fake(fn () => Process::result(errorOutput: 'Permission denied', exitCode: 1)); + $countBefore = StandaloneDocker::query()->count(); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'brand-new-net']) + ->assertUnprocessable() + ->assertJsonPath('message', 'Could not read the Docker networks on the server. Check that the server is reachable and try again.'); + + expect(StandaloneDocker::query()->count())->toBe($countBefore); + }); + + test('creates a destination with a new network', function () { + Process::fake(fn () => Process::result(output: "bridge\nhost\nnone\ncoolify\n")); + + $this->withHeaders(serverDeployAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson("/api/v1/servers/{$this->server->uuid}/destinations", ['network' => 'brand-new-net']) + ->assertCreated() + ->assertJsonPath('network', 'brand-new-net'); + + expect(StandaloneDocker::query()->where('server_id', $this->server->id)->where('network', 'brand-new-net')->exists())->toBeTrue(); + }); +}); diff --git a/tests/Feature/Api/ServerDockerRegistriesApiTest.php b/tests/Feature/Api/ServerDockerRegistriesApiTest.php index 7693387cd4..dde328bccc 100644 --- a/tests/Feature/Api/ServerDockerRegistriesApiTest.php +++ b/tests/Feature/Api/ServerDockerRegistriesApiTest.php @@ -42,7 +42,7 @@ beforeEach(function () { $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); $this->server->refresh(); - $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write']); + $this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write', 'deploy']); $this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']); }); diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php index f27649e19c..9a18bb8cf1 100644 --- a/tests/Feature/Api/ServerProxyApiTest.php +++ b/tests/Feature/Api/ServerProxyApiTest.php @@ -279,7 +279,7 @@ test('PUT /api/v1/servers/{uuid}/proxy/configuration requires the deploy permiss 'configuration' => base64_encode($configuration), ]) ->assertForbidden() - ->assertJsonPath('message', 'Missing required permissions: deploy'); + ->assertJsonPath('message', 'Missing required permissions: deploy. Coolify applies the proxy configuration on the server, so it needs a token with the deploy permission.'); }); test('PUT /api/v1/servers/{uuid}/proxy/configuration saves with write and deploy permissions', function () { diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index b3a0847a70..631ae83241 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -206,13 +206,13 @@ describe('Log drains API', function () { 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 () { + test('PATCH lets a write and deploy 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; + $deployToken = $this->user->createToken('server-subsystems-deploy', ['write', 'deploy'])->plainTextToken; $this->withHeaders([ - 'Authorization' => 'Bearer '.$writeOnlyToken, + 'Authorization' => 'Bearer '.$deployToken, 'Accept' => 'application/json', ]) ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ diff --git a/tests/Feature/Api/ServerSubsystemsDeployAbilityTest.php b/tests/Feature/Api/ServerSubsystemsDeployAbilityTest.php new file mode 100644 index 0000000000..9c7ee30277 --- /dev/null +++ b/tests/Feature/Api/ServerSubsystemsDeployAbilityTest.php @@ -0,0 +1,97 @@ + 'array', + 'cache.default' => 'array', + 'cache.stores.redis.driver' => 'array', + ]); + 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]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); +}); + +function serverSubsystemsDeployHeaders(array $abilities): array +{ + return [ + 'Authorization' => 'Bearer '.test()->user->createToken('server-subsystems', $abilities)->plainTextToken, + 'Accept' => 'application/json', + ]; +} + +test('a write-only token cannot change a server subsystem that runs on the server', function (string $method, string $path, array $payload) { + $this->withHeaders(serverSubsystemsDeployHeaders(['write'])) + ->json($method, "/api/v1/servers/{$this->server->uuid}{$path}", $payload) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); + + $settings = $this->server->settings->fresh(); + expect($settings->logdrain_axiom_dataset_name)->toBeNull() + ->and((bool) $settings->is_sentinel_debug_enabled)->toBeFalse(); + Queue::assertNothingPushed(); + Process::assertNothingRan(); +})->with([ + 'log drains' => ['PATCH', '/log-drains', ['logdrain_axiom_dataset_name' => 'api-dataset']], + 'sentinel' => ['PATCH', '/sentinel', ['is_sentinel_debug_enabled' => true]], + 'docker cleanup run' => ['POST', '/docker-cleanup/run', []], + 'registry login' => ['POST', '/registries', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']], +]); + +test('a write-only token cannot turn on unused volume deletion', function () { + $this->server->settings->update(['delete_unused_volumes' => false]); + + $this->withHeaders(serverSubsystemsDeployHeaders(['write'])) + ->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", ['delete_unused_volumes' => true]) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Deleting unused volumes removes data on the server, so it needs a token with the deploy permission.'); + + expect((bool) $this->server->settings->fresh()->delete_unused_volumes)->toBeFalse(); +}); + +test('a write-only token can change docker cleanup settings that keep volumes', function (bool $volumesBefore, array $payload) { + $this->server->settings->update(['delete_unused_volumes' => $volumesBefore]); + + $this->withHeaders(serverSubsystemsDeployHeaders(['write'])) + ->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", $payload) + ->assertOk(); + + $settings = $this->server->settings->fresh(); + foreach ($payload as $field => $value) { + expect($settings->{$field})->toEqual($value); + } +})->with([ + 'unused networks' => [false, ['delete_unused_networks' => true, 'docker_cleanup_threshold' => 50]], + 'turn off volume deletion' => [true, ['delete_unused_volumes' => false]], + 'volume deletion already on' => [true, ['delete_unused_volumes' => true]], +]); + +test('a write and deploy token can turn on unused volume deletion', function (array $abilities) { + $this->server->settings->update(['delete_unused_volumes' => false]); + + $this->withHeaders(serverSubsystemsDeployHeaders($abilities)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", ['delete_unused_volumes' => true]) + ->assertOk() + ->assertJsonPath('delete_unused_volumes', true); + + expect((bool) $this->server->settings->fresh()->delete_unused_volumes)->toBeTrue(); +})->with([ + 'write and deploy' => [['write', 'deploy']], + 'root' => [['root']], +]); diff --git a/tests/Feature/Api/ServerValidationApiTest.php b/tests/Feature/Api/ServerValidationApiTest.php index 7274d91dc6..7764095d31 100644 --- a/tests/Feature/Api/ServerValidationApiTest.php +++ b/tests/Feature/Api/ServerValidationApiTest.php @@ -38,6 +38,8 @@ it('validates without installing by default', function () { }); it('validates and installs when explicitly requested', function () { + $this->token = $this->user->createToken('server-validation-install', ['write', 'deploy'])->plainTextToken; + $response = $this->withHeaders(serverValidationHeaders()) ->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]); diff --git a/tests/Feature/Api/ServiceDeleteMessageApiTest.php b/tests/Feature/Api/ServiceDeleteMessageApiTest.php index 6cc34f4ffd..cdee77e6c6 100644 --- a/tests/Feature/Api/ServiceDeleteMessageApiTest.php +++ b/tests/Feature/Api/ServiceDeleteMessageApiTest.php @@ -24,7 +24,7 @@ beforeEach(function () { $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); - $token = $this->user->createToken('service-delete-message', ['write']); + $token = $this->user->createToken('service-delete-message', ['write', 'deploy']); $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); $this->bearerToken = $token->plainTextToken; diff --git a/tests/Feature/Api/StorageContentDeployAbilityTest.php b/tests/Feature/Api/StorageContentDeployAbilityTest.php new file mode 100644 index 0000000000..ae76e45dc4 --- /dev/null +++ b/tests/Feature/Api/StorageContentDeployAbilityTest.php @@ -0,0 +1,113 @@ + 'array', 'cache.default' => 'array']); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + Queue::fake(); + Process::fake(fn () => Process::result(output: 'OK')); + + $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, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first(); + $destinationAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $application = Application::factory()->create($destinationAttributes); + $service = Service::factory()->create($destinationAttributes + ['server_id' => $server->id]); + $serviceApplication = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); + $database = StandalonePostgresql::create($destinationAttributes + [ + 'name' => 'storage-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + ]); + + $this->targets = [ + 'applications' => ["/api/v1/applications/{$application->uuid}/storages", [], $application], + 'services' => ["/api/v1/services/{$service->uuid}/storages", ['resource_uuid' => $serviceApplication->uuid], $serviceApplication], + 'databases' => ["/api/v1/databases/{$database->uuid}/storages", [], $database], + ]; +}); + +function storageContentAbilityHeaders(User $user, int $teamId, array $abilities): array +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'storage-content-ability-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $teamId, + ]); + + return ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; +} + +test('write-only token cannot create a file storage with content', function (string $type) { + [$url, $payload] = $this->targets[$type]; + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;']) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy. Coolify writes the file content on the server immediately, so it needs a token with the deploy permission.'); + + expect(LocalFileVolume::query()->count())->toBe(0); + Queue::assertNotPushed(ServerStorageSaveJob::class); +})->with(['applications', 'services', 'databases']); + +test('write-only token can create a directory storage', function (string $type) { + [$url, $payload, $resource] = $this->targets[$type]; + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write'])) + ->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data', 'mount_path' => '/data']) + ->assertCreated(); + + expect($resource->fileStorages()->where('is_directory', true)->count())->toBe(1); +})->with(['applications', 'services', 'databases']); + +test('write and deploy token creates a file storage with content', function (string $type) { + [$url, $payload, $resource] = $this->targets[$type]; + + $this->withHeaders(storageContentAbilityHeaders($this->user, $this->team->id, ['write', 'deploy'])) + ->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;']) + ->assertCreated(); + + expect($resource->fileStorages()->where('mount_path', '/etc/app.conf')->value('content'))->toBe('listen 8080;'); + Queue::assertPushed(ServerStorageSaveJob::class); +})->with(['applications', 'services', 'databases']); diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index 4497fe2b4a..43576c4364 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -144,3 +144,42 @@ test('owner can deselect root and falls back to read permission', function () { ->set('permissions', []) ->assertSet('permissions', ['read']); }); + +test('deploy permission can be combined with write permission', function (array $steps) { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(ApiTokens::class)->set('permissions', ['read']); + + foreach ($steps as $permission) { + $component->set('permissions.'.count($component->get('permissions')), $permission); + } + + $component->assertSet('permissions', ['deploy', 'read', 'write']); +})->with([ + 'write then deploy' => [['write', 'deploy']], + 'deploy then write' => [['deploy', 'write']], +]); + +test('owner can create token with read, write, and deploy permissions', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'deploy-write-token') + ->set('expiresInDays', 30) + ->set('permissions', ['read', 'write', 'deploy']) + ->call('addNewToken') + ->assertHasNoErrors(); + + $token = $owner->tokens()->latest()->first(); + + expect($token)->not->toBeNull() + ->and($token->abilities)->toEqualCanonicalizing(['read', 'write', 'deploy']); +}); diff --git a/tests/Feature/ComposeContentVolumeConfinementTest.php b/tests/Feature/ComposeContentVolumeConfinementTest.php index 8dc867bf65..b1e72ccf1b 100644 --- a/tests/Feature/ComposeContentVolumeConfinementTest.php +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -217,7 +217,7 @@ test('the application parser stores a content volume at its host path and Coolif contentHostPathAssertWritten($writePath, $content, $user); })->with(contentHostPathTargets())->with(['root', 'coolify']); -test('loading files for a service writes content at its host path', function (string $target, string $content, string $user) { +test('loading files for a service writes content inside the resource directory', function (string $target, string $content, string $user) { contentHostPathUseServerUser($user); $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE); serviceParser($service); @@ -232,7 +232,26 @@ test('loading files for a service writes content at its host path', function (st $base64 = base64_encode($content); Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p -- \"$({$sudo}dirname -- {$escapedPath})\"")); Process::assertRan(fn ($process) => str_contains($process->command, "echo '{$base64}' | {$sudo}base64 -d | {$sudo}tee -- {$escapedPath}")); -})->with(contentHostPathTargets())->with(['root', 'coolify']); +})->with([ + 'home directory' => ['/etc/b.conf', 'b'], + 'bare relative path' => ['/etc/c.conf', 'c'], +])->with(['root', 'coolify']); + +// A token with only the write permission can save a Compose file. Content outside the resource +// directory is written when the resource is deployed or started, not when its files are loaded. +test('loading files for a service does not write content outside the resource directory', function (string $target, string $content) { + $service = contentHostPathService(CONTENT_HOST_PATH_COMPOSE); + serviceParser($service); + $fileVolume = LocalFileVolume::query()->where('mount_path', $target)->sole(); + + contentHostPathFakeServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); + + Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode($content))); +})->with([ + 'absolute host file' => ['/etc/a.conf', 'a'], + 'parent directory' => ['/etc/d.conf', 'd'], +]); test('a quote in a content path is shell-escaped in every write command', function (string $user) { contentHostPathUseServerUser($user); @@ -245,11 +264,9 @@ test('a quote in a content path is shell-escaped in every write command', functi contentHostPathFakeServer(); $fileVolume->saveStorageOnServer(); - getFilesystemVolumesFromServer($fileVolume->resource, true); $sudo = $user === 'root' ? '' : 'sudo '; Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee '/etc/my'\\''app/app.conf' > /dev/null")); - Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}tee -- '/etc/my'\\''app/app.conf'")); Process::assertRan(fn ($process) => str_contains($process->command, "{$sudo}mkdir -p '/etc/my'\\''app'")); })->with(['root', 'coolify']); @@ -410,3 +427,15 @@ test('the service API rejects shell injection in a content volume source', funct expect($service->fresh()->docker_compose_raw)->toBe(CONTENT_HOST_PATH_SAFE_COMPOSE) ->and(LocalFileVolume::query()->count())->toBe(0); }); + +test('loading files for a service does not create directories outside the resource directory', function () { + $compose = "services:\n app:\n image: nginx:alpine\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /data\n"; + $service = contentHostPathService($compose); + serviceParser($service); + $fileVolume = LocalFileVolume::query()->sole(); + + contentHostPathFakeServer(); + getFilesystemVolumesFromServer($fileVolume->resource, true); + + Process::assertNotRan(fn ($process) => str_contains($process->command, '/etc/cron.d/coolify-test') && str_contains($process->command, 'mkdir')); +}); diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index c479fa21ea..769a0dae13 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -587,6 +587,8 @@ function dockerCleanupRunImageCommand(string $command, array $images): string file_put_contents("$bin/images", implode("\n", $images)."\n"); file_put_contents("$bin/docker", "#!/bin/sh\nif [ \"\$1\" = images ]; then cat $bin/images; elif [ \"\$1\" = rmi ]; then echo \"\$@\"; fi\n"); chmod("$bin/docker", 0755); + file_put_contents("$bin/sudo", "#!/bin/sh\nexec \"\$@\"\n"); + chmod("$bin/sudo", 0755); $output = shell_exec('PATH='.escapeshellarg("$bin:".getenv('PATH')).' /bin/sh -c '.escapeshellarg($command)); @@ -632,3 +634,21 @@ it('removes unused images pulled by digest but keeps application images', functi expect($command)->toContain('docker images --digests') ->and(explode("\n", $output))->toBe(['rmi gtstef/filebrowser@sha256:abc', 'rmi postgres:16']); }); + +it('keeps an application image name with a single quote inside the grep pattern', function (bool $nonRootUser) { + $marker = sys_get_temp_dir().'/docker-cleanup-injected-'.bin2hex(random_bytes(4)); + $imageName = "evil'; touch {$marker}; echo '"; + + $command = (new ReflectionMethod(CleanupDocker::class, 'buildImagePruneCommand'))->invoke(new CleanupDocker, collect([$imageName, 'app-uuid']), '1.0.17'); + if ($nonRootUser) { + $command = implode("\n", parseCommandsByLineForSudo(collect([$command]), new Server(['user' => 'deploy']))); + } + + $output = dockerCleanupRunImageCommand($command, [ + 'app-uuid:v1', + 'postgres:16', + ]); + + expect(file_exists($marker))->toBeFalse() + ->and(explode("\n", $output))->toBe(['rmi postgres:16']); +})->with(['root user' => false, 'non-root user' => true]); diff --git a/tests/Feature/LogDrain/CustomLogDrainValidationTest.php b/tests/Feature/LogDrain/CustomLogDrainValidationTest.php index d3084685b4..b1667590b0 100644 --- a/tests/Feature/LogDrain/CustomLogDrainValidationTest.php +++ b/tests/Feature/LogDrain/CustomLogDrainValidationTest.php @@ -127,16 +127,16 @@ describe('custom log drain API', function () { 'parser only' => [['logdrain_custom_config_parser' => SAFE_CUSTOM_LOG_DRAIN_PARSERS]], ]); - test('a write-only token can still change the built-in log drains', function () { + test('a write-only token cannot change the built-in log drains either', function () { Queue::fake(); $token = $this->user->createToken('write-only', ['write'])->plainTextToken; customLogDrainPatch($token, $this->server->uuid, [ 'logdrain_axiom_dataset_name' => 'api-dataset', 'logdrain_axiom_api_key' => 'axiom-key-123', - ])->assertOk(); + ])->assertForbidden(); - expect($this->server->settings->fresh()->logdrain_axiom_dataset_name)->toBe('api-dataset'); + expect($this->server->settings->fresh()->logdrain_axiom_dataset_name)->toBeNull(); }); test('a deploy token can save a safe custom log drain', function () { diff --git a/tests/Feature/LogDrain/LogDrainServerNameTest.php b/tests/Feature/LogDrain/LogDrainServerNameTest.php new file mode 100644 index 0000000000..3445e5e712 --- /dev/null +++ b/tests/Feature/LogDrain/LogDrainServerNameTest.php @@ -0,0 +1,50 @@ + false]); + InstanceSettings::forceCreate(['id' => 0]); +}); + +test('a server name with a line break cannot add a section to the fluent-bit config', function (array $settings) { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'name' => "prod\n[OUTPUT]\n Name stdout\r\n Match *", + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update($settings); + + $commands = new Collection; + Process::fake(function ($process) use ($commands) { + $commands->push($process->command); + + return Process::result(); + }); + + StartLogDrain::run($server->fresh()); + + preg_match("/echo '([A-Za-z0-9+\\/=]+)' \\| base64 -d \\| tee \\S+\\/fluent-bit\\.conf/", $commands->implode("\n"), $matches); + $config = base64_decode($matches[1]); + + expect($config)->toContain("Set coolify.server_name prod [OUTPUT] Name stdout Match *\n") + ->and(preg_match_all('/^\[OUTPUT\]/m', $config))->toBe(1) + ->and($config)->not->toMatch('/^\s*Name stdout/m'); +})->with([ + 'new relic' => [['is_logdrain_newrelic_enabled' => true, 'logdrain_newrelic_license_key' => 'key', 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com/log/v1']], + 'axiom' => [['is_logdrain_axiom_enabled' => true, 'logdrain_axiom_dataset_name' => 'dataset', 'logdrain_axiom_api_key' => 'key']], +]); diff --git a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php index 16c5b49786..3c5c20bdaa 100644 --- a/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php +++ b/tests/Feature/ServerDeleteFailsQueuedDeploymentsTest.php @@ -59,7 +59,7 @@ afterEach(function () { test('deleting a server fails its queued deployments', function (string $path) { if ($path === 'api') { - $token = $this->user->createToken('server-delete', ['write'])->plainTextToken; + $token = $this->user->createToken('server-delete', ['write', 'deploy'])->plainTextToken; $this->withHeaders(['Authorization' => 'Bearer '.$token]) ->deleteJson("/api/v1/servers/{$this->server->uuid}?force=true") ->assertOk(); diff --git a/tests/Feature/ServerStorageSaveDispatchTest.php b/tests/Feature/ServerStorageSaveDispatchTest.php index 75b943d9fb..917f98422a 100644 --- a/tests/Feature/ServerStorageSaveDispatchTest.php +++ b/tests/Feature/ServerStorageSaveDispatchTest.php @@ -97,3 +97,55 @@ it('writes a new file mount immediately when there is no transaction', function expect($this->processedSaves)->toBe(1); Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('KEY=value'))); }); + +it('does not write compose content outside the resource directory before a deployment', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /etc/cron.d/coolify-test\n target: /x\n content: 'outside-content'\n", + ]); + + LocalFileVolume::create([ + 'fs_path' => '/etc/cron.d/coolify-test', + 'mount_path' => '/x', + 'content' => 'outside-content', + 'is_directory' => false, + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + expect($this->processedSaves)->toBe(0); + Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-content'))); +}); + +it('writes compose content inside the resource directory immediately', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ./config.env\n target: /app/config.env\n content: 'inside-content'\n", + ]); + + LocalFileVolume::create([ + 'fs_path' => application_configuration_dir().'/'.$this->application->uuid.'/config.env', + 'mount_path' => '/app/config.env', + 'content' => 'inside-content', + 'is_directory' => false, + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + expect($this->processedSaves)->toBe(1); + Process::assertRan(fn ($process) => str_contains($process->command, base64_encode('inside-content'))); +}); + +it('does not write a mount outside the resource directory before a deployment', function () { + LocalFileVolume::create([ + 'fs_path' => '/opt/coolify-outside.conf', + 'mount_path' => '/app/outside.conf', + 'content' => 'outside-without-compose', + 'is_directory' => false, + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + expect($this->processedSaves)->toBe(0); + Process::assertNotRan(fn ($process) => str_contains($process->command, base64_encode('outside-without-compose'))); +}); diff --git a/tests/Feature/ServerTransferPreviewOwnershipTest.php b/tests/Feature/ServerTransferPreviewOwnershipTest.php index 03cabfa874..a5560fd74e 100644 --- a/tests/Feature/ServerTransferPreviewOwnershipTest.php +++ b/tests/Feature/ServerTransferPreviewOwnershipTest.php @@ -76,7 +76,7 @@ test('preview imports reject another team UUID and roll back all imported record $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => $role]); session(['currentTeam' => $team]); - $token = $user->createToken('preview-import', ['write']); + $token = $user->createToken('preview-import', ['write', 'deploy']); $token->accessToken->forceFill(['team_id' => $team->id])->save(); $counts = [Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()]; diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 652fa88ffa..7d4a854646 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -298,8 +298,8 @@ describe('POST /api/v1/applications/{uuid}/storages', function () { test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () { $app = createTestApplication($this); - $writeToken = createStorageApiToken($this, ['write']); - $sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']); + $writeToken = createStorageApiToken($this, ['write', 'deploy']); + $sensitiveToken = createStorageApiToken($this, ['write', 'deploy', 'read:sensitive']); $writeResponse = $this->withHeaders([ 'Authorization' => 'Bearer '.$writeToken, diff --git a/tests/Feature/StorageHostPathTest.php b/tests/Feature/StorageHostPathTest.php index c22cce8ca1..ac526078a8 100644 --- a/tests/Feature/StorageHostPathTest.php +++ b/tests/Feature/StorageHostPathTest.php @@ -411,10 +411,10 @@ test('a write token creates mounts inside the resource directory', function (str ->postJson($url, [...$payload, 'type' => 'file', 'is_directory' => true, 'fs_path' => 'data/zz', 'mount_path' => '/data']) ->assertCreated(); $this->withHeaders(['Authorization' => 'Bearer '.$token]) - ->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf', 'content' => 'listen 8080;']) + ->postJson($url, [...$payload, 'type' => 'file', 'fs_path' => $workdir.'/config/app.conf', 'mount_path' => '/etc/app.conf']) ->assertCreated(); $this->withHeaders(['Authorization' => 'Bearer '.$token]) - ->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf', 'content' => 'listen 8081;']) + ->postJson($url, [...$payload, 'type' => 'file', 'mount_path' => '/etc/other.conf']) ->assertCreated(); $this->withHeaders(['Authorization' => 'Bearer '.$token]) ->postJson($url, [...$payload, 'type' => 'file', 'is_host_file' => true, 'fs_path' => '/etc/hosts', 'mount_path' => '/etc/hosts']) diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index e3197ed25c..f7b220522f 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -168,7 +168,7 @@ it('quotes literal file-storage paths and safely expands persisted expressions', ]); $literal = Mockery::mock(LocalFileVolume::class)->makePartial(); - $literal->fs_path = '/data/my files/config.yaml'; + $literal->fs_path = '/data/application/my files/config.yaml'; $literal->is_directory = true; $literal->shouldReceive('save')->once(); $file = Mockery::mock(LocalFileVolume::class)->makePartial(); @@ -190,6 +190,8 @@ it('quotes literal file-storage paths and safely expands persisted expressions', $application->shouldReceive('fileStorages')->once()->andReturn($fileStorages); $application->setRelation('destination', (object) ['server' => $server]); $file->setRelation('resource', $application); + $literal->setRelation('resource', $application); + $expression->setRelation('resource', $application); Process::fake(fn ($process) => Process::result(output: match (true) { str_contains($process->command, 'readlink -f') => 'OK', @@ -198,8 +200,8 @@ it('quotes literal file-storage paths and safely expands persisted expressions', })); getFilesystemVolumesFromServer($application, true); - Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/my files/config.yaml'")); - Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/my files/config.yaml'")); + Process::assertRan(fn ($process) => str_contains($process->command, "test -f '/data/application/my files/config.yaml'")); + Process::assertRan(fn ($process) => str_contains($process->command, "mkdir -p -- '/data/application/my files/config.yaml'")); Process::assertRan(fn ($process) => str_contains($process->command, "dirname -- '/data/application/my files/settings.json'")); Process::assertRan(fn ($process) => str_contains($process->command, "tee -- '/data/application/my files/settings.json'")); Process::assertRan(fn ($process) => str_contains($process->command, '${DATA_PATH:-/srv/app/config.yaml}'));