diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php new file mode 100644 index 000000000..c907af46f --- /dev/null +++ b/app/Http/Controllers/Api/GitlabController.php @@ -0,0 +1,540 @@ +attributes->get('can_read_sensitive', false) === true) { + $gitlabApp->makeVisible([ + 'client_secret', + 'webhook_token', + 'access_token', + 'refresh_token', + ]); + } else { + $gitlabApp->makeHidden([ + 'client_secret', + 'webhook_token', + 'access_token', + 'refresh_token', + ]); + } + + return serializeApiResponse($gitlabApp); + } + + private function findTeamGitlabApp(int|string $gitlabAppId, int $teamId): GitlabApp + { + return GitlabApp::where('id', $gitlabAppId) + ->where('team_id', $teamId) + ->firstOrFail(); + } + + private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string + { + return rtrim($htmlUrl, '/').'/api/v4'; + } + + #[OA\Get( + summary: 'List', + description: 'List all GitLab apps for the current team (and system-wide sources).', + path: '/gitlab-apps', + operationId: 'list-gitlab-apps', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + responses: [ + new OA\Response( + response: 200, + description: 'List of GitLab apps.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'client_id' => ['type' => 'string', 'nullable' => true], + 'group_name' => ['type' => 'string', 'nullable' => true], + 'redirect_uri' => ['type' => 'string', 'nullable' => true], + 'is_system_wide' => ['type' => 'boolean'], + 'is_public' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function list_gitlab_apps(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $gitlabApps = GitlabApp::where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + })->get(); + + $gitlabApps = $gitlabApps->map(function ($app) { + return $this->removeSensitiveData($app); + }); + + return response()->json($gitlabApps); + } + + #[OA\Post( + summary: 'Create GitLab App', + description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.', + path: '/gitlab-apps', + operationId: 'create-gitlab-app', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + requestBody: new OA\RequestBody( + description: 'GitLab app creation payload.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'Name of the GitLab app.'], + 'html_url' => ['type' => 'string', 'description' => 'GitLab instance URL (e.g., https://gitlab.com).'], + 'api_url' => ['type' => 'string', 'description' => 'GitLab API URL (defaults to {html_url}/api/v4).'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'], + 'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional comma-separated group names to filter repositories.'], + 'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application ID.'], + 'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application Secret.'], + 'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token (auto-generated when omitted).'], + 'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI registered in GitLab.'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (non-cloud instances only).'], + ], + required: ['name', 'html_url'], + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'GitLab app created successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'client_id' => ['type' => 'string', 'nullable' => true], + 'group_name' => ['type' => 'string', 'nullable' => true], + 'redirect_uri' => ['type' => 'string', 'nullable' => true], + 'is_system_wide' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_gitlab_app(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [GitlabApp::class]); + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowedFields = [ + 'name', + 'html_url', + 'api_url', + 'custom_user', + 'custom_port', + 'group_name', + 'client_id', + 'client_secret', + 'webhook_token', + 'redirect_uri', + 'is_system_wide', + ]; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl], + 'custom_user' => 'nullable|string|max:255', + 'custom_port' => 'nullable|integer|min:1|max:65535', + 'group_name' => 'nullable|string|max:255', + 'client_id' => 'nullable|string|max:255', + 'client_secret' => 'nullable|string', + 'webhook_token' => 'nullable|string', + // Callback to this Coolify instance — may be a private/LAN URL; do not use SafeExternalUrl. + 'redirect_uri' => ['nullable', 'string', 'url'], + 'is_system_wide' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + try { + $htmlUrl = rtrim((string) $request->input('html_url'), '/'); + $apiUrl = filled($request->input('api_url')) + ? rtrim((string) $request->input('api_url'), '/') + : $this->gitlabApiUrlFromHtmlUrl($htmlUrl); + + $payload = [ + 'name' => $request->input('name'), + 'html_url' => $htmlUrl, + 'api_url' => $apiUrl, + 'custom_user' => $request->input('custom_user', 'git'), + 'custom_port' => $request->input('custom_port', 22), + 'group_name' => $request->input('group_name'), + 'client_id' => $request->input('client_id'), + 'client_secret' => $request->input('client_secret'), + 'webhook_token' => $request->input('webhook_token') ?: Str::random(32), + 'redirect_uri' => $request->input('redirect_uri'), + 'is_public' => false, + 'team_id' => $teamId, + ]; + + if (! isCloud()) { + $payload['is_system_wide'] = $request->boolean('is_system_wide', false); + } + + $gitlabApp = GitlabApp::create($payload); + + auditLog('api.gitlab_app.created', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $gitlabApp->uuid, + 'gitlab_app_name' => $gitlabApp->name, + ]); + + return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + } catch (\Throwable $e) { + return handleError($e); + } + } + + #[OA\Patch( + path: '/gitlab-apps/{gitlab_app_id}', + operationId: 'updateGitlabApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + summary: 'Update GitLab App', + description: 'Update an existing GitLab app.', + parameters: [ + new OA\Parameter( + name: 'gitlab_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitLab App ID' + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'GitLab App name'], + 'html_url' => ['type' => 'string', 'description' => 'GitLab HTML URL'], + 'api_url' => ['type' => 'string', 'description' => 'GitLab API URL'], + 'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'], + 'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'], + 'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional group filter'], + 'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application ID'], + 'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application Secret'], + 'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token'], + 'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI'], + 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'], + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'GitLab app updated successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitLab app updated successfully'], + 'data' => ['type' => 'object', 'description' => 'Updated GitLab app data'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitLab app not found'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_gitlab_app(Request $request, $gitlab_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId); + $this->authorize('update', $gitlabApp); + + $allowedFields = [ + 'name', + 'html_url', + 'api_url', + 'custom_user', + 'custom_port', + 'group_name', + 'client_id', + 'client_secret', + 'webhook_token', + 'redirect_uri', + ]; + + if (! isCloud()) { + $allowedFields[] = 'is_system_wide'; + } + + $payload = $request->only($allowedFields); + + $rules = []; + if (isset($payload['name'])) { + $rules['name'] = 'string|max:255'; + } + if (isset($payload['html_url'])) { + $rules['html_url'] = ['url', new SafeExternalUrl]; + } + if (isset($payload['api_url'])) { + $rules['api_url'] = ['url', new SafeExternalUrl]; + } + if (isset($payload['custom_user'])) { + $rules['custom_user'] = 'string|max:255'; + } + if (isset($payload['custom_port'])) { + $rules['custom_port'] = 'integer|min:1|max:65535'; + } + if (array_key_exists('group_name', $payload)) { + $rules['group_name'] = 'nullable|string|max:255'; + } + if (array_key_exists('client_id', $payload)) { + $rules['client_id'] = 'nullable|string|max:255'; + } + if (array_key_exists('client_secret', $payload)) { + $rules['client_secret'] = 'nullable|string'; + } + if (array_key_exists('webhook_token', $payload)) { + $rules['webhook_token'] = 'nullable|string'; + } + if (array_key_exists('redirect_uri', $payload)) { + // Callback to this Coolify instance — may be a private/LAN URL. + $rules['redirect_uri'] = 'nullable|url'; + } + if (! isCloud() && isset($payload['is_system_wide'])) { + $rules['is_system_wide'] = 'boolean'; + } + + $validator = customApiValidator($payload, $rules); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation error', + 'errors' => $validator->errors(), + ], 422); + } + + if (isset($payload['html_url'])) { + $payload['html_url'] = rtrim((string) $payload['html_url'], '/'); + if (! filled($payload['api_url'] ?? null)) { + $payload['api_url'] = $this->gitlabApiUrlFromHtmlUrl($payload['html_url']); + } + } + if (isset($payload['api_url'])) { + $payload['api_url'] = rtrim((string) $payload['api_url'], '/'); + } + + $gitlabApp->update($payload); + + auditLog('api.gitlab_app.updated', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $gitlabApp->uuid, + 'gitlab_app_name' => $gitlabApp->name, + 'changed_fields' => array_values(array_diff(array_keys($payload), ['client_secret', 'webhook_token'])), + ]); + + return response()->json([ + 'message' => 'GitLab app updated successfully', + 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + ]); + } catch (ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitLab app not found', + ], 404); + } + } + + #[OA\Delete( + path: '/gitlab-apps/{gitlab_app_id}', + operationId: 'deleteGitlabApp', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitLab Apps'], + summary: 'Delete GitLab App', + description: 'Delete a GitLab app if it is not being used by any applications.', + parameters: [ + new OA\Parameter( + name: 'gitlab_app_id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer'), + description: 'GitLab App ID' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'GitLab app deleted successfully', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'GitLab app deleted successfully'], + ] + ) + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'GitLab app not found'), + new OA\Response( + response: 409, + description: 'Conflict - GitLab app is in use', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'This GitLab app is being used by 5 application(s). Please delete all applications first.'], + ] + ) + ) + ), + ] + )] + public function delete_gitlab_app($gitlab_app_id) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + try { + $gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId); + $this->authorize('delete', $gitlabApp); + + if ($gitlabApp->applications->isNotEmpty()) { + $count = $gitlabApp->applications->count(); + + return response()->json([ + 'message' => "This GitLab app is being used by {$count} application(s). Please delete all applications first.", + ], 409); + } + + $deletedUuid = $gitlabApp->uuid; + $deletedName = $gitlabApp->name; + $gitlabApp->delete(); + + auditLog('api.gitlab_app.deleted', [ + 'team_id' => $teamId, + 'gitlab_app_uuid' => $deletedUuid, + 'gitlab_app_name' => $deletedName, + ]); + + return response()->json([ + 'message' => 'GitLab app deleted successfully', + ]); + } catch (ModelNotFoundException $e) { + return response()->json([ + 'message' => 'GitLab app not found', + ], 404); + } + } +} diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index cd68c1b67..e521093d7 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -6,10 +6,14 @@ use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; +use App\Livewire\Source\Gitlab\Change as GitlabSource; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\GitlabApp; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class Gitlab extends Controller @@ -17,6 +21,307 @@ class Gitlab extends Controller use DetectsSkipDeployCommits; use MatchesManualWebhookApplications; + public function redirect(Request $request) + { + try { + $code = $request->query('code'); + $state = $request->query('state'); + + if (! $code || ! $state) { + return redirect()->route('source.all')->with('error', 'Invalid GitLab OAuth callback. Missing code or state.'); + } + + // Validate the one-time, team-bound state (not a guessable source UUID) to stop forged callbacks from overwriting a source's tokens. + $payload = Cache::pull(GitlabSource::oauthStateCacheKey($state)); + $team_id = $request->user()?->currentTeam()?->id; + if (! is_array($payload) || is_null($team_id) || (int) data_get($payload, 'team_id') !== (int) $team_id) { + return redirect()->route('source.all')->with('error', 'Invalid or expired GitLab OAuth state. Please start the authorization again.'); + } + + $gitlabApp = GitlabApp::whereKey(data_get($payload, 'gitlab_app_id'))->firstOrFail(); + + // Only users who may administer the source can complete OAuth and store tokens. + if (! $request->user()->can('update', $gitlabApp)) { + return redirect()->route('source.all')->with('error', 'You are not authorized to connect this GitLab App.'); + } + + $baseUrl = rtrim($gitlabApp->html_url, '/'); + + $response = Http::asForm()->post("{$baseUrl}/oauth/token", [ + 'client_id' => $gitlabApp->client_id, + 'client_secret' => $gitlabApp->client_secret, + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $gitlabApp->redirect_uri, + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', 'Token exchange failed'); + + return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]) + ->with('error', "GitLab OAuth failed: {$error}"); + } + + $data = $response->json(); + $gitlabApp->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'expires_at' => time() + ($data['expires_in'] ?? 7200), + ]); + + return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]); + } catch (Exception $e) { + return redirect()->route('source.all')->with('error', $e->getMessage()); + } + } + + public function normal(Request $request) + { + try { + $return_payloads = collect([]); + $payload = $request->collect(); + $x_gitlab_token = $request->header('X-Gitlab-Token'); + $object_kind = data_get($payload, 'object_kind'); + $project_id = data_get($payload, 'project.id'); + + $allowed_events = ['push', 'merge_request']; + if (! in_array($object_kind, $allowed_events)) { + return response([ + 'status' => 'failed', + 'message' => 'Event not allowed. Only push and merge_request events are allowed.', + ]); + } + + if (empty($x_gitlab_token)) { + auditLogWebhookFailure('gitlab', 'webhook_token_missing', [ + 'event' => $object_kind, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Missing X-Gitlab-Token header.', + ], 401); + } + + $gitlab_app = GitlabApp::findByWebhookToken($x_gitlab_token); + if (! $gitlab_app) { + auditLogWebhookFailure('gitlab', 'invalid_token', [ + 'event' => $object_kind, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Invalid webhook token.', + ], 401); + } + + $applications = Application::where('source_id', $gitlab_app->id) + ->where('source_type', GitlabApp::class) + ->where('repository_project_id', $project_id); + + if ($object_kind === 'push') { + $branch = data_get($payload, 'ref'); + if (Str::isMatch('/refs\/heads\/*/', $branch)) { + $branch = Str::after($branch, 'refs/heads/'); + } + if (! $branch) { + return response([ + 'status' => 'failed', + 'message' => 'No branch found in the request.', + ]); + } + + $applications = $applications->where('git_branch', $branch)->get(); + $added_files = data_get($payload, 'commits.*.added'); + $removed_files = data_get($payload, 'commits.*.removed'); + $modified_files = data_get($payload, 'commits.*.modified'); + $changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten(); + $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', [])); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (! $application->isDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Deployments disabled', + ]); + + continue; + } + + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if (! $is_watch_path_triggered && ! blank($application->watch_paths)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths.', + ]); + + continue; + } + + if ($skip_deploy_commits) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'after', 'HEAD'), + force_rebuild: false, + is_webhook: true, + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitlab', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid->toString(), + 'commit' => data_get($payload, 'after'), + ]); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Deployment queued.', + ]); + } + } + + if ($object_kind === 'merge_request') { + $action = data_get($payload, 'object_attributes.action'); + $branch = data_get($payload, 'object_attributes.source_branch'); + $base_branch = data_get($payload, 'object_attributes.target_branch'); + $pull_request_id = data_get($payload, 'object_attributes.iid'); + $pull_request_html_url = data_get($payload, 'object_attributes.url'); + $pull_request_title = data_get($payload, 'object_attributes.title'); + $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message'); + $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]); + + $applications = $applications->where('git_branch', $base_branch)->get(); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) { + if (! $application->isPRDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Preview deployments disabled', + ]); + + continue; + } + + if ($skip_deploy_pr) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title or latest commit contains [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitlab', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitlab', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'), + force_rebuild: false, + is_webhook: true, + git_type: 'gitlab', + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Preview Deployment queued', + ]); + } elseif (in_array($action, ['closed', 'close', 'merge'])) { + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if ($found) { + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', + ]); + } + } + } + } + + return response($return_payloads); + } catch (Exception $e) { + return handleError($e); + } + } + public function manual(Request $request) { try { @@ -291,8 +596,6 @@ class Gitlab extends Controller } elseif ($action === 'closed' || $action === 'close' || $action === 'merge') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Use comprehensive cleanup that cancels active deployments, - // kills helper containers, and removes all PR containers CleanupPreviewDeployment::run($application, $pull_request_id, $found); $return_payloads->push([ diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index fe6a6397a..29f798d59 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -96,9 +96,19 @@ class Source extends Component private function getSources() { - // filter the current source out - $this->sources = currentTeam()->sources()->whereNotNull('app_id')->reject(function ($source) { - return $source->id === $this->application->source_id; + $this->sources = currentTeam()->sources()->filter(function ($source) { + if ($source->id === $this->application->source_id + && $source->getMorphClass() === $this->application->source_type) { + return false; + } + if ($source instanceof GithubApp) { + return ! is_null($source->app_id); + } + if ($source instanceof GitlabApp) { + return $source->isConnected(); + } + + return true; })->sortBy('name'); } @@ -137,7 +147,6 @@ class Source extends Component public function changeSource($sourceId, $sourceType) { - try { $this->authorize('update', $this->application); $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; @@ -150,16 +159,24 @@ class Source extends Component $this->dispatch('configurationChanged'); ['repository' => $customRepository] = $this->application->customRepository(); - $repository = githubApi($this->application->source, "repos/{$customRepository}"); - $data = data_get($repository, 'data'); - $repository_project_id = data_get($data, 'id'); - if (isset($repository_project_id)) { - if ($this->application->repository_project_id !== $repository_project_id) { - $this->application->repository_project_id = $repository_project_id; - $this->application->save(); + $repository_project_id = null; + + if ($sourceType === GithubApp::class) { + $repository = githubApi($source, "repos/{$customRepository}"); + $repository_project_id = data_get($repository, 'data.id'); + } elseif ($sourceType === GitlabApp::class) { + if ($source->isConnected()) { + $encoded = urlencode($customRepository); + $project = gitlabApi($source, "/projects/{$encoded}"); + $repository_project_id = data_get($project, 'data.id'); } } + if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) { + $this->application->repository_project_id = $repository_project_id; + $this->application->save(); + } + $this->application->refresh(); $this->getSources(); $this->dispatch('success', 'Source updated!'); diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php new file mode 100644 index 000000000..a0ff15304 --- /dev/null +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -0,0 +1,242 @@ +where('id', $this->gitlab_app_id)->firstOrFail(); + } + + public function mount() + { + $this->currentRoute = Route::currentRouteName(); + $this->parameters = get_route_parameters(); + $this->query = request()->query(); + $this->repositories = $this->branches = collect(); + $this->gitlab_apps = GitlabApp::private()->select(['id', 'name', 'html_url', 'team_id', 'is_system_wide', 'is_public'])->get(); + } + + public function updatedSelectedProjectId(): void + { + $this->loadBranches(); + } + + public function updatedBuildPack() + { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { + $this->show_is_static = true; + if (! $this->is_static) { + $this->port = 3000; + } + } elseif ($this->build_pack === 'static') { + $this->show_is_static = false; + $this->is_static = false; + $this->port = 80; + } else { + $this->show_is_static = false; + $this->is_static = false; + } + } + + public function loadRepositories($gitlab_app_id) + { + $this->repositories = collect(); + $this->branches = collect(); + $this->total_branches_count = 0; + $this->page = 1; + $this->selected_gitlab_app_id = $gitlab_app_id; + $this->gitlab_app_id = $gitlab_app_id; + $gitlab_app = $this->getGitlabApp(); + + try { + $result = loadGitlabRepositories($gitlab_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + + while ($result['has_more'] && $this->page < 50) { + $this->page++; + $result = loadGitlabRepositories($gitlab_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + } + $this->total_repositories_count = $this->repositories->count(); + + $this->repositories = $this->repositories->sortBy('name'); + if ($this->repositories->count() > 0) { + $first = $this->repositories->first(); + $this->selected_project_id = data_get($first, 'id'); + $this->selected_repository_path = data_get($first, 'path_with_namespace'); + } + $this->current_step = 'repository'; + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function loadBranches() + { + $repo = $this->repositories->where('id', $this->selected_project_id)->first(); + $this->selected_repository_path = data_get($repo, 'path_with_namespace', $this->selected_repository_path); + $this->branches = collect(); + $this->page = 1; + $gitlab_app = $this->getGitlabApp(); + + try { + $branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + + while ($this->total_branches_count === 100) { + $this->page++; + $branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + } + + $this->branches = sortBranchesByPriority($this->branches); + $defaultBranch = data_get($repo, 'default_branch', 'main'); + $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function submit() + { + try { + $this->authorize('create', Application::class); + + $validator = validator([ + 'selected_repository_path' => $this->selected_repository_path, + 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, + ], [ + 'selected_repository_path' => 'required|string', + 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ValidationPatterns::filePathRules(), + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); + } + + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + throw new \Exception('Destination not found.'); + } + $destination_class = $destination->getMorphClass(); + + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + + $gitlab_app = $this->getGitlabApp(); + + $application = Application::create([ + 'name' => generate_application_name($this->selected_repository_path, $this->selected_branch_name), + 'repository_project_id' => $this->selected_project_id, + 'git_repository' => $this->selected_repository_path, + 'git_branch' => str($this->selected_branch_name)->trim()->toString(), + 'build_pack' => $this->build_pack, + 'ports_exposes' => $this->port, + 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination_class, + 'source_id' => $gitlab_app->id, + 'source_type' => $gitlab_app->getMorphClass(), + ]); + $application->settings->is_static = $this->is_static; + $application->settings->save(); + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application->health_check_enabled = false; + } + if ($this->build_pack === 'dockercompose') { + $application['docker_compose_location'] = $this->docker_compose_location; + } + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); + $application->fqdn = $fqdn; + $application->name = generate_application_name($this->selected_repository_path, $this->selected_branch_name, $application->uuid); + $application->save(); + + return redirect()->route('project.application.configuration', [ + 'application_uuid' => $application->uuid, + 'environment_uuid' => $environment->uuid, + 'project_uuid' => $project->uuid, + ]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + if ($this->is_static) { + $this->port = 80; + $this->publish_directory = '/dist'; + } else { + $this->port = 3000; + $this->publish_directory = null; + } + $this->dispatch('success', 'Application settings updated!'); + } +} diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 08047fc79..9bc2667ac 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -170,6 +170,12 @@ class Select extends Component 'description' => 'You can deploy public & private repositories through your GitHub Apps.', 'logo' => asset('svgs/github.svg'), ], + [ + 'id' => 'private-gitlab-app', + 'name' => 'Private Repository (with GitLab App)', + 'description' => 'You can deploy public & private repositories through your GitLab Apps.', + 'logo' => asset('svgs/gitlab.svg'), + ], [ 'id' => 'private-deploy-key', 'name' => 'Private Repository (with Deploy Key)', diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index a24ed9ce3..1876b8c49 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -8,6 +8,7 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Livewire\Component; @@ -79,6 +80,8 @@ class Change extends Component public string $activeTab = 'general'; + public bool $isConnected = false; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; protected function rules(): array @@ -230,6 +233,7 @@ class Change extends Component GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->syncData(false); + $this->isConnected = $this->github_app->isConnected(); $this->name = str($this->github_app->name)->kebab(); $this->dispatch('success', 'Github App permissions updated.'); @@ -247,6 +251,55 @@ class Change extends Component } } + public function testConnection() + { + try { + $this->authorize('view', $this->github_app); + + if (! $this->github_app->isConnected()) { + $this->dispatch('error', 'GitHub App is not fully set up. Please complete installation first.'); + + return; + } + + if (! $this->github_app->private_key_id || ! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + + $jwt = generateGithubJwt($this->github_app); + $appResponse = Http::withHeaders([ + 'Authorization' => "Bearer $jwt", + 'Accept' => 'application/vnd.github+json', + ])->timeout(10)->get("{$this->github_app->api_url}/app"); + + if (! $appResponse->successful()) { + $error = data_get($appResponse->json(), 'message', 'Unknown error'); + $this->dispatch('error', "Connection failed: {$error}"); + + return; + } + + // Confirm installation credentials can mint an installation access token. + generateGithubInstallationToken($this->github_app); + + $appName = data_get($appResponse->json(), 'name') + ?? data_get($appResponse->json(), 'slug', 'unknown'); + $this->dispatch('success', "Connection successful! Authenticated as GitHub App: {$appName}"); + } catch (\Throwable $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'DECODER routines::unsupported') || + str_contains($errorMessage, 'parse your key')) { + $this->dispatch('error', 'The selected private key format is not supported for GitHub Apps.

Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY).

OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.'); + + return; + } + + return handleError($e, $this); + } + } + public function mount() { try { @@ -260,6 +313,7 @@ class Change extends Component // Sync data from model to properties $this->syncData(false); + $this->isConnected = $this->github_app->isConnected(); // Override name with kebab case for display $this->name = str($this->github_app->name)->kebab(); @@ -373,6 +427,7 @@ class Change extends Component $this->syncData(true); $this->github_app->save(); + $this->isConnected = $this->github_app->isConnected(); $this->dispatch('success', 'Github App updated.'); } catch (ValidationException $e) { throw $e; @@ -404,6 +459,7 @@ class Change extends Component $this->syncData(true); $this->github_app->save(); + $this->isConnected = $this->github_app->isConnected(); $this->dispatch('success', 'Github App updated.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php new file mode 100644 index 000000000..f22f1845d --- /dev/null +++ b/app/Livewire/Source/Gitlab/Change.php @@ -0,0 +1,359 @@ + 'required|string', + 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'clientId' => 'nullable|string', + 'clientSecretInput' => 'nullable|string', + 'webhookToken' => 'nullable|string', + 'groupName' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'privateKeyId' => 'nullable|int', + 'webhook_endpoint' => ['required', 'string', 'url'], + 'custom_webhook_endpoint' => ['nullable', 'string', 'url'], + 'use_custom_webhook_endpoint' => ['required', 'bool'], + ]; + } + + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl) + || $this->apiUrl === rtrim($this->htmlUrl, '/').'/api/v4'; + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->apiUrl = rtrim($this->htmlUrl, '/').'/api/v4'; + } + } + + public function updatedWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + public function updatedUseCustomWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + public function updatedCustomWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + private function persistRedirectUriFromEndpoint(): void + { + $this->refreshRedirectUri(); + + if (! $this->gitlab_app || blank($this->redirectUri)) { + return; + } + + try { + $this->authorize('update', $this->gitlab_app); + if ($this->gitlab_app->redirect_uri !== $this->redirectUri) { + $this->gitlab_app->redirect_uri = $this->redirectUri; + $this->gitlab_app->save(); + } + } catch (\Throwable) { + // Keep the live redirect URI even if the user cannot persist yet. + } + } + + public function mount() + { + try { + $gitlab_app_uuid = request()->gitlab_app_uuid; + $this->gitlab_app = GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->whereUuid($gitlab_app_uuid)->firstOrFail(); + + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + $this->applications = $this->gitlab_app->applications; + + $settings = instanceSettings(); + + $this->syncData(false); + + $this->isConnected = $this->gitlab_app->isConnected(); + $this->fqdn = $settings->fqdn; + + if ($settings->public_ipv4) { + $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); + } + if ($settings->public_ipv6) { + $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + } + + $this->parameters = get_route_parameters(); + + if (isCloud() && ! isDev()) { + $this->webhook_endpoint = config('app.url'); + } else { + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; + } + + // Prefer a previously saved redirect base when it matches one of the selectable endpoints + // or when it differs (restore custom mode for self-hosted / tunnel setups). + $savedRedirect = $this->gitlab_app->redirect_uri; + if (filled($savedRedirect)) { + $savedBase = rtrim(str($savedRedirect)->before('/webhooks/source/gitlab/redirect')->toString(), '/'); + $known = collect([$this->fqdn, $this->ipv4, $this->ipv6, config('app.url')]) + ->filter() + ->map(fn ($url) => rtrim((string) $url, '/')); + + if ($known->contains($savedBase)) { + $this->webhook_endpoint = $savedBase; + $this->use_custom_webhook_endpoint = false; + } elseif (! (isCloud() && ! isDev()) && filled($savedBase)) { + $this->use_custom_webhook_endpoint = true; + $this->custom_webhook_endpoint = $savedBase; + } + } + + $this->refreshRedirectUri(); + + $this->oauthState = $this->createOAuthState(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function refreshRedirectUri(): void + { + $base = $this->resolvePublicBaseUrl(); + $this->redirectUri = $base === '' + ? '' + : $base.'/webhooks/source/gitlab/redirect'; + } + + public function resolvePublicBaseUrl(): string + { + if ($this->use_custom_webhook_endpoint && filled($this->custom_webhook_endpoint)) { + return rtrim($this->custom_webhook_endpoint, '/'); + } + + return rtrim($this->webhook_endpoint ?: (config('app.url') ?? ''), '/'); + } + + public static function oauthStateCacheKey(string $state): string + { + return 'gitlab-app-oauth-state:'.hash('sha256', $state); + } + + private function createOAuthState(): string + { + $state = Str::random(64); + + Cache::put(self::oauthStateCacheKey($state), [ + 'gitlab_app_id' => $this->gitlab_app->id, + 'team_id' => currentTeam()->id, + ], now()->addMinutes(60)); + + return $state; + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->gitlab_app->name = $this->name; + $this->gitlab_app->api_url = $this->apiUrl; + $this->gitlab_app->html_url = rtrim($this->htmlUrl, '/'); + $this->gitlab_app->custom_user = $this->customUser; + $this->gitlab_app->custom_port = $this->customPort; + $this->gitlab_app->client_id = $this->clientId; + if (! empty($this->clientSecretInput)) { + $this->gitlab_app->client_secret = $this->clientSecretInput; + } + if (! empty($this->webhookToken)) { + $this->gitlab_app->webhook_token = $this->webhookToken; + } + $this->gitlab_app->group_name = $this->groupName; + $this->gitlab_app->is_system_wide = $this->isSystemWide; + $this->gitlab_app->private_key_id = $this->privateKeyId; + $this->refreshRedirectUri(); + $this->gitlab_app->redirect_uri = $this->redirectUri; + } else { + $this->name = $this->gitlab_app->name; + $this->apiUrl = $this->gitlab_app->api_url; + $this->htmlUrl = $this->gitlab_app->html_url; + $this->customUser = $this->gitlab_app->custom_user; + $this->customPort = $this->gitlab_app->custom_port; + $this->clientId = $this->gitlab_app->client_id; + if (Gate::allows('update', $this->gitlab_app)) { + $this->clientSecretInput = $this->gitlab_app->client_secret; + $this->webhookToken = $this->gitlab_app->webhook_token; + } + $this->groupName = $this->gitlab_app->group_name; + $this->isSystemWide = $this->gitlab_app->is_system_wide; + $this->privateKeyId = $this->gitlab_app->private_key_id; + } + } + + public function submit() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->validate(); + + $this->syncData(true); + $this->gitlab_app->save(); + $this->dispatch('success', 'GitLab App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->validateOnly('isSystemWide'); + + $this->gitlab_app->is_system_wide = $this->isSystemWide; + $this->gitlab_app->save(); + $this->dispatch('success', 'GitLab App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function testConnection() + { + try { + $this->authorize('view', $this->gitlab_app); + + if (! $this->gitlab_app->isConnected()) { + $this->dispatch('error', 'GitLab App is not connected. Please complete the OAuth flow first.'); + + return; + } + + refreshGitlabToken($this->gitlab_app); + + $apiUrl = $this->gitlab_app->apiUrlBase(); + $response = Http::GitLab($apiUrl, $this->gitlab_app->access_token) + ->timeout(10) + ->get('/user'); + + if ($response->successful()) { + $username = data_get($response->json(), 'username', 'unknown'); + $this->dispatch('success', "Connection successful! Authenticated as: {$username}"); + } else { + $error = data_get($response->json(), 'message', 'Unknown error'); + $this->dispatch('error', "Connection failed: {$error}"); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function delete() + { + try { + $this->authorize('delete', $this->gitlab_app); + + if ($this->gitlab_app->applications->isNotEmpty()) { + $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); + + return; + } + $this->gitlab_app->delete(); + + return redirect()->route('source.all'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getOAuthUrl(): string + { + $this->refreshRedirectUri(); + $baseUrl = rtrim($this->htmlUrl, '/'); + + $query = http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'scope' => 'api read_user read_repository', + 'state' => $this->oauthState ??= $this->createOAuthState(), + ]); + + return "{$baseUrl}/oauth/authorize?{$query}"; + } +} diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php new file mode 100644 index 000000000..200bd6d40 --- /dev/null +++ b/app/Livewire/Source/Gitlab/Create.php @@ -0,0 +1,98 @@ +name = substr(generate_random_name(), 0, 30); + } + + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url) + || $this->api_url === $this->gitlabApiUrlFromHtmlUrl($this->html_url); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->api_url = $this->gitlabApiUrlFromHtmlUrl($this->html_url); + } + } + + public function createGitLabApp() + { + try { + $this->authorize('createAnyResource'); + + $this->html_url = rtrim($this->html_url, '/'); + $this->api_url = filled($this->api_url) + ? rtrim($this->api_url, '/') + : $this->gitlabApiUrlFromHtmlUrl($this->html_url); + + $this->validate([ + 'name' => 'required|string', + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'api_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'custom_user' => 'required|string', + 'custom_port' => 'required|int', + 'is_system_wide' => 'required|bool', + 'group_name' => 'nullable|string', + ]); + + $gitlab_app = GitlabApp::create([ + 'name' => $this->name, + 'api_url' => $this->api_url, + 'html_url' => $this->html_url, + 'custom_user' => $this->custom_user, + 'custom_port' => $this->custom_port, + 'is_system_wide' => $this->is_system_wide, + 'group_name' => $this->group_name, + 'webhook_token' => Str::random(32), + 'team_id' => currentTeam()->id, + ]); + + if (session('from')) { + session(['from' => session('from') + ['source_id' => $gitlab_app->id]]); + } + + return redirectRoute($this, 'source.gitlab.show', ['gitlab_app_uuid' => $gitlab_app->uuid]); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string + { + return rtrim($htmlUrl, '/').'/api/v4'; + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 732142b0d..eff03b175 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1378,11 +1378,11 @@ class Application extends BaseModel if ($this->deploymentType() === 'source') { $source_html_url = data_get($this, 'source.html_url'); - $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)); - $source_html_url_host = $url['host']; - $source_html_url_scheme = $url['scheme']; if ($this->source->getMorphClass() == 'App\Models\GithubApp') { + $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: []; + $source_html_url_host = $url['host'] ?? ''; + $source_html_url_scheme = $url['scheme'] ?? ''; $escapedCustomRepository = escapeshellarg($customRepository); if ($this->source->is_public) { $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); @@ -1420,6 +1420,32 @@ class Application extends BaseModel if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; + + if ($gitlabSource->isConnected()) { + $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: []; + $source_html_url_host = $this->urlHostWithPort($url); + $source_html_url_scheme = $url['scheme'] ?? ''; + $token = generateGitlabCloneToken($gitlabSource); + $encodedToken = rawurlencode($token); + $pathPrefix = rtrim($url['path'] ?? '', '/'); + $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $fullRepoUrl = $repoUrl; + $base_command = "{$base_command} {$escapedRepoUrl}"; + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $base_command)); + } else { + $commands->push($base_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + $private_key = data_get($gitlabSource, 'privateKey.private_key'); if ($private_key) { @@ -1444,7 +1470,6 @@ class Application extends BaseModel ]; } - // GitLab source without private key — use URL as-is (supports user-embedded basic auth) $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $base_command = "{$base_command} {$escapedCustomRepository}"; @@ -1677,6 +1702,52 @@ class Application extends BaseModel if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; + + if ($gitlabSource->isConnected()) { + $token = generateGitlabCloneToken($gitlabSource); + $encodedToken = rawurlencode($token); + $pathPrefix = rtrim($url['path'] ?? '', '/'); + $source_html_url_host = $this->urlHostWithPort($url ?: []); + + // Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials. + $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/"); + $gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption); + + $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $fullRepoUrl = $repoUrl; + $git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions); + if ($only_checkout) { + $git_clone_command = $git_clone_command_base; + } else { + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions); + } + + if ($pull_request_id !== 0) { + $branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}"; + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'")); + } else { + $commands->push("echo 'Checking out {$branch}'"); + } + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions); + $escapedPrBranch = escapeshellarg($branch); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}"; + } + + if ($exec_in_docker) { + $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); + } else { + $commands->push($git_clone_command); + } + + return [ + 'commands' => $commands->implode(' && '), + 'branch' => $branch, + 'fullRepoUrl' => $fullRepoUrl, + ]; + } + $private_key = data_get($gitlabSource, 'privateKey.private_key'); if ($private_key) { @@ -1717,7 +1788,6 @@ class Application extends BaseModel ]; } - // GitLab source without private key — use URL as-is (supports user-embedded basic auth) $fullRepoUrl = $customRepository; $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; @@ -2382,6 +2452,14 @@ class Application extends BaseModel ]; } + private function urlHostWithPort(array $url): string + { + $host = $url['host'] ?? ''; + $port = isset($url['port']) ? ":{$url['port']}" : ''; + + return "{$host}{$port}"; + } + public function generateConfig($is_json = false) { $generator = new ConfigurationGenerator($this); diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index e5032d2d0..7c2f8c062 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -98,4 +98,17 @@ class GithubApp extends BaseModel }, ); } + + /** + * A private GitHub App is connected once it has been registered and installed. + * Public sources do not require installation credentials. + */ + public function isConnected(): bool + { + if ($this->is_public) { + return true; + } + + return filled($this->app_id) && filled($this->installation_id); + } } diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 06df8fd8d..09a48e8b9 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,6 +2,10 @@ namespace App\Models; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Support\Facades\Crypt; + class GitlabApp extends BaseModel { protected $fillable = [ @@ -16,20 +20,109 @@ class GitlabApp extends BaseModel 'app_id', 'app_secret', 'oauth_id', + 'client_id', + 'client_secret', + 'access_token', + 'refresh_token', + 'expires_at', + 'redirect_uri', 'group_name', 'public_key', 'webhook_token', 'deploy_key_id', + 'private_key_id', + 'team_id', ]; protected $hidden = [ 'webhook_token', 'app_secret', + 'client_secret', + 'access_token', + 'refresh_token', ]; + protected function casts(): array + { + return [ + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'client_secret' => 'encrypted', + 'is_system_wide' => 'boolean', + 'is_public' => 'boolean', + ]; + } + + /** + * Encrypt webhook tokens at rest. Supports legacy plaintext values until they are re-saved. + * Not a standard encrypted cast: webhooks look up by token value (see findByWebhookToken). + */ + protected function webhookToken(): Attribute + { + return Attribute::make( + get: function (?string $value): ?string { + if ($value === null || $value === '') { + return $value; + } + + try { + return Crypt::decryptString($value); + } catch (DecryptException) { + // Legacy rows stored the token in plaintext. + return $value; + } + }, + set: function (?string $value): ?string { + if ($value === null || $value === '') { + return $value; + } + + return Crypt::encryptString($value); + }, + ); + } + + public static function findByWebhookToken(string $token): ?self + { + if ($token === '') { + return null; + } + + // Encrypted values cannot be matched with a SQL equality; sources are few per instance. + return static::query()->get()->first( + fn (self $app): bool => filled($app->webhook_token) && hash_equals((string) $app->webhook_token, $token) + ); + } + + protected static function booted(): void + { + static::deleting(function (GitlabApp $gitlabApp) { + if ($gitlabApp->applications()->count() > 0) { + throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); + } + }); + } + public static function ownedByCurrentTeam() { - return GitlabApp::whereTeamId(currentTeam()->id); + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id) + ->orWhere('is_system_wide', true); + }); + } + + public static function public() + { + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->where('is_public', true); + } + + public static function private() + { + return GitlabApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->where('is_public', false)->whereNotNull('access_token'); } public function applications() @@ -41,4 +134,25 @@ class GitlabApp extends BaseModel { return $this->belongsTo(PrivateKey::class); } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function isConnected(): bool + { + return ! empty($this->access_token) && ! empty($this->refresh_token); + } + + public function apiUrlBase(): string + { + $apiUrl = rtrim($this->api_url, '/'); + + if (! str_contains($apiUrl, '/api/v4')) { + $apiUrl .= '/api/v4'; + } + + return $apiUrl; + } } diff --git a/app/Policies/GitlabAppPolicy.php b/app/Policies/GitlabAppPolicy.php new file mode 100644 index 000000000..56861e89b --- /dev/null +++ b/app/Policies/GitlabAppPolicy.php @@ -0,0 +1,77 @@ +is_system_wide) { + return true; + } + + return $user->teams->contains('id', $gitlabApp->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, GitlabApp $gitlabApp): bool + { + if ($gitlabApp->is_system_wide) { + return $user->canAccessSystemResources(); + } + + return $user->isAdminOfTeam($gitlabApp->team_id); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, GitlabApp $gitlabApp): bool + { + if ($gitlabApp->is_system_wide) { + return $user->canAccessSystemResources(); + } + + return $user->isAdminOfTeam($gitlabApp->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, GitlabApp $gitlabApp): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, GitlabApp $gitlabApp): bool + { + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 580aaa078..c76d808a3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -102,5 +102,16 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); + + Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { + $client = Http::withHeaders([ + 'Accept' => 'application/json', + ])->baseUrl($api_url); + if ($access_token) { + $client = $client->withToken($access_token); + } + + return $client; + }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 8903e750b..7bc8f162d 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,6 +13,7 @@ use App\Models\EmailNotificationSettings; use App\Models\Environment; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Project; @@ -51,6 +52,7 @@ use App\Policies\DatabasePolicy; use App\Policies\EnvironmentPolicy; use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; +use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; @@ -127,6 +129,9 @@ class AuthServiceProvider extends ServiceProvider // Git source policies GithubApp::class => GithubAppPolicy::class, + GitlabApp::class => GitlabAppPolicy::class, + + // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 465500b06..66fe0c233 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -1,7 +1,6 @@ refresh_token) { + throw new RuntimeException('GitLab source has no refresh token. Please reconnect the GitLab app.'); + } + + $safetyMargin = 60; + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $lock = Cache::lock("gitlab_token_refresh_{$source->id}", 20); + if (! $lock->block(20)) { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + throw new RuntimeException('GitLab token refresh timed out. Please try again.'); + } + + try { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $baseUrl = rtrim($source->html_url, '/'); + + $response = Http::asForm()->post("{$baseUrl}/oauth/token", [ + 'client_id' => $source->client_id, + 'client_secret' => $source->client_secret, + 'refresh_token' => $source->refresh_token, + 'grant_type' => 'refresh_token', + 'redirect_uri' => $source->redirect_uri, + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', $response->body()); + throw new RuntimeException("Failed to refresh GitLab token: {$error}"); + } + + $data = $response->json(); + $source->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'expires_at' => time() + ($data['expires_in'] ?? 7200), + ]); + } finally { + $lock->release(); + } +} + +function gitlabApi(GitlabApp $source, string $endpoint, string $method = 'get', ?array $data = null): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + + $client = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false); + + if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { + $response = $client->$method($endpoint, $data); + } else { + $response = $client->$method($endpoint); + } + + if (! $response->successful()) { + $errorMessage = data_get($response->json(), 'message', $response->body()); + throw new RuntimeException("GitLab API call failed: {$errorMessage}"); + } + + return [ + 'data' => collect($response->json()), + 'total' => (int) $response->header('x-total', 0), + ]; +} + +function generateGitlabCloneToken(GitlabApp $source): string +{ + refreshGitlabToken($source); + + return $source->access_token; +} + +function loadGitlabRepositories(GitlabApp $source, int $page = 1): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + $response = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/projects', [ + 'membership' => 'true', + 'per_page' => 100, + 'page' => $page, + 'order_by' => 'name', + 'sort' => 'asc', + ]); + + if (! $response->successful()) { + return ['total_count' => 0, 'has_more' => false, 'repositories' => []]; + } + + $projects = collect($response->json()); + $rawCount = count($response->json()); + $hasMore = $rawCount === 100; + + $groupName = $source->group_name; + if (! empty($groupName)) { + $groups = collect(explode(',', $groupName))->map(fn ($g) => strtolower(trim($g)))->filter(); + $projects = $projects->filter(function ($project) use ($groups) { + $namespacePath = strtolower(data_get($project, 'namespace.full_path', '')); + + return $groups->contains(fn ($group) => $namespacePath === $group || str_starts_with($namespacePath, $group.'/')); + }); + } + + return [ + 'total_count' => $projects->count(), + 'has_more' => $hasMore, + 'repositories' => $projects->map(fn ($project) => [ + 'id' => data_get($project, 'id'), + 'name' => data_get($project, 'name'), + 'path_with_namespace' => data_get($project, 'path_with_namespace'), + 'default_branch' => data_get($project, 'default_branch', 'main'), + 'web_url' => data_get($project, 'web_url'), + 'namespace' => [ + 'full_path' => data_get($project, 'namespace.full_path'), + 'kind' => data_get($project, 'namespace.kind'), + ], + ])->values()->all(), + ]; +} + +function loadGitlabBranches(GitlabApp $source, int $projectId, int $page = 1): array +{ + refreshGitlabToken($source); + + $apiUrl = $source->apiUrlBase(); + $response = Http::GitLab($apiUrl, $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/projects/{$projectId}/repository/branches", [ + 'per_page' => 100, + 'page' => $page, + ]); + + if (! $response->successful()) { + return []; + } + + return $response->json(); +} diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 0f3a7c4dd..84522a5e1 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -301,6 +301,7 @@ function remove_iip($text) // Git access tokens $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); + $text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text); // ANSI color codes $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); diff --git a/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php b/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php new file mode 100644 index 000000000..ec2ae6982 --- /dev/null +++ b/database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php @@ -0,0 +1,34 @@ +longText('client_id')->nullable()->after('app_secret'); + $table->longText('client_secret')->nullable()->after('client_id'); + $table->longText('access_token')->nullable()->after('client_secret'); + $table->longText('refresh_token')->nullable()->after('access_token'); + $table->integer('expires_at')->nullable()->after('refresh_token'); + $table->string('redirect_uri')->nullable()->after('expires_at'); + }); + } + + public function down(): void + { + Schema::table('gitlab_apps', function (Blueprint $table) { + $table->dropColumn([ + 'client_id', + 'client_secret', + 'access_token', + 'refresh_token', + 'expires_at', + 'redirect_uri', + ]); + }); + } +}; diff --git a/database/schema/testing-schema.sql b/database/schema/testing-schema.sql index 4a73bf37d..b1d1f4a3a 100644 --- a/database/schema/testing-schema.sql +++ b/database/schema/testing-schema.sql @@ -370,6 +370,12 @@ CREATE TABLE IF NOT EXISTS "gitlab_apps" ( "is_public" INTEGER DEFAULT false NOT NULL, "app_id" INTEGER, "app_secret" TEXT, + "client_id" TEXT, + "client_secret" TEXT, + "access_token" TEXT, + "refresh_token" TEXT, + "expires_at" INTEGER, + "redirect_uri" TEXT, "oauth_id" INTEGER, "group_name" TEXT, "public_key" TEXT, @@ -1898,6 +1904,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1 INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314); +INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table', 315); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317); INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318); diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index 1e624738c..6e8d791de 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -11,7 +11,7 @@ Open Repository - @if (data_get($application, 'source.is_public') === false) + @if (data_get($application, 'source.is_public') === false && $application->source instanceof \App\Models\GithubApp) Open Git App @@ -67,7 +67,7 @@ @forelse ($sources as $source)
{{ $source->name }} - @if ($application->source_id === $source->id) + @if ($application->source_id === $source->id && $application->source_type === $source->getMorphClass()) (current) @endif
diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php new file mode 100644 index 000000000..e51e623fe --- /dev/null +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -0,0 +1,152 @@ +
+
+

Create a new Application

+ + + + @if ($repositories->count() > 0 && $gitlab_app_id) + + Refresh Repository List + + @endif +
+
Deploy any public or private Git repositories through a GitLab App.
+ @if ($gitlab_apps->count() !== 0) +
+ @if ($current_step === 'gitlab_apps') +

Select a GitLab App

+
+ @foreach ($gitlab_apps as $glapp) +
+
+
+
+
+ {{ data_get($glapp, 'name') }} +
+
+ {{ data_get($glapp, 'html_url') }}
+
+
+
+
+ +
+
+ @endforeach +
+ @endif + @if ($current_step === 'repository') + @if ($repositories->count() > 0) +
+
+ + @foreach ($repositories as $repo) + + @endforeach + +
+ + Load Repository + + +
+ @else +
No repositories found. Check your GitLab App configuration.
+ @endif + @if ($branches->count() > 0) +

Configuration

+
+
+
+
+ + + @foreach ($branches as $branch) + @if ($loop->first) + + @else + + @endif + @endforeach + + + + + + + + + @if ($is_static) + + @endif +
+ @if ($build_pack === 'dockercompose') +
+ + +
+ + Compose file location in your repository: +
+
+ @else + + @endif + @if ($show_is_static) + +
+ +
+ @endif +
+ + Continue + +
+
+ @endif + @endif +
+ @else +
+ @endif +
diff --git a/resources/views/livewire/project/resource/create.blade.php b/resources/views/livewire/project/resource/create.blade.php index f5861b35b..2821dbfc2 100644 --- a/resources/views/livewire/project/resource/create.blade.php +++ b/resources/views/livewire/project/resource/create.blade.php @@ -6,6 +6,8 @@ @elseif ($type === 'private-gh-app') + @elseif ($type === 'private-gitlab-app') + @elseif ($type === 'private-deploy-key') @elseif ($type === 'dockerfile') diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 051f9809f..3b0ac9388 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -2,11 +2,18 @@ @if (data_get($github_app, 'app_id'))
-

GitHub App

+
+

GitHub App

+ @if ($isConnected) + + @endif +
@if (data_get($github_app, 'installation_id')) Save + Test Connection @endif @can('delete', $github_app) @if ($applications->count() > 0) diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php new file mode 100644 index 000000000..b743fa0d7 --- /dev/null +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -0,0 +1,271 @@ +
+ @if ($isConnected) + +
+
+

GitLab App

+ +
+
+ Save + Test Connection + @can('delete', $gitlab_app) + + @endcan +
+
+
Your GitLab App for private repositories.
+
+ + @if (!isCloud()) +
+ +
+ @if ($isSystemWide) + + System-wide GitLab Apps are shared across all teams on this Coolify instance. This means any team + can use this GitLab App to deploy applications from your repositories. For better security and + isolation, it's recommended to create team-specific GitLab Apps instead. + + @endif + @endif + +

OAuth Credentials

+ + + + +
+
+ +
+
+
+ + +
+
+ + +
+
+ + + @foreach ($privateKeys as $key) + + @endforeach + +
+
+
+
+
+ +

Webhook

+
+
+ Configure this webhook URL in your GitLab project settings + (Settings > Webhooks): +
+ + +
+ + @if ($applications->count() > 0) +

Applications Using This Source

+ + @endif +
+ + @else +
+

GitLab App

+
+ @can('delete', $gitlab_app) + + @endcan +
+
+
Connect your GitLab instance to deploy private repositories.
+ +
+ + + + You must complete this step before you can use this source! +
+ +
+

Step 1: Create an OAuth Application on GitLab

+
+

Go to your GitLab instance and create a new OAuth Application:

+ + {{ rtrim($htmlUrl, '/') }}/-/profile/applications + + +
    +
  • Set Redirect URI to: + {{ $redirectUri }} +
  • +
  • Enable scopes: api, read_user, read_repository
  • +
  • Uncheck Confidential if you run into issues
  • +
+
+ +
+
+

Step 2: Enter the credentials

+ Save +
+ + + + + + @if (!isCloud() || isDev()) +
+
+ GitLab will redirect back to this Coolify URL. It must match the Callback URL on your GitLab OAuth Application exactly. +
+ +
+ + @if ($fqdn) + + @endif + @if ($ipv4) + + @endif + @if ($ipv6) + + @endif + @if (config('app.url')) + + @endif + +
+
+ +
+
+ @endif + +
+
+ +
+
+
+ + +
+
+ + +
+ @if (!isCloud()) +
+ +
+ @endif +
+
+
+
+ + + @if ($clientId) +

Step 3: Authorize with GitLab

+
Click the button below to authorize Coolify with your GitLab instance. The redirect URI must match the Callback URL configured in GitLab.
+ + + Connect to GitLab + + + @endif +
+ @endif +
diff --git a/resources/views/livewire/source/gitlab/create.blade.php b/resources/views/livewire/source/gitlab/create.blade.php new file mode 100644 index 000000000..4b053edb7 --- /dev/null +++ b/resources/views/livewire/source/gitlab/create.blade.php @@ -0,0 +1,70 @@ +@can('createAnyResource') +
+
This is required if you would like to get full integration (deployments from + private repositories, webhooks, etc) with GitLab.
+
+ + +
+ @if (!isCloud()) +
+
+ +
+
+ +
+ System-wide GitLab Apps are shared across all teams on this Coolify instance. This means any team + can use this GitLab App to deploy applications from your repositories. For better security and + isolation, it's recommended to create team-specific GitLab Apps instead. +
+
+
+
+ @endif +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+ + + Continue + +
+@else + + You don't have permission to create new GitLab Apps. Please contact your team administrator for access. + +@endcan diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index c6566d2f9..c74e46093 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -5,9 +5,12 @@

Sources

@can('createAnyResource') - + + + + @endcan
Git sources for your applications.
@@ -17,15 +20,31 @@ - {{-- --}}
-
{{ $source->name }}
- @if (is_null($source->app_id)) - Configuration is not finished. +
+ + {{ $source->name }} +
+ @if ($source->isConnected()) + Connected @else - @if ($source->organization) - Organization: {{ $source->organization }} - @endif + Setup required + @endif +
+
+ @elseif ($source->getMorphClass() === 'App\Models\GitlabApp') + +
+
+ + {{ $source->name }} +
+ @if ($source->isConnected()) + Connected + @else + Setup required @endif
diff --git a/routes/api.php b/routes/api.php index b4c174a51..b9d2f6c6f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,9 +7,9 @@ use App\Http\Controllers\Api\DeployController; use App\Http\Controllers\Api\DestinationsController; use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; +use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\Internal\FluxResourceStatusController; -use App\Support\V5\V5Feature; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController; @@ -25,6 +25,7 @@ use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\VolumeBackupsController; use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; +use App\Support\V5\V5Feature; use Illuminate\Support\Facades\Route; Route::get('/health', [OtherController::class, 'healthcheck']); @@ -185,6 +186,11 @@ Route::group([ Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); + Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']); + Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']); + Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']); + Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']); + Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']); Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']); diff --git a/routes/web.php b/routes/web.php index c26b3eb15..6a212725f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex; use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow; use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex; use App\Livewire\Source\Github\Change as GitHubChange; +use App\Livewire\Source\Gitlab\Change as GitLabChange; use App\Livewire\Storage\Index as StorageIndex; use App\Livewire\Storage\Show as StorageShow; use App\Livewire\Subscription\Index as SubscriptionIndex; @@ -353,6 +354,7 @@ Route::middleware(['auth'])->group(function () { Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show'); Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions'); Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources'); + Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show'); }); Route::middleware(['auth'])->group(function () { diff --git a/routes/webhooks.php b/routes/webhooks.php index 804fd7bcb..27796b807 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Route; Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () { Route::get('/source/github/redirect', [Github::class, 'redirect']); Route::get('/source/github/install', [Github::class, 'install']); + Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']); }); Route::post('/source/github/events', [Github::class, 'normal']); Route::post('/source/github/events/manual', [Github::class, 'manual']); +Route::post('/source/gitlab/events', [Gitlab::class, 'normal']); Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']); Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']); diff --git a/tests/Feature/Api/GitlabAppsApiTest.php b/tests/Feature/Api/GitlabAppsApiTest.php new file mode 100644 index 000000000..65332c0dd --- /dev/null +++ b/tests/Feature/Api/GitlabAppsApiTest.php @@ -0,0 +1,182 @@ +set('app.maintenance.driver', 'file'); + config()->set('cache.default', '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->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; +}); + +describe('GET /api/v1/gitlab-apps', function () { + test('returns 401 when not authenticated', function () { + $this->getJson('/api/v1/gitlab-apps')->assertStatus(401); + }); + + test('returns empty array when no gitlab apps exist', function () { + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/gitlab-apps') + ->assertSuccessful() + ->assertJson([]); + }); + + test('returns team gitlab apps without secrets for read tokens', function () { + GitlabApp::create([ + 'name' => 'Team GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'client_id' => 'client-id', + 'client_secret' => 'secret-should-be-hidden', + 'webhook_token' => 'webhook-should-be-hidden', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + ])->getJson('/api/v1/gitlab-apps'); + + $response->assertSuccessful() + ->assertJsonCount(1) + ->assertJsonFragment(['name' => 'Team GitLab']); + + expect($response->json('0'))->not->toHaveKey('client_secret') + ->and($response->json('0'))->not->toHaveKey('webhook_token'); + }); +}); + +describe('POST /api/v1/gitlab-apps', function () { + test('creates a gitlab app with derived api url and generated webhook token', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/gitlab-apps', [ + 'name' => 'Self-hosted GitLab', + 'html_url' => 'https://gitlab.com/', + 'group_name' => 'mygroup', + ]); + + $response->assertCreated() + ->assertJsonFragment([ + 'name' => 'Self-hosted GitLab', + 'html_url' => 'https://gitlab.com', + 'api_url' => 'https://gitlab.com/api/v4', + 'group_name' => 'mygroup', + 'custom_user' => 'git', + 'custom_port' => 22, + ]); + + $app = GitlabApp::where('name', 'Self-hosted GitLab')->first(); + expect($app)->not->toBeNull() + ->and($app->team_id)->toBe($this->team->id) + ->and($app->webhook_token)->not->toBeEmpty() + ->and(strlen((string) $app->webhook_token))->toBe(32); + }); + + test('creates a fully configured gitlab oauth source', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson('/api/v1/gitlab-apps', [ + 'name' => 'Configured GitLab', + 'html_url' => 'https://gitlab.com', + 'client_id' => 'oauth-app-id', + 'client_secret' => 'oauth-app-secret', + 'webhook_token' => 'custom-webhook-token', + 'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect', + ]); + + $response->assertCreated() + ->assertJsonFragment([ + 'name' => 'Configured GitLab', + 'client_id' => 'oauth-app-id', + 'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect', + ]); + + $app = GitlabApp::where('name', 'Configured GitLab')->first(); + $app->makeVisible(['client_secret', 'webhook_token']); + expect($app->client_secret)->toBe('oauth-app-secret') + ->and($app->webhook_token)->toBe('custom-webhook-token'); + }); + + test('rejects members without create permission', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-token', ['write'])->plainTextToken; + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + ])->postJson('/api/v1/gitlab-apps', [ + 'name' => 'Forbidden GitLab', + 'html_url' => 'https://gitlab.com', + ])->assertForbidden(); + }); +}); + +describe('PATCH /api/v1/gitlab-apps/{id}', function () { + test('updates gitlab app credentials', function () { + $app = GitlabApp::create([ + 'name' => 'Existing', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/gitlab-apps/{$app->id}", [ + 'client_id' => 'new-client-id', + 'group_name' => 'ops', + ])->assertSuccessful() + ->assertJsonPath('message', 'GitLab app updated successfully') + ->assertJsonPath('data.client_id', 'new-client-id') + ->assertJsonPath('data.group_name', 'ops'); + }); +}); + +describe('DELETE /api/v1/gitlab-apps/{id}', function () { + test('deletes unused gitlab app', function () { + $app = GitlabApp::create([ + 'name' => 'Delete me', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/gitlab-apps/{$app->id}") + ->assertSuccessful() + ->assertJsonPath('message', 'GitLab app deleted successfully'); + + expect(GitlabApp::find($app->id))->toBeNull(); + }); +}); diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index 40a4e7314..6eb97e420 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -653,4 +653,161 @@ describe('GitHub Source Change Component', function () { Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app'); }); + + test('isConnected is true only when app and installation are present', function () { + $incomplete = GithubApp::create([ + 'name' => 'Incomplete App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $connected = GithubApp::create([ + 'name' => 'Connected App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $public = new GithubApp([ + 'is_public' => true, + ]); + + expect($incomplete->isConnected())->toBeFalse() + ->and($connected->isConnected())->toBeTrue() + ->and($public->isConnected())->toBeTrue(); + }); + + test('shows connected badge and test connection for installed github apps', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Connected GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->assertSet('isConnected', true) + ->assertSee('Connected') + ->assertSee('Test Connection'); + }); + + test('testConnection succeeds when github app credentials are valid', function () { + $privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Connected GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app' => Http::response([ + 'name' => 'Coolify GitHub App', + 'slug' => 'coolify-github-app', + ]), + 'https://api.github.com/app/installations/67890/access_tokens' => Http::response([ + 'token' => 'ghs_test_installation_token', + ]), + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->call('testConnection') + ->assertDispatched('success', 'Connection successful! Authenticated as GitHub App: Coolify GitHub App'); + }); + + test('testConnection fails when github app is not fully installed', function () { + $githubApp = GithubApp::create([ + 'name' => 'Incomplete GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) + ->test(Change::class) + ->assertSuccessful() + ->assertSet('isConnected', false) + ->call('testConnection') + ->assertDispatched('error', 'GitHub App is not fully set up. Please complete installation first.'); + }); + + test('sources list shows Connected for finished github apps', function () { + GithubApp::create([ + 'name' => 'Finished GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'installation_id' => 67890, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + GithubApp::create([ + 'name' => 'Incomplete GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $this->get(route('source.all')) + ->assertSuccessful() + ->assertSee('Finished GitHub App') + ->assertSee('Connected') + ->assertSee('Incomplete GitHub App') + ->assertSee('Setup required'); + }); }); diff --git a/tests/Feature/ApplicationSourceTypeFilterTest.php b/tests/Feature/ApplicationSourceTypeFilterTest.php new file mode 100644 index 000000000..a4ff86d4f --- /dev/null +++ b/tests/Feature/ApplicationSourceTypeFilterTest.php @@ -0,0 +1,73 @@ +id = 0; + $settings->save(); + } + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('a GitLab source is not hidden by a GitHub source sharing the same numeric id', function () { + $githubApp = GithubApp::create([ + 'name' => 'gh', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'app_id' => 123, + ]); + + $gitlabApp = GitlabApp::create([ + 'name' => 'gl', + 'team_id' => $this->team->id, + 'api_url' => 'https://gitlab.example.test/api/v4', + 'html_url' => 'https://gitlab.example.test', + 'is_public' => false, + 'access_token' => 'token', + 'refresh_token' => 'refresh', + 'expires_at' => time() + 3600, + ]); + + // The two source tables auto-increment independently, so the first row in each shares id 1. + expect($gitlabApp->id)->toBe($githubApp->id); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'private_key_id' => null, + 'source_id' => $githubApp->id, + 'source_type' => GithubApp::class, + ]); + + $component = Livewire::test(Source::class, ['application' => $application]); + $sources = $component->get('sources'); + + expect($sources->contains(fn ($s) => $s instanceof GitlabApp && $s->id === $gitlabApp->id))->toBeTrue(); + expect($sources->contains(fn ($s) => $s instanceof GithubApp && $s->id === $githubApp->id))->toBeFalse(); + + // The GitLab source renders as selectable, not flagged as the current GitHub source despite the shared id. + $component->assertSee('gl')->assertDontSee('(current)'); +}); diff --git a/tests/Feature/GitlabAppAuthorizationTest.php b/tests/Feature/GitlabAppAuthorizationTest.php new file mode 100644 index 000000000..4ff591120 --- /dev/null +++ b/tests/Feature/GitlabAppAuthorizationTest.php @@ -0,0 +1,147 @@ +team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->member = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + $this->team->members()->attach($this->member->id, ['role' => 'member']); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => null, + 'public_ipv4' => null, + 'public_ipv6' => null, + ]); + + $this->gitlabApp = GitlabApp::create([ + 'name' => 'Self-hosted GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'webhook_token' => 'secret-webhook-token', + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +describe('GitLab App authorization', function () { + test('unrelated users cannot inspect system-wide source secrets in the component payload', function () { + $otherTeam = Team::factory()->create(); + $systemWideSource = GitlabApp::create([ + 'name' => 'Shared GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'client_id' => 'shared-client-id', + 'client_secret' => 'shared-client-secret', + 'webhook_token' => 'shared-webhook-token', + 'access_token' => 'shared-access-token', + 'refresh_token' => 'shared-refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + $component = Livewire::withQueryParams(['gitlab_app_uuid' => $systemWideSource->uuid]) + ->test(Change::class) + ->assertSet('clientSecretInput', null) + ->assertSet('webhookToken', null); + + expect($component->html()) + ->not->toContain('shared-client-secret') + ->not->toContain('shared-webhook-token'); + }); + + test('team member cannot update a gitlab app via instantSave', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->set('isSystemWide', true) + ->call('instantSave') + ->assertDispatched('error'); + + expect($this->gitlabApp->refresh()->is_system_wide)->toBeFalse(); + }); + + test('team owner can update a gitlab app via instantSave', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->set('isSystemWide', true) + ->call('instantSave') + ->assertDispatched('success'); + + expect($this->gitlabApp->refresh()->is_system_wide)->toBeTrue(); + }); + + test('instantSave rejects unsafe GitLab URLs', function (string $url) { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->set('htmlUrl', $url) + ->set('apiUrl', $url.'/api/v4') + ->set('isSystemWide', true) + ->call('instantSave') + ->assertDispatched('success'); + + $this->gitlabApp->refresh(); + + expect($this->gitlabApp->html_url)->toBe('https://gitlab.example.com') + ->and($this->gitlabApp->api_url)->toBe('https://gitlab.example.com/api/v4') + ->and($this->gitlabApp->is_system_wide)->toBeTrue(); + })->with([ + 'private address' => 'http://10.0.0.1', + 'loopback address' => 'http://127.0.0.1', + 'metadata service address' => 'http://169.254.169.254', + ]); + + test('team member cannot create an application from a private gitlab repository', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $applicationsBefore = Application::count(); + + // Avoid setting selected_project_id — its updated* hook loads branches and is unrelated to this auth check. + Livewire::test(GitlabPrivateRepository::class, ['type' => 'private-gitlab-app']) + ->set('selected_repository_path', 'group/repo') + ->set('selected_branch_name', 'main') + ->set('selected_gitlab_app_id', $this->gitlabApp->id) + ->set('gitlab_app_id', $this->gitlabApp->id) + ->call('submit') + ->assertDispatched('error'); + + expect(Application::count())->toBe($applicationsBefore); + }); +}); diff --git a/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php new file mode 100644 index 000000000..0a44e9c5a --- /dev/null +++ b/tests/Feature/GitlabAppWebhookTokenEncryptionTest.php @@ -0,0 +1,67 @@ +team = Team::create([ + 'name' => 'Webhook Token Team', + 'personal_team' => false, + ]); +}); + +it('encrypts webhook tokens at rest', function () { + $app = GitlabApp::create([ + 'name' => 'Encrypted webhook', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'plain-webhook-secret', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $raw = DB::table('gitlab_apps')->where('id', $app->id)->value('webhook_token'); + expect($raw)->not->toBe('plain-webhook-secret') + ->and(Crypt::decryptString($raw))->toBe('plain-webhook-secret') + ->and($app->fresh()->webhook_token)->toBe('plain-webhook-secret'); +}); + +it('finds an app by webhook token for both encrypted and legacy plaintext values', function () { + $encrypted = GitlabApp::create([ + 'name' => 'Encrypted', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'webhook_token' => 'encrypted-secret', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + $legacy = GitlabApp::create([ + 'name' => 'Legacy', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + DB::table('gitlab_apps')->where('id', $legacy->id)->update([ + 'webhook_token' => 'legacy-plain-secret', + ]); + + expect(GitlabApp::findByWebhookToken('encrypted-secret')?->id)->toBe($encrypted->id) + ->and(GitlabApp::findByWebhookToken('legacy-plain-secret')?->id)->toBe($legacy->id) + ->and(GitlabApp::findByWebhookToken('missing'))->toBeNull(); +}); diff --git a/tests/Feature/GitlabOAuthCallbackStateTest.php b/tests/Feature/GitlabOAuthCallbackStateTest.php new file mode 100644 index 000000000..c9d693dfe --- /dev/null +++ b/tests/Feature/GitlabOAuthCallbackStateTest.php @@ -0,0 +1,129 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->gitlabApp = GitlabApp::create([ + 'name' => 'Self-hosted GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +describe('GitLab OAuth callback state validation', function () { + test('rejects a callback whose state is the source UUID (the old attack vector)', function () { + Http::fake(); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$this->gitlabApp->uuid); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback with an unknown / expired state and never exchanges the code', function () { + Http::fake(); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=not-a-real-state'); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a state issued for a different team', function () { + Http::fake(); + $otherTeam = Team::factory()->create(); + $state = 'state-for-other-team'; + Cache::put(GitlabSource::oauthStateCacheKey($state), [ + 'gitlab_app_id' => $this->gitlabApp->id, + 'team_id' => $otherTeam->id, + ], now()->addMinutes(60)); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); + + test('accepts a valid one-time state, exchanges the code, and consumes the state', function () { + Http::fake([ + '*/oauth/token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 7200, + ]), + ]); + + $state = 'a-valid-server-issued-state'; + $key = GitlabSource::oauthStateCacheKey($state); + Cache::put($key, [ + 'gitlab_app_id' => $this->gitlabApp->id, + 'team_id' => $this->team->id, + ], now()->addMinutes(60)); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=valid-code&state='.$state); + + $response->assertRedirect(route('source.gitlab.show', ['gitlab_app_uuid' => $this->gitlabApp->uuid])); + + $fresh = $this->gitlabApp->refresh(); + $fresh->makeVisible(['access_token', 'refresh_token']); + expect($fresh->access_token)->toBe('new-access-token'); + expect($fresh->refresh_token)->toBe('new-refresh-token'); + + // State must be single-use. + expect(Cache::get($key))->toBeNull(); + }); + + test('requires authentication', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=any'); + + $response->assertRedirect(route('login')); + }); + + test('rejects a callback from a team member who cannot administer the source', function () { + Http::fake(); + + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $state = 'member-state'; + Cache::put(GitlabSource::oauthStateCacheKey($state), [ + 'gitlab_app_id' => $this->gitlabApp->id, + 'team_id' => $this->team->id, + ], now()->addMinutes(60)); + + $response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state); + + $response->assertRedirect(route('source.all')); + Http::assertNothingSent(); + expect($this->gitlabApp->refresh()->access_token)->toBeNull(); + }); +}); diff --git a/tests/Feature/GitlabRepositoryListingTest.php b/tests/Feature/GitlabRepositoryListingTest.php new file mode 100644 index 000000000..f42263b9d --- /dev/null +++ b/tests/Feature/GitlabRepositoryListingTest.php @@ -0,0 +1,52 @@ + Http::response(['message' => '401 Unauthorized'], 401)]); + + $source = new GitlabApp([ + 'api_url' => 'https://gitlab.example.test/api/v4', + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + 'expires_at' => time() + 3600, + ]); + + $result = loadGitlabRepositories($source); + + expect($result)->toHaveKeys(['total_count', 'has_more', 'repositories']); + expect($result['has_more'])->toBeFalse(); + expect($result['repositories'])->toBe([]); +}); + +it('limits GitLab repositories to the exact group and its descendants', function () { + Http::fake(['*' => Http::response([ + ['id' => 1, 'name' => 'a', 'path_with_namespace' => 'team/a', 'namespace' => ['full_path' => 'team', 'kind' => 'group']], + ['id' => 2, 'name' => 'b', 'path_with_namespace' => 'team/sub/b', 'namespace' => ['full_path' => 'team/sub', 'kind' => 'group']], + ['id' => 3, 'name' => 'c', 'path_with_namespace' => 'team-secret/c', 'namespace' => ['full_path' => 'team-secret', 'kind' => 'group']], + ], 200)]); + + $source = new GitlabApp([ + 'api_url' => 'https://gitlab.example.test/api/v4', + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + 'expires_at' => time() + 3600, + 'group_name' => 'team', + ]); + + $paths = collect(loadGitlabRepositories($source)['repositories'])->pluck('path_with_namespace')->all(); + + expect($paths)->toContain('team/a', 'team/sub/b'); + expect($paths)->not->toContain('team-secret/c'); +}); diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php new file mode 100644 index 000000000..78fb3bf50 --- /dev/null +++ b/tests/Feature/GitlabSourceChangeViewTest.php @@ -0,0 +1,89 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => null, + 'public_ipv4' => null, + 'public_ipv6' => null, + ]); + + $this->gitlabApp = GitlabApp::create([ + 'name' => 'Self-hosted GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +describe('GitLab source setup view', function () { + test('shows red incomplete-setup alert and keeps advanced fields collapsed', function () { + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->assertSee('You must complete this step before you can use this source!') + ->assertSeeHtml('alert-error') + ->assertSee('Advanced / Self-hosted') + ->assertSee('Application ID') + ->assertSee('Application Secret') + ->assertSee('Save') + ->assertDontSee('alert-warning'); + }); + + test('derives api url when gitlab url changes', function () { + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->set('htmlUrl', 'https://gitlab.example.com') + ->assertSet('apiUrl', 'https://gitlab.example.com/api/v4'); + }); + + test('saves and reloads the application secret after refresh', function () { + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->set('clientId', 'gitlab-app-id') + ->set('clientSecretInput', 'super-secret-value') + ->call('submit') + ->assertDispatched('success'); + + $this->gitlabApp->refresh()->makeVisible(['client_secret']); + expect($this->gitlabApp->client_secret)->toBe('super-secret-value'); + + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->assertSet('clientId', 'gitlab-app-id') + ->assertSet('clientSecretInput', 'super-secret-value'); + }); + + test('supports github-style custom public endpoint for oauth redirect uri', function () { + Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid]) + ->test(Change::class) + ->assertSee('Use custom webhook endpoint') + ->assertSee('Selected endpoint') + ->set('use_custom_webhook_endpoint', true) + ->set('custom_webhook_endpoint', 'http://100.75.155.70:8000') + ->assertSet('redirectUri', 'http://100.75.155.70:8000/webhooks/source/gitlab/redirect'); + + expect($this->gitlabApp->refresh()->redirect_uri) + ->toBe('http://100.75.155.70:8000/webhooks/source/gitlab/redirect'); + }); +}); diff --git a/tests/Feature/GitlabSourceCreateModalTest.php b/tests/Feature/GitlabSourceCreateModalTest.php new file mode 100644 index 000000000..073fc1b74 --- /dev/null +++ b/tests/Feature/GitlabSourceCreateModalTest.php @@ -0,0 +1,50 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('GitLab source create modal', function () { + test('matches github create modal structure', function () { + Livewire::test(Create::class) + ->assertSee('This is required if you would like to get full integration') + ->assertSee('Self-hosted GitLab') + ->assertSee('Continue') + ->assertDontSee('>SaveassertDontSeeHtml('

New GitLab App

'); + }); + + test('creates a gitlab app with defaults for gitlab.com', function () { + Livewire::test(Create::class) + ->set('name', 'my-gitlab') + ->call('createGitLabApp') + ->assertRedirect(); + + $app = GitlabApp::where('name', 'my-gitlab')->first(); + expect($app)->not->toBeNull() + ->and($app->html_url)->toBe('https://gitlab.com') + ->and($app->api_url)->toBe('https://gitlab.com/api/v4') + ->and($app->custom_user)->toBe('git') + ->and($app->custom_port)->toBe(22); + }); + + test('derives api url when html url changes', function () { + Livewire::test(Create::class) + ->set('html_url', 'https://gitlab.example.com') + ->assertSet('api_url', 'https://gitlab.example.com/api/v4'); + }); +}); diff --git a/tests/Feature/GitlabSystemWideSourceTest.php b/tests/Feature/GitlabSystemWideSourceTest.php new file mode 100644 index 000000000..05aab8762 --- /dev/null +++ b/tests/Feature/GitlabSystemWideSourceTest.php @@ -0,0 +1,51 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('ownedByCurrentTeam resolves a system-wide GitLab source owned by another team', function () { + $otherTeam = Team::factory()->create(); + + $systemWide = GitlabApp::create([ + 'name' => 'Shared GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + // Mirrors Source::changeSource() resolution; before the fix this returned null for system-wide sources (404). + $resolved = GitlabApp::ownedByCurrentTeam()->find($systemWide->id); + + expect($resolved)->not->toBeNull(); + expect($resolved->id)->toBe($systemWide->id); +}); + +test('ownedByCurrentTeam still excludes other teams private (non system-wide) sources', function () { + $otherTeam = Team::factory()->create(); + + $foreign = GitlabApp::create([ + 'name' => 'Foreign GitLab', + 'api_url' => 'https://gitlab.example.com/api/v4', + 'html_url' => 'https://gitlab.example.com', + 'team_id' => $otherTeam->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + + expect(GitlabApp::ownedByCurrentTeam()->find($foreign->id))->toBeNull(); +}); diff --git a/tests/Unit/GitlabAppSubmoduleCredentialsTest.php b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php new file mode 100644 index 000000000..bf713070f --- /dev/null +++ b/tests/Unit/GitlabAppSubmoduleCredentialsTest.php @@ -0,0 +1,56 @@ +forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => 'group/private-app', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + + $source = new GitlabApp; + $source->forceFill([ + 'html_url' => 'https://gitlab.example.test', + 'api_url' => 'https://gitlab.example.test/api/v4', + 'is_public' => false, + ]); + // A non-expired token short-circuits refreshGitlabToken(), so no HTTP call is made. + $source->access_token = 'gl-token/with+sym'; + $source->refresh_token = 'gl-refresh-token'; + $source->expires_at = time() + 3600; + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + $expectedConfig = "git -c 'url.https://oauth2:gl-token%2Fwith%2Bsym@gitlab.example.test/.insteadOf=https://gitlab.example.test/' -c http.version=HTTP/1.1"; + + expect($result['commands']) + ->not->toContain('git config --global') + ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main'") + ->toContain("{$expectedConfig} submodule sync") + ->toContain("{$expectedConfig} submodule update --init --recursive"); +}); diff --git a/tests/Unit/GitlabHelperTest.php b/tests/Unit/GitlabHelperTest.php new file mode 100644 index 000000000..bac21b205 --- /dev/null +++ b/tests/Unit/GitlabHelperTest.php @@ -0,0 +1,52 @@ + 'https://gitlab.example.com']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('returns api base url unchanged when /api/v4 already present', function () { + $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('strips trailing slash from api url base', function () { + $app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']); + expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4'); +}); + +it('reports connected when tokens are present', function () { + $app = new GitlabApp([ + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => str_repeat('r', 20), // ggignore + ]); + expect($app->isConnected())->toBeTrue(); +}); + +it('reports not connected when tokens are missing', function () { + $app = new GitlabApp([ + 'access_token' => null, + 'refresh_token' => null, + ]); + expect($app->isConnected())->toBeFalse(); +}); + +it('reports not connected when only access token is present', function () { + $app = new GitlabApp([ + 'access_token' => str_repeat('t', 20), // ggignore + 'refresh_token' => null, + ]); + expect($app->isConnected())->toBeFalse(); +}); diff --git a/tests/Unit/GitlabOAuthRedactionTest.php b/tests/Unit/GitlabOAuthRedactionTest.php new file mode 100644 index 000000000..e5a4d6569 --- /dev/null +++ b/tests/Unit/GitlabOAuthRedactionTest.php @@ -0,0 +1,27 @@ +not->toContain($fakeOAuthToken); + expect($result)->toContain('oauth2:'); +}); + +it('redacts x-access-token from logs', function () { + $fakeToken = str_repeat('x', 40); // ggignore + $text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app"; + $result = remove_iip($text); + + expect($result)->not->toContain($fakeToken); + expect($result)->toContain('x-access-token:'); +}); + +it('redacts gitlab personal access tokens', function () { + $fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore + $text = "Authorization: Bearer {$fakeToken}"; + $result = remove_iip($text); + + expect($result)->not->toContain($fakeToken); +}); diff --git a/tests/Unit/GitlabSourceCommandsTest.php b/tests/Unit/GitlabSourceCommandsTest.php index 129a86506..e27236a8f 100644 --- a/tests/Unit/GitlabSourceCommandsTest.php +++ b/tests/Unit/GitlabSourceCommandsTest.php @@ -3,9 +3,16 @@ use App\Models\Application; use App\Models\GitlabApp; use App\Models\PrivateKey; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Encryption\Encrypter; use Illuminate\Support\Collection; +beforeEach(function () { + Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC')); +}); + afterEach(function () { + Model::encryptUsing(null); Mockery::close(); }); @@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function ( $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1); $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () { $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null); $gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null); + $gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null); + $gitlabSource->shouldReceive('isConnected')->andReturn(false); $application = Mockery::mock(Application::class)->makePartial(); $application->git_branch = 'main'; @@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () { expect($lsRemoteResult)->not->toBeNull(); expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']); }); + +it('preserves custom GitLab http port for connected OAuth sources', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = new GitlabApp([ + 'html_url' => 'http://gitlab.example.test:8081', + 'access_token' => 'gitlab-access-token', + 'refresh_token' => 'gitlab-refresh-token', + 'expires_at' => time() + 3600, + ]); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'root/qa-private-app', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->source = $gitlabSource; + + $result = $application->generateGitLsRemoteCommands($deploymentUuid, false); + + expect($result['fullRepoUrl']) + ->toContain('gitlab.example.test:8081') + ->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git'); + expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git'); +}); + +it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () { + $deploymentUuid = 'test-deployment-uuid'; + + $gitlabSource = new GitlabApp([ + 'html_url' => 'https://gitlab.example.test', + 'access_token' => 'gitlab-access-token', + 'refresh_token' => 'gitlab-refresh-token', + 'expires_at' => time() + 3600, + ]); + + $settings = (object) [ + 'is_git_shallow_clone_enabled' => false, + 'is_git_submodules_enabled' => true, + ]; + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'root/qa-private-app', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings); + $application->source = $gitlabSource; + + $result = $application->generateGitImportCommands( + deployment_uuid: $deploymentUuid, + pull_request_id: 2, + exec_in_docker: false, + only_checkout: true, + custom_base_dir: '/artifacts/test', + ); + + $commands = $result['commands']; + // The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail. + expect($commands) + ->toContain('oauth2:gitlab-access-token@gitlab.example.test') + ->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'") + ->toContain('http.version=HTTP/1.1 submodule update --init --recursive'); +}); diff --git a/tests/Unit/Policies/GitlabAppPolicyTest.php b/tests/Unit/Policies/GitlabAppPolicyTest.php new file mode 100644 index 000000000..a2cd2a091 --- /dev/null +++ b/tests/Unit/Policies/GitlabAppPolicyTest.php @@ -0,0 +1,172 @@ +makePartial(); + + $policy = new GitlabAppPolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows any user to view system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGitlabApp(teamId: 1, isSystemWide: true); + + $policy = new GitlabAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('allows team member to view non-system-wide gitlab app', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view non-system-wide gitlab app', function () { + $teams = collect([ + (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new GitlabAppPolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new GitlabAppPolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows user with system access to update system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = mockGitlabApp(teamId: 1, isSystemWide: true); + + $policy = new GitlabAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies user without system access to update system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = mockGitlabApp(teamId: 1, isSystemWide: true); + + $policy = new GitlabAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to update non-system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update non-system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows user with system access to delete system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = mockGitlabApp(teamId: 1, isSystemWide: true); + + $policy = new GitlabAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies user without system access to delete system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = mockGitlabApp(teamId: 1, isSystemWide: true); + + $policy = new GitlabAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete non-system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete non-system-wide gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of gitlab app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGitlabApp(teamId: 1, isSystemWide: false); + + $policy = new GitlabAppPolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); + +function mockGitlabApp(int $teamId, bool $isSystemWide): GitlabApp +{ + $gitlabApp = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabApp->team_id = $teamId; + $gitlabApp->is_system_wide = $isSystemWide; + + return $gitlabApp; +}