From a26091de0ad6e1001d0946cbc3c91136188f5bee Mon Sep 17 00:00:00 2001 From: Mike Chong Date: Tue, 14 Jul 2026 12:53:45 -0600 Subject: [PATCH 01/19] feat: self-hosted GitLab Apps OAuth integration Adds self-hosted GitLab OAuth sources so Coolify can connect to a self-managed GitLab instance, list private repositories, clone over an OAuth token, and deploy (the GitLab counterpart to GitHub Apps). Hardening: authenticated, one-time team-bound OAuth callback state; token redaction in deploy logs; custom host port/path kept in clone and ls-remote URLs; submodule OAuth auth; system-wide source selection. Covered by unit and feature tests. cosigned by OpenAI Codex at M1 Max --- app/Http/Controllers/Webhook/Gitlab.php | 302 +++++++++++++++++- app/Livewire/Project/Application/Source.php | 39 ++- .../Project/New/GitlabPrivateRepository.php | 237 ++++++++++++++ app/Livewire/Project/New/Select.php | 6 + app/Livewire/Source/Gitlab/Change.php | 278 ++++++++++++++++ app/Livewire/Source/Gitlab/Create.php | 62 ++++ app/Models/Application.php | 88 ++++- app/Models/GitlabApp.php | 71 +++- app/Policies/GitlabAppPolicy.php | 52 +++ app/Providers/AppServiceProvider.php | 11 + app/Providers/AuthServiceProvider.php | 5 + bootstrap/helpers/github.php | 3 +- bootstrap/helpers/gitlab.php | 162 ++++++++++ bootstrap/helpers/remoteProcess.php | 1 + ..._add_oauth_fields_to_gitlab_apps_table.php | 34 ++ database/schema/testing-schema.sql | 7 + .../project/application/source.blade.php | 6 +- .../new/gitlab-private-repository.blade.php | 152 +++++++++ .../project/resource/create.blade.php | 2 + .../livewire/source/gitlab/change.blade.php | 175 ++++++++++ .../livewire/source/gitlab/create.blade.php | 18 ++ resources/views/source/all.blade.php | 22 +- routes/web.php | 2 + routes/webhooks.php | 2 + .../ApplicationSourceTypeFilterTest.php | 73 +++++ .../Feature/GitlabOAuthCallbackStateTest.php | 108 +++++++ tests/Feature/GitlabRepositoryListingTest.php | 52 +++ tests/Feature/GitlabSystemWideSourceTest.php | 51 +++ .../GitlabAppSubmoduleCredentialsTest.php | 56 ++++ tests/Unit/GitlabHelperTest.php | 52 +++ tests/Unit/GitlabOAuthRedactionTest.php | 27 ++ tests/Unit/GitlabSourceCommandsTest.php | 86 +++++ 32 files changed, 2216 insertions(+), 26 deletions(-) create mode 100644 app/Livewire/Project/New/GitlabPrivateRepository.php create mode 100644 app/Livewire/Source/Gitlab/Change.php create mode 100644 app/Livewire/Source/Gitlab/Create.php create mode 100644 app/Policies/GitlabAppPolicy.php create mode 100644 bootstrap/helpers/gitlab.php create mode 100644 database/migrations/2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table.php create mode 100644 resources/views/livewire/project/new/gitlab-private-repository.blade.php create mode 100644 resources/views/livewire/source/gitlab/change.blade.php create mode 100644 resources/views/livewire/source/gitlab/create.blade.php create mode 100644 tests/Feature/ApplicationSourceTypeFilterTest.php create mode 100644 tests/Feature/GitlabOAuthCallbackStateTest.php create mode 100644 tests/Feature/GitlabRepositoryListingTest.php create mode 100644 tests/Feature/GitlabSystemWideSourceTest.php create mode 100644 tests/Unit/GitlabAppSubmoduleCredentialsTest.php create mode 100644 tests/Unit/GitlabHelperTest.php create mode 100644 tests/Unit/GitlabOAuthRedactionTest.php diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index cd68c1b67..0a7efed99 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,302 @@ 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(); + + $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::where('webhook_token', $x_gitlab_token)->first(); + 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 +591,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..798a0dc50 --- /dev/null +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -0,0 +1,237 @@ +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 { + $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/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php new file mode 100644 index 000000000..0cb36a5f8 --- /dev/null +++ b/app/Livewire/Source/Gitlab/Change.php @@ -0,0 +1,278 @@ + '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', + ]; + } + + 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') ?? ''; + } + + $this->redirectUri = $this->webhook_endpoint.'/webhooks/source/gitlab/redirect'; + + $this->oauthState = $this->createOAuthState(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + 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; + } + $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->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; + $this->clientSecretInput = null; + $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->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']); + $this->syncData(true); + $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 disconnect() + { + try { + $this->authorize('update', $this->gitlab_app); + + $this->gitlab_app->update([ + 'access_token' => null, + 'refresh_token' => null, + 'expires_at' => null, + ]); + + $this->isConnected = false; + $this->dispatch('success', 'GitLab App disconnected.'); + } 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 + { + $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..7f37219ec --- /dev/null +++ b/app/Livewire/Source/Gitlab/Create.php @@ -0,0 +1,62 @@ +name = substr(generate_random_name(), 0, 30); + } + + public function createGitLabApp() + { + try { + $this->authorize('createAnyResource'); + + $this->validate([ + 'name' => 'required|string', + 'html_url' => ['required', 'string', 'url', new SafeExternalUrl], + 'is_system_wide' => 'required|bool', + 'group_name' => 'nullable|string', + ]); + + $htmlUrl = rtrim($this->html_url, '/'); + $apiUrl = $htmlUrl.'/api/v4'; + + $gitlab_app = GitlabApp::create([ + 'name' => $this->name, + 'api_url' => $apiUrl, + 'html_url' => $htmlUrl, + '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 (\Throwable $e) { + return handleError($e, $this); + } + } +} 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/GitlabApp.php b/app/Models/GitlabApp.php index 06df8fd8d..2279fe641 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -16,20 +16,68 @@ 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', + ]; + } + + 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 +89,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..596e7e15a --- /dev/null +++ b/app/Policies/GitlabAppPolicy.php @@ -0,0 +1,52 @@ +is_system_wide) { + return true; + } + + return true; + } + + public function delete(User $user, GitlabApp $gitlabApp): bool + { + if ($gitlabApp->is_system_wide) { + return true; + } + + return true; + } + + public function restore(User $user, GitlabApp $gitlabApp): bool + { + return false; + } + + 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/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php new file mode 100644 index 000000000..3072f457d --- /dev/null +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -0,0 +1,175 @@ +
+ @if ($isConnected) +
+
+

GitLab App

+
+ Save + Test Connection + @can('delete', $gitlab_app) + + @endcan +
+
+
Your GitLab App for private repositories.
+
+ + + Connected + + + Disconnect + +
+
+ +
+ + +
+ +
+ + +
+ @if (!isCloud()) +
+ +
+ @endif + +

OAuth Credentials

+ + + +

Webhook

+
+
+ Configure this webhook URL in your GitLab project settings + (Settings > Webhooks): +
+ + +
+ +

SSH Key (Optional)

+
+ Only needed if you prefer SSH-based git clone over HTTPS OAuth token. +
+
+ + + @foreach ($privateKeys as $key) + + @endforeach + +
+ + @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.
+ +
+ + + + Complete the setup below to connect this GitLab 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

+
+ +
+ + +
+ + + +
+ + +
+ @if (!isCloud()) + + @endif + Save Credentials + + + @if ($clientId) +

Step 3: Authorize with GitLab

+
Click the button below to authorize Coolify with your GitLab instance.
+ + + 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..61e2bedeb --- /dev/null +++ b/resources/views/livewire/source/gitlab/create.blade.php @@ -0,0 +1,18 @@ +
+
+
+

New GitLab App

+ Save +
+
Add a self-hosted or GitLab.com instance as a source for your applications.
+ + + + @if (!isCloud()) + + @endif + +
diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index c6566d2f9..b5f5d5e99 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,7 +20,6 @@ - {{-- --}}
{{ $source->name }}
@if (is_null($source->app_id)) @@ -29,6 +31,22 @@ @endif
+ @elseif ($source->getMorphClass() === 'App\Models\GitlabApp') + +
+
+ + {{ $source->name }} +
+ @if ($source->isConnected()) + Connected — {{ $source->html_url }} + @else + Setup required — {{ $source->html_url }} + @endif +
+
@endif @empty
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/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/GitlabOAuthCallbackStateTest.php b/tests/Feature/GitlabOAuthCallbackStateTest.php new file mode 100644 index 000000000..2bd8e2aa8 --- /dev/null +++ b/tests/Feature/GitlabOAuthCallbackStateTest.php @@ -0,0 +1,108 @@ +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')); + }); +}); 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/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'); +}); From 6f557cf17fdb960d515fcf8c5044a3b4b0d9cbfd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:20:08 +0200 Subject: [PATCH 02/19] fix(security): enforce GitLab App authorization parity with GitHub GitlabAppPolicy previously allowed any authenticated user to update, delete, and create GitLab sources. Align it with GithubAppPolicy, require Application create authorization on the private-repo wizard, and reject OAuth callbacks from non-admins so members cannot escalate privileges. --- app/Http/Controllers/Webhook/Gitlab.php | 5 + .../Project/New/GitlabPrivateRepository.php | 5 + app/Policies/GitlabAppPolicy.php | 37 +++- tests/Feature/GitlabAppAuthorizationTest.php | 92 ++++++++++ .../Feature/GitlabOAuthCallbackStateTest.php | 21 +++ tests/Unit/Policies/GitlabAppPolicyTest.php | 172 ++++++++++++++++++ 6 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/GitlabAppAuthorizationTest.php create mode 100644 tests/Unit/Policies/GitlabAppPolicyTest.php diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 0a7efed99..9371d9d91 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -40,6 +40,11 @@ class Gitlab extends Controller $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", [ diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index 798a0dc50..a0ff15304 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -7,11 +7,14 @@ use App\Models\GitlabApp; use App\Models\Project; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Route; use Livewire\Component; class GitlabPrivateRepository extends Component { + use AuthorizesRequests; + public $current_step = 'gitlab_apps'; public $gitlab_apps; @@ -158,6 +161,8 @@ class GitlabPrivateRepository extends Component 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, diff --git a/app/Policies/GitlabAppPolicy.php b/app/Policies/GitlabAppPolicy.php index 596e7e15a..56861e89b 100644 --- a/app/Policies/GitlabAppPolicy.php +++ b/app/Policies/GitlabAppPolicy.php @@ -7,44 +7,69 @@ use App\Models\User; class GitlabAppPolicy { + /** + * Determine whether the user can view any models. + */ public function viewAny(User $user): bool { return true; } + /** + * Determine whether the user can view the model. + */ public function view(User $user, GitlabApp $gitlabApp): bool { - return true; + if ($gitlabApp->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 true; + 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 true; + return $user->canAccessSystemResources(); } - return true; + 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 true; + return $user->canAccessSystemResources(); } - return true; + 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/tests/Feature/GitlabAppAuthorizationTest.php b/tests/Feature/GitlabAppAuthorizationTest.php new file mode 100644 index 000000000..7fc20d07f --- /dev/null +++ b/tests/Feature/GitlabAppAuthorizationTest.php @@ -0,0 +1,92 @@ +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('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('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/GitlabOAuthCallbackStateTest.php b/tests/Feature/GitlabOAuthCallbackStateTest.php index 2bd8e2aa8..c9d693dfe 100644 --- a/tests/Feature/GitlabOAuthCallbackStateTest.php +++ b/tests/Feature/GitlabOAuthCallbackStateTest.php @@ -105,4 +105,25 @@ describe('GitLab OAuth callback state validation', function () { $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/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; +} From 418287d511405eb7564e8b86352f26f070968627 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:54:59 +0200 Subject: [PATCH 03/19] fix(ui): align GitLab App create modal with GitHub Match the GitHub create modal layout: intro copy, name/group row, system-wide warning, self-hosted accordion (URL/API/SSH), and a bottom Continue button instead of a duplicate header Save. --- app/Livewire/Source/Gitlab/Create.php | 46 +++++++++-- .../livewire/source/gitlab/create.blade.php | 78 +++++++++++++++---- tests/Feature/GitlabSourceCreateModalTest.php | 50 ++++++++++++ 3 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/GitlabSourceCreateModalTest.php diff --git a/app/Livewire/Source/Gitlab/Create.php b/app/Livewire/Source/Gitlab/Create.php index 7f37219ec..200bd6d40 100644 --- a/app/Livewire/Source/Gitlab/Create.php +++ b/app/Livewire/Source/Gitlab/Create.php @@ -6,6 +6,7 @@ use App\Models\GitlabApp; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Create extends Component @@ -16,34 +17,62 @@ class Create extends Component public string $html_url = 'https://gitlab.com'; + public string $api_url = 'https://gitlab.com/api/v4'; + + public string $custom_user = 'git'; + + public int $custom_port = 22; + public bool $is_system_wide = false; public ?string $group_name = null; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + public function mount() { $this->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', ]); - $htmlUrl = rtrim($this->html_url, '/'); - $apiUrl = $htmlUrl.'/api/v4'; - $gitlab_app = GitlabApp::create([ 'name' => $this->name, - 'api_url' => $apiUrl, - 'html_url' => $htmlUrl, + '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), @@ -55,8 +84,15 @@ class Create extends Component } 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/resources/views/livewire/source/gitlab/create.blade.php b/resources/views/livewire/source/gitlab/create.blade.php index 61e2bedeb..4b053edb7 100644 --- a/resources/views/livewire/source/gitlab/create.blade.php +++ b/resources/views/livewire/source/gitlab/create.blade.php @@ -1,18 +1,70 @@ -
-
+@can('createAnyResource') + +
This is required if you would like to get full integration (deployments from + private repositories, webhooks, etc) with GitLab.
-

New GitLab App

- Save + +
-
Add a self-hosted or GitLab.com instance as a source for your applications.
- - - @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/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'); + }); +}); From fda8e9139646769f0188be911b4d4072d0b396f4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:59:09 +0200 Subject: [PATCH 04/19] fix(ui): simplify GitLab source setup view Use the red incomplete-setup alert like GitHub, keep name + OAuth credentials front-and-center, and tuck GitLab URL / API / SSH / system wide options into an Advanced accordion for self-hosted users. --- app/Livewire/Source/Gitlab/Change.php | 15 ++ .../livewire/source/gitlab/change.blade.php | 129 +++++++++++++----- tests/Feature/GitlabSourceChangeViewTest.php | 59 ++++++++ 3 files changed, 166 insertions(+), 37 deletions(-) create mode 100644 tests/Feature/GitlabSourceChangeViewTest.php diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 0cb36a5f8..2e36a1e1f 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -59,6 +59,8 @@ class Change extends Component public ?string $oauthState = null; + private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false; + protected function rules(): array { return [ @@ -76,6 +78,19 @@ class Change extends Component ]; } + 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 mount() { try { diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 3072f457d..830a9a0a7 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -29,28 +29,67 @@
-
- - -
-
- - -
@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

@@ -63,19 +102,6 @@ helper="Set this same token in your GitLab webhook's 'Secret token' field." />
-

SSH Key (Optional)

-
- Only needed if you prefer SSH-based git clone over HTTPS OAuth token. -
-
- - - @foreach ($privateKeys as $key) - - @endforeach - -
- @if ($applications->count() > 0)

Applications Using This Source

@@ -112,13 +138,13 @@
Connect your GitLab instance to deploy private repositories.
-
+
- Complete the setup below to connect this GitLab source. + You must complete this step before you can use this source!
@@ -140,24 +166,53 @@

Step 2: Enter the credentials

-
- - -
- -
- - + +
+
+ +
+
+
+ + +
+ +
+ + +
+ @if (!isCloud()) +
+ +
+ @endif +
+
+
- @if (!isCloud()) - - @endif + Save Credentials diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php new file mode 100644 index 000000000..b99875e6b --- /dev/null +++ b/tests/Feature/GitlabSourceChangeViewTest.php @@ -0,0 +1,59 @@ +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 Credentials') + ->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'); + }); +}); From 8782079dccffc9803e3b80c365e5e63dce65ca08 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:00:34 +0200 Subject: [PATCH 05/19] fix(ui): place GitLab group name after application secret Keep the primary setup/credentials flow ordered as name, OAuth credentials, then group name, with self-hosted URL fields advanced-only. --- resources/views/livewire/source/gitlab/change.blade.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 830a9a0a7..a1e41c68a 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -29,8 +29,6 @@
- @if (!isCloud())
OAuth Credentials +
+
-
From ef4a18833e6ff8405c1a5ed0b4658f25928d2235 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:02:04 +0200 Subject: [PATCH 06/19] fix(ui): put Save Credentials beside Step 2 heading Match the page action pattern used elsewhere by placing the submit button next to the credentials step title instead of under the form. --- resources/views/livewire/source/gitlab/change.blade.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index a1e41c68a..7248a33e9 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -163,8 +163,11 @@
-

Step 2: Enter the credentials

+
+

Step 2: Enter the credentials

+ Save Credentials +
@@ -212,8 +215,6 @@
- - Save Credentials @if ($clientId) From 2450a32d6c6c21931fb53f78faea014aefabf941 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:02:41 +0200 Subject: [PATCH 07/19] fix(ui): rename GitLab setup submit button to Save --- resources/views/livewire/source/gitlab/change.blade.php | 2 +- tests/Feature/GitlabSourceChangeViewTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 7248a33e9..caedc88de 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -166,7 +166,7 @@

Step 2: Enter the credentials

- Save Credentials + Save
assertSee('Advanced / Self-hosted') ->assertSee('Application ID') ->assertSee('Application Secret') - ->assertSee('Save Credentials') + ->assertSee('Save') ->assertDontSee('alert-warning'); }); From e962b81c4e615deabfc6e0c27abb2f3a3935d14e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:07:16 +0200 Subject: [PATCH 08/19] fix(gitlab): reload application secret after save The secret was always stored encrypted, but the setup form wiped the input on every load. Load it back for admins (GitHub App parity) so a reload no longer looks like a failed save. --- app/Livewire/Source/Gitlab/Change.php | 4 +++- .../livewire/source/gitlab/change.blade.php | 8 +++++--- tests/Feature/GitlabSourceChangeViewTest.php | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 2e36a1e1f..5a386b429 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -173,7 +173,9 @@ class Change extends Component $this->customUser = $this->gitlab_app->custom_user; $this->customPort = $this->gitlab_app->custom_port; $this->clientId = $this->gitlab_app->client_id; - $this->clientSecretInput = null; + // Decrypt and surface for authorized editors (same pattern as GitHub App client_secret). + $this->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']); + $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; diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index caedc88de..5cb859ec5 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -46,7 +46,8 @@

OAuth Credentials

- + @@ -171,8 +172,9 @@ - + diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php index 60f8f0d96..a5e43a1cc 100644 --- a/tests/Feature/GitlabSourceChangeViewTest.php +++ b/tests/Feature/GitlabSourceChangeViewTest.php @@ -56,4 +56,21 @@ describe('GitLab source setup view', function () { ->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'); + }); }); From eaa0b0156dac5efeeefa9a9d16a794a57930d3de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:11:20 +0200 Subject: [PATCH 09/19] feat(gitlab): add custom public endpoint for OAuth redirect Match the GitHub App endpoint picker so self-hosted / tunnel setups can select FQDN, IP, app URL, or a custom base. Redirect URI is derived as {base}/webhooks/source/gitlab/redirect and persisted for token exchange. --- app/Livewire/Source/Gitlab/Change.php | 80 ++++++++++++++++++- .../livewire/source/gitlab/change.blade.php | 56 +++++++++++-- tests/Feature/GitlabSourceChangeViewTest.php | 13 +++ 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 5a386b429..bab6a41d1 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -17,6 +17,10 @@ class Change extends Component public string $webhook_endpoint = ''; + public string $custom_webhook_endpoint = ''; + + public bool $use_custom_webhook_endpoint = false; + public ?string $ipv4 = null; public ?string $ipv6 = null; @@ -75,6 +79,9 @@ class Change extends Component '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'], ]; } @@ -91,6 +98,40 @@ class Change extends Component } } + 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 { @@ -124,7 +165,25 @@ class Change extends Component $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; } - $this->redirectUri = $this->webhook_endpoint.'/webhooks/source/gitlab/redirect'; + // 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) { @@ -132,6 +191,23 @@ class Change extends Component } } + 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); @@ -165,6 +241,7 @@ class Change extends Component $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; @@ -280,6 +357,7 @@ class Change extends Component public function getOAuthUrl(): string { + $this->refreshRedirectUri(); $baseUrl = rtrim($this->htmlUrl, '/'); $query = http_build_query([ diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 5cb859ec5..cd219f7d3 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -98,7 +98,7 @@ (Settings > Webhooks):
+ value="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitlab/events" />
@@ -148,7 +148,51 @@ You must complete this step before you can use this source!
-
+
+ @if (!isCloud() || isDev()) +
+

Public endpoint

+
+ 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 +

Step 1: Create an OAuth Application on GitLab

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

@@ -158,7 +202,9 @@
    -
  • Set Redirect URI to: {{ $redirectUri }}
  • +
  • Set Redirect URI to: + {{ $redirectUri }} +
  • Enable scopes: api, read_user, read_repository
  • Uncheck Confidential if you run into issues
@@ -221,8 +267,8 @@ @if ($clientId)

Step 3: Authorize with GitLab

-
Click the button below to authorize Coolify with your GitLab instance.
- +
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 diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php index a5e43a1cc..78fb3bf50 100644 --- a/tests/Feature/GitlabSourceChangeViewTest.php +++ b/tests/Feature/GitlabSourceChangeViewTest.php @@ -73,4 +73,17 @@ describe('GitLab source setup view', function () { ->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'); + }); }); From 1d63c1eaa2602eb272bace46c136cf1406626b5f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:13:22 +0200 Subject: [PATCH 10/19] fix(ui): move GitLab public endpoint controls into step 2 Keep OAuth app instructions first; put endpoint selection with the credentials form so setup flows top-to-bottom without a separate section. --- .../livewire/source/gitlab/change.blade.php | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index cd219f7d3..fbe126656 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -158,41 +158,6 @@ return base ? base.replace(/\/+$/, '') + this.redirectPath : ''; } }"> - @if (!isCloud() || isDev()) -
-

Public endpoint

-
- 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 -

Step 1: Create an OAuth Application on GitLab

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

@@ -224,6 +189,41 @@ + @if (!isCloud() || isDev()) +
+
Public endpoint
+
+ 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 +
-
Public endpoint
GitLab will redirect back to this Coolify URL. It must match the Callback URL on your GitLab OAuth Application exactly.
From 94c3129ad18133a9de4e77a7d1b8fa18f1be3fc1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:19:48 +0200 Subject: [PATCH 12/19] fix(ui): show GitLab Connected badge beside title Match application status layout by placing the Connected badge next to the page heading, and drop the redundant Disconnect action (re-auth is done via Connect after tokens expire or credentials are updated). --- app/Livewire/Source/Gitlab/Change.php | 18 ------------------ .../livewire/source/gitlab/change.blade.php | 15 ++++----------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index bab6a41d1..3038ff2a1 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -319,24 +319,6 @@ class Change extends Component } } - public function disconnect() - { - try { - $this->authorize('update', $this->gitlab_app); - - $this->gitlab_app->update([ - 'access_token' => null, - 'refresh_token' => null, - 'expires_at' => null, - ]); - - $this->isConnected = false; - $this->dispatch('success', 'GitLab App disconnected.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function delete() { try { diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 3f43874eb..f4156b811 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -2,7 +2,10 @@ @if ($isConnected)
-

GitLab App

+
+

GitLab App

+ +
Save Test Connection @@ -17,16 +20,6 @@
Your GitLab App for private repositories.
-
- - - Connected - - - Disconnect - -
@if (!isCloud()) From 43919ef4e03267cf47eef4151e8ead74ddde2e2f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:21:04 +0200 Subject: [PATCH 13/19] fix(security): encrypt GitLab webhook token and mask input Webhook secret was stored and shown as plaintext. Use a password field, encrypt at rest (with legacy plaintext read support), and look up tokens via findByWebhookToken so encrypted values still authenticate webhooks. --- app/Http/Controllers/Webhook/Gitlab.php | 2 +- app/Livewire/Source/Gitlab/Change.php | 4 +- app/Models/GitlabApp.php | 45 +++++++++++++ .../livewire/source/gitlab/change.blade.php | 4 +- .../GitlabAppWebhookTokenEncryptionTest.php | 67 +++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/GitlabAppWebhookTokenEncryptionTest.php diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 9371d9d91..e521093d7 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -103,7 +103,7 @@ class Gitlab extends Controller ], 401); } - $gitlab_app = GitlabApp::where('webhook_token', $x_gitlab_token)->first(); + $gitlab_app = GitlabApp::findByWebhookToken($x_gitlab_token); if (! $gitlab_app) { auditLogWebhookFailure('gitlab', 'invalid_token', [ 'event' => $object_kind, diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 3038ff2a1..723b4eeae 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -237,7 +237,9 @@ class Change extends Component if (! empty($this->clientSecretInput)) { $this->gitlab_app->client_secret = $this->clientSecretInput; } - $this->gitlab_app->webhook_token = $this->webhookToken; + 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; diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index 2279fe641..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 = [ @@ -49,6 +53,47 @@ class GitlabApp extends BaseModel ]; } + /** + * 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) { diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index f4156b811..b743fa0d7 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -92,8 +92,8 @@
- +
@if ($applications->count() > 0) 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(); +}); From 3772a554586b8b238b119ce2ad676c4264aea8f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:21:58 +0200 Subject: [PATCH 14/19] fix(ui): show GitHub icon next to GitHub sources Match the GitLab source cards by rendering the shared git-icon next to GitHub App names on the sources index. --- resources/views/source/all.blade.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index b5f5d5e99..3eecfed17 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -21,7 +21,10 @@ {{ wireNavigate() }} href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
-
{{ $source->name }}
+
+ + {{ $source->name }} +
@if (is_null($source->app_id)) Configuration is not finished. @else From 53a42530d0853ec965926766a7b30fdaddc1de37 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:22:57 +0200 Subject: [PATCH 15/19] fix(ui): unify incomplete source status as Setup required Use the same warning label for unfinished GitHub and GitLab sources, and drop the host URL from the GitLab incomplete state line. --- resources/views/source/all.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index 3eecfed17..a3e907349 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -26,7 +26,7 @@ {{ $source->name }}
@if (is_null($source->app_id)) - Configuration is not finished. + Setup required @else @if ($source->organization) Organization: {{ $source->organization }} @@ -46,7 +46,7 @@ @if ($source->isConnected()) Connected — {{ $source->html_url }} @else - Setup required — {{ $source->html_url }} + Setup required @endif
From add7d6ac10963d00089ab1ea7f9faf41b9926c90 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:23:44 +0200 Subject: [PATCH 16/19] fix(ui): show only Connected for finished GitLab sources --- resources/views/source/all.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index a3e907349..904005248 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -44,7 +44,7 @@ {{ $source->name }}
@if ($source->isConnected()) - Connected — {{ $source->html_url }} + Connected @else Setup required @endif From b2fed043c5d6b45eea877f34c808d36dd683373f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:35:59 +0200 Subject: [PATCH 17/19] feat(api): add GitLab Apps CRUD endpoints There was no API for creating GitLab sources. Add /api/v1/gitlab-apps list/create/update/delete with OpenAPI docs, sensitive-field redaction, and feature coverage mirroring the GitHub Apps API. --- app/Http/Controllers/Api/GitlabController.php | 540 ++++++++++++++++++ routes/api.php | 8 +- tests/Feature/Api/GitlabAppsApiTest.php | 182 ++++++ 3 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Api/GitlabController.php create mode 100644 tests/Feature/Api/GitlabAppsApiTest.php 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/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/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(); + }); +}); From aeeb2665cd950dbb74e78701b973217dd5a4ca58 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:54:15 +0200 Subject: [PATCH 18/19] feat(github): add GitHub App connection testing --- app/Livewire/Source/Github/Change.php | 56 +++++++ app/Models/GithubApp.php | 13 ++ .../livewire/source/github/change.blade.php | 9 +- resources/views/source/all.blade.php | 8 +- .../Application/GithubSourceChangeTest.php | 157 ++++++++++++++++++ 5 files changed, 237 insertions(+), 6 deletions(-) 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/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/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/source/all.blade.php b/resources/views/source/all.blade.php index 904005248..c74e46093 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -25,12 +25,10 @@ {{ $source->name }}
- @if (is_null($source->app_id)) - Setup required + @if ($source->isConnected()) + Connected @else - @if ($source->organization) - Organization: {{ $source->organization }} - @endif + Setup required @endif
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'); + }); }); From 3a863378b1f71bf62ef548cf96a6ce8f91023923 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:11:25 +0200 Subject: [PATCH 19/19] fix(gitlab): hide source secrets from unauthorized users Only persist the system-wide setting during instant saves, preventing unvalidated source details from being stored. --- app/Livewire/Source/Gitlab/Change.php | 14 ++--- tests/Feature/GitlabAppAuthorizationTest.php | 55 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index 723b4eeae..f22f1845d 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -7,6 +7,7 @@ use App\Models\PrivateKey; use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Livewire\Component; @@ -252,10 +253,10 @@ class Change extends Component $this->customUser = $this->gitlab_app->custom_user; $this->customPort = $this->gitlab_app->custom_port; $this->clientId = $this->gitlab_app->client_id; - // Decrypt and surface for authorized editors (same pattern as GitHub App client_secret). - $this->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']); - $this->clientSecretInput = $this->gitlab_app->client_secret; - $this->webhookToken = $this->gitlab_app->webhook_token; + 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; @@ -282,8 +283,9 @@ class Change extends Component try { $this->authorize('update', $this->gitlab_app); - $this->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']); - $this->syncData(true); + $this->validateOnly('isSystemWide'); + + $this->gitlab_app->is_system_wide = $this->isSystemWide; $this->gitlab_app->save(); $this->dispatch('success', 'GitLab App updated.'); } catch (\Throwable $e) { diff --git a/tests/Feature/GitlabAppAuthorizationTest.php b/tests/Feature/GitlabAppAuthorizationTest.php index 7fc20d07f..4ff591120 100644 --- a/tests/Feature/GitlabAppAuthorizationTest.php +++ b/tests/Feature/GitlabAppAuthorizationTest.php @@ -46,6 +46,38 @@ beforeEach(function () { }); 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]); @@ -72,6 +104,29 @@ describe('GitLab App authorization', function () { 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]);