From 593e0dbb5d47a7f845e6ea86fa24bfe5bc838d35 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:42:15 +0100 Subject: [PATCH 01/86] Remove source path from volume creation, show only when set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove host_path input from volume creation modal (users should use Directory Mount for bind mounts instead) - Conditionally display Source Path field only when host_path has a value - Add "Remove" button with confirmation modal to clear existing source paths - Add clearHostPath() method to handle source path removal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Shared/Storages/Show.php | 9 ++++ .../project/service/storage.blade.php | 16 -------- .../project/shared/storages/show.blade.php | 41 ++++++++++++------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2091eca14a..e904761955 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -88,4 +88,13 @@ class Show extends Component $this->storage->delete(); $this->dispatch('refreshStorages'); } + + public function clearHostPath() + { + $this->authorize('update', $this->resource); + $this->hostPath = null; + $this->storage->host_path = null; + $this->storage->save(); + $this->dispatch('success', 'Source path removed. Use Directory Mount for host directory bindings.'); + } } diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 9e32cd22da..a33d9b0057 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -118,25 +118,9 @@
Docker Volumes mounted to the container.
- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 694f7d4f29..7b7e58dc5b 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -17,24 +17,20 @@ @endif - @if ($isService || $startedAt) + @if ($hostPath) - - @else - - @endif +
@else
- + @if ($hostPath) + + @endif
@endif @@ -43,14 +39,18 @@ @if ($isFirst)
- + @if ($hostPath) + + @endif
@else
- + @if ($hostPath) + + @endif
@endif @@ -58,6 +58,13 @@ Update + @if ($hostPath) + + @endif - + @if ($hostPath) + + @endif @else
- + @if ($hostPath) + + @endif
@endif From 687d0f88a3e446c5d39697f281181ef0ca11bfb3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:07:08 +0100 Subject: [PATCH 02/86] fix: Adjust dropdown transition margin for improved UI layout --- resources/views/livewire/project/service/storage.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index a33d9b0057..67a2fda815 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -41,7 +41,7 @@
From de214d0bf7124f888bf8b8615bb9915ebc479041 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:09:38 +0100 Subject: [PATCH 03/86] Add auto-generated default volume name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fill volume name with -data (slugified) when creating a new volume mount, improving UX by providing a sensible default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Service/Storage.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 12d8bcbc32..0a309361be 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -56,6 +56,7 @@ class Storage extends Component } $this->refreshStorages(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -202,7 +203,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -216,6 +217,14 @@ class Storage extends Component } } + private function generateDefaultVolumeName(): string + { + return str($this->resource->name ?? 'volume') + ->slug() + ->append('-data') + ->value(); + } + public function render() { return view('livewire.project.service.storage'); From 520b4547fc785582254fb34584c6f22ab8c15ab7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:10:01 +0100 Subject: [PATCH 04/86] Add Monaco editor to file content textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/project/service/storage.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 67a2fda815..5e19adce40 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -182,7 +182,7 @@ label="Destination Path" required helper="File location inside the container" /> + id="file_storage_content" useMonacoEditor> Add From f5f904a403a7c490dd6d72b812a4ceaf289ab412 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:01:36 +0200 Subject: [PATCH 05/86] refactor(storage): separate volumes from directory mounts --- app/Livewire/Project/Service/Storage.php | 15 +++++-- app/Livewire/Project/Shared/Storages/All.php | 19 ++++++++ app/Livewire/Project/Shared/Storages/Show.php | 9 ---- .../project/service/storage.blade.php | 16 ------- .../project/shared/storages/all.blade.php | 16 ++++++- .../PersistentStorageVolumesLayoutTest.php | 45 +++++++++++++++++++ 6 files changed, 90 insertions(+), 30 deletions(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b6..bb8a39d2b1 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,14 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + return str($this->resource->name ?? 'volume') + ->slug() + ->append('-data') + ->value(); + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a4..efe54a6a7d 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ class All extends Component $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 8acdbcb6e4..7e1e2dec1d 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -197,13 +197,4 @@ class Show extends Component return true; } - - public function clearHostPath() - { - $this->authorize('update', $this->resource); - $this->hostPath = null; - $this->storage->host_path = null; - $this->storage->save(); - $this->dispatch('success', 'Source path removed. Use Directory Mount for host directory bindings.'); - } } diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 81c19bd3f0..42ade3da6e 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@

Mount a Docker volume inside the container.

- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 25a4fd7492..e78cb70727 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,7 +154,21 @@
Source Path - + @if (filled($form['hostPath'])) +
+
+ +
+ +
+ @else + - + @endif
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index b22d998914..b6fd9dd74a 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -33,6 +33,7 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); }); +use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; @@ -206,6 +207,50 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); }); +it('keeps bind mount source paths out of the add volume form', function () { + $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); + + expect($storageView) + ->not->toContain('id="host_path"') + ->not->toContain('Swarm Mode detected'); +}); + +it('creates named volumes without a host path in swarm mode', function () { + [$application] = createApplicationWithVolume(); + $application->persistentStorages()->delete(); + + Livewire::test(Storage::class, ['resource' => $application]) + ->set('isSwarm', true) + ->set('name', 'storage-app-data') + ->set('mount_path', '/data') + ->call('submitPersistentVolume') + ->assertHasNoErrors(); + + expect($application->persistentStorages()->first()) + ->name->toBe($application->uuid.'-storage-app-data') + ->host_path->toBeNull(); +}); + +it('uses a resource based default name for new volumes', function () { + [$application] = createApplicationWithVolume(['name' => 'Storage App']); + + Livewire::test(Storage::class, ['resource' => $application]) + ->assertSet('name', 'storage-app-data'); +}); + +it('removes existing bind mount source paths from the volume table', function () { + [$application, $volume] = createApplicationWithVolume(volumeAttributes: [ + 'host_path' => '/srv/storage', + ]); + + Livewire::test(All::class, ['resource' => $application]) + ->assertSet("forms.{$volume->id}.hostPath", '/srv/storage') + ->call('clearHostPath', $volume->id) + ->assertHasNoErrors(); + + expect($volume->refresh()->host_path)->toBeNull(); +}); + it('creates and exposes volume backups for service storage', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, From 680a7e892d16eb901bf4e27d42aec0b07efbd890 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:05:09 +0200 Subject: [PATCH 06/86] fix(storage): clarify source path removal impact --- .../views/livewire/project/shared/storages/all.blade.php | 2 ++ tests/Feature/PersistentStorageVolumesLayoutTest.php | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index e78cb70727..09377dac91 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -163,6 +163,8 @@ buttonTitle="Remove" submitAction="clearHostPath({{ $id }})" :actions="[ 'Are you sure you want to remove the source path?', + 'The next deployment will use a named Docker volume instead.', + 'Data from the existing host directory will not be copied to the named volume.', 'Use a Directory Mount when you need to mount a host directory.', ]" />
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index b6fd9dd74a..1a94eda2e7 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -209,10 +209,14 @@ it('renders volumes as a data table with shared column headers', function () { it('keeps bind mount source paths out of the add volume form', function () { $storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); + $volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); expect($storageView) ->not->toContain('id="host_path"') - ->not->toContain('Swarm Mode detected'); + ->not->toContain('Swarm Mode detected') + ->and($volumesView) + ->toContain('The next deployment will use a named Docker volume instead.') + ->toContain('Data from the existing host directory will not be copied to the named volume.'); }); it('creates named volumes without a host path in swarm mode', function () { From b4f9c9b51d07394177be69eb409bf195e213104c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:14:29 +0200 Subject: [PATCH 07/86] feat(development): add QEMU VM provisioning and server seeding Add configurable development QEMU profiles, host setup, VM lifecycle management, and commands to seed managed VMs as Coolify servers. Improve installation dialog logs with responsive mobile presentation. --- .../ConfigureDevelopmentQemuHost.php | 122 +++++++++ .../Development/ManageDevelopmentQemuVm.php | 33 +++ .../Development/SeedDevelopmentQemuServer.php | 60 +++++ .../Development/StartDevelopmentQemuVm.php | 219 ++++++++++++++++ .../ManageDevelopmentQemuVmCommand.php | 40 +++ .../SeedDevelopmentQemuServerCommand.php | 27 ++ config/development-qemu.php | 124 +++++++++ resources/css/app.css | 30 +++ .../views/components/process-dialog.blade.php | 8 +- .../views/livewire/server/show.blade.php | 2 +- .../server/validate-and-install.blade.php | 25 +- tests/Feature/DevelopmentQemuVmTest.php | 242 ++++++++++++++++++ tests/Feature/ServerValidationDialogTest.php | 43 +++- 13 files changed, 962 insertions(+), 13 deletions(-) create mode 100644 app/Actions/Development/ConfigureDevelopmentQemuHost.php create mode 100644 app/Actions/Development/ManageDevelopmentQemuVm.php create mode 100644 app/Actions/Development/SeedDevelopmentQemuServer.php create mode 100644 app/Actions/Development/StartDevelopmentQemuVm.php create mode 100644 app/Console/Commands/ManageDevelopmentQemuVmCommand.php create mode 100644 app/Console/Commands/SeedDevelopmentQemuServerCommand.php create mode 100644 config/development-qemu.php create mode 100644 tests/Feature/DevelopmentQemuVmTest.php diff --git a/app/Actions/Development/ConfigureDevelopmentQemuHost.php b/app/Actions/Development/ConfigureDevelopmentQemuHost.php new file mode 100644 index 0000000000..a999b77ada --- /dev/null +++ b/app/Actions/Development/ConfigureDevelopmentQemuHost.php @@ -0,0 +1,122 @@ +ensureDevelopmentEnvironment(); + $this->installDependencies(); + $this->runOrFail('systemctl enable --now libvirtd'); + $this->configureLibvirtNetwork(); + $this->configureIpForwarding(); + $this->configureStorage(); + $this->configureDockerForwarding(); + } + + private function installDependencies(): void + { + $binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install']; + $check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && '); + + if (Process::run($check)->successful()) { + return; + } + + if (! File::exists('/usr/bin/apt-get')) { + throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.'); + } + + $this->runOrFail('apt-get update'); + $this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst'); + } + + private function configureLibvirtNetwork(): void + { + $network = config('development-qemu.libvirt_network'); + $networkInfo = Process::run('virsh net-info '.escapeshellarg($network)); + + if ($networkInfo->failed()) { + $networkXml = config('development-qemu.storage_path').'/libvirt-network.xml'; + File::ensureDirectoryExists(dirname($networkXml), 0777, true); + File::put($networkXml, $this->libvirtNetworkXml($network)); + $this->runOrFail('virsh net-define '.escapeshellarg($networkXml)); + $networkInfo = Process::result(output: 'Active: no'); + } + + if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) { + $this->runOrFail('virsh net-start '.escapeshellarg($network)); + } + + $this->runOrFail('virsh net-autostart '.escapeshellarg($network)); + } + + private function configureIpForwarding(): void + { + $this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf"); + $this->runOrFail('sysctl -w net.ipv4.ip_forward=1'); + } + + private function configureStorage(): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory, 0777, true); + File::chmod($directory, 0777); + } + + private function configureDockerForwarding(): void + { + $dockerNetwork = escapeshellarg(config('development-qemu.docker_network')); + $subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}')); + $subnet = trim($subnetResult->output()); + + if ($subnetResult->failed() || $subnet === '') { + throw new RuntimeException('Unable to determine the Coolify Docker network subnet.'); + } + + $rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet'))); + + Process::run("iptables -D LIBVIRT_FWI {$rule}"); + $this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}"); + } + + private function libvirtNetworkXml(string $network): string + { + return << + {$network} + + + + + + + + +XML; + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU host configuration may only run in development environments.'); + } + } +} diff --git a/app/Actions/Development/ManageDevelopmentQemuVm.php b/app/Actions/Development/ManageDevelopmentQemuVm.php new file mode 100644 index 0000000000..a1257cbda1 --- /dev/null +++ b/app/Actions/Development/ManageDevelopmentQemuVm.php @@ -0,0 +1,33 @@ + $profileNames */ + public function handle(string|array $profileNames): void + { + $profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames]; + + foreach ($profileNames as $index => $profileName) { + StartDevelopmentQemuVm::run($profileName, $index === 0); + + try { + SeedDevelopmentQemuServer::run($profileName, $index === 0); + } catch (QueryException $exception) { + $keepOthers = $index === 0 ? '' : ' --keep-others'; + $result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers); + + if ($result->failed()) { + throw $exception; + } + } + } + } +} diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php new file mode 100644 index 0000000000..10cb0b3c7d --- /dev/null +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -0,0 +1,60 @@ +ensureDevelopmentEnvironment(); + $profile = config("development-qemu.profiles.{$profileName}"); + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + $privateKey = PrivateKey::query()->find(1); + + if (! $privateKey) { + throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.'); + } + + if ($removeOtherServers) { + Server::query() + ->where('uuid', 'like', 'development-qemu-%') + ->where('uuid', '!=', $profile['uuid']) + ->delete(); + } + + $server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server; + $server->forceFill(['uuid' => $profile['uuid']]); + $server->fill([ + 'name' => $profile['name'], + 'description' => 'Development-only QEMU virtual machine managed by dev:qemu.', + 'ip' => $profile['ip'], + 'port' => 22, + 'user' => $profile['user'], + 'team_id' => 0, + 'private_key_id' => $privateKey->id, + ]); + $server->deleted_at = null; + $server->save(); + + return $server->fresh(); + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VM servers may only be seeded in development environments.'); + } + } +} diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php new file mode 100644 index 0000000000..d6d9c00b2f --- /dev/null +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -0,0 +1,219 @@ +ensureDevelopmentEnvironment(); + $profiles = config('development-qemu.profiles'); + $profile = $profiles[$profileName] ?? null; + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + ConfigureDevelopmentQemuHost::run(); + $this->configureDhcpReservation($profile); + + if ($resetManagedVms) { + foreach ($profiles as $managedProfile) { + Process::run('virsh destroy '.escapeshellarg($managedProfile['domain'])); + Process::run('virsh undefine '.escapeshellarg($managedProfile['domain'])); + $this->deleteVmData($managedProfile['domain']); + } + } + + $this->createVm($profile); + + ConfigureDevelopmentQemuHost::run(); + $this->waitForSsh($profile['ip']); + } + + /** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ + private function createVm(array $profile): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory); + File::chmod($directory, 0777); + $this->moveLegacyFiles($directory); + $baseImage = "{$directory}/{$profile['image']}"; + $disk = "{$directory}/{$profile['domain']}.qcow2"; + $userData = "{$directory}/{$profile['domain']}-user-data.yaml"; + $networkConfig = "{$directory}/{$profile['domain']}-network.yaml"; + + if (! File::exists($baseImage)) { + $this->runOrFail(sprintf( + 'curl --fail --location --output %s %s', + escapeshellarg($baseImage), + escapeshellarg($profile['image_url']), + )); + } + + if (! File::exists($disk)) { + $this->runOrFail(sprintf( + 'qemu-img create -f qcow2 -F qcow2 -b %s %s %s', + escapeshellarg($baseImage), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.disk_size')), + )); + } + + if (File::exists($baseImage)) { + File::chmod($baseImage, 0644); + } + + if (File::exists($disk)) { + File::chmod($disk, 0666); + } + + File::put($userData, $this->userData($profile)); + File::put($networkConfig, $this->networkConfig($profile)); + + $this->runOrFail(sprintf( + 'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole', + escapeshellarg($profile['domain']), + config('development-qemu.memory'), + config('development-qemu.vcpus'), + escapeshellarg($profile['os_variant']), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.libvirt_network')), + escapeshellarg($profile['mac']), + escapeshellarg($userData), + escapeshellarg($networkConfig), + )); + } + + private function moveLegacyFiles(string $directory): void + { + $legacyDirectory = storage_path('app/development-qemu'); + + if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) { + return; + } + + foreach (File::files($legacyDirectory) as $file) { + $destination = "{$directory}/{$file->getFilename()}"; + + if (! File::exists($destination)) { + File::move($file->getPathname(), $destination); + } + } + } + + private function deleteVmData(string $domain): void + { + $directory = config('development-qemu.storage_path'); + File::delete([ + "{$directory}/{$domain}.qcow2", + "{$directory}/{$domain}-user-data.yaml", + "{$directory}/{$domain}-network.yaml", + ]); + } + + /** @param array{user: string, provisioner: string} $profile */ + private function userData(array $profile): string + { + $publicKey = config('development-qemu.public_key'); + $adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel'; + $sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n"; + + [$packages, $startDocker] = match ($profile['provisioner']) { + 'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'], + 'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'], + default => [" - docker.io\n - sudo", 'systemctl enable --now docker'], + }; + $addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}"; + + return <<failed()) { + throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.'); + } + + if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) { + return; + } + + $host = sprintf("", $profile['mac'], $profile['domain'], $profile['ip']); + $this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config'); + } + + private function waitForSsh(string $ip): void + { + $container = escapeshellarg(config('development-qemu.coolify_container')); + $probe = <<<'PHP' +$deadline = time() + 120; +do { + $socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1); + if (is_resource($socket)) { + fclose($socket); + exit(0); + } + sleep(1); +} while (time() < $deadline); +exit(1); +PHP; + $this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip)); + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VMs may only be managed in development environments.'); + } + } +} diff --git a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php new file mode 100644 index 0000000000..a93e1779b9 --- /dev/null +++ b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php @@ -0,0 +1,40 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $profiles = config('development-qemu.profiles'); + $profileNames = $this->argument('profiles') ?: multiselect( + label: 'Which QEMU servers should be started and seeded?', + options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(), + required: true, + ); + + ManageDevelopmentQemuVm::run($profileNames); + + foreach ($profileNames as $profileName) { + $profile = $profiles[$profileName]; + $this->info("Started and seeded {$profile['label']} at {$profile['ip']}."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php new file mode 100644 index 0000000000..4772317c39 --- /dev/null +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -0,0 +1,27 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others')); + $this->info("Seeded {$server->name} at {$server->ip}."); + + return self::SUCCESS; + } +} diff --git a/config/development-qemu.php b/config/development-qemu.php new file mode 100644 index 0000000000..ae77c8341e --- /dev/null +++ b/config/development-qemu.php @@ -0,0 +1,124 @@ + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68', + 'storage_path' => env('DEVELOPMENT_QEMU_STORAGE_PATH', '/var/lib/libvirt/images/coolify-development'), + 'gateway' => '192.168.122.1', + 'subnet' => '192.168.122.0/24', + 'prefix' => 24, + 'dns' => '1.1.1.1', + 'memory' => 2048, + 'vcpus' => 2, + 'disk_size' => '20G', + 'libvirt_network' => 'default', + 'docker_network' => 'coolify', + 'coolify_container' => 'coolify', + 'profiles' => [ + 'ubuntu-root' => [ + 'label' => 'Ubuntu 24.04 (root)', + 'domain' => 'coolify-dev-ubuntu-root', + 'uuid' => 'development-qemu-ubuntu-root', + 'name' => 'QEMU Ubuntu (root)', + 'ip' => '192.168.122.10', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:01', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'ubuntu-non-root' => [ + 'label' => 'Ubuntu 24.04 (non-root)', + 'domain' => 'coolify-dev-ubuntu-non-root', + 'uuid' => 'development-qemu-ubuntu-non-root', + 'name' => 'QEMU Ubuntu (non-root)', + 'ip' => '192.168.122.11', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:02', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'debian-root' => [ + 'label' => 'Debian 12 (root)', + 'domain' => 'coolify-dev-debian-root', + 'uuid' => 'development-qemu-debian-root', + 'name' => 'QEMU Debian (root)', + 'ip' => '192.168.122.20', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:03', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'debian-non-root' => [ + 'label' => 'Debian 12 (non-root)', + 'domain' => 'coolify-dev-debian-non-root', + 'uuid' => 'development-qemu-debian-non-root', + 'name' => 'QEMU Debian (non-root)', + 'ip' => '192.168.122.21', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:04', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'centos-root' => [ + 'label' => 'CentOS Stream 9 (root)', + 'domain' => 'coolify-dev-centos-root', + 'uuid' => 'development-qemu-centos-root', + 'name' => 'QEMU CentOS Stream (root)', + 'ip' => '192.168.122.30', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:05', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'centos-non-root' => [ + 'label' => 'CentOS Stream 9 (non-root)', + 'domain' => 'coolify-dev-centos-non-root', + 'uuid' => 'development-qemu-centos-non-root', + 'name' => 'QEMU CentOS Stream (non-root)', + 'ip' => '192.168.122.31', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:06', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'alpine-root' => [ + 'label' => 'Alpine Linux 3.24 (root)', + 'domain' => 'coolify-dev-alpine-root', + 'uuid' => 'development-qemu-alpine-root', + 'name' => 'QEMU Alpine (root)', + 'ip' => '192.168.122.40', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:07', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + 'alpine-non-root' => [ + 'label' => 'Alpine Linux 3.24 (non-root)', + 'domain' => 'coolify-dev-alpine-non-root', + 'uuid' => 'development-qemu-alpine-non-root', + 'name' => 'QEMU Alpine (non-root)', + 'ip' => '192.168.122.41', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:08', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + ], +]; diff --git a/resources/css/app.css b/resources/css/app.css index 9dc39b7cc5..30ccf4ac02 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2121,6 +2121,36 @@ input[type="search"]::-webkit-search-results-decoration { width: 100%; } +.validation-installation-logs { + border: 1px solid var(--coollabs-fill); +} + +.checkpoint-scroll-fade::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 1; + width: 2rem; + background: linear-gradient(to left, var(--coollabs-base), transparent); + pointer-events: none; +} + +@media (max-width: 639px) { + .process-dialog-mobile-fullscreen { + height: 100dvh !important; + min-height: 100dvh; + max-height: 100dvh; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important; + } + + .process-dialog-mobile-fullscreen .process-dialog-body { + border-radius: 0; + } +} + /* Data table (layer-card body, full-bleed) */ .data-table-header { display: grid; diff --git a/resources/views/components/process-dialog.blade.php b/resources/views/components/process-dialog.blade.php index dc4f9c3b59..193e5680ce 100644 --- a/resources/views/components/process-dialog.blade.php +++ b/resources/views/components/process-dialog.blade.php @@ -1,5 +1,6 @@ @props([ 'closeWithX' => false, + 'mobileFullscreen' => false, 'open' => false, 'size' => 'lg', ]) @@ -40,7 +41,11 @@
+ @class([ + 'flex min-h-full items-center justify-center', + 'p-4 sm:p-6' => ! $mobileFullscreen, + 'p-0 sm:p-6' => $mobileFullscreen, + ])>
$mobileFullscreen, $panelWidth, // Fixed shell size so empty “waiting for process” state does not collapse. 'min-h-[min(70dvh,28rem)] h-[min(85dvh,52rem)] max-h-[calc(100dvh-2rem)]', diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d2d28f0955..1ee4279bd4 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -184,7 +184,7 @@
@endif - + Validate and configure @else
+ class="shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">

Validation checkpoints

-
- @foreach ($checkpoints as $checkpoint) - - @endforeach +
+
+ @foreach ($checkpoints as $checkpoint) + + @endforeach +
@@ -107,7 +118,7 @@
@elseif ($isInstalling) -
+
diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php new file mode 100644 index 0000000000..18e5ff9952 --- /dev/null +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -0,0 +1,242 @@ + 'local']); + $this->seed([UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class]); +}); + +it('registers the interactive qemu command', function () { + expect(Artisan::all()) + ->toHaveKey('dev:qemu') + ->toHaveKey('dev:qemu:seed') + ->and(Artisan::all()['dev:qemu'])->toBeInstanceOf(ManageDevelopmentQemuVmCommand::class) + ->and(Artisan::all()['dev:qemu']->getDefinition()->getArgument('profiles')->isArray())->toBeTrue() + ->and(Artisan::all()['dev:qemu:seed'])->toBeInstanceOf(SeedDevelopmentQemuServerCommand::class); +}); + +it('prevents qemu commands from running outside development', function () { + config(['app.env' => 'production']); + Process::fake(); + + expect(Artisan::call('dev:qemu', ['profiles' => ['ubuntu-root']]))->toBe(Command::FAILURE) + ->and(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root']))->toBe(Command::FAILURE); + + Process::assertNothingRan(); +}); + +it('provides root and non-root profiles for every supported distribution', function () { + $profiles = collect(config('development-qemu.profiles')); + + expect($profiles->keys()->all())->toBe([ + 'ubuntu-root', + 'ubuntu-non-root', + 'debian-root', + 'debian-non-root', + 'centos-root', + 'centos-non-root', + 'alpine-root', + 'alpine-non-root', + ])->and($profiles->pluck('ip')->unique()->count())->toBe(8) + ->and($profiles->pluck('mac')->unique()->count())->toBe(8) + ->and($profiles->filter(fn (array $profile) => $profile['user'] === 'root')->count())->toBe(4) + ->and($profiles->filter(fn (array $profile) => $profile['user'] !== 'root')->count())->toBe(4); +}); + +it('stores vm disks in a libvirt-accessible directory', function () { + expect(config('development-qemu.storage_path'))->toStartWith('/var/lib/libvirt/images/'); +}); + +it('automatically configures the qemu host', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'command -v')) { + return Process::result(); + } + + if (str_contains($process->command, 'net-info')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'systemctl enable --now libvirtd')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-define')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-autostart')); + Process::assertRan(fn ($process) => str_contains($process->command, 'sysctl -w net.ipv4.ip_forward=1')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('does not restart an active libvirt network', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'net-info')) { + return Process::result(output: "Name: default\nActive: yes\n"); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI 1')); +}); + +it('seeds one predefined root qemu server', function () { + $server = SeedDevelopmentQemuServer::run('ubuntu-root'); + + expect($server->uuid)->toBe('development-qemu-ubuntu-root') + ->and($server->ip)->toBe('192.168.122.10') + ->and($server->user)->toBe('root') + ->and($server->team_id)->toBe(0) + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('replaces the seeded qemu server with the selected non-root equivalent', function () { + SeedDevelopmentQemuServer::run('ubuntu-root'); + $server = SeedDevelopmentQemuServer::run('ubuntu-non-root'); + + expect($server->ip)->toBe('192.168.122.11') + ->and($server->user)->toBe('coolify') + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('deletes managed vm data and freshly creates only the selected vm', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-reset-test-'.uniqid(); + config(['development-qemu.storage_path' => $storagePath]); + File::ensureDirectoryExists($storagePath); + File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'old data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data'); + + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* iptables -C *' => Process::result(exitCode: 1), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-non-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'net-update') && str_contains($process->command, 'ip-dhcp-host') && str_contains($process->command, '192.168.122.11')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); + expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBeFalse() + ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse(); +}); + +it('rejects qemu vm management outside development', function () { + config(['app.env' => 'production']); + + expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root')) + ->toThrow(RuntimeException::class, 'development environments'); +}); + +it('can create a vm without a host database connection', function () { + config([ + 'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-test-'.uniqid(), + ]); + DB::enableQueryLog(); + Process::fake(function ($process) { + if (str_contains($process->command, 'net-dumpxml')) { + return Process::result(output: ''); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'virsh dominfo')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + expect(DB::getQueryLog())->toBeEmpty(); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('seeds through the coolify container when the host database is unavailable', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fallback-test-'.uniqid()]); + SeedDevelopmentQemuServer::mock() + ->shouldReceive('handle') + ->once() + ->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable'))); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec coolify php artisan dev:qemu:seed') && str_contains($process->command, 'ubuntu-root')); +}); + +it('starts and seeds root and non-root profiles together', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-multi-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run(['ubuntu-root', 'ubuntu-non-root']); + + expect(Server::query()->whereIn('uuid', [ + 'development-qemu-ubuntu-root', + 'development-qemu-ubuntu-non-root', + ])->count())->toBe(2); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); +}); diff --git a/tests/Feature/ServerValidationDialogTest.php b/tests/Feature/ServerValidationDialogTest.php index a22056cac4..e1c75e14af 100644 --- a/tests/Feature/ServerValidationDialogTest.php +++ b/tests/Feature/ServerValidationDialogTest.php @@ -4,12 +4,29 @@ test('server revalidation opens in the centered process dialog', function () { $view = file_get_contents(resource_path('views/livewire/server/show.blade.php')); expect($view) - ->toContain('') + ->toContain('') ->toContain(':isHighlighted="! $server->isFunctional()"') ->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"') ->not->toContain('toContain('') + ->and($dialog) + ->toContain("'mobileFullscreen' => false") + ->toContain("'process-dialog-mobile-fullscreen' => \$mobileFullscreen") + ->and($styles) + ->toContain('@media (max-width: 639px)') + ->toContain('.process-dialog-mobile-fullscreen') + ->toContain('height: 100dvh !important') + ->toContain('box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important'); +}); + test('completed server validation shows a close action instead of empty logs', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); @@ -25,11 +42,17 @@ test('completed server validation shows a close action instead of empty logs', f test('installation logs are only shown after an installation starts', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); $component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + $styles = file_get_contents(resource_path('css/app.css')); - expect($view)->toContain('@elseif ($isInstalling)') + expect($view) + ->toContain('@elseif ($isInstalling)') + ->toContain('application-settings-section validation-installation-logs') ->and($component) ->toContain('public bool $isInstalling = false;') - ->toContain('$this->isInstalling = true;'); + ->toContain('$this->isInstalling = true;') + ->and($styles) + ->toContain('.validation-installation-logs') + ->toContain('border: 1px solid var(--coollabs-fill)'); }); test('server validation content scrolls within the dialog', function () { @@ -43,10 +66,22 @@ test('server validation content scrolls within the dialog', function () { test('validation checkpoints use the standard bordered list treatment', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $styles = file_get_contents(resource_path('css/app.css')); expect($view) ->toContain('data-validation-checkpoints') - ->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]'); + ->toContain('shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]') + ->toContain('checkpoint-scroll-fade') + ->toContain('snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth scrollbar') + ->toContain('data-checkpoint-status="{{ $checkpoint[\'status\'] }}"') + ->toContain('basis-[88%] shrink-0 snap-start sm:basis-72 lg:basis-80') + ->toContain("querySelector('[data-checkpoint-status=running]')") + ->toContain("scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })") + ->toContain('new MutationObserver') + ->toContain("attributeFilter: ['data-checkpoint-status']") + ->toContain('x-destroy="observer?.disconnect()"') + ->and($styles) + ->toContain('.checkpoint-scroll-fade::after'); }); test('all validation checkpoints remain visible while only the current phase runs', function () { From 3d08bc898a20f0a6084df57cffdcc961e9174ba4 Mon Sep 17 00:00:00 2001 From: Rohit Tiwari Date: Wed, 19 Aug 2026 01:43:25 +0530 Subject: [PATCH 08/86] fix(ui): close account menu on outside click when appearance is expanded (#11374) --- .../views/components/top-user-menu.blade.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index 8b9d42e73e..b176f4f219 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -16,13 +16,19 @@ themeColor: localStorage.getItem('themeColor') || '#6b16ed', themeColorFrame: null, avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null), + openPanel() { + this.appearanceOpen = false; + this.open = true; + }, + closePanel() { + this.open = false; + }, setTheme(type, closeMenu = true) { this.theme = type; localStorage.setItem('theme', type); if (closeMenu) { - this.appearanceOpen = false; - this.open = false; + this.closePanel(); } const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; @@ -62,9 +68,9 @@ localStorage.setItem('themeColor', color); localStorage.setItem('theme', 'custom'); }, -}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false" - @click.outside="open = false; appearanceOpen = false"> -
@foreach (auth()->user()->teams as $team) - @@ -66,7 +66,7 @@ Teams
@foreach (auth()->user()->teams as $team) - diff --git a/resources/views/livewire/upgrade.blade.php b/resources/views/livewire/upgrade.blade.php index 97c08c0357..b1ad048e1c 100644 --- a/resources/views/livewire/upgrade.blade.php +++ b/resources/views/livewire/upgrade.blade.php @@ -6,6 +6,14 @@ })"> @if ($isUpgradeAvailable)
+ @if ($fullButton) + + Upgrade now + + + Updating… + + @else + @endif
+ @elseif ($fullButton) +

Coolify is up to date.

@endif
diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index 647c327dee..d6bb96d15a 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -59,6 +59,7 @@ describe('GET /api/v1/notifications/*', function () { $response->assertJsonStructure([ 'team_id', 'smtp_enabled', + 'smtp_ehlo_domain', 'deployment_failure_email_notifications', 'use_instance_email_settings', ]); @@ -171,6 +172,7 @@ describe('PATCH /api/v1/notifications/*', function () { 'smtp_enabled' => true, 'smtp_from_address' => 'alerts@example.com', 'smtp_host' => 'smtp.example.com', + 'smtp_ehlo_domain' => 'coolify.example.com', 'smtp_port' => 587, 'smtp_encryption' => 'starttls', 'deployment_failure_email_notifications' => false, @@ -178,16 +180,42 @@ describe('PATCH /api/v1/notifications/*', function () { $response->assertSuccessful(); $response->assertJsonPath('smtp_enabled', true); + $response->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); $response->assertJsonPath('deployment_failure_email_notifications', false); $settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); expect($settings->smtp_enabled)->toBeTrue() ->and($settings->smtp_from_address)->toBe('alerts@example.com') ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com') ->and($settings->smtp_port)->toBe(587) ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('validates the smtp ehlo domain', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => 'not a hostname', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); + }); + + test('clears the smtp ehlo domain', function () { + $this->team->emailNotificationSettings->update([ + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_ehlo_domain' => null, + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', null); + + expect($this->team->emailNotificationSettings->fresh()->smtp_ehlo_domain)->toBeNull(); + }); + test('updates discord notification settings', function () { $response = $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/discord', [ diff --git a/tests/Feature/SwitchTeamTest.php b/tests/Feature/SwitchTeamTest.php new file mode 100644 index 0000000000..9c4724939a --- /dev/null +++ b/tests/Feature/SwitchTeamTest.php @@ -0,0 +1,32 @@ + 0]); + + $this->user = User::factory()->create(); + $this->currentTeam = $this->user->teams()->first(); + $this->otherTeam = Team::factory()->create(); + $this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->currentTeam]); +}); + +test('switching teams keeps the current page URL', function () { + $currentUrl = route('security.api-tokens', ['page' => 2]); + + Livewire::test(SwitchTeam::class) + ->call('switch_to', $this->otherTeam->id, $currentUrl) + ->assertRedirect($currentUrl); + + expect(session('currentTeam')->is($this->otherTeam))->toBeTrue(); +}); diff --git a/tests/Feature/UpgradeComponentTest.php b/tests/Feature/UpgradeComponentTest.php index dc14516a53..306a486de7 100644 --- a/tests/Feature/UpgradeComponentTest.php +++ b/tests/Feature/UpgradeComponentTest.php @@ -64,18 +64,34 @@ it('treats a brief upgrade poll miss as a reconnect, not a lost-contact failure' ->not->toContain('Lost contact with Coolify'); }); -it('uses sidebar state css instead of nested alpine state for upgrade labels', function () { +it('renders the upgrade control in the mobile top bar', function () { + $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); + + expect($layout) + ->toMatch('/MOBILE TOP BAR[\s\S]*?]*>[\s\S]*?Open sidebar/') + ->toContain('key="mobile-upgrade"'); +}); + +it('supports a full size update button for settings', function () { $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); - $utilitiesCss = file_get_contents(resource_path('css/utilities.css')); + $settingsView = file_get_contents(resource_path('views/livewire/settings/updates.blade.php')); expect($upgradeView) - ->toContain('class="text-left menu-item-label sidebar-collapsed-label"') - ->toContain('>In progress') - ->toContain('>Upgrade') - ->not->toContain(':class="collapsed && \'lg:hidden\'"') - ->and($utilitiesCss) - ->toContain('.sidebar-collapsed .sidebar-collapsed-label') - ->toContain('display: none;'); + ->toContain('$fullButton') + ->toContain('Upgrade now') + ->and($settingsView) + ->toContain('Update Coolify') + ->toContain(':full-button="true"') + ->toContain('key="settings-upgrade"'); +}); + +it('uses compact labels that do not depend on desktop sidebar state', function () { + $upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php')); + + expect($upgradeView) + ->toContain('Updating') + ->toContain('Update available') + ->not->toContain(':class="collapsed'); }); it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () { From 59be75bdaca25cecbf59f3d2acad81d07ce7bcd5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:52:59 +0200 Subject: [PATCH 19/86] fix(email): prevent Proton SMTP From header folding (#11400) --- app/Notifications/Channels/EmailChannel.php | 3 +- .../Channels/TransactionalEmailChannel.php | 4 +-- .../TransactionalEmails/ResetPassword.php | 1 + bootstrap/helpers/notifications.php | 35 +++++++++++++++---- tests/Unit/MailFromIdentityTest.php | 35 +++++++++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 3f40b694b9..fd62a90720 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -88,8 +88,7 @@ class EmailChannel ); $mailer = new Mailer($transport); - $email = (new Email) - ->from(mail_from_address($settings)) + $email = mail_from_email(new Email, $settings) ->to(...$recipients) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()); diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 83c61f6d0f..f4e8b294a4 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -27,12 +27,10 @@ class TransactionalEmailChannel } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); - $from = mail_from_identity($settings); Mail::send( [], [], - fn (Message $message) => $message - ->from($from['address'], $from['name']) + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mailMessage->subject) ->html((string) $mailMessage->render()) diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 0e33bf495f..cb65391068 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -57,6 +57,7 @@ class ResetPassword extends Notification $from = mail_from_identity($this->settings); $mail = new MailMessage; $mail->from($from['address'], $from['name']); + $mail->withSymfonyMessage(fn ($message) => prevent_mail_from_header_folding($message, $this->settings)); $mail->subject('Coolify: Reset Password'); $mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]); diff --git a/bootstrap/helpers/notifications.php b/bootstrap/helpers/notifications.php index c5b4388807..e76487383d 100644 --- a/bootstrap/helpers/notifications.php +++ b/bootstrap/helpers/notifications.php @@ -6,6 +6,7 @@ use Illuminate\Mail\Message; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Mail; use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; function is_transactional_emails_enabled(): bool { @@ -45,6 +46,32 @@ function mail_from_formatted(object $settings): string return mail_from_address($settings)->toString(); } +function mail_from_email(Email $email, object $settings): Email +{ + $email->from(mail_from_address($settings)); + prevent_mail_from_header_folding($email, $settings); + + return $email; +} + +function mail_from_message(Message $message, object $settings): Message +{ + $identity = mail_from_identity($settings); + $message->from($identity['address'], $identity['name']); + prevent_mail_from_header_folding($message->getSymfonyMessage(), $settings); + + return $message; +} + +function prevent_mail_from_header_folding(Email $email, object $settings): void +{ + if (strtolower(trim((string) ($settings->smtp_host ?? ''))) !== 'smtp.protonmail.ch') { + return; + } + + $email->getHeaders()->get('From')?->setMaxLineLength(998); +} + function send_internal_notification(string $message): void { try { @@ -61,14 +88,11 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null if (blank($type)) { throw new Exception('No email settings found.'); } - $from = mail_from_identity($settings); - if ($cc) { Mail::send( [], [], - fn (Message $message) => $message - ->from($from['address'], $from['name']) + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->replyTo($email) ->cc($cc) @@ -79,8 +103,7 @@ function send_user_an_email(MailMessage $mail, string $email, ?string $cc = null Mail::send( [], [], - fn (Message $message) => $message - ->from($from['address'], $from['name']) + fn (Message $message) => mail_from_message($message, $settings) ->to($email) ->subject($mail->subject) ->html((string) $mail->render()) diff --git a/tests/Unit/MailFromIdentityTest.php b/tests/Unit/MailFromIdentityTest.php index 4a317c91ca..d9587f9c7f 100644 --- a/tests/Unit/MailFromIdentityTest.php +++ b/tests/Unit/MailFromIdentityTest.php @@ -1,6 +1,8 @@ toBe('"Coolify" '); }); +it('keeps the smtp from name and address on the same header line', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps the Laravel mail from name and address on the same header line', function () { + $message = mail_from_message(new Message(new Email), (object) [ + 'smtp_host' => 'smtp.protonmail.ch', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($message->getSymfonyMessage()->getHeaders()->get('From')?->toString()) + ->toBe('From: Advanced Digital Marketing LTDA '); +}); + +it('keeps Symfony header folding for other smtp providers', function () { + $email = mail_from_email(new Email, (object) [ + 'smtp_host' => 'smtp.example.com', + 'smtp_from_address' => 'contact@advanceddigitalmarketingltda.com', + 'smtp_from_name' => 'Advanced Digital Marketing LTDA', + ]); + + expect($email->getHeaders()->get('From')?->toString()) + ->toBe("From: Advanced Digital Marketing LTDA\r\n "); +}); + it('treats a blank from name as missing instead of sending an unnamed address', function () { $identity = mail_from_identity((object) [ 'smtp_from_address' => 'admin@example.com', From 89480bcc61636856c186ca8a279d8eeb4e3db973 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:57:26 +0200 Subject: [PATCH 20/86] feat(api): add instance email settings endpoints Add root-team-authorized API access for SMTP and Resend settings with validation, sensitive-field controls, auditing, and coverage. --- .../Api/InstanceEmailSettingsController.php | 97 ++++++++++++ routes/api.php | 3 + .../Api/InstanceEmailSettingsApiTest.php | 141 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 app/Http/Controllers/Api/InstanceEmailSettingsController.php create mode 100644 tests/Feature/Api/InstanceEmailSettingsApiTest.php diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php new file mode 100644 index 0000000000..ad84b6629d --- /dev/null +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -0,0 +1,97 @@ + []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + ] + )] + public function show(): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('view', $settings); + + return response()->json($this->serialize($settings)); + } + + #[OA\Patch( + summary: 'Update instance email settings', + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.', + path: '/settings/email', operationId: 'update-instance-email-settings', + security: [['bearerAuth' => []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Updated instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('update', $settings); + + $validator = customApiValidator($request->json()->all(), [ + 'smtp_enabled' => 'sometimes|boolean', + 'smtp_from_address' => 'sometimes|nullable|email', + 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'smtp_host' => 'sometimes|nullable|string|max:255', + 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', + 'smtp_username' => 'sometimes|nullable|string|max:255', + 'smtp_password' => 'sometimes|nullable|string|max:255', + 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname], + 'resend_enabled' => 'sometimes|boolean', + 'resend_api_key' => 'sometimes|nullable|string|max:255', + ]); + + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $settings->fill($validator->validated()); + $settings->save(); + + auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]); + + return response()->json($this->serialize($settings->refresh())); + } + + private function authorizeRootTeam(string $ability, InstanceSettings $settings): void + { + $teamId = getTeamIdFromToken(); + abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.'); + $this->authorize($ability, $settings); + } + + private function serialize(InstanceSettings $settings): array + { + exposeSensitiveFields($settings); + + return Arr::only($settings->toArray(), self::FIELDS); + } +} diff --git a/routes/api.php b/routes/api.php index 0ce9a7f11f..2d19ae96dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ 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\InstanceEmailSettingsController; use App\Http\Controllers\Api\NotificationsController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; @@ -83,6 +84,8 @@ Route::group([ Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']); Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']); Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']); + Route::get('/settings/email', [InstanceEmailSettingsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/settings/email', [InstanceEmailSettingsController::class, 'update'])->middleware(['api.ability:write:sensitive']); Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/InstanceEmailSettingsApiTest.php b/tests/Feature/Api/InstanceEmailSettingsApiTest.php new file mode 100644 index 0000000000..116481faa9 --- /dev/null +++ b/tests/Feature/Api/InstanceEmailSettingsApiTest.php @@ -0,0 +1,141 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->rootTeam = Team::factory()->create(['id' => 0]); +}); + +function instanceEmailToken(User $user, Team $team, string $role, array $abilities): string +{ + $team->members()->attach($user->id, ['role' => $role]); + session(['currentTeam' => $team]); + + return $user->createToken('instance-email-test', $abilities)->plainTextToken; +} + +function instanceEmailHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +test('root team owners can get instance email settings', function () { + InstanceSettings::findOrFail(0)->update([ + 'smtp_enabled' => true, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_enabled', true) + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com') + ->assertJsonMissingPath('smtp_password'); +}); + +test('root team admins can update instance email settings', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'alerts@example.com', + 'smtp_from_name' => 'Coolify', + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_encryption' => 'starttls', + 'smtp_username' => 'coolify', + 'smtp_password' => 'secret', + 'smtp_timeout' => 10, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); + + $settings = InstanceSettings::findOrFail(0); + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com'); +}); + +test('instance email settings reject non-root teams', function () { + $team = Team::factory()->create(); + $token = instanceEmailToken(User::factory()->create(), $team, 'owner', ['read', 'write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('instance email settings reject root team members', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'member', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('instance email settings validate the smtp ehlo domain', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_ehlo_domain' => 'not a hostname']) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); +}); + +test('updating instance email settings requires write sensitive', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('root admins cannot use a token issued for another team', function () { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $team = Team::factory()->create(); + $token = instanceEmailToken($user, $team, 'owner', ['read', 'write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('read sensitive exposes instance email secrets to root team admins', function () { + InstanceSettings::findOrFail(0)->update(['smtp_password' => 'secret']); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['read', 'read:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_password', 'secret'); +}); From c3a86b054a78557e65172ad7e033c93de4b80584 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:18:05 +0200 Subject: [PATCH 21/86] fix(upgrade): persist target image before container recreation (#11401) --- other/nightly/upgrade.sh | 3 +++ scripts/upgrade.sh | 3 +++ tests/Unit/UpgradePostgresScriptTest.php | 29 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 94fb77607f..c5f38df6e0 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -216,6 +216,9 @@ done log "All images pulled successfully" echo " All images pulled successfully." +set_env_var "LATEST_IMAGE" "$LATEST_IMAGE" +set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE" + log_section "Step 4/6: Stopping and restarting containers" write_status "4" "Stopping containers" echo "" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 516a9d7ebc..5baac23acb 100644 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -229,6 +229,9 @@ done log "All images pulled successfully" echo " All images pulled successfully." +set_env_var "LATEST_IMAGE" "$LATEST_IMAGE" +set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE" + log_section "Step 4/6: Stopping and restarting containers" write_status "4" "Stopping containers" echo "" diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php index a888bc3a41..677a8fec3e 100644 --- a/tests/Unit/UpgradePostgresScriptTest.php +++ b/tests/Unit/UpgradePostgresScriptTest.php @@ -62,6 +62,35 @@ it('persists the selected registry url during upgrades', function (string $path) 'nightly upgrade' => 'other/nightly/upgrade.sh', ]); +it('persists the target image and runtime version before recreating containers', function (string $path) { + $script = file_get_contents(getcwd().'/'.$path); + if ($script === false) { + throw new RuntimeException("Unable to read {$path}"); + } + + $position = static function (string $needle) use ($script): int { + $offset = strpos($script, $needle); + if ($offset === false) { + throw new RuntimeException("Missing marker: {$needle}"); + } + + return $offset; + }; + + $latestImagePosition = $position('set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"'); + $coolifyVersionPosition = $position('set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"'); + $imagesPulledPosition = $position('log "All images pulled successfully"'); + $composeUpPosition = $position('docker compose --env-file /data/coolify/source/.env'); + + expect($latestImagePosition)->toBeGreaterThan($imagesPulledPosition) + ->and($coolifyVersionPosition)->toBeGreaterThan($imagesPulledPosition) + ->and($latestImagePosition)->toBeLessThan($composeUpPosition) + ->and($coolifyVersionPosition)->toBeLessThan($composeUpPosition); +})->with([ + 'stable upgrade' => 'scripts/upgrade.sh', + 'nightly upgrade' => 'other/nightly/upgrade.sh', +]); + it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) { $script = file_get_contents(getcwd().'/'.$path); From bb1d3f13f2e62eac7eba21f15664ea20125ddfbe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:22:51 +0200 Subject: [PATCH 22/86] feat(ci): add next build and RC release workflows Replace generic staging builds with traceable next images and reviewed RC releases. --- .github/workflows/coolify-next-build.yml | 152 ++++++++++ .github/workflows/coolify-rc-release.yml | 304 ++++++++++++++++++++ .github/workflows/coolify-staging-build.yml | 134 --------- RELEASE.md | 11 +- other/nightly/versions.json | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 49 +++- versions.json | 2 +- 7 files changed, 508 insertions(+), 146 deletions(-) create mode 100644 .github/workflows/coolify-next-build.yml create mode 100644 .github/workflows/coolify-rc-release.yml delete mode 100644 .github/workflows/coolify-staging-build.yml diff --git a/.github/workflows/coolify-next-build.yml b/.github/workflows/coolify-next-build.yml new file mode 100644 index 0000000000..9985edb411 --- /dev/null +++ b/.github/workflows/coolify-next-build.yml @@ -0,0 +1,152 @@ +name: Build Coolify Next + +on: + push: + branches: [next] + paths-ignore: + - .github/workflows/coolify-helper.yml + - .github/workflows/coolify-helper-next.yml + - .github/workflows/coolify-realtime.yml + - .github/workflows/coolify-realtime-next.yml + - .github/workflows/pr-quality.yaml + - docker/coolify-helper/Dockerfile + - docker/coolify-realtime/Dockerfile + - docker/testing-host/Dockerfile + - templates/** + - CHANGELOG.md + +permissions: + contents: read + packages: write + +concurrency: + group: coolify-next-build + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + prepare: + runs-on: ubuntu-24.04 + outputs: + rc_version: ${{ steps.version.outputs.rc_version }} + short_sha: ${{ steps.version.outputs.short_sha }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Resolve next version + id: version + run: | + RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Invalid next RC version: ${RC_VERSION}" + exit 1 + fi + + SHORT_SHA="${GITHUB_SHA::7}" + VERSION="${RC_VERSION}.${SHORT_SHA}" + echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + build: + needs: prepare + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push next image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.prepare.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }} + + publish: + needs: [prepare, build] + runs-on: ubuntu-24.04 + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish next manifest on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish next manifest on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + SHA: ${{ needs.prepare.outputs.short_sha }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="next-build-${SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" diff --git a/.github/workflows/coolify-rc-release.yml b/.github/workflows/coolify-rc-release.yml new file mode 100644 index 0000000000..90005a97d2 --- /dev/null +++ b/.github/workflows/coolify-rc-release.yml @@ -0,0 +1,304 @@ +name: Release Coolify RC +run-name: ${{ inputs.tag }} + +on: + workflow_dispatch: + inputs: + tag: + description: Existing draft prerelease tag (for example, v4.4-rc.1) + required: true + type: string + +permissions: {} + +concurrency: + group: coolify-rc-release + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + validate: + runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + release_id: ${{ steps.draft.outputs.release_id }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Reject releases outside next + if: ${{ github.ref != 'refs/heads/next' }} + run: | + echo "RC releases must run from the next branch, not ${{ github.ref }}." + exit 1 + + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate version + id: version + env: + TAG_NAME: ${{ inputs.tag }} + run: | + if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "Unsupported RC tag: ${TAG_NAME}" + exit 1 + fi + + VERSION="${TAG_NAME#v}" + CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json) + if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then + echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}." + exit 1 + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Validate and pin draft prerelease + id: draft + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME); + + if (!release) { + core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`); + return; + } + if (!release.draft) { + core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`); + return; + } + if (!release.prerelease) { + core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + }); + core.setOutput('release_id', release.id); + + build: + needs: validate + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push RC image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.validate.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + + revalidate: + needs: [validate, build] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Revalidate draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + } catch (error) { + if (error.status !== 404) throw error; + } + + publish: + needs: [validate, build, revalidate] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish RC and next on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish RC and next on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="rc-release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:next" + + - name: Publish reviewed draft prerelease + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: Number(process.env.RELEASE_ID), + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + prerelease: true, + draft: false, + }); diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml deleted file mode 100644 index ccbd141295..0000000000 --- a/.github/workflows/coolify-staging-build.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Staging Build - -on: - push: - branches-ignore: - - main - - v3.x - - '**v5.x**' - paths-ignore: - - .github/workflows/coolify-helper.yml - - .github/workflows/coolify-helper-next.yml - - .github/workflows/coolify-realtime.yml - - .github/workflows/coolify-realtime-next.yml - - .github/workflows/pr-quality.yaml - - docker/coolify-helper/Dockerfile - - docker/coolify-realtime/Dockerfile - - docker/testing-host/Dockerfile - - templates/** - - CHANGELOG.md - -permissions: - contents: read - packages: write - -env: - GITHUB_REGISTRY: ghcr.io - DOCKER_REGISTRY: docker.io - IMAGE_NAME: "coollabsio/coolify" - -jobs: - build-push: - strategy: - matrix: - include: - - arch: amd64 - platform: linux/amd64 - runner: ubuntu-24.04 - - arch: aarch64 - platform: linux/aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and Push Image (${{ matrix.arch }}) - uses: docker/build-push-action@v6 - with: - context: . - file: docker/production/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: | - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }} - cache-from: | - type=gha,scope=build-${{ matrix.arch }} - type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }} - - merge-manifest: - runs-on: ubuntu-24.04 - needs: build-push - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - - name: Sanitize branch name for Docker tag - id: sanitize - run: | - # Replace slashes and other invalid characters with dashes - SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g') - echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT - - - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} - run: | - docker buildx imagetools create \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \ - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \ - --tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }} - - - uses: sarisia/actions-status-discord@v1 - if: always() - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} diff --git a/RELEASE.md b/RELEASE.md index d278d76902..3733392a48 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,7 +9,7 @@ | `feature/*` | New features based on and merged into `next` | | `hotfix/X.Y.Z` | Production fixes based on `main` | -Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. +Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go @@ -25,11 +25,12 @@ feature/* → next → RC ``` 1. Merge feature branches into `next`. -2. Set the intended RC version on `next`, such as `4.4-rc.1`. -3. Regular builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. +2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`. +3. Regular `next` builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag. 4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. -5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft. -6. Advance `next` to the next intended RC version. +5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`. +6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease. +7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow diff --git a/other/nightly/versions.json b/other/nightly/versions.json index ae1ecfae54..440ad36160 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -4,7 +4,7 @@ "version": "4.3.10" }, "nightly": { - "version": "4.3.11" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15" diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 738b501d57..5fcada3091 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -25,7 +25,7 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->and($constants) ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.10'") ->and($versions['coolify']['v4']['version'])->toBe('4.3.10') - ->and($versions['coolify']['nightly']['version'])->toBe('4.3.11') + ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); @@ -34,6 +34,12 @@ it('orders a maintenance development build before its stable release', function ->and(version_compare('4.3.2', '4.3.2-dev.d64cbda3e', '>'))->toBeTrue(); }); +it('orders rolling and exact release candidates before the stable release', function () { + expect(version_compare('4.4-rc.1.d64cbda', '4.4-rc.1', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.1', '4.4-rc.2', '<'))->toBeTrue() + ->and(version_compare('4.4-rc.2', '4.4.0', '<'))->toBeTrue(); +}); + it('requires a reviewed draft release before building a stable version', function () { $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml'); @@ -104,12 +110,45 @@ it('generates the production changelog from main', function () { ->not->toContain('v4.x'); }); -it('excludes main from staging builds', function () { - $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml'); +it('publishes traceable rolling builds from next without creating an exact rc tag', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-next-build.yml'); expect($workflow) - ->toContain(' - main') - ->not->toContain(' - v4.x'); + ->toContain('name: Build Coolify Next') + ->toContain('branches: [next]') + ->toContain('group: coolify-next-build') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('VERSION="${RC_VERSION}.${SHORT_SHA}"') + ->toContain('COOLIFY_VERSION=${{ needs.prepare.outputs.version }}') + ->toContain('--tag "${IMAGE}:sha-${SHA}"') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->not->toContain('--tag "${IMAGE}:${RC_VERSION}"') + ->not->toContain('--tag "${IMAGE}:latest"'); +}); + +it('requires a reviewed draft prerelease before publishing an exact rc', function () { + $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-rc-release.yml'); + + expect($workflow) + ->toContain('name: Release Coolify RC') + ->toContain('run-name: ${{ inputs.tag }}') + ->toContain("github.ref != 'refs/heads/next'") + ->toContain('group: coolify-rc-release') + ->toContain('^v[0-9]+\\.[0-9]+-rc\\.[0-9]+$') + ->toContain("jq -r '.coolify.nightly.version' versions.json") + ->toContain('release.draft') + ->toContain('!release.prerelease') + ->toContain('release.body?.trim()') + ->toContain('revalidate:') + ->toContain('needs: [validate, build, revalidate]') + ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}') + ->toContain('--tag "${IMAGE}:${VERSION}"') + ->toContain('--tag "${IMAGE}:next"') + ->toContain('prerelease: true') + ->toContain('actions/github-script@v8') + ->not->toContain('--tag "${IMAGE}:latest"') + ->not->toContain('environment:'); }); it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () { diff --git a/versions.json b/versions.json index ae1ecfae54..440ad36160 100644 --- a/versions.json +++ b/versions.json @@ -4,7 +4,7 @@ "version": "4.3.10" }, "nightly": { - "version": "4.3.11" + "version": "4.4-rc.1" }, "helper": { "version": "1.0.15" From 38aaf8cb2a029a25dc89954b7d3dd97fac192687 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:31:36 +0200 Subject: [PATCH 23/86] fix(caddy): prevent exact label generation argument error (#11397) --- app/Models/Application.php | 6 ++- bootstrap/helpers/docker.php | 1 - database/factories/ApplicationFactory.php | 4 ++ .../CaddyApplicationLabelGenerationTest.php | 38 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/CaddyApplicationLabelGenerationTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index fef76cd393..0868bdf9cd 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -12,6 +12,7 @@ use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -120,7 +121,10 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; + + /** @use HasFactory */ + use HasFactory; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index a60ba675be..210c84a86a 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -891,7 +891,6 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, - escape_redirect_replacement_for_compose: false, )); break; } diff --git a/database/factories/ApplicationFactory.php b/database/factories/ApplicationFactory.php index ded507c56d..188d32954b 100644 --- a/database/factories/ApplicationFactory.php +++ b/database/factories/ApplicationFactory.php @@ -2,8 +2,12 @@ namespace Database\Factories; +use App\Models\Application; use Illuminate\Database\Eloquent\Factories\Factory; +/** + * @extends Factory + */ class ApplicationFactory extends Factory { public function definition(): array diff --git a/tests/Feature/CaddyApplicationLabelGenerationTest.php b/tests/Feature/CaddyApplicationLabelGenerationTest.php new file mode 100644 index 0000000000..b31fa29a46 --- /dev/null +++ b/tests/Feature/CaddyApplicationLabelGenerationTest.php @@ -0,0 +1,38 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::CADDY->value], + ]); + $server->settings->update(['generate_exact_labels' => true]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://example.com', + 'redirect' => 'both', + 'is_http_basic_auth_enabled' => false, + ]); + + $labels = generateLabelsApplication($application); + + expect($labels) + ->toContain('caddy_ingress_network=coolify') + ->not->toContain('traefik.enable=true'); +}); From 15359833d3cd3a0fd7c935365220f89b4f2df0da Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:33:16 +0200 Subject: [PATCH 24/86] Revert "Merge origin/next into main" This reverts commit 541d743907f7142c0d03c6712f0e93e4007bbdd8, reversing changes made to bb1d3f13f2e62eac7eba21f15664ea20125ddfbe. --- app/Actions/Fortify/CreateNewUser.php | 2 +- app/Actions/Server/CheckUpdates.php | 40 +-- app/Actions/Server/InstallDocker.php | 27 +- app/Actions/Server/InstallPrerequisites.php | 16 - app/Actions/Server/UpdatePackage.php | 4 - .../Exceptions/OidcDiscoveryException.php | 5 - app/Auth/Oidc/Exceptions/OidcException.php | 7 - .../Oidc/Exceptions/OidcJwksException.php | 5 - .../OidcSigningKeyNotFoundException.php | 5 - .../Oidc/Exceptions/OidcTokenException.php | 5 - app/Auth/Oidc/OidcConfig.php | 34 -- app/Auth/Oidc/OidcDiscoveryDocument.php | 61 ---- app/Auth/Oidc/OidcDiscoveryService.php | 97 ----- app/Auth/Oidc/OidcTokenValidator.php | 199 ----------- app/Auth/Oidc/OidcUser.php | 32 -- app/Auth/Oidc/Socialite/OidcProvider.php | 299 ---------------- app/Helpers/SshMultiplexingHelper.php | 8 +- app/Http/Controllers/OauthController.php | 61 ++-- app/Livewire/Notifications/Discord.php | 24 -- app/Livewire/Notifications/Email.php | 121 ++----- app/Livewire/Notifications/Pushover.php | 28 -- app/Livewire/Notifications/Slack.php | 26 -- app/Livewire/Notifications/Telegram.php | 28 -- app/Livewire/Notifications/Webhook.php | 24 -- app/Livewire/Profile/Index.php | 59 +--- .../Security/IntegrationTokenEditor.php | 114 ------ .../Security/IntegrationTokenForm.php | 81 ----- app/Livewire/Security/IntegrationTokens.php | 41 --- app/Livewire/Server/LogDrains.php | 72 ---- app/Livewire/Settings/Advanced.php | 6 - app/Livewire/SettingsEmail.php | 124 ++----- app/Livewire/SettingsOauth.php | 334 ++++++------------ app/Models/InstanceSettings.php | 16 - app/Models/IntegrationToken.php | 38 -- app/Models/OauthIdentity.php | 35 -- app/Models/OauthSetting.php | 51 +-- app/Models/Team.php | 5 - app/Models/User.php | 17 +- app/Policies/IntegrationTokenPolicy.php | 34 -- app/Providers/AppServiceProvider.php | 36 +- app/Providers/AuthServiceProvider.php | 3 - app/Providers/FortifyServiceProvider.php | 8 +- app/Services/Auth/OauthLoginService.php | 228 ------------ app/Services/CloudflareTokenValidator.php | 42 --- bootstrap/helpers/shared.php | 9 +- bootstrap/helpers/socialite.php | 28 +- composer.json | 1 - composer.lock | 2 +- config/services.php | 8 - ...ation_deployment_configuration_columns.php | 6 - ...dd_oidc_fields_to_oauth_settings_table.php | 40 --- ...4_091631_create_oauth_identities_table.php | 36 -- ...tion_policy_to_instance_settings_table.php | 28 -- ...join_root_team_to_oauth_settings_table.php | 28 -- ...000000_create_integration_tokens_table.php | 29 -- database/seeders/OauthSettingSeeder.php | 1 - database/seeders/UserSeeder.php | 2 + lang/de.json | 1 - lang/en.json | 1 - lang/pl.json | 1 - public/svgs/oidc.svg | 5 - resources/views/auth/login.blade.php | 8 +- .../security/settings-layout.blade.php | 6 - .../components/settings/sidebar.blade.php | 18 - .../views/livewire/profile/index.blade.php | 18 +- .../integration-token-editor.blade.php | 52 --- .../security/integration-token-form.blade.php | 49 --- .../security/integration-tokens.blade.php | 84 ----- .../server/security/patches.blade.php | 4 +- .../views/livewire/settings-oauth.blade.php | 142 +++----- .../livewire/settings/advanced.blade.php | 11 +- routes/web.php | 5 - templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- tests/Feature/EnableActionButtonsTest.php | 179 ---------- .../LogDrain/LogDrainToggleRollbackTest.php | 45 --- tests/Feature/LoginPageBrandingTest.php | 22 -- tests/Feature/OauthControllerTest.php | 114 +----- tests/Feature/OauthRegistrationPolicyTest.php | 52 --- tests/Feature/OidcOauthControllerTest.php | 275 -------------- tests/Feature/ProfileSsoIndicatorTest.php | 91 ----- .../Security/IntegrationTokenFormTest.php | 253 ------------- .../SecuritySettingsNavigationTest.php | 2 - .../SettingsEmailProviderExclusivityTest.php | 64 ---- tests/Feature/SettingsNavigationTest.php | 52 --- tests/Feature/SettingsOauthTest.php | 277 --------------- tests/Feature/SshMultiplexingLockTest.php | 2 +- tests/Feature/UserSeederTest.php | 16 - .../Server/AlpinePackageManagerTest.php | 62 ---- .../ApplicationConfigurationSnapshotTest.php | 16 +- tests/Unit/OauthSettingTest.php | 30 -- tests/Unit/OidcDiscoveryServiceTest.php | 119 ------- tests/Unit/OidcProviderPkceTest.php | 148 -------- tests/Unit/OidcTokenValidatorTest.php | 187 ---------- tests/Unit/SshMultiplexingDisableTest.php | 10 - tests/v4/Feature/DangerDeleteResourceTest.php | 18 +- 96 files changed, 314 insertions(+), 4853 deletions(-) delete mode 100644 app/Auth/Oidc/Exceptions/OidcDiscoveryException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcJwksException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcTokenException.php delete mode 100644 app/Auth/Oidc/OidcConfig.php delete mode 100644 app/Auth/Oidc/OidcDiscoveryDocument.php delete mode 100644 app/Auth/Oidc/OidcDiscoveryService.php delete mode 100644 app/Auth/Oidc/OidcTokenValidator.php delete mode 100644 app/Auth/Oidc/OidcUser.php delete mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php delete mode 100644 app/Livewire/Security/IntegrationTokenEditor.php delete mode 100644 app/Livewire/Security/IntegrationTokenForm.php delete mode 100644 app/Livewire/Security/IntegrationTokens.php delete mode 100644 app/Models/IntegrationToken.php delete mode 100644 app/Models/OauthIdentity.php delete mode 100644 app/Policies/IntegrationTokenPolicy.php delete mode 100644 app/Services/Auth/OauthLoginService.php delete mode 100644 app/Services/CloudflareTokenValidator.php delete mode 100644 database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php delete mode 100644 database/migrations/2026_06_04_091631_create_oauth_identities_table.php delete mode 100644 database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php delete mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php delete mode 100644 database/migrations/2026_08_15_000000_create_integration_tokens_table.php delete mode 100644 public/svgs/oidc.svg delete mode 100644 resources/views/livewire/security/integration-token-editor.blade.php delete mode 100644 resources/views/livewire/security/integration-token-form.blade.php delete mode 100644 resources/views/livewire/security/integration-tokens.blade.php delete mode 100644 tests/Feature/EnableActionButtonsTest.php delete mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php delete mode 100644 tests/Feature/OauthRegistrationPolicyTest.php delete mode 100644 tests/Feature/OidcOauthControllerTest.php delete mode 100644 tests/Feature/ProfileSsoIndicatorTest.php delete mode 100644 tests/Feature/Security/IntegrationTokenFormTest.php delete mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php delete mode 100644 tests/Feature/SettingsNavigationTest.php delete mode 100644 tests/Feature/SettingsOauthTest.php delete mode 100644 tests/Feature/UserSeederTest.php delete mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php delete mode 100644 tests/Unit/OauthSettingTest.php delete mode 100644 tests/Unit/OidcDiscoveryServiceTest.php delete mode 100644 tests/Unit/OidcProviderPkceTest.php delete mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index d437a3a176..44a03c17da 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->isPasswordRegistrationAllowed()) { + if (! $settings->is_registration_enabled) { abort(403); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 5cf5658f8f..f90e007089 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,7 +3,6 @@ namespace App\Actions\Server; use App\Models\Server; -use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -107,15 +106,6 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; - return $out; - case 'apk': - instant_remote_process(['apk update -q'], $server); - $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); - - $out = $this->parseApkOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - return $out; default: return [ @@ -276,39 +266,11 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - Log::debug('Pacman output contained unparsed lines', [ + \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } return $result; } - - private function parseApkOutput(string $output): array - { - $updates = []; - $lines = explode("\n", $output); - - foreach ($lines as $line) { - // Skip empty lines - if (empty($line)) { - continue; - } - - // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] - if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { - $updates[] = [ - 'package' => $matches[1], - 'new_version' => $matches[2], - 'architecture' => $matches[3], - 'current_version' => $matches[4], - ]; - } - } - - return [ - 'total_updates' => count($updates), - 'updates' => $updates, - ]; - } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 552445d728..2e08ec6ad9 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,8 +79,6 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); - } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -95,8 +93,9 @@ class InstallDocker "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', ]); - $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -155,28 +154,6 @@ class InstallDocker 'systemctl start docker.service'; } - private function getAlpineDockerInstallCommand(): string - { - return 'apk update && '. - 'apk add docker docker-cli-buildx docker-cli-compose && '. - 'mkdir -p /etc/docker'; - } - - private function getDockerServiceCommands(bool $usesOpenRc): array - { - if ($usesOpenRc) { - return [ - 'rc-update add docker default', - 'rc-service docker restart', - ]; - } - - return [ - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', - ]; - } - private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 57fd4f1d7c..84be7f2068 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,8 +53,6 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); - } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -63,18 +61,4 @@ class InstallPrerequisites return remote_process($command, $server); } - - private function getAlpinePrerequisiteCommands(): array - { - return [ - "echo 'Installing Prerequisites for Alpine Linux...'", - "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", - 'apk update', - 'command -v bash >/dev/null || apk add bash', - 'command -v curl >/dev/null || apk add curl', - 'command -v wget >/dev/null || apk add wget', - 'command -v git >/dev/null || apk add git', - 'command -v jq >/dev/null || apk add jq', - ]; - } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 2b06e06011..ab0ca94943 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,10 +58,6 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; - case 'apk': - $commandAll = 'apk update && apk upgrade'; - $commandInstall = 'apk upgrade '.$sanitizedPackage; - break; default: return [ 'error' => 'OS not supported', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php deleted file mode 100644 index e4a2ba0dfe..0000000000 --- a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php +++ /dev/null @@ -1,5 +0,0 @@ - $scopes - */ - public function __construct( - public string $issuerUrl, - public string $clientId, - public string $clientSecret, - public string $redirectUri, - public array $scopes = ['openid', 'email', 'profile'], - public bool $usePkce = true, - public int $clockSkewSeconds = 60, - ) {} - - public static function fromOauthSetting(OauthSetting $setting): self - { - return new self( - issuerUrl: rtrim((string) $setting->base_url, '/'), - clientId: (string) $setting->client_id, - clientSecret: (string) $setting->client_secret, - redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'), - scopes: $setting->scopeList(), - usePkce: $setting->use_pkce ?? true, - clockSkewSeconds: $setting->clock_skew_seconds ?? 60, - ); - } -} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php deleted file mode 100644 index d17061c51d..0000000000 --- a/app/Auth/Oidc/OidcDiscoveryDocument.php +++ /dev/null @@ -1,61 +0,0 @@ - $supportedScopes - * @param array $supportedClaims - * @param array $idTokenSigningAlgValuesSupported - */ - public function __construct( - public string $issuer, - public string $authorizationEndpoint, - public string $tokenEndpoint, - public string $userinfoEndpoint, - public string $jwksUri, - public ?string $endSessionEndpoint = null, - public array $supportedScopes = [], - public array $supportedClaims = [], - public array $idTokenSigningAlgValuesSupported = [], - ) {} - - /** - * @param array $payload - */ - public static function fromArray(array $payload): self - { - foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) { - if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') { - throw new OidcDiscoveryException("Discovery document is missing required field: {$field}"); - } - } - - return new self( - issuer: $payload['issuer'], - authorizationEndpoint: $payload['authorization_endpoint'], - tokenEndpoint: $payload['token_endpoint'], - userinfoEndpoint: $payload['userinfo_endpoint'], - jwksUri: $payload['jwks_uri'], - endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null, - supportedScopes: self::stringList($payload['scopes_supported'] ?? []), - supportedClaims: self::stringList($payload['claims_supported'] ?? []), - idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []), - ); - } - - /** - * @return array - */ - private static function stringList(mixed $value): array - { - if (! is_array($value)) { - return []; - } - - return array_values(array_map('strval', $value)); - } -} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php deleted file mode 100644 index 0847afc9a7..0000000000 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ /dev/null @@ -1,97 +0,0 @@ -assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); - - $issuerUrl = rtrim($issuerUrl, '/'); - $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); - - return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { - $url = $issuerUrl.'/.well-known/openid-configuration'; - - try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); - } catch (Throwable $e) { - throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); - } - - if ($response->failed()) { - throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}"); - } - - $json = $response->json(); - if (! is_array($json) || $json === []) { - throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); - } - - $discovery = OidcDiscoveryDocument::fromArray($json); - if (rtrim($discovery->issuer, '/') !== $issuerUrl) { - throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); - } - - return $discovery; - }); - } - - /** - * Fetch the JWKS for the given URI. - * - * When $forceRefresh is true the cached document is bypassed so freshly - * rotated signing keys become visible immediately. A short cooldown still - * prevents a flood of upstream requests if many logins miss the same kid. - * - * @return array - */ - public function jwks(string $jwksUri, bool $forceRefresh = false): array - { - $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); - - $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); - - if ($forceRefresh) { - $cooldownKey = $cacheKey.':refresh'; - if (Cache::add($cooldownKey, true, 60)) { - Cache::forget($cacheKey); - } - } - - return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { - try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); - } catch (Throwable $e) { - throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); - } - - if ($response->failed()) { - throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}"); - } - - $json = $response->json(); - if (! is_array($json) || ! is_array($json['keys'] ?? null)) { - throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'."); - } - - return $json; - }); - } - - private function assertHttpsUrl(string $url, Throwable $exception): void - { - $parts = parse_url($url); - - if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') { - throw $exception; - } - } -} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php deleted file mode 100644 index a8563611dd..0000000000 --- a/app/Auth/Oidc/OidcTokenValidator.php +++ /dev/null @@ -1,199 +0,0 @@ - $jwks - * @return array - */ - public function validate( - string $idToken, - OidcDiscoveryDocument $discovery, - array $jwks, - string $clientId, - ?string $expectedNonce = null, - int $clockSkewSeconds = 60, - ): array { - $kid = $this->extractKid($idToken); - - try { - $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM); - } catch (Throwable $e) { - throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e); - } - - // Surface an unknown signing key distinctly so the caller can refresh - // the JWKS once (key rotation) before giving up. - if (! array_key_exists($kid, $keys)) { - throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); - } - - $previousLeeway = JWT::$leeway; - JWT::$leeway = $clockSkewSeconds; - - try { - // Validates signature, header alg against the key alg (RS256), - // exp, nbf and iat. Throws on any failure. - $claims = (array) JWT::decode($idToken, $keys); - } catch (OidcTokenException $e) { - throw $e; - } catch (Throwable $e) { - throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e); - } finally { - JWT::$leeway = $previousLeeway; - } - - $this->assertExpiry($claims); - $this->assertIssuer($claims, $discovery->issuer); - $this->assertAudience($claims, $clientId); - $this->assertNonce($claims, $expectedNonce); - $this->assertSubject($claims); - - return $claims; - } - - /** - * Drop JWKS entries explicitly marked for anything other than signing - * (e.g. "use":"enc") so they can never verify an id_token signature. - * firebase/php-jwt does not honour the "use" parameter on its own. - * - * @param array $jwks - * @return array - */ - private function signingKeysOnly(array $jwks): array - { - $keys = array_values(array_filter( - $jwks['keys'] ?? [], - fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'), - )); - - return ['keys' => $keys]; - } - - /** - * Decode just the JWT header to read the kid before signature - * verification, so an unknown key can be reported as a rotation miss. - */ - private function extractKid(string $idToken): string - { - $segments = explode('.', $idToken); - if (count($segments) !== 3) { - throw new OidcTokenException('Malformed id_token.'); - } - - $header = json_decode($this->base64UrlDecode($segments[0]), true); - if (! is_array($header)) { - throw new OidcTokenException('id_token header contains invalid JSON.'); - } - - if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) { - throw new OidcTokenException('id_token uses a disallowed algorithm.'); - } - - $kid = $header['kid'] ?? null; - if (! is_string($kid) || $kid === '') { - throw new OidcTokenException('id_token header is missing kid.'); - } - - return $kid; - } - - private function base64UrlDecode(string $value): string - { - $remainder = strlen($value) % 4; - if ($remainder !== 0) { - $value .= str_repeat('=', 4 - $remainder); - } - - $decoded = base64_decode(strtr($value, '-_', '+/'), true); - if ($decoded === false) { - throw new OidcTokenException('Invalid base64url value in id_token header.'); - } - - return $decoded; - } - - /** - * @param array $claims - */ - private function assertExpiry(array $claims): void - { - // Firebase enforces the exp window when present; OIDC requires it to exist. - if (! is_numeric($claims['exp'] ?? null)) { - throw new OidcTokenException('id_token is missing the exp claim.'); - } - } - - /** - * @param array $claims - */ - private function assertSubject(array $claims): void - { - $subject = $claims['sub'] ?? null; - if (! is_string($subject) || $subject === '') { - throw new OidcTokenException('id_token subject is missing or invalid.'); - } - } - - /** - * @param array $claims - */ - private function assertIssuer(array $claims, string $expectedIssuer): void - { - if (($claims['iss'] ?? null) !== $expectedIssuer) { - throw new OidcTokenException('id_token issuer does not match discovery issuer.'); - } - } - - /** - * @param array $claims - */ - private function assertAudience(array $claims, string $clientId): void - { - $audience = $claims['aud'] ?? null; - if (is_string($audience)) { - $audience = [$audience]; - } - - if (! is_array($audience) || ! in_array($clientId, $audience, true)) { - throw new OidcTokenException('id_token audience does not include configured client id.'); - } - - if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) { - throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.'); - } - - if (isset($claims['azp']) && $claims['azp'] !== $clientId) { - throw new OidcTokenException('id_token azp does not match configured client id.'); - } - } - - /** - * @param array $claims - */ - private function assertNonce(array $claims, ?string $expectedNonce): void - { - if ($expectedNonce === null) { - return; - } - - if (($claims['nonce'] ?? null) !== $expectedNonce) { - throw new OidcTokenException('id_token nonce does not match.'); - } - } -} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php deleted file mode 100644 index 645130e019..0000000000 --- a/app/Auth/Oidc/OidcUser.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - public array $idTokenClaims = []; - - /** - * @param array $claims - */ - public function setIdTokenClaims(array $claims): self - { - $this->idTokenClaims = $claims; - $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null; - $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null; - $this->emailVerified = ($claims['email_verified'] ?? false) === true; - - return $this; - } -} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php deleted file mode 100644 index 383b0cc910..0000000000 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ /dev/null @@ -1,299 +0,0 @@ - - */ - protected $scopes = ['openid', 'email', 'profile']; - - protected $scopeSeparator = ' '; - - protected ?OidcConfig $oidcConfig = null; - - protected ?OidcDiscoveryDocument $discovery = null; - - public function __construct( - Request $request, - protected OidcDiscoveryService $discoveryService, - protected OidcTokenValidator $tokenValidator, - string $clientId, - string $clientSecret, - string $redirectUrl, - ) { - parent::__construct($request, $clientId, $clientSecret, $redirectUrl); - } - - public function setConfig(OidcConfig $config): self - { - $this->oidcConfig = $config; - $this->clientId = $config->clientId; - $this->clientSecret = $config->clientSecret; - $this->redirectUrl = $config->redirectUri; - $this->scopes = $config->scopes; - $this->discovery = null; - - return $this; - } - - public function getConfig(): OidcConfig - { - if ($this->oidcConfig === null) { - throw new OidcException('OIDC provider config is not set.'); - } - - return $this->oidcConfig; - } - - protected function getAuthUrl($state): string - { - $config = $this->getConfig(); - $nonce = Str::random(40); - $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); - - $extra = ['nonce' => $nonce]; - if ($config->usePkce) { - $verifier = $this->generateCodeVerifier(); - $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); - $extra['code_challenge'] = $this->codeChallenge($verifier); - $extra['code_challenge_method'] = 'S256'; - } - - return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state) - .'&'.http_build_query($extra, '', '&', $this->encodingType); - } - - protected function getTokenUrl(): string - { - return $this->resolveDiscovery()->tokenEndpoint; - } - - /** - * @return array - */ - protected function getUserByToken($token): array - { - $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [ - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.$token, - ], - RequestOptions::CONNECT_TIMEOUT => 5, - RequestOptions::TIMEOUT => 10, - ]); - - $decoded = json_decode((string) $response->getBody(), true); - - return is_array($decoded) ? $decoded : []; - } - - /** - * @param array $user - */ - protected function mapUserToObject(array $user) - { - return (new OidcUser)->setRaw($user)->map([ - 'id' => $user['sub'] ?? null, - 'nickname' => $user['preferred_username'] ?? null, - 'name' => $this->resolveName($user), - 'email' => $user['email'] ?? null, - 'avatar' => $user['picture'] ?? null, - ]); - } - - public function user() - { - if ($this->user) { - return $this->user; - } - - if ($this->hasInvalidState()) { - throw new InvalidStateException; - } - - $tokenResponse = $this->getAccessTokenResponse($this->getCode()); - $accessToken = Arr::get($tokenResponse, 'access_token'); - $idToken = Arr::get($tokenResponse, 'id_token'); - - if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') { - throw new OidcException('OIDC token endpoint did not return required tokens.'); - } - - $discovery = $this->resolveDiscovery(); - $config = $this->getConfig(); - $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state'))); - if ($expectedNonce === null) { - throw new OidcException('OIDC login session expired. Please try again.'); - } - - $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce); - - $userinfo = $this->getUserByToken($accessToken); - - // OIDC core §5.3.2: the userinfo sub MUST match the id_token sub. - // Reject the response rather than trust unsigned userinfo claims. - $userinfoSub = $userinfo['sub'] ?? null; - if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) { - throw new OidcException('OIDC userinfo subject does not match the id_token subject.'); - } - - $merged = array_merge($userinfo, $claims); - - /** @var OidcUser $user */ - $user = $this->mapUserToObject($merged); - $user->setIdTokenClaims($claims) - ->setToken($accessToken) - ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token')) - ->setExpiresIn(Arr::get($tokenResponse, 'expires_in')); - - return $this->user = $user; - } - - /** - * Validate the id_token, retrying once against a freshly fetched JWKS when - * the signing key is unknown. This keeps logins working immediately after - * the IdP rotates keys instead of failing until the JWKS cache expires. - * - * @return array - */ - protected function validateIdToken( - string $idToken, - OidcDiscoveryDocument $discovery, - OidcConfig $config, - ?string $expectedNonce, - ): array { - foreach ([false, true] as $forceRefresh) { - try { - return $this->tokenValidator->validate( - idToken: $idToken, - discovery: $discovery, - jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh), - clientId: $config->clientId, - expectedNonce: $expectedNonce, - clockSkewSeconds: $config->clockSkewSeconds, - ); - } catch (OidcSigningKeyNotFoundException $e) { - if ($forceRefresh) { - throw $e; - } - } - } - - throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); - } - - /** - * @return array - */ - public function getAccessTokenResponse($code) - { - $fields = $this->getTokenFields($code); - if ($this->getConfig()->usePkce) { - $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); - if ($verifier === null) { - throw new OidcException('OIDC login session expired. Please try again.'); - } - - $fields['code_verifier'] = $verifier; - } - - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::FORM_PARAMS => $fields, - RequestOptions::CONNECT_TIMEOUT => 5, - RequestOptions::TIMEOUT => 10, - ]); - - $decoded = json_decode((string) $response->getBody(), true); - - return is_array($decoded) ? $decoded : []; - } - - protected function resolveDiscovery(): OidcDiscoveryDocument - { - return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl); - } - - protected function generateCodeVerifier(): string - { - return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); - } - - protected function codeChallenge(string $verifier): string - { - return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); - } - - /** - * @param array $user - */ - protected function resolveName(array $user): ?string - { - if (is_string($user['name'] ?? null) && $user['name'] !== '') { - return $user['name']; - } - - $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? ''))); - - return $name === '' ? null : $name; - } - - protected function putOidcFlowValue(string $key, string $value): void - { - $this->request->session()->put($key, [ - 'value' => $value, - 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, - ]); - } - - protected function pullOidcFlowValue(string $key): ?string - { - $entry = $this->request->session()->pull($key); - - if (! is_array($entry)) { - return null; - } - - $value = $entry['value'] ?? null; - $expiresAt = $entry['expires_at'] ?? null; - - if (! is_string($value) || $value === '' || ! is_int($expiresAt)) { - return null; - } - - if ($expiresAt < now()->timestamp) { - return null; - } - - return $value; - } - - protected function nonceSessionKey(string $state): string - { - return "oidc.nonce.{$state}"; - } - - protected function verifierSessionKey(string $state): string - { - return "oidc.code_verifier.{$state}"; - } -} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index e7d6d071b4..cbb18945e2 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -243,18 +243,12 @@ class SshMultiplexingHelper $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } - private static function remoteShellCommand(): string - { - return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; - } - public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 93d27615a7..4038fe63e2 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,60 +2,47 @@ namespace App\Http\Controllers; -use App\Models\OauthSetting; -use App\Services\Auth\OauthLoginService; -use Illuminate\Support\Facades\Log; +use App\Models\User; +use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { public function redirect(string $provider) { - $oauthSetting = $this->enabledProvider($provider); - $socialiteProvider = get_socialite_provider($oauthSetting->provider); + $socialite_provider = get_socialite_provider($provider); - return $socialiteProvider->redirect(); + return $socialite_provider->redirect(); } - public function callback(string $provider, OauthLoginService $oauthLoginService) + public function callback(string $provider) { try { - $oauthSetting = $this->enabledProvider($provider); - $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); - $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + $oauthUser = get_socialite_provider($provider)->user(); + $email = trim((string) $oauthUser->email); + if ($email === '') { + abort(403, 'OAuth provider did not return an email address'); + } + $email = strtolower($email); + $user = User::whereEmail($email)->first(); + if (! $user) { + $settings = instanceSettings(); + if (! $settings->is_registration_enabled) { + abort(403, 'Registration is disabled'); + } + + $user = User::create([ + 'name' => $oauthUser->name, + 'email' => $email, + ]); + } + Auth::login($user); return redirect('/'); } catch (\Exception $e) { - $this->logCallbackFailure($provider, $e); - $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; return redirect()->route('login')->withErrors([__($errorCode)]); } } - - private function logCallbackFailure(string $provider, \Throwable $exception): void - { - Log::error('OAuth callback failed.', [ - 'provider' => $provider, - 'exception_class' => $exception::class, - 'exception_message' => $exception->getMessage(), - 'request_error' => request()->query('error'), - 'request_error_description' => request()->query('error_description'), - 'has_code' => request()->query->has('code'), - 'has_state' => request()->query->has('state'), - 'ip' => request()->ip(), - 'exception' => $exception, - ]); - } - - private function enabledProvider(string $provider): OauthSetting - { - $oauthSetting = OauthSetting::where('provider', $provider)->first(); - if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) { - throw new HttpException(403, 'OAuth provider is not enabled'); - } - - return $oauthSetting; - } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 59ecb06e8e..797db83629 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -166,30 +166,6 @@ class Discord extends Component } } - public function toggleDiscordEnabled(): void - { - try { - $this->resetErrorBag(); - - if ($this->discordEnabled) { - $this->discordEnabled = false; - } else { - $this->validate([ - 'discordWebhookUrl' => 'required', - ], [ - 'discordWebhookUrl.required' => 'Discord Webhook URL is required.', - ]); - $this->discordEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - handleError($e, $this); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 2a373a5065..3d95668b91 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,6 +2,7 @@ namespace App\Livewire\Notifications; +use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -14,7 +15,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests; + use AuthorizesRequests, TogglesNotificationEvents; protected $listeners = ['refresh' => '$refresh']; @@ -251,59 +252,32 @@ class Email extends Component } } - public function toggleSmtp() - { - try { - $this->resetErrorBag(); - - if ($this->smtpEnabled) { - $this->smtpEnabled = false; - $this->saveModel(); - } else { - $this->validateSmtpSettings(); - $this->smtpEnabled = true; - $this->resendEnabled = false; - $this->submitSmtp(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - - public function toggleResend() - { - try { - $this->resetErrorBag(); - - if ($this->resendEnabled) { - $this->resendEnabled = false; - $this->saveModel(); - } else { - $this->validateResendSettings(); - $this->resendEnabled = true; - $this->smtpEnabled = false; - $this->submitResend(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function submitSmtp() { $this->authorize('update', $this->settings); try { $this->resetErrorBag(); - $this->validateSmtpSettings(); + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); if ($this->smtpEnabled) { $this->settings->resend_enabled = $this->resendEnabled = false; @@ -335,7 +309,17 @@ class Email extends Component try { $this->resetErrorBag(); - $this->validateResendSettings(); + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); if ($this->resendEnabled) { $this->settings->smtp_enabled = $this->smtpEnabled = false; } @@ -352,45 +336,6 @@ class Email extends Component } } - private function validateSmtpSettings(): void - { - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); - } - - private function validateResendSettings(): void - { - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); - } - public function sendTestEmail() { try { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index b1608c5ea2..3b7c3c6aeb 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -159,34 +159,6 @@ class Pushover extends Component } } - public function togglePushoverEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->pushoverEnabled) { - $this->pushoverEnabled = false; - } else { - $this->validate([ - 'pushoverUserKey' => 'required', - 'pushoverApiToken' => 'required', - ], [ - 'pushoverUserKey.required' => 'Pushover User Key is required.', - 'pushoverApiToken.required' => 'Pushover API Token is required.', - ]); - $this->pushoverEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index c4ca7da802..9ee3624025 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -150,32 +150,6 @@ class Slack extends Component } } - public function toggleSlackEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->slackEnabled) { - $this->slackEnabled = false; - } else { - $this->validate([ - 'slackWebhookUrl' => 'required', - ], [ - 'slackWebhookUrl.required' => 'Slack Webhook URL is required.', - ]); - $this->slackEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 9f19b22f5f..b04d2c73d2 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -252,34 +252,6 @@ class Telegram extends Component } } - public function toggleTelegramEnabled(): void - { - try { - $this->resetErrorBag(); - - if ($this->telegramEnabled) { - $this->telegramEnabled = false; - } else { - $this->validate([ - 'telegramToken' => 'required', - 'telegramChatId' => 'required', - ], [ - 'telegramToken.required' => 'Telegram Token is required.', - 'telegramChatId.required' => 'Telegram Chat ID is required.', - ]); - $this->telegramEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function saveModel() { $this->syncData(true); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index ee07694767..fcf1107781 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -144,30 +144,6 @@ class Webhook extends Component } } - public function toggleWebhookEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->webhookEnabled) { - $this->webhookEnabled = false; - } else { - $this->validate([ - 'webhookUrl' => 'required', - ], [ - 'webhookUrl.required' => 'Webhook URL is required.', - ]); - $this->webhookEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index ae5d9b3ecd..a20a1231b4 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,15 +2,19 @@ namespace App\Livewire\Profile; +use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; +use Livewire\WithFileUploads; class Index extends Component { + use WithFileUploads; + public int $userId; public string $email; @@ -32,10 +36,6 @@ class Index extends Component public bool $show_verification = false; - public bool $uses_sso = false; - - public ?string $sso_provider_label = null; - public $avatar; public function uploadAvatar(AvatarStorageService $avatarStorage): bool @@ -75,12 +75,8 @@ class Index extends Component $this->name = Auth::user()->name; $this->email = Auth::user()->email; - $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first(); - $this->uses_sso = $oauthIdentity !== null; - $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null; - // Check if there's a pending email change - if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) { + if (Auth::user()->hasEmailChangeRequest()) { $this->new_email = Auth::user()->pending_email; $this->show_verification = true; } @@ -105,10 +101,6 @@ class Index extends Component public function requestEmailChange() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -167,10 +159,6 @@ class Index extends Component public function verifyEmailChange() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -216,6 +204,7 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); + $this->dispatch('close-email-change-modal'); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -227,10 +216,6 @@ class Index extends Component public function resendVerificationCode() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - // Check if there's a pending request if (! Auth::user()->hasEmailChangeRequest()) { $this->dispatch('error', 'No pending email change request.'); @@ -284,30 +269,6 @@ class Index extends Component $this->dispatch('success', 'Email change request cancelled.'); } - public function showEmailChangeForm() - { - if ($this->rejectSsoEmailChange()) { - return; - } - - $this->show_email_change = true; - $this->new_email = ''; - } - - private function rejectSsoEmailChange(): bool - { - if (! Auth::user()->hasSsoIdentity()) { - return false; - } - - $this->uses_sso = true; - $this->show_email_change = false; - $this->show_verification = false; - $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.'); - - return true; - } - public function resetPassword() { try { @@ -338,14 +299,6 @@ class Index extends Component } } - private function providerLabel(string $provider): string - { - return match ($provider) { - 'oidc' => 'OIDC', - default => str($provider)->headline()->toString(), - }; - } - public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php deleted file mode 100644 index 453a7e8ae8..0000000000 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ /dev/null @@ -1,114 +0,0 @@ -integrationToken = IntegrationToken::ownedByCurrentTeam() - ->whereUuid($integration_token_uuid) - ->firstOrFail(); - - $this->authorize('view', $this->integrationToken); - - $this->name = $this->integrationToken->name; - $this->capabilities = $this->integrationToken->capabilities; - } - - protected function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'newToken' => ['nullable', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], - ]; - } - - protected function messages(): array - { - return [ - 'capabilities.required' => 'Select at least one capability.', - 'capabilities.min' => 'Select at least one capability.', - ]; - } - - public function save(CloudflareTokenValidator $validator): void - { - $this->authorize('update', $this->integrationToken); - $validated = $this->validate(); - $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; - $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() - !== collect($this->integrationToken->capabilities)->sort()->values()->all(); - - try { - if ((filled($validated['newToken']) || $capabilitiesChanged) - && ! $validator->validate($token, $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); - - return; - } - - $updates = [ - 'name' => $validated['name'], - 'capabilities' => $validated['capabilities'], - ]; - - if (filled($validated['newToken'])) { - $updates['token'] = $validated['newToken']; - } - - $this->integrationToken->update($updates); - $this->newToken = ''; - - auditLog('ui.integration_token.updated', [ - 'team_id' => currentTeam()->id, - 'integration_token_uuid' => $this->integrationToken->uuid, - 'integration_token_name' => $this->integrationToken->name, - 'provider' => $this->integrationToken->provider, - 'rotated' => array_key_exists('token', $updates), - ]); - - $this->dispatch( - 'integration-token-updated', - uuid: $this->integrationToken->uuid, - name: $this->integrationToken->name, - capabilities: $this->integrationToken->capabilities, - ); - $this->dispatch('success', 'Integration token updated successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function delete(string $password = ''): void - { - $this->authorize('delete', $this->integrationToken); - $this->integrationToken->delete(); - - $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); - $this->dispatch('close-modal'); - $this->dispatch('success', 'Integration token deleted successfully.'); - } - - public function render() - { - return view('livewire.security.integration-token-editor'); - } -} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php deleted file mode 100644 index 7a7637bf5e..0000000000 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ /dev/null @@ -1,81 +0,0 @@ -authorize('create', IntegrationToken::class); - } - - protected function rules(): array - { - return [ - 'provider' => ['required', 'in:cloudflare'], - 'name' => ['required', 'string', 'max:255'], - 'token' => ['required', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], - ]; - } - - protected function messages(): array - { - return [ - 'capabilities.required' => 'Select at least one capability.', - 'capabilities.min' => 'Select at least one capability.', - ]; - } - - public function addToken(CloudflareTokenValidator $validator): void - { - $validated = $this->validate(); - - try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); - - return; - } - - IntegrationToken::query()->create([ - ...$validated, - 'team_id' => currentTeam()->id, - ]); - - $this->reset(['name', 'token']); - $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); - - if ($this->modal_mode) { - $this->dispatch('close-modal'); - } - - $this->dispatch('success', 'Integration token added successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function render() - { - return view('livewire.security.integration-token-form'); - } -} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php deleted file mode 100644 index 39db135b38..0000000000 --- a/app/Livewire/Security/IntegrationTokens.php +++ /dev/null @@ -1,41 +0,0 @@ -authorize('viewAny', IntegrationToken::class); - $this->loadTokens(); - } - - #[On('integrationTokenAdded')] - public function loadTokens(): void - { - $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); - } - - public function deleteToken(int $tokenId, string $password = ''): void - { - $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); - $this->authorize('delete', $token); - $token->delete(); - $this->loadTokens(); - $this->dispatch('success', 'Integration token deleted successfully.'); - } - - public function render() - { - return view('livewire.security.integration-tokens'); - } -} diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ae53488bd5..3af0a22610 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,49 +177,6 @@ class LogDrains extends Component } } - public function toggleLogDrain(string $type): void - { - $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; - $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; - $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; - - try { - $this->authorize('update', $this->server); - $this->resetErrorBag(); - - $enabledProperty = $this->enabledProperty($type); - - if ($this->{$enabledProperty}) { - $this->{$enabledProperty} = false; - } else { - $this->validateLogDrainSettings($type); - $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; - $this->isLogDrainAxiomEnabled = $type === 'axiom'; - $this->isLogDrainCustomEnabled = $type === 'custom'; - } - - $this->syncData(true); - - if ($this->server->isLogDrainEnabled()) { - StartLogDrain::run($this->server); - $this->dispatch('success', 'Log drain service started.'); - } else { - StopLogDrain::run($this->server); - $this->dispatch('success', 'Log drain service stopped.'); - } - } catch (\Throwable $e) { - // Restore the previously persisted enabled flags so the UI/DB never - // claim a runtime state that the Start/StopLogDrain action failed to apply. - $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; - $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; - $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; - $this->server->settings->save(); - $this->syncData(); - - handleError($e, $this); - } - } - public function submit() { try { @@ -235,33 +192,4 @@ class LogDrains extends Component { return view('livewire.server.log-drains'); } - - private function enabledProperty(string $type): string - { - return match ($type) { - 'newrelic' => 'isLogDrainNewRelicEnabled', - 'axiom' => 'isLogDrainAxiomEnabled', - 'custom' => 'isLogDrainCustomEnabled', - default => throw new \InvalidArgumentException('Unknown log drain type.'), - }; - } - - private function validateLogDrainSettings(string $type): void - { - match ($type) { - 'newrelic' => $this->validate([ - 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - 'logDrainNewRelicBaseUri' => ['required', 'url'], - ]), - 'axiom' => $this->validate([ - 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - ]), - 'custom' => $this->validate([ - 'logDrainCustomConfig' => ['required'], - 'logDrainCustomConfigParser' => ['string', 'nullable'], - ]), - default => throw new \InvalidArgumentException('Unknown log drain type.'), - }; - } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 38a2f85a73..fd5ee616d9 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,9 +19,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; - #[Validate('boolean')] - public bool $disable_registration_when_oauth_enabled; - #[Validate('boolean')] public bool $do_not_track; @@ -62,7 +59,6 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', - 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -88,7 +84,6 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; - $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -204,7 +199,6 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; - $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 1426f61f02..9bca0db2e3 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,59 +160,30 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } - public function toggleSmtp() - { - try { - $this->resetErrorBag(); - - if ($this->smtpEnabled) { - $this->smtpEnabled = false; - $this->syncData(true); - $this->dispatch('success', 'SMTP settings updated.'); - } else { - $this->validateSmtpSettings(); - $this->smtpEnabled = true; - $this->resendEnabled = false; - $this->submitSmtp(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - - public function toggleResend() - { - try { - $this->resetErrorBag(); - - if ($this->resendEnabled) { - $this->resendEnabled = false; - $this->syncData(true); - $this->dispatch('success', 'Resend settings updated.'); - } else { - $this->validateResendSettings(); - $this->resendEnabled = true; - $this->smtpEnabled = false; - $this->submitResend(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - public function submitSmtp() { try { $this->authorize('update', $this->settings); - $this->validateSmtpSettings(); - - if ($this->smtpEnabled) { - $this->settings->resend_enabled = $this->resendEnabled = false; - } + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -239,11 +210,17 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $this->validateResendSettings(); - - if ($this->resendEnabled) { - $this->settings->smtp_enabled = $this->smtpEnabled = false; - } + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -260,45 +237,6 @@ class SettingsEmail extends Component } } - private function validateSmtpSettings(): void - { - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); - } - - private function validateResendSettings(): void - { - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); - } - public function sendTestEmail() { try { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 3b24d0cd2e..4082718191 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,89 +2,53 @@ namespace App\Livewire; -use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Http\RedirectResponse; -use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; - public InstanceSettings $settings; - public $oauth_settings_map; - public ?string $selectedProvider = null; - - public bool $disable_registration_when_oauth_enabled = false; - - protected function rules(): array + protected function rules() { - return $this->validationRules(); - } - - private function validationRules(?string $provider = null): array - { - $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { - if ($provider !== null && $setting->provider !== $provider) { - return $carry; - } - - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; - $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; - $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; - $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; + return OauthSetting::all()->reduce(function ($carry, $setting) { + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; return $carry; }, []); - - if ($provider === null) { - $rules['disable_registration_when_oauth_enabled'] = 'boolean'; - } - - return $rules; } - public function mount(?string $provider = null): ?RedirectResponse + public function mount() { if (! isInstanceAdmin()) { return redirect()->route('home'); } + $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { + $carry[$setting->provider] = [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + ]; - $this->settings = instanceSettings(); - $this->selectedProvider = $provider; - $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; - $this->oauth_settings_map = OauthSetting::all() - ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) - ->reduce(function ($carry, $setting) { - $carry[$setting->provider] = $this->oauthSettingToArray($setting); - - return $carry; - }, []); - - if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { - abort(404); - } - - return null; + return $carry; + }, []); } - private function updateOauthSettings(?string $provider = null): void + private function updateOauthSettings(?string $provider = null) { - $this->validate($this->validationRules($provider)); - if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -93,128 +57,78 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $this->fillOauthSetting($oauth, $oauthData); - $this->ensureProviderCanBeEnabled($oauth); + $oauth->fill([ + 'enabled' => $oauthData['enabled'], + 'client_id' => $oauthData['client_id'], + 'client_secret' => $oauthData['client_secret'], + 'redirect_uri' => $oauthData['redirect_uri'], + 'tenant' => $oauthData['tenant'], + 'base_url' => $oauthData['base_url'], + ]); + + if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } $oauth->save(); - $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + // Update the array with fresh data + $this->oauth_settings_map[$provider] = [ + 'id' => $oauth->id, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'client_id' => $oauth->client_id, + 'client_secret' => $oauth->client_secret, + 'redirect_uri' => $oauth->redirect_uri, + 'tenant' => $oauth->tenant, + 'base_url' => $oauth->base_url, + ]; $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); + } else { + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); - return; - } + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); + continue; + } - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + $oauth->fill([ + 'enabled' => $settingData['enabled'], + 'client_id' => $settingData['client_id'], + 'client_secret' => $settingData['client_secret'], + 'redirect_uri' => $settingData['redirect_uri'], + 'tenant' => $settingData['tenant'], + 'base_url' => $settingData['base_url'], + ]); - continue; + if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + $oauth->save(); + + // Update the array with fresh data + $this->oauth_settings_map[$oauth->provider] = [ + 'id' => $oauth->id, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'client_id' => $oauth->client_id, + 'client_secret' => $oauth->client_secret, + 'redirect_uri' => $oauth->redirect_uri, + 'tenant' => $oauth->tenant, + 'base_url' => $oauth->base_url, + ]; } - $this->fillOauthSetting($oauth, $settingData); - - if ($oauth->enabled && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); } - - if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - $oauth->enabled = false; - $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; - } - - $oauth->save(); - $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); } - - instanceSettings()->update([ - 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, - ]); - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } - } - - private function fillOauthSetting(OauthSetting $oauth, array $data): void - { - $oauth->fill([ - 'enabled' => (bool) ($data['enabled'] ?? false), - 'client_id' => $data['client_id'] ?? null, - 'client_secret' => $data['client_secret'] ?? null, - 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), - 'tenant' => $data['tenant'] ?? null, - 'base_url' => $this->nullableString($data['base_url'] ?? null), - 'custom_label' => $data['custom_label'] ?? null, - 'scopes' => $data['scopes'] ?? null, - 'allow_registration' => (bool) ($data['allow_registration'] ?? false), - 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), - 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), - 'use_pkce' => (bool) ($data['use_pkce'] ?? true), - 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), - ]); - } - - private function nullableString(mixed $value): ?string - { - if ($value === null) { - return null; - } - - $value = trim((string) $value); - - return $value === '' ? null : $value; - } - - private function ensureProviderCanBeEnabled(OauthSetting $oauth): void - { - if (! $oauth->enabled) { - return; - } - - if (! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } - - if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - $oauth->update(['enabled' => false]); - throw new \Exception("OIDC scopes must include 'openid'."); - } - } - - private function oauthSettingToArray(OauthSetting $setting): array - { - return [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - 'custom_label' => $setting->custom_label, - 'scopes' => $setting->scopes ?: 'openid email profile', - 'allow_registration' => $setting->allow_registration, - 'auto_join_root_team' => $setting->auto_join_root_team, - 'require_email_verified' => $setting->require_email_verified ?? true, - 'use_pkce' => $setting->use_pkce ?? true, - 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, - 'label' => $this->providerLabel($setting->provider), - ]; - } - - public function providerLabel(string $provider): string - { - return match ($provider) { - 'oidc' => 'OpenID Connect', - 'gitlab' => 'GitLab', - default => str($provider)->headline()->toString(), - }; } public function instantSave(string $provider) @@ -227,88 +141,56 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider) + public function toggleProvider(string $provider): mixed { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - abort(404); + throw new \Exception('OAuth provider not found.'); } - if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { - $this->validateProviderCanBeEnabled($provider); + $enabling = ! $this->oauth_settings_map[$provider]['enabled']; + if ($enabling) { + $this->validate($this->providerRules($provider)); } - $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; + $this->oauth_settings_map[$provider]['enabled'] = $enabling; $this->updateOauthSettings($provider); - } catch (\Exception $e) { - $oauth = OauthSetting::where('provider', $provider)->first(); - if ($oauth) { - $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); - } - + } catch (\Throwable $e) { return handleError($e, $this); } + + return null; } - private function validateProviderCanBeEnabled(string $provider): void + private function providerRules(string $provider): array { - $this->validate($this->validationRules($provider)); + $prefix = "oauth_settings_map.$provider"; + $rules = [ + "$prefix.client_id" => 'required', + "$prefix.client_secret" => 'required', + ]; - $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); - if (! $oauth) { - throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); + if ($provider === 'azure') { + $rules["$prefix.tenant"] = 'required'; } - $this->fillOauthSetting($oauth, [ - ...$this->oauth_settings_map[$provider], - 'enabled' => true, - ]); - - if (! $oauth->couldBeEnabled()) { - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + if (in_array($provider, ['authentik', 'clerk'], true)) { + $rules["$prefix.base_url"] = 'required'; } - if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - throw new \Exception("OIDC scopes must include 'openid'."); - } + return $rules; } - public function saveRegistrationPolicy(): void - { - $this->authorize('update', instanceSettings()); - $this->validate([ - 'disable_registration_when_oauth_enabled' => 'boolean', - ]); - - instanceSettings()->update([ - 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, - ]); - - $this->dispatch('success', 'Authentication settings updated successfully!'); - } - - public function submit(): void + public function submit() { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings($this->selectedProvider); - - if ($this->selectedProvider === null) { - $this->dispatch('success', 'Instance settings updated successfully!'); - } - } catch (ValidationException $e) { - throw $e; - } catch (\Exception $e) { - if ($this->selectedProvider !== null) { - $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); - if ($oauth) { - $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); - } - } - - handleError($e, $this); + $this->updateOauthSettings(); + $this->dispatch('success', 'Instance settings updated successfully!'); + } catch (\Throwable $e) { + return handleError($e, $this); } } } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 02f3e7ed50..eb01fa7ada 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,7 +22,6 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', - 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -89,8 +88,6 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', - 'is_registration_enabled' => 'boolean', - 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -118,19 +115,6 @@ class InstanceSettings extends Model }); } - public function isPasswordRegistrationAllowed(): bool - { - if (! $this->is_registration_enabled) { - return false; - } - - if (! $this->disable_registration_when_oauth_enabled) { - return true; - } - - return ! OauthSetting::where('enabled', true)->exists(); - } - public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php deleted file mode 100644 index 20541f6139..0000000000 --- a/app/Models/IntegrationToken.php +++ /dev/null @@ -1,38 +0,0 @@ - 'encrypted', - 'capabilities' => 'array', - ]; - } - - public function team(): BelongsTo - { - return $this->belongsTo(Team::class); - } - - public static function ownedByCurrentTeam() - { - return self::query()->where('team_id', currentTeam()->id); - } -} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php deleted file mode 100644 index 1edf71ad2f..0000000000 --- a/app/Models/OauthIdentity.php +++ /dev/null @@ -1,35 +0,0 @@ - 'array', - 'last_login_at' => 'datetime', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 7765e41160..e7999134a6 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,19 +11,7 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; - - protected function casts(): array - { - return [ - 'enabled' => 'boolean', - 'allow_registration' => 'boolean', - 'auto_join_root_team' => 'boolean', - 'require_email_verified' => 'boolean', - 'use_pkce' => 'boolean', - 'clock_skew_seconds' => 'integer', - ]; - } + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; protected $hidden = [ 'client_secret', @@ -44,46 +32,9 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': - case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } - - /** - * @return array - */ - public function scopeList(): array - { - $scopes = str($this->scopes ?: 'openid email profile') - ->replace(',', ' ') - ->explode(' ') - ->map(fn (string $scope) => trim($scope)) - ->filter() - ->unique() - ->values() - ->all(); - - return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; - } - - public function loginLabel(): string - { - if (filled($this->custom_label)) { - return $this->custom_label; - } - - $envLabel = config("services.{$this->provider}.custom_label"); - if (filled($envLabel)) { - return $envLabel; - } - - return __("auth.login.{$this->provider}"); - } - - public function isOidc(): bool - { - return $this->provider === 'oidc'; - } } diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..15085203aa 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,11 +304,6 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } - public function integrationTokens() - { - return $this->hasMany(IntegrationToken::class); - } - public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 10303422bd..5b38473962 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,7 +11,6 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -508,26 +507,12 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } - public function oauthIdentities(): HasMany - { - return $this->hasMany(OauthIdentity::class); - } - - public function hasSsoIdentity(): bool - { - return $this->oauthIdentities()->exists(); - } - /** * Check if the user has a password set. + * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } - - public function requiresPasswordConfirmation(): bool - { - return $this->hasPassword() && ! $this->hasSsoIdentity(); - } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php deleted file mode 100644 index 309c8167f2..0000000000 --- a/app/Policies/IntegrationTokenPolicy.php +++ /dev/null @@ -1,34 +0,0 @@ -isAdmin(); - } - - public function create(User $user): bool - { - return $user->isAdmin(); - } - - public function view(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } - - public function update(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } - - public function delete(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4d2b0a851..5856791662 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,6 @@ namespace App\Providers; -use App\Auth\Oidc\OidcDiscoveryService; -use App\Auth\Oidc\OidcTokenValidator; -use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -13,7 +10,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; -use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -26,11 +22,12 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); + $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - $this->configureOidcSocialite(); + } private function configureCommands(): void @@ -65,24 +62,6 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } - private function configureOidcSocialite(): void - { - if (! $this->app->bound(SocialiteFactory::class)) { - return; - } - - $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { - return new OidcProvider( - $app['request'], - $app->make(OidcDiscoveryService::class), - $app->make(OidcTokenValidator::class), - '', - '', - '', - ); - }); - } - private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -98,5 +77,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 e8e6fb42c6..09b2a3e089 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,7 +15,6 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; -use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -53,7 +52,6 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; -use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -134,7 +132,6 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, - IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index bf6fa4c4bf..1b201fb3f9 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->isPasswordRegistrationAllowed()) { + if (! $settings->is_registration_enabled) { return redirect()->route('login'); } @@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { - // If there are no users and password registration is allowed, redirect to registration. + if ($users == 0) { + // If there are no users, redirect to registration return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), + 'is_registration_enabled' => $settings->is_registration_enabled, 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php deleted file mode 100644 index 2ec8f88e3e..0000000000 --- a/app/Services/Auth/OauthLoginService.php +++ /dev/null @@ -1,228 +0,0 @@ -email)); - if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new HttpException(403, 'OAuth provider did not return a valid email address'); - } - - $user = $provider === 'oidc' - ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) - : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); - - Auth::login($user); - $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); - session(['currentTeam' => $user->currentTeam = $team]); - - return $user; - } - - private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User - { - $provider = $oauthSetting->provider; - $providerUserId = $oauthUser->id ?? null; - if ( - (! is_string($providerUserId) && ! is_int($providerUserId)) - || (is_string($providerUserId) && trim($providerUserId) === '') - ) { - throw new HttpException(403, 'OAuth provider did not return a valid user ID'); - } - $providerUserId = (string) $providerUserId; - $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; - - $identityKey = [ - 'provider' => $provider, - 'issuer' => $provider, - 'provider_user_id' => $providerUserId, - ]; - - try { - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { - $identity = OauthIdentity::where($identityKey)->first(); - - if ($identity) { - $identity->update([ - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $identity->user; - } - - $user = User::whereEmail($email)->first(); - if (! $user) { - if (! $this->canCreateUser($oauthSetting)) { - throw new HttpException(403, 'Registration is disabled'); - } - - $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); - } - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => $provider, - 'issuer' => $provider, - 'provider_user_id' => $providerUserId, - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $user; - }); - } catch (UniqueConstraintViolationException $exception) { - return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; - } - } - - private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User - { - $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) - ? $oauthUser->issuer - : data_get($oauthUser->user, 'iss'); - $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) - ? $oauthUser->subject - : data_get($oauthUser->user, 'sub', $oauthUser->id); - $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) - || data_get($oauthUser->user, 'email_verified') === true; - - if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { - throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); - } - - if ($oauthSetting->require_email_verified && ! $emailVerified) { - throw new HttpException(403, 'OIDC provider did not verify the email address'); - } - - $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; - - $identityKey = [ - 'provider' => 'oidc', - 'issuer' => $issuer, - 'provider_user_id' => $subject, - ]; - - try { - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { - $identity = OauthIdentity::where($identityKey)->first(); - - if ($identity) { - $identity->update([ - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $identity->user; - } - - $user = User::whereEmail($email)->first(); - - // Linking a new OIDC identity to an existing local account by email - // is account takeover unless the provider attests the email. This - // guard is independent of the require_email_verified toggle, which - // only governs the broader login flow. - if ($user && ! $emailVerified) { - throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); - } - - if (! $user) { - if (! $this->canCreateUser($oauthSetting)) { - throw new HttpException(403, 'Registration is disabled'); - } - - $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); - } - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => $issuer, - 'provider_user_id' => $subject, - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $user; - }); - } catch (UniqueConstraintViolationException $exception) { - return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; - } - } - - private function canCreateUser(OauthSetting $oauthSetting): bool - { - return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; - } - - private function createUser(string $name, string $email, OauthSetting $oauthSetting): User - { - if (User::count() === 0) { - $user = (new User)->forceFill([ - 'id' => 0, - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ]); - $user->save(); - - $team = $user->teams()->first() ?? Team::find(0); - if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { - $user->teams()->attach($team, ['role' => 'owner']); - } - - instanceSettings()->update(['is_registration_enabled' => false]); - - return $user; - } - - if ($oauthSetting->auto_join_root_team) { - return $this->createRootTeamOnlyUser($name, $email); - } - - return User::create([ - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ]); - } - - private function createRootTeamOnlyUser(string $name, string $email): User - { - return DB::transaction(function () use ($name, $email) { - $rootTeam = Team::find(0); - if ($rootTeam === null) { - throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); - } - - $user = User::withoutEvents(fn () => User::create([ - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ])); - - $user->teams()->attach($rootTeam, ['role' => 'member']); - - return $user; - }); - } -} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php deleted file mode 100644 index 2a4a761027..0000000000 --- a/app/Services/CloudflareTokenValidator.php +++ /dev/null @@ -1,42 +0,0 @@ -client($token); - $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); - - if (! $verification->successful() || $verification->json('result.status') !== 'active') { - return false; - } - - if (in_array('dns', $capabilities, true)) { - $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); - $zoneId = $zones->json('result.0.id'); - - if (! $zones->successful() || ! is_string($zoneId)) { - return false; - } - - return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ - 'per_page' => 1, - ])->successful(); - } - - return true; - } - - private function client(string $token): PendingRequest - { - return Http::withToken($token) - ->acceptJson() - ->connectTimeout(5) - ->timeout(10); - } -} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 461e7c2669..8a003ec40d 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * - User has no password (OAuth users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4566,9 +4566,8 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // OAuth users may have an unusable generated password, so the linked - // identity is the source of truth for whether confirmation is possible. - if (! Auth::user()?->requiresPasswordConfirmation()) { + // Skip if user has no password (OAuth users) + if (! Auth::user()?->hasPassword()) { return true; } @@ -4579,7 +4578,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * - User has no password (OAuth users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index f177e6c16f..fd3fbe74ba 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,13 +1,7 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -29,7 +23,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new Config( + $authentik_clerk_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -40,7 +34,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new Config( + $zitadel_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -50,12 +44,8 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } - if ($provider === 'oidc') { - return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); - } - if ($provider == 'google') { - $google_config = new Config( + $google_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -73,11 +63,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => BitbucketProvider::class, - 'discord' => Provider::class, - 'github' => GithubProvider::class, - 'gitlab' => GitlabProvider::class, - 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, + 'discord' => \SocialiteProviders\Discord\Provider::class, + 'github' => \Laravel\Socialite\Two\GithubProvider::class, + 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, + 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index 18e125510d..871c6f010c 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", - "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", diff --git a/composer.lock b/composer.lock index 18c5260f32..c2c42ba71a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d511da9e5e82eade5aa7e5094c888ae", + "content-hash": "971daeb1b3078a36428c0fb56bb895b7", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/services.php b/config/services.php index 3a2a0631ef..c5956cf6c9 100644 --- a/config/services.php +++ b/config/services.php @@ -60,14 +60,6 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], - 'oidc' => [ - 'client_id' => env('OIDC_CLIENT_ID'), - 'client_secret' => env('OIDC_CLIENT_SECRET'), - 'redirect' => env('OIDC_REDIRECT_URI'), - 'base_url' => env('OIDC_BASE_URL'), - 'custom_label' => env('OIDC_LOGIN_LABEL'), - ], - 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 13fe6b6784..19c4445b26 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,12 +8,6 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. - * - * Coolify's own backend runs exclusively on PostgreSQL in production and - * SQLite in testing (see config/database.php — the only configured - * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed - * resources, never Coolify's application database, so no driver path is - * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php deleted file mode 100644 index 3160ef9ddb..0000000000 --- a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php +++ /dev/null @@ -1,40 +0,0 @@ -string('custom_label')->nullable(); - $table->string('scopes')->nullable(); - $table->boolean('allow_registration')->default(true); - $table->boolean('require_email_verified')->default(true); - $table->boolean('use_pkce')->default(true); - $table->unsignedSmallInteger('clock_skew_seconds')->default(60); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('oauth_settings', function (Blueprint $table) { - $table->dropColumn([ - 'custom_label', - 'scopes', - 'allow_registration', - 'require_email_verified', - 'use_pkce', - 'clock_skew_seconds', - ]); - }); - } -}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php deleted file mode 100644 index 9f838e5779..0000000000 --- a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('provider'); - $table->string('issuer'); - $table->string('provider_user_id'); - $table->string('email')->nullable()->index(); - $table->json('raw_claims')->nullable(); - $table->timestamp('last_login_at')->nullable(); - $table->timestamps(); - - $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('oauth_identities'); - } -}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php deleted file mode 100644 index 06c0f1dd52..0000000000 --- a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('disable_registration_when_oauth_enabled')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('disable_registration_when_oauth_enabled'); - }); - } -}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php deleted file mode 100644 index b0f5aad18a..0000000000 --- a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('auto_join_root_team')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('oauth_settings', function (Blueprint $table) { - $table->dropColumn('auto_join_root_team'); - }); - } -}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php deleted file mode 100644 index a17d3972d5..0000000000 --- a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - $table->string('uuid')->unique(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - $table->string('provider'); - $table->string('name'); - $table->text('token'); - $table->json('capabilities'); - $table->timestamps(); - - $table->index(['team_id', 'provider']); - }); - } - - public function down(): void - { - Schema::dropIfExists('integration_tokens'); - } -}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index f916c4a9cd..2e3e63defd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,7 +23,6 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', - 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 19d3aa42e8..2ac615cc01 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,10 +15,12 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ + 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ + 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/lang/de.json b/lang/de.json index cbc2237a75..7c43300e67 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,7 +7,6 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", - "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index b97a10d629..12c21b6665 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,7 +8,6 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", - "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index b05437ac4e..bcd8e23937 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,7 +8,6 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", - "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg deleted file mode 100644 index 9c542584ef..0000000000 --- a/public/svgs/oidc.svg +++ /dev/null @@ -1,5 +0,0 @@ - - OpenID Connect - - - diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 12eb57867c..829a26cad3 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,15 +80,11 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - @if ($provider_setting->provider !== 'oidc') - - @endif - {{ $provider_setting->loginLabel() }} + {{ __("auth.login.$provider_setting->provider") }} @endforeach
diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index a17b0b96a6..d2b3e30a6f 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,12 +12,6 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, - auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ - 'label' => 'Integration Tokens', - 'route' => 'security.integration-tokens', - 'active' => request()->routeIs('security.integration-tokens'), - 'icon' => 'network', - ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index dbe381e050..0e0de551fd 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,24 +12,6 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], - [ - 'label' => 'Authentication', - 'route' => 'settings.oauth', - 'active' => $activeMenu === 'oauth', - 'icon' => 'keys', - ], - [ - 'label' => 'Transactional Email', - 'route' => 'settings.email', - 'active' => $activeMenu === 'email', - 'icon' => 'notifications', - ], - [ - 'label' => 'Instance Backup', - 'route' => 'settings.backup', - 'active' => $activeMenu === 'backup', - 'icon' => 'database', - ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 33f1b9a98e..ef54d3e215 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,22 +134,15 @@
+ x-bind:disabled="emailModalOpen"> Change
- - + + - @if ($uses_sso) - - Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider. - - @endif - - @if (! $uses_sso) -
diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php deleted file mode 100644 index b7e53dbc7c..0000000000 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ /dev/null @@ -1,52 +0,0 @@ -
- -
- - -
- -
-
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
- - Create a replacement token in Cloudflare - -
- @endif - -
- - - Validate and save - -
- -
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php deleted file mode 100644 index d847fff7fb..0000000000 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -
-
- - -
- - -
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
-

Limit zone resources to the zones Coolify should manage.

- - Create this token in Cloudflare - -
- @endif - -
- - Validate and add - -
- -
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php deleted file mode 100644 index b4961551ae..0000000000 --- a/resources/views/livewire/security/integration-tokens.blade.php +++ /dev/null @@ -1,84 +0,0 @@ -
- - Integration Tokens | Coolify - - - -
- - - @can('create', App\Models\IntegrationToken::class) - - - - - - - @endcan - - - @if ($tokens->isEmpty()) - - @else -
- @foreach ($tokens as $savedToken) -
- - -
-
-

- -

-
-
- {{ ucfirst($savedToken->provider) }} -
-
- -
- -
-
- -
-
- @endforeach -
- @endif -
-
-
-
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index f1e4fc3f7a..d490b6f1db 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status - notifications can be managed from + Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications + can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 822c035b31..97822b9251 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,126 +5,76 @@ -
- -
+
+ +
-
+ @foreach ($oauth_settings_map as $oauth_setting) + @php + $provider = $oauth_setting['provider']; + $providerLabel = str($provider)->headline(); + @endphp - - - - - @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $providerLabel }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- @if ($provider === 'oidc') - - - - - - -
- -
- @else - - - - @endif + + + + @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif - -
- -
- @if ($provider === 'oidc') - - - - @endif -
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d05ac5ac98..d15a1b87ab 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,19 +13,12 @@
- - + ]" /> create(); - $user = User::factory()->create(['email' => 'owner@example.com']); - $user->teams()->attach($team, ['role' => 'owner']); - - session(['currentTeam' => $team]); - test()->actingAs($user); - - return [$user, $team]; -} - -function actingAsEnableActionInstanceAdmin(): User -{ - $team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); - $user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']); - if (! $user->teams()->whereKey($team->id)->exists()) { - $user->teams()->attach($team, ['role' => 'owner']); - } - - session(['currentTeam' => $team]); - test()->actingAs($user); - - return $user; -} - -beforeEach(function () { - InstanceSettings::forceCreate(['id' => 0]); - Once::flush(); -}); - -it('renders settings email enable actions instead of enabled checkboxes', function () { - $view = file_get_contents(resource_path('views/livewire/settings-email.blade.php')); - - expect($view)->toContain('Enable SMTP Server') - ->and($view)->toContain('Disable SMTP Server') - ->and($view)->toContain('Enable Resend') - ->and($view)->toContain('Disable Resend') - ->and($view)->not->toContain('id="smtpEnabled" label="Enabled"') - ->and($view)->not->toContain('id="resendEnabled" label="Enabled"'); -}); - -it('keeps transactional smtp disabled when enable validation fails', function () { - actingAsEnableActionInstanceAdmin(); - - Livewire::test(SettingsEmail::class) - ->call('toggleSmtp') - ->assertDispatched('error') - ->assertSet('smtpEnabled', false); - - expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse(); -}); - -it('enables transactional smtp only after required fields validate', function () { - actingAsEnableActionInstanceAdmin(); - - Livewire::test(SettingsEmail::class) - ->set('smtpFromAddress', 'mail@example.com') - ->set('smtpFromName', 'Coolify') - ->set('smtpHost', 'smtp.example.com') - ->set('smtpPort', '587') - ->set('smtpEncryption', 'starttls') - ->call('toggleSmtp') - ->assertHasNoErrors() - ->assertSet('smtpEnabled', true) - ->assertSet('resendEnabled', false); - - expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue() - ->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse(); -}); - -it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) { - $contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php")); - - expect($contents)->toContain($enableLabel) - ->and($contents)->not->toContain($checkboxSnippet); -})->with([ - 'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'], - 'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'], - 'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'], - 'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'], - 'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'], -]); - -it('shows notification provider save buttons while disabled', function (string $component) { - actingAsEnableActionOwner(); - - Livewire::test($component) - ->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false) - ->assertSee('Save'); -})->with([ - 'discord' => [Discord::class], - 'slack' => [Slack::class], - 'telegram' => [Telegram::class], - 'pushover' => [Pushover::class], - 'webhook' => [Webhook::class], -]); - -it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) { - actingAsEnableActionOwner(); - - Livewire::test($component) - ->assertDontSee('Send Test Notification'); - - Livewire::test($component) - ->set($enabledProperty, true) - ->assertSee('Send Test Notification'); -})->with([ - 'discord' => [Discord::class, 'discordEnabled'], - 'slack' => [Slack::class, 'slackEnabled'], - 'telegram' => [Telegram::class, 'telegramEnabled'], - 'pushover' => [Pushover::class, 'pushoverEnabled'], - 'webhook' => [Webhook::class, 'webhookEnabled'], -]); - -it('hides the email test button while email notifications are disabled', function () { - actingAsEnableActionOwner(); - - Livewire::test(Email::class) - ->assertDontSee('Send Test Email'); -}); - -it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) { - [, $team] = actingAsEnableActionOwner(); - - Livewire::test($component) - ->call($method) - ->assertDispatched('error') - ->assertSet($enabledProperty, false); - - expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse(); -})->with([ - 'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'], - 'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'], - 'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'], - 'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'], - 'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'], -]); - -it('renders notification email and log drain enable actions instead of enabled checkboxes', function () { - $notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php')); - $logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php')); - - expect($notificationEmail)->toContain('Enable SMTP Server') - ->and($notificationEmail)->toContain('Enable Resend') - ->and($notificationEmail)->not->toContain('id="smtpEnabled"') - ->and($notificationEmail)->not->toContain('id="resendEnabled"') - ->and($logDrains)->toContain('Enable New Relic') - ->and($logDrains)->toContain('Enable Axiom') - ->and($logDrains)->toContain('Enable Custom FluentBit') - ->and($logDrains)->not->toContain('label="Enabled"'); -}); - -it('keeps notification email smtp disabled when enable validation fails', function () { - actingAsEnableActionOwner(); - - Livewire::test(Email::class) - ->call('toggleSmtp') - ->assertDispatched('error') - ->assertSet('smtpEnabled', false); -}); diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php deleted file mode 100644 index 994c96398b..0000000000 --- a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php +++ /dev/null @@ -1,45 +0,0 @@ -user = User::factory()->create(); - $this->team = $this->user->teams()->first(); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); - - $this->actingAs($this->user); - session(['currentTeam' => $this->team]); -}); - -it('reverts the persisted enabled flag when starting the log drain fails', function () { - StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); - - expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); - - Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) - ->set('logDrainNewRelicLicenseKey', 'abc123') - ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') - ->call('toggleLogDrain', 'newrelic') - ->assertSet('isLogDrainNewRelicEnabled', false); - - expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); -}); - -it('keeps the enabled flag persisted when starting the log drain succeeds', function () { - StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); - - Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) - ->set('logDrainNewRelicLicenseKey', 'abc123') - ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') - ->call('toggleLogDrain', 'newrelic') - ->assertSet('isLogDrainNewRelicEnabled', true); - - expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy(); -}); diff --git a/tests/Feature/LoginPageBrandingTest.php b/tests/Feature/LoginPageBrandingTest.php index ffe7da67ce..5d60290abd 100644 --- a/tests/Feature/LoginPageBrandingTest.php +++ b/tests/Feature/LoginPageBrandingTest.php @@ -37,28 +37,6 @@ test('auth pages use the Coollabs purple background glow', function () { ->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s'); }); -test('external login providers are centered and full width', function () { - $login = file_get_contents(resource_path('views/auth/login.blade.php')); - - expect($login) - ->toContain('class="flex flex-col gap-2"') - ->toContain('class="w-full justify-center"') - ->not->toContain('sm:w-[calc(50%-0.25rem)]'); -}); - -test('external login providers display their icons except oidc', function () { - $login = file_get_contents(resource_path('views/auth/login.blade.php')); - - expect($login) - ->toContain("@if (\$provider_setting->provider !== 'oidc')") - ->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')") - ->toContain('class="size-5 shrink-0 dark:invert"'); - - foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) { - expect(public_path("svgs/{$provider}.svg"))->toBeFile(); - } -}); - test('error pages use the Coollabs purple background glow', function () { $styles = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php index 4671183ae7..1388e29808 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -1,35 +1,25 @@ 0, 'is_registration_enabled' => false, ]); - Once::flush(); - OauthSetting::create([ 'provider' => 'google', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', 'tenant' => 'example.com', - 'enabled' => true, ]); }); @@ -56,75 +46,6 @@ it('logs in an existing user when the oauth provider returns a mixed-case email' $response->assertRedirect('/'); $this->assertAuthenticatedAs($user); expect(User::count())->toBe(1); - expect(OauthIdentity::where([ - 'user_id' => $user->id, - 'provider' => 'google', - 'provider_user_id' => 'google-user-id', - ])->exists())->toBeTrue(); -}); - -it('never moves an existing oauth identity when the provider email changes', function () { - config()->set('app.maintenance.driver', 'file'); - - $identityOwner = User::factory()->create(['email' => 'old@example.com']); - $otherUser = User::factory()->create(['email' => 'new@example.com']); - $identity = OauthIdentity::create([ - 'user_id' => $identityOwner->id, - 'provider' => 'google', - 'issuer' => 'google', - 'provider_user_id' => 'google-user-id', - 'email' => 'old@example.com', - ]); - - $provider = Mockery::mock(); - $provider->shouldReceive('setConfig')->once()->andReturnSelf(); - $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); - $provider->shouldReceive('user')->once()->andReturn((object) [ - 'email' => 'new@example.com', - 'name' => 'Example User', - 'id' => 'google-user-id', - ]); - - Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); - - $this->get(route('auth.callback', 'google'))->assertRedirect('/'); - - $this->assertAuthenticatedAs($identityOwner); - expect($identity->refresh()->user_id)->toBe($identityOwner->id) - ->and($identity->email)->toBe('new@example.com') - ->and($identity->user_id)->not->toBe($otherUser->id); -}); - -it('continues oauth login when another request creates the identity first', function () { - $user = User::factory()->create(['email' => 'race@example.com']); - $eventName = 'eloquent.creating: '.OauthIdentity::class; - - Event::listen($eventName, function (OauthIdentity $identity): void { - $attributes = $identity->getAttributes(); - - DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes)); - - throw new UniqueConstraintViolationException( - DB::getDefaultConnection(), - 'insert into oauth_identities', - [], - new PDOException('duplicate identity'), - ); - }); - - try { - $resolvedUser = app(OauthLoginService::class)->login('google', (object) [ - 'email' => 'race@example.com', - 'name' => 'Race User', - 'id' => 'google-race-id', - ], OauthSetting::where('provider', 'google')->firstOrFail()); - } finally { - Event::forget($eventName); - } - - expect($resolvedUser->is($user))->toBeTrue() - ->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1); - $this->assertAuthenticatedAs($user); }); it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { @@ -155,37 +76,4 @@ it('rejects oauth logins when the provider does not return an email address', fu })->with([ 'null email' => [null], 'blank email' => [' '], - 'malformed email' => ['not-an-email'], - 'missing domain' => ['user@'], -]); - -it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) { - $oauthUser = (object) [ - 'email' => 'user@example.edu', - 'name' => 'Example User', - ]; - - if ($invalidId !== 'missing') { - $oauthUser->id = $invalidId; - } - - try { - app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail()); - } catch (HttpException $exception) { - expect($exception->getStatusCode())->toBe(403) - ->and(OauthIdentity::count())->toBe(0) - ->and(User::count())->toBe(0); - - return; - } - - $this->fail('Expected an invalid OAuth provider user ID to be rejected.'); -})->with([ - 'null id' => [null], - 'missing id' => ['missing'], - 'blank id' => [' '], - 'non-scalar id' => [[]], - 'true id' => [true], - 'false id' => [false], - 'float id' => [1.0], ]); diff --git a/tests/Feature/OauthRegistrationPolicyTest.php b/tests/Feature/OauthRegistrationPolicyTest.php deleted file mode 100644 index 86186cca8c..0000000000 --- a/tests/Feature/OauthRegistrationPolicyTest.php +++ /dev/null @@ -1,52 +0,0 @@ - 0, - 'is_registration_enabled' => true, - 'disable_registration_when_oauth_enabled' => true, - ]); - Once::flush(); -}); - -it('blocks password registration when oauth registration policy disables it', function () { - OauthSetting::create([ - 'provider' => 'oidc', - 'enabled' => true, - 'client_id' => 'client-id', - 'client_secret' => 'secret', - 'base_url' => 'https://idp.example.com', - ]); - - app(CreateNewUser::class)->create([ - 'name' => 'Password User', - 'email' => 'password@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); -})->throws(HttpException::class); - -it('allows password registration when no oauth provider is enabled', function () { - OauthSetting::create([ - 'provider' => 'oidc', - 'enabled' => false, - ]); - - $user = app(CreateNewUser::class)->create([ - 'name' => 'Password User', - 'email' => 'password@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', - ]); - - expect($user->email)->toBe('password@example.com'); -}); diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php deleted file mode 100644 index 084347f66d..0000000000 --- a/tests/Feature/OidcOauthControllerTest.php +++ /dev/null @@ -1,275 +0,0 @@ -set('app.maintenance.driver', 'file'); - - InstanceSettings::forceCreate([ - 'id' => 0, - 'is_registration_enabled' => false, - ]); - - Once::flush(); - - OauthSetting::create([ - 'provider' => 'oidc', - 'enabled' => true, - 'client_id' => 'client-id', - 'client_secret' => 'client-secret', - 'base_url' => 'https://idp.example.com', - 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback', - 'allow_registration' => false, - ]); -}); - -function fakeOidcProvider(array $claims = []): void -{ - $user = (new OidcUser)->setRaw(array_merge([ - 'iss' => 'https://idp.example.com', - 'sub' => 'okta-user-1', - 'email' => 'user@example.com', - 'email_verified' => true, - 'name' => 'Okta User', - ], $claims))->map([ - 'id' => $claims['sub'] ?? 'okta-user-1', - 'name' => $claims['name'] ?? 'Okta User', - 'email' => $claims['email'] ?? 'user@example.com', - ]); - - $provider = Mockery::mock(); - $provider->shouldReceive('setConfig')->andReturnSelf(); - $provider->shouldReceive('user')->andReturn($user); - - Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); -} - -it('logs in a user through an existing oidc identity', function () { - $user = User::factory()->create(['email' => 'existing@example.com']); - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'okta-user-1', - 'email' => 'existing@example.com', - ]); - - fakeOidcProvider(['email' => 'existing@example.com']); - - $response = $this->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/'); - $this->assertAuthenticatedAs($user); -}); - -it('continues oidc login when another request creates the identity first', function () { - $user = User::factory()->create(['email' => 'race@example.com']); - $eventName = 'eloquent.creating: '.OauthIdentity::class; - - Event::listen($eventName, function (OauthIdentity $identity): void { - $attributes = $identity->getAttributes(); - - DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes)); - - throw new UniqueConstraintViolationException( - DB::getDefaultConnection(), - 'insert into oauth_identities', - [], - new PDOException('duplicate identity'), - ); - }); - - try { - $resolvedUser = app(OauthLoginService::class)->login('oidc', (new OidcUser)->setRaw([ - 'iss' => 'https://idp.example.com', - 'sub' => 'oidc-race-id', - 'email' => 'race@example.com', - 'email_verified' => true, - 'name' => 'Race User', - ])->map([ - 'id' => 'oidc-race-id', - 'name' => 'Race User', - 'email' => 'race@example.com', - ]), OauthSetting::where('provider', 'oidc')->firstOrFail()); - } finally { - Event::forget($eventName); - } - - expect($resolvedUser->is($user))->toBeTrue() - ->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1); - $this->assertAuthenticatedAs($user); -}); - -it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () { - OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); - - fakeOidcProvider(['email' => 'newuser@example.com']); - - $response = $this->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/'); - $user = User::whereEmail('newuser@example.com')->first(); - expect($user)->not->toBeNull() - ->and($user->password)->not->toBeNull(); - $this->assertAuthenticatedAs($user); - $this->assertDatabaseHas('oauth_identities', [ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'okta-user-1', - ]); -}); - -it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () { - Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); - (new User)->forceFill([ - 'id' => 0, - 'name' => 'Root User', - 'email' => 'root@example.com', - 'password' => 'password', - ])->save(); - - OauthSetting::where('provider', 'oidc')->update([ - 'allow_registration' => true, - 'auto_join_root_team' => true, - ]); - - fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']); - - $response = $this->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/'); - $user = User::whereEmail('root-member@example.com')->first(); - expect($user)->not->toBeNull() - ->and($user->teams()->count())->toBe(1); - - $rootMembership = $user->teams()->where('teams.id', 0)->first(); - expect($rootMembership)->not->toBeNull() - ->and($rootMembership->pivot->role)->toBe('member'); - - $this->assertDatabaseMissing('teams', [ - 'name' => "Root Member's Team", - ]); - expect(session('currentTeam')->id)->toBe(0); - $this->assertAuthenticatedAs($user); -}); - -it('rejects linking an unverified oidc email to an existing local account', function () { - $user = User::factory()->create(['email' => 'victim@example.com']); - - fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]); - - $response = $this->from('/login')->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/login'); - $this->assertGuest(); - $this->assertDatabaseMissing('oauth_identities', [ - 'user_id' => $user->id, - 'provider' => 'oidc', - ]); -}); - -it('rejects new oidc users when neither normal nor provider registration is enabled', function () { - fakeOidcProvider(['email' => 'blocked@example.com']); - - $response = $this->from('/login')->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/login'); - expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse(); -}); - -it('creates the root user when oidc provisions the first account', function () { - Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); - OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); - - fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']); - - $response = $this->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/'); - $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']); - $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']); - expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); -}); - -it('persists raw claims as an array on the oauth identity', function () { - OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); - - fakeOidcProvider(['email' => 'claims@example.com']); - - $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); - - $identity = OauthIdentity::where('email', 'claims@example.com')->first(); - expect($identity->raw_claims)->toBeArray() - ->and($identity->raw_claims['sub'])->toBe('okta-user-1'); -}); - -it('stores empty raw claims when the provider returns no user payload', function () { - OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); - - $user = (new OidcUser)->setIdTokenClaims([ - 'iss' => 'https://idp.example.com', - 'sub' => 'okta-no-payload', - 'email_verified' => true, - ])->map([ - 'id' => 'okta-no-payload', - 'name' => 'No Payload', - 'email' => 'nopayload@example.com', - ]); - $user->user = null; - - $provider = Mockery::mock(); - $provider->shouldReceive('setConfig')->andReturnSelf(); - $provider->shouldReceive('user')->andReturn($user); - Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); - - $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); - - $identity = OauthIdentity::where('email', 'nopayload@example.com')->first(); - expect($identity->raw_claims)->toBe([]); -}); - -it('rejects callbacks for disabled oidc provider', function () { - OauthSetting::where('provider', 'oidc')->update(['enabled' => false]); - - $response = $this->from('/login')->get(route('auth.callback', 'oidc')); - - $response->assertRedirect('/login'); -}); - -it('logs callback failures with diagnostic context', function () { - Log::spy(); - - $provider = Mockery::mock(); - $provider->shouldReceive('setConfig')->andReturnSelf(); - $provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed')); - Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); - - $response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value'])); - - $response->assertRedirect('/login'); - Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) { - return $message === 'OAuth callback failed.' - && $context['provider'] === 'oidc' - && $context['exception_class'] === RuntimeException::class - && $context['exception_message'] === 'Token exchange failed' - && $context['has_code'] === true - && $context['has_state'] === true - && $context['exception'] instanceof RuntimeException; - }); -}); diff --git a/tests/Feature/ProfileSsoIndicatorTest.php b/tests/Feature/ProfileSsoIndicatorTest.php deleted file mode 100644 index 0d48225eb5..0000000000 --- a/tests/Feature/ProfileSsoIndicatorTest.php +++ /dev/null @@ -1,91 +0,0 @@ -create(['name' => 'Profile User']); - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'idp-user-1', - 'email' => $user->email, - ]); - - $this->actingAs($user); - - Livewire::test(ProfileIndex::class) - ->assertSee('Signed in with SSO') - ->assertSee('OIDC'); -}); - -it('does not show sso status for password-only profile users', function () { - $user = User::factory()->create(['name' => 'Profile User']); - - $this->actingAs($user); - - Livewire::test(ProfileIndex::class) - ->assertDontSee('Signed in with SSO'); -}); - -it('prevents sso linked users from opening or requesting profile email changes', function () { - $user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']); - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'idp-user-1', - 'email' => $user->email, - ]); - - $this->actingAs($user); - - Livewire::test(ProfileIndex::class) - ->assertSee('Email is managed by your SSO provider.') - ->call('showEmailChangeForm') - ->assertSet('show_email_change', false) - ->assertDispatched('error') - ->set('new_email', 'changed@example.com') - ->call('requestEmailChange') - ->assertSet('show_email_change', false) - ->assertSet('show_verification', false) - ->assertDispatched('error'); - - $user->refresh(); - - expect($user->email)->toBe('sso@example.com') - ->and($user->pending_email)->toBeNull() - ->and($user->email_change_code)->toBeNull() - ->and($user->email_change_code_expires_at)->toBeNull(); -}); - -it('keeps profile email changes available for password-only users', function () { - config()->set('constants.coolify.self_hosted', false); - Notification::fake(); - - $user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']); - - $this->actingAs($user); - - Livewire::test(ProfileIndex::class) - ->call('showEmailChangeForm') - ->assertSet('show_email_change', true) - ->set('new_email', 'changed@example.com') - ->call('requestEmailChange') - ->assertSet('show_verification', true) - ->assertDispatched('success'); - - $user->refresh(); - - expect($user->pending_email)->toBe('changed@example.com') - ->and($user->email_change_code)->not->toBeNull(); -}); diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php deleted file mode 100644 index 113fa4c032..0000000000 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ /dev/null @@ -1,253 +0,0 @@ -whereKey(0)->exists()) { - $settings = new InstanceSettings; - $settings->id = 0; - $settings->save(); - } - Once::flush(); - - $this->team = Team::factory()->create(); - $this->user = User::factory()->create(); - $this->team->members()->attach($this->user->id, ['role' => 'owner']); - - session(['currentTeam' => $this->team]); - $this->actingAs($this->user); -}); - -test('a cloudflare dns token is validated with read only requests before it is saved', function () { - Http::fake([ - 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ - 'success' => true, - 'result' => ['status' => 'active'], - ]), - 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ - 'success' => true, - 'result' => [['id' => 'zone-id']], - ]), - 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ - 'success' => true, - 'result' => [], - ]), - ]); - - Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true]) - ->set('provider', 'cloudflare') - ->set('name', 'Production DNS') - ->set('token', 'cloudflare-token') - ->set('capabilities', ['dns']) - ->call('addToken') - ->assertHasNoErrors() - ->assertDispatched('close-modal'); - - $this->assertDatabaseHas('integration_tokens', [ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - ]); - - Http::assertSentCount(3); - Http::assertSent(fn ($request) => $request->method() === 'GET' - && $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1'); -}); - -test('a cloudflare token is not saved when scope validation fails', function () { - Http::fake([ - 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ - 'success' => true, - 'result' => ['status' => 'active'], - ]), - 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ - 'success' => false, - 'errors' => [['message' => 'Authentication error']], - ], 403), - ]); - - Livewire::test(IntegrationTokenForm::class) - ->set('name', 'Invalid DNS token') - ->set('token', 'cloudflare-token') - ->set('capabilities', ['dns']) - ->call('addToken') - ->assertDispatched('error'); - - $this->assertDatabaseCount('integration_tokens', 0); -}); - -test('at least one capability is required when adding a cloudflare token', function () { - Livewire::test(IntegrationTokenForm::class) - ->set('name', 'Account token') - ->set('token', 'cloudflare-token') - ->set('capabilities', []) - ->call('addToken') - ->assertHasErrors(['capabilities' => 'required']); - - $this->assertDatabaseCount('integration_tokens', 0); - Http::assertNothingSent(); -}); - -test('integration tokens page lists saved provider and capabilities', function () { - IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'secret', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokens::class) - ->assertSee('Production DNS') - ->assertSee('Cloudflare') - ->assertSee('DNS'); -}); - -test('cloudflare dns scope guidance and token creation link are shown', function () { - Livewire::test(IntegrationTokenForm::class) - ->set('capabilities', ['dns']) - ->assertSee('Zone - DNS - Edit') - ->assertSee('Zone - Zone - Read') - ->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management'); - - expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'))) - ->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D'); -}); - -test('capability selection uses the shared checkbox component', function () { - $view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')); - - expect($view) - ->toContain('toContain('class="mt-3 rounded-lg border') - ->not->toContain('toContain('wire:target="addToken" isHighlighted') - ->not->toContain('class="button-highlighted"'); -}); - -test('saved integration token rows render modal editors with a gear button', function () { - IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'original-token', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokens::class) - ->assertSee('Edit Integration Token') - ->assertSee('Production DNS') - ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"'); -}); - -test('an integration token can be rotated after validating its capabilities', function () { - Http::fake([ - 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ - 'success' => true, - 'result' => ['status' => 'active'], - ]), - 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ - 'success' => true, - 'result' => [['id' => 'zone-id']], - ]), - 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ - 'success' => true, - 'result' => [], - ]), - ]); - - $savedToken = IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'original-token', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) - ->set('name', 'Rotated DNS') - ->set('newToken', 'rotated-token') - ->call('save') - ->assertHasNoErrors() - ->assertDispatched('success'); - - $savedToken->refresh(); - - expect($savedToken->name)->toBe('Rotated DNS') - ->and($savedToken->token)->toBe('rotated-token'); -}); - -test('leaving the token field blank keeps the existing integration token', function () { - Http::fake(); - - $savedToken = IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'original-token', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) - ->set('name', 'Renamed DNS') - ->set('newToken', '') - ->call('save') - ->assertHasNoErrors(); - - $savedToken->refresh(); - - expect($savedToken->name)->toBe('Renamed DNS') - ->and($savedToken->token)->toBe('original-token'); - - Http::assertNothingSent(); -}); - -test('an invalid replacement does not rotate the integration token', function () { - Http::fake([ - 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ - 'success' => false, - ], 403), - ]); - - $savedToken = IntegrationToken::query()->create([ - 'team_id' => $this->team->id, - 'provider' => 'cloudflare', - 'name' => 'Production DNS', - 'token' => 'original-token', - 'capabilities' => ['dns'], - ]); - - Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) - ->set('newToken', 'invalid-token') - ->call('save') - ->assertDispatched('error'); - - expect($savedToken->fresh()->token)->toBe('original-token'); -}); - -test('editor updates its row without rerendering the teleported parent modal', function () { - $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php')); - - expect($component) - ->toContain("'integration-token-updated'") - ->toContain("'integration-token-deleted'") - ->not->toContain('integrationTokenChanged'); -}); diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php index 9eab92cc28..e89bf8093a 100644 --- a/tests/Feature/SecuritySettingsNavigationTest.php +++ b/tests/Feature/SecuritySettingsNavigationTest.php @@ -8,7 +8,6 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { 'security/private-key/index.blade.php', 'security/private-key/show.blade.php', 'security/cloud-tokens.blade.php', - 'security/integration-tokens.blade.php', 'security/cloud-provider-token/show.blade.php', 'security/cloud-init-scripts.blade.php', 'security/cloud-init-script/show.blade.php', @@ -23,7 +22,6 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { ->toContain('application-settings-navigation') ->toContain("'label' => 'Private Keys'") ->toContain("'label' => 'Cloud Tokens'") - ->toContain("'label' => 'Integration Tokens'") ->toContain("'label' => 'Cloud-Init Scripts'") ->toContain("'label' => 'API Tokens'"); diff --git a/tests/Feature/SettingsEmailProviderExclusivityTest.php b/tests/Feature/SettingsEmailProviderExclusivityTest.php deleted file mode 100644 index 7e6b6ff232..0000000000 --- a/tests/Feature/SettingsEmailProviderExclusivityTest.php +++ /dev/null @@ -1,64 +0,0 @@ -settings = new InstanceSettings; - $this->settings->id = 0; - $this->settings->save(); - $this->rootTeam = Team::factory()->create(['id' => 0]); - $this->user = User::factory()->create(); - $this->user->teams()->attach($this->rootTeam, ['role' => 'owner']); - - $this->actingAs($this->user); - session(['currentTeam' => $this->rootTeam]); -}); - -test('enabling SMTP disables Resend in storage', function () { - $this->settings->update([ - 'resend_enabled' => true, - 'resend_api_key' => 're_test_key', - 'smtp_from_address' => 'from@example.com', - 'smtp_from_name' => 'Coolify', - ]); - - Livewire::test(SettingsEmail::class) - ->set('smtpHost', 'smtp.example.com') - ->set('smtpPort', '587') - ->set('smtpEncryption', 'starttls') - ->set('smtpFromAddress', 'from@example.com') - ->set('smtpFromName', 'Coolify') - ->call('toggleSmtp'); - - $this->settings->refresh(); - expect($this->settings->smtp_enabled)->toBeTrue(); - expect($this->settings->resend_enabled)->toBeFalse(); -}); - -test('enabling Resend disables SMTP in storage', function () { - $this->settings->update([ - 'smtp_enabled' => true, - 'smtp_host' => 'smtp.example.com', - 'smtp_port' => '587', - 'smtp_encryption' => 'starttls', - 'smtp_from_address' => 'from@example.com', - 'smtp_from_name' => 'Coolify', - ]); - - Livewire::test(SettingsEmail::class) - ->set('resendApiKey', 're_test_key') - ->set('smtpFromAddress', 'from@example.com') - ->set('smtpFromName', 'Coolify') - ->call('toggleResend'); - - $this->settings->refresh(); - expect($this->settings->resend_enabled)->toBeTrue(); - expect($this->settings->smtp_enabled)->toBeFalse(); -}); diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php deleted file mode 100644 index 96b01d8d98..0000000000 --- a/tests/Feature/SettingsNavigationTest.php +++ /dev/null @@ -1,52 +0,0 @@ -blade('') - ->assertSeeText('Configuration') - ->assertSeeText('OAuth') - ->assertSeeText('Scheduled Jobs') - ->assertDontSeeText('Instance Backup') - ->assertDontSeeText('Transactional Email'); -}); - -it('shows backup and transactional email in the settings configuration sidebar', function () { - $view = $this->blade('') - ->assertSeeTextInOrder([ - 'General', - 'Advanced', - 'Instance Backup', - 'Transactional Email', - 'Updates', - ]); - - expect((string) $view) - ->toContain(route('settings.backup')) - ->toContain(route('settings.email')) - ->and(substr_count((string) $view, 'menu-item-active'))->toBe(1); -}); - -it('renders backup and transactional email pages with the settings configuration sidebar', function () { - expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) - ->toContain('') - ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) - ->toContain(''); -}); - -it('uses the same title and description spacing on backup and transactional email settings pages', function () { - expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) - ->not->toContain('class="flex items-center gap-2 pb-2"') - ->toContain('
Instance backup configuration for Coolify instance.
') - ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) - ->not->toContain('class="flex flex-col gap-2 pb-4"') - ->toContain('
Instance wide email settings for password resets, invitations, etc.
'); -}); - -it('uses instance backup as the backup settings label', function () { - expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php'))) - ->toContain('Instance Backup') - ->not->toContain('Backup') - ->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) - ->toContain('

Instance Backup

') - ->toContain('Instance backup configuration for Coolify instance.') - ->not->toContain('

Backup

'); -}); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php deleted file mode 100644 index 95ea47e948..0000000000 --- a/tests/Feature/SettingsOauthTest.php +++ /dev/null @@ -1,277 +0,0 @@ - 0, 'name' => 'Root Team', 'personal_team' => true]); - $user = User::factory()->create(['id' => 0, 'email' => 'root@example.com', 'email_verified_at' => now()]); - if (! $user->teams()->whereKey($team->id)->exists()) { - $user->teams()->attach($team, ['role' => 'owner']); - } - session(['currentTeam' => $team]); - test()->actingAs($user); - - return $user; -} - -beforeEach(function () { - $this->withoutVite(); - config()->set('app.maintenance.driver', 'file'); - - InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]); - Once::flush(); - OauthSetting::create(['provider' => 'oidc']); - OauthSetting::create(['provider' => 'authentik']); - OauthSetting::create(['provider' => 'bitbucket']); -}); - -it('uses the standard settings design and keeps every oauth provider on one page', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Authentication') - ->assertSee('Registration') - ->assertSee('Authentik') - ->assertSee('Bitbucket') - ->assertSee('OpenID Connect') - ->assertSee('Disable password registration when OAuth is enabled') - ->assertSee('Client secret') - ->assertSee('application-settings-form', false) - ->assertDontSee(route('settings.oauth.provider', 'authentik'), false); -}); - -it('lists openid connect before the other oauth providers', function () { - actingAsInstanceAdmin(); - - $providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map')); - - expect($providers[0])->toBe('oidc'); -}); - -it('has an icon for openid connect', function () { - expect(public_path('svgs/oidc.svg'))->toBeFile(); -}); - -it('auto saves registration policy without a general save button', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee("wire:click='saveRegistrationPolicy'", false) - ->assertDontSee('Save', false); - - Livewire::test(SettingsOauth::class) - ->set('disable_registration_when_oauth_enabled', true) - ->call('saveRegistrationPolicy') - ->assertHasNoErrors() - ->assertDispatched('success'); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('shows oidc fields with a naked okta issuer url example', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('OpenID Connect') - ->assertSee('https://example.okta.com', false) - ->assertDontSee('/oauth2/default', false); -}); - -it('groups oidc fields in the expected desktop order', function () { - $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); - $fields = [ - 'redirect_uri', - 'base_url', - 'client_id', - 'client_secret', - 'scopes', - 'clock_skew_seconds', - 'custom_label', - ]; - $positions = array_map( - fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), - $fields, - ); - - expect($positions)->not->toContain(false) - ->and($positions)->toBe(collect($positions)->sort()->values()->all()) - ->and($view)->toContain('
'); -}); - -it('shows provider enable controls as settings section actions', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Enable') - ->assertDontSee('label="Enabled"', false) - ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); -}); - -it('stacks oidc option checkboxes vertically', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Allow OIDC user creation') - ->assertSee('Require verified email') - ->assertSee('Use PKCE') - ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); -}); - -it('does not show unknown oauth providers', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get('/settings/oauth/unknown') - ->assertNotFound(); -}); - -it('defaults oidc user creation and verified email requirement to enabled', function () { - $setting = OauthSetting::where('provider', 'oidc')->first(); - - expect($setting->allow_registration)->toBeTrue() - ->and($setting->require_email_verified)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeFalse(); -}); - -it('persists oidc oauth settings from livewire', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') - ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') - ->set('oauth_settings_map.oidc.allow_registration', true) - ->set('oauth_settings_map.oidc.auto_join_root_team', true) - ->set('oauth_settings_map.oidc.require_email_verified', true) - ->set('disable_registration_when_oauth_enabled', true) - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') - ->and($setting->base_url)->toBe('https://idp.example.com') - ->and($setting->custom_label)->toBe('Login with Okta') - ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->allow_registration)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeTrue(); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('saves only the selected provider from provider pages', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') - ->set('oauth_settings_map.authentik.enabled', true) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'authentik')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->client_id)->toBe('authentik-client') - ->and($setting->base_url)->toBe('https://authentik.example.com'); -}); - -it('validates oidc url fields before saving', function (string $field, string $value) { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set("oauth_settings_map.oidc.$field", $value) - ->call('submit') - ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->{$field})->toBeNull(); -})->with([ - 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], - 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], - 'invalid issuer url' => ['base_url', 'not-a-url'], - 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], -]); - -it('does not enable oidc without required fields', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->call('instantSave', 'oidc') - ->assertDispatched('error'); - - expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); -}); - -it('keeps provider disabled in the ui when enable validation fails', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->call('toggleProvider', 'authentik') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('disables an enabled provider gracefully when required fields become incomplete', function () { - actingAsInstanceAdmin(); - - OauthSetting::where('provider', 'authentik')->first()->forceFill([ - 'enabled' => true, - 'client_id' => 'authentik-client', - 'client_secret' => 'authentik-secret', - 'base_url' => 'https://authentik.example.com', - ])->save(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_secret', '') - ->call('submit') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('toggles provider enabled state from the action button', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('toggleProvider', 'authentik') - ->assertHasNoErrors(); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); -}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 272156fbd2..45e150dfab 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") + ->toContain("'bash -se' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php deleted file mode 100644 index d8ccf86510..0000000000 --- a/tests/Feature/UserSeederTest.php +++ /dev/null @@ -1,16 +0,0 @@ -seed(UserSeeder::class); - - $user = User::factory()->create(); - - expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) - ->and($user->id)->toBe(3); -}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php deleted file mode 100644 index d8050c84d9..0000000000 --- a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php +++ /dev/null @@ -1,62 +0,0 @@ -invoke(new InstallPrerequisites); - - expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); -}); - -it('installs every Docker CLI plugin required on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); - - $command = $method->invoke(new InstallDocker); - - expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); -}); - -it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); - - $action = new InstallDocker; - $commands = $method->invoke($action, true); - - expect($commands) - ->toBe(['rc-update add docker default', 'rc-service docker restart']) - ->each->not->toContain('systemctl') - ->and($method->invoke($action, false)) - ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); -}); - -it('parses Alpine package updates', function () { - $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); - $output = <<<'OUTPUT' -docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] -libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] -OUTPUT; - - $result = $method->invoke(new CheckUpdates, $output); - - expect($result)->toBe([ - 'total_updates' => 2, - 'updates' => [ - [ - 'package' => 'docker-cli-compose', - 'new_version' => '2.31.0-r5', - 'architecture' => 'x86_64', - 'current_version' => '2.31.0-r4', - ], - [ - 'package' => 'libcrypto3', - 'new_version' => '3.3.4-r0', - 'architecture' => 'aarch64', - 'current_version' => '3.3.3-r0', - ], - ], - ]); -}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 1b106a5a82..afad0593f3 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -296,7 +296,7 @@ it('accepts the historical environment sorting default in older snapshots', func expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); }); -it('detects environment variable value changes for unlocked variables', function () { +it('detects environment variable value changes without exposing secret values', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ 'key' => 'API_TOKEN', @@ -315,13 +315,13 @@ it('detects environment variable value changes for unlocked variables', function $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBeNull() - ->and($change['old_display_value'])->toBe('old-secret') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); + ->and($change['display_summary'])->toBe('Changed') + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); }); -it('describes added unlocked environment variables with their value', function () { +it('describes added environment variables as set without exposing secret values', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -342,6 +342,6 @@ it('describes added unlocked environment variables with their value', function ( expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php deleted file mode 100644 index 48fb50c375..0000000000 --- a/tests/Unit/OauthSettingTest.php +++ /dev/null @@ -1,30 +0,0 @@ - 'oidc']); - expect($setting->couldBeEnabled())->toBeFalse(); - - $setting->fill([ - 'client_id' => 'client-id', - 'client_secret' => 'secret', - 'base_url' => 'https://idp.example.com', - ]); - - expect($setting->couldBeEnabled())->toBeTrue(); -}); - -it('returns configured scopes and custom login label', function () { - $setting = new OauthSetting([ - 'provider' => 'oidc', - 'scopes' => 'openid email profile groups', - 'custom_label' => 'Login with Okta', - ]); - - expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->loginLabel())->toBe('Login with Okta'); -}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php deleted file mode 100644 index 18c358fd13..0000000000 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ /dev/null @@ -1,119 +0,0 @@ - Http::response([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]), - 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), - ]); - - $service = app(OidcDiscoveryService::class); - - $discovery = $service->discover('https://idp.example.com'); - $jwks = $service->jwks($discovery->jwksUri); - - expect($discovery->issuer)->toBe('https://idp.example.com') - ->and($jwks['keys'][0]['kid'])->toBe('one'); - - Http::assertSentCount(2); - - $service->discover('https://idp.example.com'); - $service->jwks('https://idp.example.com/jwks'); - - Http::assertSentCount(2); -}); - -it('does not cache discovery documents with mismatched issuers', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') - ->push([ - 'issuer' => 'https://evil.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]) - ->push([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]); - - $service = app(OidcDiscoveryService::class); - $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); - - expect(fn () => $service->discover('https://idp.example.com')) - ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') - ->and(Cache::has($cacheKey))->toBeFalse() - ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); - - Http::assertSentCount(2); -}); - -it('refetches jwks once on forced refresh to pick up rotated keys', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/jwks') - ->push(['keys' => [['kid' => 'old']]]) - ->push(['keys' => [['kid' => 'new']]]); - - $service = app(OidcDiscoveryService::class); - - expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); - - // Forced refresh bypasses the cache and sees the rotated key. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); - - // Cooldown prevents a second immediate upstream fetch; cached value returned. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); -}); - -it('rejects invalid discovery and jwks payloads', function () { - Cache::flush(); - Http::fake([ - 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), - ]); - - app(OidcDiscoveryService::class)->discover('https://bad.example.com'); -})->throws(OidcDiscoveryException::class); - -it('rejects jwks responses without keys', function () { - Cache::flush(); - Http::fake([ - 'https://idp.example.com/jwks' => Http::response(['empty' => true]), - ]); - - app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); -})->throws(OidcJwksException::class); - -it('rejects non-https issuer urls', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->discover('http://idp.example.com'); -})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); - -it('rejects non-https jwks uris', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); -})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php deleted file mode 100644 index b92ff58ffe..0000000000 --- a/tests/Unit/OidcProviderPkceTest.php +++ /dev/null @@ -1,148 +0,0 @@ -getAuthUrl($state); - } -} - -function oidc_provider_discovery_document(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -function oidc_provider_session(): Store -{ - $session = new Store('testing', new ArraySessionHandler(1200)); - $session->start(); - - return $session; -} - -function oidc_provider_request(Store $session, string $state = 'state-value'): Request -{ - $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); - $request->setLaravelSession($session); - - return $request; -} - -function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl -{ - /** @var OidcDiscoveryService&MockInterface $discoveryService */ - $discoveryService = Mockery::mock(OidcDiscoveryService::class); - $discoveryService->shouldReceive('discover') - ->byDefault() - ->with('https://idp.example.com') - ->andReturn(oidc_provider_discovery_document()); - - /** @var OidcTokenValidator&MockInterface $tokenValidator */ - $tokenValidator = Mockery::mock(OidcTokenValidator::class); - - return (new TestOidcProviderWithExposedAuthUrl( - $request, - $discoveryService, - $tokenValidator, - 'client-id', - 'client-secret', - 'https://coolify.example.com/auth/oidc/callback', - ))->setConfig(new OidcConfig( - issuerUrl: 'https://idp.example.com', - clientId: 'client-id', - clientSecret: 'client-secret', - redirectUri: 'https://coolify.example.com/auth/oidc/callback', - usePkce: true, - )); -} - -it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { - Carbon::setTestNow('2026-06-15 12:00:00'); - - try { - $session = oidc_provider_session(); - $provider = oidc_provider(oidc_provider_request($session)); - - $provider->authUrlForState('state-value'); - - $nonceEntry = $session->get('oidc.nonce.state-value'); - $verifierEntry = $session->get('oidc.code_verifier.state-value'); - - expect($nonceEntry)->toBeArray() - ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() - ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) - ->and($verifierEntry)->toBeArray() - ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() - ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); - } finally { - Carbon::setTestNow(); - } -}); - -it('sends a fresh oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'fresh-verifier', - 'expires_at' => now()->addMinute()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); - - parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); - - expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') - ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); -}); - -it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'expired-verifier', - 'expires_at' => now()->subSecond()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); -})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php deleted file mode 100644 index 9b1d9a24c3..0000000000 --- a/tests/Unit/OidcTokenValidatorTest.php +++ /dev/null @@ -1,187 +0,0 @@ - 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - openssl_pkey_export($privateKey, $privatePem); - $details = openssl_pkey_get_details($privateKey); - - return [ - 'private_pem' => $privatePem, - 'jwks' => [ - 'keys' => [[ - 'kty' => 'RSA', - 'kid' => $kid, - 'alg' => 'RS256', - 'use' => 'sig', - 'n' => oidc_base64url($details['rsa']['n']), - 'e' => oidc_base64url($details['rsa']['e']), - ]], - ], - ]; -} - -function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string -{ - $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); - $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); - $signatureInput = $header.'.'.$payload; - openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); - - return $signatureInput.'.'.oidc_base64url($signature); -} - -function oidc_discovery(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -it('validates a well formed RS256 id token', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - 'email' => 'User@Example.com', - ], $keyset['private_pem']); - - $claims = app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); - - expect($claims['sub'])->toBe('okta-user-1') - ->and($claims['email'])->toBe('User@Example.com'); -}); - -it('rejects invalid token claims', function (array $claimOverrides, string $message) { - $keyset = oidc_keyset(); - $now = time(); - $claims = array_merge([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $claimOverrides); - - $token = oidc_token($claims, $keyset['private_pem']); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], - 'audience mismatch' => [['aud' => 'other-client'], 'audience'], - 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], - 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], - 'expired token' => [['exp' => time() - 3600], 'expired'], - 'future issued at' => [['iat' => time() + 3600], 'issued'], - 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], - 'missing subject' => [['sub' => null], 'subject'], - 'empty subject' => [['sub' => ''], 'subject'], - 'non-string subject' => [['sub' => 123], 'subject'], -]); - -it('rejects a bad signature and unknown key id', function (string $kid) { - $keyset = oidc_keyset('test-key'); - $otherKeyset = oidc_keyset($kid); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $otherKeyset['private_pem'], $kid); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'same kid with bad signature' => ['test-key'], - 'unknown kid' => ['other-key'], -]); - -it('rejects disallowed algorithms', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem'], algorithm: 'HS256'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); - -it('throws a dedicated exception when the signing key is unknown', function () { - $keyset = oidc_keyset('current-key'); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => time(), - 'exp' => time() + 600, - ], $keyset['private_pem'], 'rotated-key'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcSigningKeyNotFoundException::class); - -it('rejects a jwks key not designated for signing', function () { - $keyset = oidc_keyset(); - $keyset['jwks']['keys'][0]['use'] = 'enc'; - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem']); - - // An encryption-only key is dropped from the keyset, so the kid no longer resolves. - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index 4dedc7a768..d2d4ae600f 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,16 +23,6 @@ class SshMultiplexingDisableTest extends TestCase ); } - public function test_remote_shell_prefers_bash_and_falls_back_to_sh() - { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); - - $this->assertSame( - 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', - $reflection->invoke(null) - ); - } - public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 4a275ad484..7a73f59795 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,7 +4,6 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; -use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -19,7 +18,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::forceCreate(['id' => 0]); + InstanceSettings::create(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -71,21 +70,6 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); -test('delete succeeds without password for an oauth user', function () { - OauthIdentity::create([ - 'user_id' => $this->user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'oauth-user-id', - ]); - - Livewire::test(Danger::class, ['resource' => $this->application]) - ->call('delete', '') - ->assertHasNoErrors(); - - expect(Application::find($this->application->id))->toBeNull(); -}); - test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From 81227670e61e006278a30416046bec3739f134e1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:39:55 +0200 Subject: [PATCH 25/86] Reapply "Merge origin/next into main" This reverts commit 15359833d3cd3a0fd7c935365220f89b4f2df0da. --- app/Actions/Fortify/CreateNewUser.php | 2 +- app/Actions/Server/CheckUpdates.php | 40 +- app/Actions/Server/InstallDocker.php | 27 +- app/Actions/Server/InstallPrerequisites.php | 16 + app/Actions/Server/UpdatePackage.php | 4 + .../Exceptions/OidcDiscoveryException.php | 5 + app/Auth/Oidc/Exceptions/OidcException.php | 7 + .../Oidc/Exceptions/OidcJwksException.php | 5 + .../OidcSigningKeyNotFoundException.php | 5 + .../Oidc/Exceptions/OidcTokenException.php | 5 + app/Auth/Oidc/OidcConfig.php | 34 ++ app/Auth/Oidc/OidcDiscoveryDocument.php | 61 +++ app/Auth/Oidc/OidcDiscoveryService.php | 97 +++++ app/Auth/Oidc/OidcTokenValidator.php | 199 ++++++++++ app/Auth/Oidc/OidcUser.php | 32 ++ app/Auth/Oidc/Socialite/OidcProvider.php | 299 +++++++++++++++ app/Helpers/SshMultiplexingHelper.php | 8 +- app/Http/Controllers/OauthController.php | 61 +-- app/Livewire/Notifications/Discord.php | 24 ++ app/Livewire/Notifications/Email.php | 121 ++++-- app/Livewire/Notifications/Pushover.php | 28 ++ app/Livewire/Notifications/Slack.php | 26 ++ app/Livewire/Notifications/Telegram.php | 28 ++ app/Livewire/Notifications/Webhook.php | 24 ++ app/Livewire/Profile/Index.php | 59 ++- .../Security/IntegrationTokenEditor.php | 114 ++++++ .../Security/IntegrationTokenForm.php | 81 ++++ app/Livewire/Security/IntegrationTokens.php | 41 +++ app/Livewire/Server/LogDrains.php | 72 ++++ app/Livewire/Settings/Advanced.php | 6 + app/Livewire/SettingsEmail.php | 124 +++++-- app/Livewire/SettingsOauth.php | 346 ++++++++++++------ app/Models/InstanceSettings.php | 16 + app/Models/IntegrationToken.php | 38 ++ app/Models/OauthIdentity.php | 35 ++ app/Models/OauthSetting.php | 51 ++- app/Models/Team.php | 5 + app/Models/User.php | 17 +- app/Policies/IntegrationTokenPolicy.php | 34 ++ app/Providers/AppServiceProvider.php | 36 +- app/Providers/AuthServiceProvider.php | 3 + app/Providers/FortifyServiceProvider.php | 8 +- app/Services/Auth/OauthLoginService.php | 228 ++++++++++++ app/Services/CloudflareTokenValidator.php | 42 +++ bootstrap/helpers/shared.php | 9 +- bootstrap/helpers/socialite.php | 28 +- composer.json | 1 + composer.lock | 2 +- config/services.php | 8 + ...ation_deployment_configuration_columns.php | 6 + ...dd_oidc_fields_to_oauth_settings_table.php | 40 ++ ...4_091631_create_oauth_identities_table.php | 36 ++ ...tion_policy_to_instance_settings_table.php | 28 ++ ...join_root_team_to_oauth_settings_table.php | 28 ++ ...000000_create_integration_tokens_table.php | 29 ++ database/seeders/OauthSettingSeeder.php | 1 + database/seeders/UserSeeder.php | 2 - lang/de.json | 1 + lang/en.json | 1 + lang/pl.json | 1 + public/svgs/oidc.svg | 5 + resources/views/auth/login.blade.php | 8 +- .../security/settings-layout.blade.php | 6 + .../components/settings/sidebar.blade.php | 18 + .../views/livewire/profile/index.blade.php | 18 +- .../integration-token-editor.blade.php | 52 +++ .../security/integration-token-form.blade.php | 49 +++ .../security/integration-tokens.blade.php | 84 +++++ .../server/security/patches.blade.php | 4 +- .../views/livewire/settings-oauth.blade.php | 142 ++++--- .../livewire/settings/advanced.blade.php | 11 +- routes/web.php | 5 + templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- tests/Feature/EnableActionButtonsTest.php | 179 +++++++++ .../LogDrain/LogDrainToggleRollbackTest.php | 45 +++ tests/Feature/LoginPageBrandingTest.php | 22 ++ tests/Feature/OauthControllerTest.php | 114 +++++- tests/Feature/OauthRegistrationPolicyTest.php | 52 +++ tests/Feature/OidcOauthControllerTest.php | 275 ++++++++++++++ tests/Feature/ProfileSsoIndicatorTest.php | 91 +++++ .../Security/IntegrationTokenFormTest.php | 253 +++++++++++++ .../SecuritySettingsNavigationTest.php | 2 + .../SettingsEmailProviderExclusivityTest.php | 64 ++++ tests/Feature/SettingsNavigationTest.php | 52 +++ tests/Feature/SettingsOauthTest.php | 277 ++++++++++++++ tests/Feature/SshMultiplexingLockTest.php | 2 +- tests/Feature/UserSeederTest.php | 16 + .../Server/AlpinePackageManagerTest.php | 62 ++++ .../ApplicationConfigurationSnapshotTest.php | 16 +- tests/Unit/OauthSettingTest.php | 30 ++ tests/Unit/OidcDiscoveryServiceTest.php | 119 ++++++ tests/Unit/OidcProviderPkceTest.php | 148 ++++++++ tests/Unit/OidcTokenValidatorTest.php | 187 ++++++++++ tests/Unit/SshMultiplexingDisableTest.php | 10 + tests/v4/Feature/DangerDeleteResourceTest.php | 18 +- 96 files changed, 4859 insertions(+), 320 deletions(-) create mode 100644 app/Auth/Oidc/Exceptions/OidcDiscoveryException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcJwksException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcTokenException.php create mode 100644 app/Auth/Oidc/OidcConfig.php create mode 100644 app/Auth/Oidc/OidcDiscoveryDocument.php create mode 100644 app/Auth/Oidc/OidcDiscoveryService.php create mode 100644 app/Auth/Oidc/OidcTokenValidator.php create mode 100644 app/Auth/Oidc/OidcUser.php create mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php create mode 100644 app/Livewire/Security/IntegrationTokenEditor.php create mode 100644 app/Livewire/Security/IntegrationTokenForm.php create mode 100644 app/Livewire/Security/IntegrationTokens.php create mode 100644 app/Models/IntegrationToken.php create mode 100644 app/Models/OauthIdentity.php create mode 100644 app/Policies/IntegrationTokenPolicy.php create mode 100644 app/Services/Auth/OauthLoginService.php create mode 100644 app/Services/CloudflareTokenValidator.php create mode 100644 database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php create mode 100644 database/migrations/2026_06_04_091631_create_oauth_identities_table.php create mode 100644 database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php create mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php create mode 100644 database/migrations/2026_08_15_000000_create_integration_tokens_table.php create mode 100644 public/svgs/oidc.svg create mode 100644 resources/views/livewire/security/integration-token-editor.blade.php create mode 100644 resources/views/livewire/security/integration-token-form.blade.php create mode 100644 resources/views/livewire/security/integration-tokens.blade.php create mode 100644 tests/Feature/EnableActionButtonsTest.php create mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php create mode 100644 tests/Feature/OauthRegistrationPolicyTest.php create mode 100644 tests/Feature/OidcOauthControllerTest.php create mode 100644 tests/Feature/ProfileSsoIndicatorTest.php create mode 100644 tests/Feature/Security/IntegrationTokenFormTest.php create mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php create mode 100644 tests/Feature/SettingsNavigationTest.php create mode 100644 tests/Feature/SettingsOauthTest.php create mode 100644 tests/Feature/UserSeederTest.php create mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php create mode 100644 tests/Unit/OauthSettingTest.php create mode 100644 tests/Unit/OidcDiscoveryServiceTest.php create mode 100644 tests/Unit/OidcProviderPkceTest.php create mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44a03c17da..d437a3a176 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { abort(403); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -106,6 +107,15 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'apk': + instant_remote_process(['apk update -q'], $server); + $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); + + $out = $this->parseApkOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -266,11 +276,39 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } return $result; } + + private function parseApkOutput(string $output): array + { + $updates = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + // Skip empty lines + if (empty($line)) { + continue; + } + + // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] + if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'new_version' => $matches[2], + 'architecture' => $matches[3], + 'current_version' => $matches[4], + ]; + } + } + + return [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,6 +79,8 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -93,9 +95,8 @@ class InstallDocker "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', ]); + $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -154,6 +155,28 @@ class InstallDocker 'systemctl start docker.service'; } + private function getAlpineDockerInstallCommand(): string + { + return 'apk update && '. + 'apk add docker docker-cli-buildx docker-cli-compose && '. + 'mkdir -p /etc/docker'; + } + + private function getDockerServiceCommands(bool $usesOpenRc): array + { + if ($usesOpenRc) { + return [ + 'rc-update add docker default', + 'rc-service docker restart', + ]; + } + + return [ + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', + ]; + } + private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,6 +53,8 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -61,4 +63,18 @@ class InstallPrerequisites return remote_process($command, $server); } + + private function getAlpinePrerequisiteCommands(): array + { + return [ + "echo 'Installing Prerequisites for Alpine Linux...'", + "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", + 'apk update', + 'command -v bash >/dev/null || apk add bash', + 'command -v curl >/dev/null || apk add curl', + 'command -v wget >/dev/null || apk add wget', + 'command -v git >/dev/null || apk add git', + 'command -v jq >/dev/null || apk add jq', + ]; + } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,6 +58,10 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; + case 'apk': + $commandAll = 'apk update && apk upgrade'; + $commandInstall = 'apk upgrade '.$sanitizedPackage; + break; default: return [ 'error' => 'OS not supported', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php new file mode 100644 index 0000000000..e4a2ba0dfe --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php @@ -0,0 +1,5 @@ + $scopes + */ + public function __construct( + public string $issuerUrl, + public string $clientId, + public string $clientSecret, + public string $redirectUri, + public array $scopes = ['openid', 'email', 'profile'], + public bool $usePkce = true, + public int $clockSkewSeconds = 60, + ) {} + + public static function fromOauthSetting(OauthSetting $setting): self + { + return new self( + issuerUrl: rtrim((string) $setting->base_url, '/'), + clientId: (string) $setting->client_id, + clientSecret: (string) $setting->client_secret, + redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'), + scopes: $setting->scopeList(), + usePkce: $setting->use_pkce ?? true, + clockSkewSeconds: $setting->clock_skew_seconds ?? 60, + ); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php new file mode 100644 index 0000000000..d17061c51d --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryDocument.php @@ -0,0 +1,61 @@ + $supportedScopes + * @param array $supportedClaims + * @param array $idTokenSigningAlgValuesSupported + */ + public function __construct( + public string $issuer, + public string $authorizationEndpoint, + public string $tokenEndpoint, + public string $userinfoEndpoint, + public string $jwksUri, + public ?string $endSessionEndpoint = null, + public array $supportedScopes = [], + public array $supportedClaims = [], + public array $idTokenSigningAlgValuesSupported = [], + ) {} + + /** + * @param array $payload + */ + public static function fromArray(array $payload): self + { + foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) { + if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') { + throw new OidcDiscoveryException("Discovery document is missing required field: {$field}"); + } + } + + return new self( + issuer: $payload['issuer'], + authorizationEndpoint: $payload['authorization_endpoint'], + tokenEndpoint: $payload['token_endpoint'], + userinfoEndpoint: $payload['userinfo_endpoint'], + jwksUri: $payload['jwks_uri'], + endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null, + supportedScopes: self::stringList($payload['scopes_supported'] ?? []), + supportedClaims: self::stringList($payload['claims_supported'] ?? []), + idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []), + ); + } + + /** + * @return array + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map('strval', $value)); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php new file mode 100644 index 0000000000..0847afc9a7 --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,97 @@ +assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); + + $issuerUrl = rtrim($issuerUrl, '/'); + $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); + + return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { + $url = $issuerUrl.'/.well-known/openid-configuration'; + + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); + } catch (Throwable $e) { + throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || $json === []) { + throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); + } + + $discovery = OidcDiscoveryDocument::fromArray($json); + if (rtrim($discovery->issuer, '/') !== $issuerUrl) { + throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); + } + + return $discovery; + }); + } + + /** + * Fetch the JWKS for the given URI. + * + * When $forceRefresh is true the cached document is bypassed so freshly + * rotated signing keys become visible immediately. A short cooldown still + * prevents a flood of upstream requests if many logins miss the same kid. + * + * @return array + */ + public function jwks(string $jwksUri, bool $forceRefresh = false): array + { + $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); + + $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); + + if ($forceRefresh) { + $cooldownKey = $cacheKey.':refresh'; + if (Cache::add($cooldownKey, true, 60)) { + Cache::forget($cacheKey); + } + } + + return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); + } catch (Throwable $e) { + throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || ! is_array($json['keys'] ?? null)) { + throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'."); + } + + return $json; + }); + } + + private function assertHttpsUrl(string $url, Throwable $exception): void + { + $parts = parse_url($url); + + if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') { + throw $exception; + } + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..a8563611dd --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,199 @@ + $jwks + * @return array + */ + public function validate( + string $idToken, + OidcDiscoveryDocument $discovery, + array $jwks, + string $clientId, + ?string $expectedNonce = null, + int $clockSkewSeconds = 60, + ): array { + $kid = $this->extractKid($idToken); + + try { + $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM); + } catch (Throwable $e) { + throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e); + } + + // Surface an unknown signing key distinctly so the caller can refresh + // the JWKS once (key rotation) before giving up. + if (! array_key_exists($kid, $keys)) { + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + $previousLeeway = JWT::$leeway; + JWT::$leeway = $clockSkewSeconds; + + try { + // Validates signature, header alg against the key alg (RS256), + // exp, nbf and iat. Throws on any failure. + $claims = (array) JWT::decode($idToken, $keys); + } catch (OidcTokenException $e) { + throw $e; + } catch (Throwable $e) { + throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e); + } finally { + JWT::$leeway = $previousLeeway; + } + + $this->assertExpiry($claims); + $this->assertIssuer($claims, $discovery->issuer); + $this->assertAudience($claims, $clientId); + $this->assertNonce($claims, $expectedNonce); + $this->assertSubject($claims); + + return $claims; + } + + /** + * Drop JWKS entries explicitly marked for anything other than signing + * (e.g. "use":"enc") so they can never verify an id_token signature. + * firebase/php-jwt does not honour the "use" parameter on its own. + * + * @param array $jwks + * @return array + */ + private function signingKeysOnly(array $jwks): array + { + $keys = array_values(array_filter( + $jwks['keys'] ?? [], + fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'), + )); + + return ['keys' => $keys]; + } + + /** + * Decode just the JWT header to read the kid before signature + * verification, so an unknown key can be reported as a rotation miss. + */ + private function extractKid(string $idToken): string + { + $segments = explode('.', $idToken); + if (count($segments) !== 3) { + throw new OidcTokenException('Malformed id_token.'); + } + + $header = json_decode($this->base64UrlDecode($segments[0]), true); + if (! is_array($header)) { + throw new OidcTokenException('id_token header contains invalid JSON.'); + } + + if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) { + throw new OidcTokenException('id_token uses a disallowed algorithm.'); + } + + $kid = $header['kid'] ?? null; + if (! is_string($kid) || $kid === '') { + throw new OidcTokenException('id_token header is missing kid.'); + } + + return $kid; + } + + private function base64UrlDecode(string $value): string + { + $remainder = strlen($value) % 4; + if ($remainder !== 0) { + $value .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + if ($decoded === false) { + throw new OidcTokenException('Invalid base64url value in id_token header.'); + } + + return $decoded; + } + + /** + * @param array $claims + */ + private function assertExpiry(array $claims): void + { + // Firebase enforces the exp window when present; OIDC requires it to exist. + if (! is_numeric($claims['exp'] ?? null)) { + throw new OidcTokenException('id_token is missing the exp claim.'); + } + } + + /** + * @param array $claims + */ + private function assertSubject(array $claims): void + { + $subject = $claims['sub'] ?? null; + if (! is_string($subject) || $subject === '') { + throw new OidcTokenException('id_token subject is missing or invalid.'); + } + } + + /** + * @param array $claims + */ + private function assertIssuer(array $claims, string $expectedIssuer): void + { + if (($claims['iss'] ?? null) !== $expectedIssuer) { + throw new OidcTokenException('id_token issuer does not match discovery issuer.'); + } + } + + /** + * @param array $claims + */ + private function assertAudience(array $claims, string $clientId): void + { + $audience = $claims['aud'] ?? null; + if (is_string($audience)) { + $audience = [$audience]; + } + + if (! is_array($audience) || ! in_array($clientId, $audience, true)) { + throw new OidcTokenException('id_token audience does not include configured client id.'); + } + + if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) { + throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.'); + } + + if (isset($claims['azp']) && $claims['azp'] !== $clientId) { + throw new OidcTokenException('id_token azp does not match configured client id.'); + } + } + + /** + * @param array $claims + */ + private function assertNonce(array $claims, ?string $expectedNonce): void + { + if ($expectedNonce === null) { + return; + } + + if (($claims['nonce'] ?? null) !== $expectedNonce) { + throw new OidcTokenException('id_token nonce does not match.'); + } + } +} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php new file mode 100644 index 0000000000..645130e019 --- /dev/null +++ b/app/Auth/Oidc/OidcUser.php @@ -0,0 +1,32 @@ + + */ + public array $idTokenClaims = []; + + /** + * @param array $claims + */ + public function setIdTokenClaims(array $claims): self + { + $this->idTokenClaims = $claims; + $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null; + $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null; + $this->emailVerified = ($claims['email_verified'] ?? false) === true; + + return $this; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..383b0cc910 --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,299 @@ + + */ + protected $scopes = ['openid', 'email', 'profile']; + + protected $scopeSeparator = ' '; + + protected ?OidcConfig $oidcConfig = null; + + protected ?OidcDiscoveryDocument $discovery = null; + + public function __construct( + Request $request, + protected OidcDiscoveryService $discoveryService, + protected OidcTokenValidator $tokenValidator, + string $clientId, + string $clientSecret, + string $redirectUrl, + ) { + parent::__construct($request, $clientId, $clientSecret, $redirectUrl); + } + + public function setConfig(OidcConfig $config): self + { + $this->oidcConfig = $config; + $this->clientId = $config->clientId; + $this->clientSecret = $config->clientSecret; + $this->redirectUrl = $config->redirectUri; + $this->scopes = $config->scopes; + $this->discovery = null; + + return $this; + } + + public function getConfig(): OidcConfig + { + if ($this->oidcConfig === null) { + throw new OidcException('OIDC provider config is not set.'); + } + + return $this->oidcConfig; + } + + protected function getAuthUrl($state): string + { + $config = $this->getConfig(); + $nonce = Str::random(40); + $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); + + $extra = ['nonce' => $nonce]; + if ($config->usePkce) { + $verifier = $this->generateCodeVerifier(); + $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); + $extra['code_challenge'] = $this->codeChallenge($verifier); + $extra['code_challenge_method'] = 'S256'; + } + + return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state) + .'&'.http_build_query($extra, '', '&', $this->encodingType); + } + + protected function getTokenUrl(): string + { + return $this->resolveDiscovery()->tokenEndpoint; + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user) + { + return (new OidcUser)->setRaw($user)->map([ + 'id' => $user['sub'] ?? null, + 'nickname' => $user['preferred_username'] ?? null, + 'name' => $this->resolveName($user), + 'email' => $user['email'] ?? null, + 'avatar' => $user['picture'] ?? null, + ]); + } + + public function user() + { + if ($this->user) { + return $this->user; + } + + if ($this->hasInvalidState()) { + throw new InvalidStateException; + } + + $tokenResponse = $this->getAccessTokenResponse($this->getCode()); + $accessToken = Arr::get($tokenResponse, 'access_token'); + $idToken = Arr::get($tokenResponse, 'id_token'); + + if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') { + throw new OidcException('OIDC token endpoint did not return required tokens.'); + } + + $discovery = $this->resolveDiscovery(); + $config = $this->getConfig(); + $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state'))); + if ($expectedNonce === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce); + + $userinfo = $this->getUserByToken($accessToken); + + // OIDC core §5.3.2: the userinfo sub MUST match the id_token sub. + // Reject the response rather than trust unsigned userinfo claims. + $userinfoSub = $userinfo['sub'] ?? null; + if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) { + throw new OidcException('OIDC userinfo subject does not match the id_token subject.'); + } + + $merged = array_merge($userinfo, $claims); + + /** @var OidcUser $user */ + $user = $this->mapUserToObject($merged); + $user->setIdTokenClaims($claims) + ->setToken($accessToken) + ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token')) + ->setExpiresIn(Arr::get($tokenResponse, 'expires_in')); + + return $this->user = $user; + } + + /** + * Validate the id_token, retrying once against a freshly fetched JWKS when + * the signing key is unknown. This keeps logins working immediately after + * the IdP rotates keys instead of failing until the JWKS cache expires. + * + * @return array + */ + protected function validateIdToken( + string $idToken, + OidcDiscoveryDocument $discovery, + OidcConfig $config, + ?string $expectedNonce, + ): array { + foreach ([false, true] as $forceRefresh) { + try { + return $this->tokenValidator->validate( + idToken: $idToken, + discovery: $discovery, + jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh), + clientId: $config->clientId, + expectedNonce: $expectedNonce, + clockSkewSeconds: $config->clockSkewSeconds, + ); + } catch (OidcSigningKeyNotFoundException $e) { + if ($forceRefresh) { + throw $e; + } + } + } + + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + /** + * @return array + */ + public function getAccessTokenResponse($code) + { + $fields = $this->getTokenFields($code); + if ($this->getConfig()->usePkce) { + $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); + if ($verifier === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $fields['code_verifier'] = $verifier; + } + + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + RequestOptions::FORM_PARAMS => $fields, + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + protected function resolveDiscovery(): OidcDiscoveryDocument + { + return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl); + } + + protected function generateCodeVerifier(): string + { + return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); + } + + protected function codeChallenge(string $verifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + /** + * @param array $user + */ + protected function resolveName(array $user): ?string + { + if (is_string($user['name'] ?? null) && $user['name'] !== '') { + return $user['name']; + } + + $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? ''))); + + return $name === '' ? null : $name; + } + + protected function putOidcFlowValue(string $key, string $value): void + { + $this->request->session()->put($key, [ + 'value' => $value, + 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, + ]); + } + + protected function pullOidcFlowValue(string $key): ?string + { + $entry = $this->request->session()->pull($key); + + if (! is_array($entry)) { + return null; + } + + $value = $entry['value'] ?? null; + $expiresAt = $entry['expires_at'] ?? null; + + if (! is_string($value) || $value === '' || ! is_int($expiresAt)) { + return null; + } + + if ($expiresAt < now()->timestamp) { + return null; + } + + return $value; + } + + protected function nonceSessionKey(string $state): string + { + return "oidc.nonce.{$state}"; + } + + protected function verifierSessionKey(string $state): string + { + return "oidc.code_verifier.{$state}"; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index cbb18945e2..e7d6d071b4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -243,12 +243,18 @@ class SshMultiplexingHelper $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); + $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } + private static function remoteShellCommand(): string + { + return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; + } + public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e2..93d27615a7 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,47 +2,60 @@ namespace App\Http\Controllers; -use App\Models\User; -use Illuminate\Support\Facades\Auth; +use App\Models\OauthSetting; +use App\Services\Auth\OauthLoginService; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { public function redirect(string $provider) { - $socialite_provider = get_socialite_provider($provider); + $oauthSetting = $this->enabledProvider($provider); + $socialiteProvider = get_socialite_provider($oauthSetting->provider); - return $socialite_provider->redirect(); + return $socialiteProvider->redirect(); } - public function callback(string $provider) + public function callback(string $provider, OauthLoginService $oauthLoginService) { try { - $oauthUser = get_socialite_provider($provider)->user(); - $email = trim((string) $oauthUser->email); - if ($email === '') { - abort(403, 'OAuth provider did not return an email address'); - } - $email = strtolower($email); - $user = User::whereEmail($email)->first(); - if (! $user) { - $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { - abort(403, 'Registration is disabled'); - } - - $user = User::create([ - 'name' => $oauthUser->name, - 'email' => $email, - ]); - } - Auth::login($user); + $oauthSetting = $this->enabledProvider($provider); + $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); + $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); return redirect('/'); } catch (\Exception $e) { + $this->logCallbackFailure($provider, $e); + $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; return redirect()->route('login')->withErrors([__($errorCode)]); } } + + private function logCallbackFailure(string $provider, \Throwable $exception): void + { + Log::error('OAuth callback failed.', [ + 'provider' => $provider, + 'exception_class' => $exception::class, + 'exception_message' => $exception->getMessage(), + 'request_error' => request()->query('error'), + 'request_error_description' => request()->query('error_description'), + 'has_code' => request()->query->has('code'), + 'has_state' => request()->query->has('state'), + 'ip' => request()->ip(), + 'exception' => $exception, + ]); + } + + private function enabledProvider(string $provider): OauthSetting + { + $oauthSetting = OauthSetting::where('provider', $provider)->first(); + if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) { + throw new HttpException(403, 'OAuth provider is not enabled'); + } + + return $oauthSetting; + } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 797db83629..59ecb06e8e 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -166,6 +166,30 @@ class Discord extends Component } } + public function toggleDiscordEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->discordEnabled) { + $this->discordEnabled = false; + } else { + $this->validate([ + 'discordWebhookUrl' => 'required', + ], [ + 'discordWebhookUrl.required' => 'Discord Webhook URL is required.', + ]); + $this->discordEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 3d95668b91..2a373a5065 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,7 +2,6 @@ namespace App\Livewire\Notifications; -use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -15,7 +14,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests, TogglesNotificationEvents; + use AuthorizesRequests; protected $listeners = ['refresh' => '$refresh']; @@ -252,32 +251,59 @@ class Email extends Component } } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->saveModel(); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->saveModel(); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function submitSmtp() { $this->authorize('update', $this->settings); try { $this->resetErrorBag(); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); if ($this->smtpEnabled) { $this->settings->resend_enabled = $this->resendEnabled = false; @@ -309,17 +335,7 @@ class Email extends Component try { $this->resetErrorBag(); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); if ($this->resendEnabled) { $this->settings->smtp_enabled = $this->smtpEnabled = false; } @@ -336,6 +352,45 @@ class Email extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 3b7c3c6aeb..b1608c5ea2 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -159,6 +159,34 @@ class Pushover extends Component } } + public function togglePushoverEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->pushoverEnabled) { + $this->pushoverEnabled = false; + } else { + $this->validate([ + 'pushoverUserKey' => 'required', + 'pushoverApiToken' => 'required', + ], [ + 'pushoverUserKey.required' => 'Pushover User Key is required.', + 'pushoverApiToken.required' => 'Pushover API Token is required.', + ]); + $this->pushoverEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 9ee3624025..c4ca7da802 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -150,6 +150,32 @@ class Slack extends Component } } + public function toggleSlackEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->slackEnabled) { + $this->slackEnabled = false; + } else { + $this->validate([ + 'slackWebhookUrl' => 'required', + ], [ + 'slackWebhookUrl.required' => 'Slack Webhook URL is required.', + ]); + $this->slackEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index b04d2c73d2..9f19b22f5f 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -252,6 +252,34 @@ class Telegram extends Component } } + public function toggleTelegramEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->telegramEnabled) { + $this->telegramEnabled = false; + } else { + $this->validate([ + 'telegramToken' => 'required', + 'telegramChatId' => 'required', + ], [ + 'telegramToken.required' => 'Telegram Token is required.', + 'telegramChatId.required' => 'Telegram Chat ID is required.', + ]); + $this->telegramEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function saveModel() { $this->syncData(true); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fcf1107781..ee07694767 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -144,6 +144,30 @@ class Webhook extends Component } } + public function toggleWebhookEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->webhookEnabled) { + $this->webhookEnabled = false; + } else { + $this->validate([ + 'webhookUrl' => 'required', + ], [ + 'webhookUrl.required' => 'Webhook URL is required.', + ]); + $this->webhookEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a20a1231b4..ae5d9b3ecd 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,19 +2,15 @@ namespace App\Livewire\Profile; -use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; -use Livewire\WithFileUploads; class Index extends Component { - use WithFileUploads; - public int $userId; public string $email; @@ -36,6 +32,10 @@ class Index extends Component public bool $show_verification = false; + public bool $uses_sso = false; + + public ?string $sso_provider_label = null; + public $avatar; public function uploadAvatar(AvatarStorageService $avatarStorage): bool @@ -75,8 +75,12 @@ class Index extends Component $this->name = Auth::user()->name; $this->email = Auth::user()->email; + $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first(); + $this->uses_sso = $oauthIdentity !== null; + $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null; + // Check if there's a pending email change - if (Auth::user()->hasEmailChangeRequest()) { + if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) { $this->new_email = Auth::user()->pending_email; $this->show_verification = true; } @@ -101,6 +105,10 @@ class Index extends Component public function requestEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -159,6 +167,10 @@ class Index extends Component public function verifyEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -204,7 +216,6 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); - $this->dispatch('close-email-change-modal'); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -216,6 +227,10 @@ class Index extends Component public function resendVerificationCode() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // Check if there's a pending request if (! Auth::user()->hasEmailChangeRequest()) { $this->dispatch('error', 'No pending email change request.'); @@ -269,6 +284,30 @@ class Index extends Component $this->dispatch('success', 'Email change request cancelled.'); } + public function showEmailChangeForm() + { + if ($this->rejectSsoEmailChange()) { + return; + } + + $this->show_email_change = true; + $this->new_email = ''; + } + + private function rejectSsoEmailChange(): bool + { + if (! Auth::user()->hasSsoIdentity()) { + return false; + } + + $this->uses_sso = true; + $this->show_email_change = false; + $this->show_verification = false; + $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.'); + + return true; + } + public function resetPassword() { try { @@ -299,6 +338,14 @@ class Index extends Component } } + private function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OIDC', + default => str($provider)->headline()->toString(), + }; + } + public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..453a7e8ae8 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,114 @@ +integrationToken = IntegrationToken::ownedByCurrentTeam() + ->whereUuid($integration_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->integrationToken); + + $this->name = $this->integrationToken->name; + $this->capabilities = $this->integrationToken->capabilities; + } + + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'newToken' => ['nullable', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function save(CloudflareTokenValidator $validator): void + { + $this->authorize('update', $this->integrationToken); + $validated = $this->validate(); + $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() + !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + + try { + if ((filled($validated['newToken']) || $capabilitiesChanged) + && ! $validator->validate($token, $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + $updates = [ + 'name' => $validated['name'], + 'capabilities' => $validated['capabilities'], + ]; + + if (filled($validated['newToken'])) { + $updates['token'] = $validated['newToken']; + } + + $this->integrationToken->update($updates); + $this->newToken = ''; + + auditLog('ui.integration_token.updated', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $this->integrationToken->provider, + 'rotated' => array_key_exists('token', $updates), + ]); + + $this->dispatch( + 'integration-token-updated', + uuid: $this->integrationToken->uuid, + name: $this->integrationToken->name, + capabilities: $this->integrationToken->capabilities, + ); + $this->dispatch('success', 'Integration token updated successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete(string $password = ''): void + { + $this->authorize('delete', $this->integrationToken); + $this->integrationToken->delete(); + + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); + $this->dispatch('close-modal'); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-token-editor'); + } +} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..7a7637bf5e --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,81 @@ +authorize('create', IntegrationToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => ['required', 'in:cloudflare'], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function addToken(CloudflareTokenValidator $validator): void + { + $validated = $this->validate(); + + try { + if (! $validator->validate($validated['token'], $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + IntegrationToken::query()->create([ + ...$validated, + 'team_id' => currentTeam()->id, + ]); + + $this->reset(['name', 'token']); + $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } + + $this->dispatch('success', 'Integration token added successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.integration-token-form'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..39db135b38 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,41 @@ +authorize('viewAny', IntegrationToken::class); + $this->loadTokens(); + } + + #[On('integrationTokenAdded')] + public function loadTokens(): void + { + $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); + } + + public function deleteToken(int $tokenId, string $password = ''): void + { + $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + $token->delete(); + $this->loadTokens(); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-tokens'); + } +} diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 3af0a22610..ae53488bd5 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,6 +177,49 @@ class LogDrains extends Component } } + public function toggleLogDrain(string $type): void + { + $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; + $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; + $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + + try { + $this->authorize('update', $this->server); + $this->resetErrorBag(); + + $enabledProperty = $this->enabledProperty($type); + + if ($this->{$enabledProperty}) { + $this->{$enabledProperty} = false; + } else { + $this->validateLogDrainSettings($type); + $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; + $this->isLogDrainAxiomEnabled = $type === 'axiom'; + $this->isLogDrainCustomEnabled = $type === 'custom'; + } + + $this->syncData(true); + + if ($this->server->isLogDrainEnabled()) { + StartLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service started.'); + } else { + StopLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service stopped.'); + } + } catch (\Throwable $e) { + // Restore the previously persisted enabled flags so the UI/DB never + // claim a runtime state that the Start/StopLogDrain action failed to apply. + $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; + $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; + $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->save(); + $this->syncData(); + + handleError($e, $this); + } + } + public function submit() { try { @@ -192,4 +235,33 @@ class LogDrains extends Component { return view('livewire.server.log-drains'); } + + private function enabledProperty(string $type): string + { + return match ($type) { + 'newrelic' => 'isLogDrainNewRelicEnabled', + 'axiom' => 'isLogDrainAxiomEnabled', + 'custom' => 'isLogDrainCustomEnabled', + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } + + private function validateLogDrainSettings(string $type): void + { + match ($type) { + 'newrelic' => $this->validate([ + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainNewRelicBaseUri' => ['required', 'url'], + ]), + 'axiom' => $this->validate([ + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + ]), + 'custom' => $this->validate([ + 'logDrainCustomConfig' => ['required'], + 'logDrainCustomConfigParser' => ['string', 'nullable'], + ]), + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..38a2f85a73 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,6 +19,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; + #[Validate('boolean')] + public bool $disable_registration_when_oauth_enabled; + #[Validate('boolean')] public bool $do_not_track; @@ -59,6 +62,7 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -84,6 +88,7 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; + $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -199,6 +204,7 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; + $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 9bca0db2e3..1426f61f02 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,30 +160,59 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'SMTP settings updated.'); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'Resend settings updated.'); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function submitSmtp() { try { $this->authorize('update', $this->settings); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); + + if ($this->smtpEnabled) { + $this->settings->resend_enabled = $this->resendEnabled = false; + } $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -210,17 +239,11 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); + + if ($this->resendEnabled) { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -237,6 +260,45 @@ class SettingsEmail extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 4082718191..3b24d0cd2e 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,53 +2,89 @@ namespace App\Livewire; +use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; + public InstanceSettings $settings; + public $oauth_settings_map; - protected function rules() + public ?string $selectedProvider = null; + + public bool $disable_registration_when_oauth_enabled = false; + + protected function rules(): array { - return OauthSetting::all()->reduce(function ($carry, $setting) { - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; + return $this->validationRules(); + } + + private function validationRules(?string $provider = null): array + { + $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { + if ($provider !== null && $setting->provider !== $provider) { + return $carry; + } + + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; + $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; + $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; return $carry; }, []); + + if ($provider === null) { + $rules['disable_registration_when_oauth_enabled'] = 'boolean'; + } + + return $rules; } - public function mount() + public function mount(?string $provider = null): ?RedirectResponse { if (! isInstanceAdmin()) { return redirect()->route('home'); } - $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { - $carry[$setting->provider] = [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - ]; - return $carry; - }, []); + $this->settings = instanceSettings(); + $this->selectedProvider = $provider; + $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; + $this->oauth_settings_map = OauthSetting::all() + ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) + ->reduce(function ($carry, $setting) { + $carry[$setting->provider] = $this->oauthSettingToArray($setting); + + return $carry; + }, []); + + if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { + abort(404); + } + + return null; } - private function updateOauthSettings(?string $provider = null) + private function updateOauthSettings(?string $provider = null): void { + $this->validate($this->validationRules($provider)); + if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -57,78 +93,128 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $oauth->fill([ - 'enabled' => $oauthData['enabled'], - 'client_id' => $oauthData['client_id'], - 'client_secret' => $oauthData['client_secret'], - 'redirect_uri' => $oauthData['redirect_uri'], - 'tenant' => $oauthData['tenant'], - 'base_url' => $oauthData['base_url'], - ]); - - if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } + $this->fillOauthSetting($oauth, $oauthData); + $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); - // Update the array with fresh data - $this->oauth_settings_map[$provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); - } else { - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - - continue; - } - - $oauth->fill([ - 'enabled' => $settingData['enabled'], - 'client_id' => $settingData['client_id'], - 'client_secret' => $settingData['client_secret'], - 'redirect_uri' => $settingData['redirect_uri'], - 'tenant' => $settingData['tenant'], - 'base_url' => $settingData['base_url'], - ]); - - if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; - } - - $oauth->save(); - - // Update the array with fresh data - $this->oauth_settings_map[$oauth->provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; - } - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } + return; } + + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); + + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + + continue; + } + + $this->fillOauthSetting($oauth, $settingData); + + if ($oauth->enabled && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->enabled = false; + $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; + } + + $oauth->save(); + $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); + } + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); + } + } + + private function fillOauthSetting(OauthSetting $oauth, array $data): void + { + $oauth->fill([ + 'enabled' => (bool) ($data['enabled'] ?? false), + 'client_id' => $data['client_id'] ?? null, + 'client_secret' => $data['client_secret'] ?? null, + 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), + 'tenant' => $data['tenant'] ?? null, + 'base_url' => $this->nullableString($data['base_url'] ?? null), + 'custom_label' => $data['custom_label'] ?? null, + 'scopes' => $data['scopes'] ?? null, + 'allow_registration' => (bool) ($data['allow_registration'] ?? false), + 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), + 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), + 'use_pkce' => (bool) ($data['use_pkce'] ?? true), + 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), + ]); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function ensureProviderCanBeEnabled(OauthSetting $oauth): void + { + if (! $oauth->enabled) { + return; + } + + if (! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } + + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->update(['enabled' => false]); + throw new \Exception("OIDC scopes must include 'openid'."); + } + } + + private function oauthSettingToArray(OauthSetting $setting): array + { + return [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + 'custom_label' => $setting->custom_label, + 'scopes' => $setting->scopes ?: 'openid email profile', + 'allow_registration' => $setting->allow_registration, + 'auto_join_root_team' => $setting->auto_join_root_team, + 'require_email_verified' => $setting->require_email_verified ?? true, + 'use_pkce' => $setting->use_pkce ?? true, + 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, + 'label' => $this->providerLabel($setting->provider), + ]; + } + + public function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OpenID Connect', + 'gitlab' => 'GitLab', + default => str($provider)->headline()->toString(), + }; } public function instantSave(string $provider) @@ -141,56 +227,88 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider): mixed + public function toggleProvider(string $provider) { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - throw new \Exception('OAuth provider not found.'); + abort(404); } - $enabling = ! $this->oauth_settings_map[$provider]['enabled']; - if ($enabling) { - $this->validate($this->providerRules($provider)); + if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { + $this->validateProviderCanBeEnabled($provider); } - $this->oauth_settings_map[$provider]['enabled'] = $enabling; + $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; $this->updateOauthSettings($provider); - } catch (\Throwable $e) { + } catch (\Exception $e) { + $oauth = OauthSetting::where('provider', $provider)->first(); + if ($oauth) { + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + } + return handleError($e, $this); } - - return null; } - private function providerRules(string $provider): array + private function validateProviderCanBeEnabled(string $provider): void { - $prefix = "oauth_settings_map.$provider"; - $rules = [ - "$prefix.client_id" => 'required', - "$prefix.client_secret" => 'required', - ]; + $this->validate($this->validationRules($provider)); - if ($provider === 'azure') { - $rules["$prefix.tenant"] = 'required'; + $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); + if (! $oauth) { + throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - if (in_array($provider, ['authentik', 'clerk'], true)) { - $rules["$prefix.base_url"] = 'required'; + $this->fillOauthSetting($oauth, [ + ...$this->oauth_settings_map[$provider], + 'enabled' => true, + ]); + + if (! $oauth->couldBeEnabled()) { + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); } - return $rules; + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + throw new \Exception("OIDC scopes must include 'openid'."); + } } - public function submit() + public function saveRegistrationPolicy(): void + { + $this->authorize('update', instanceSettings()); + $this->validate([ + 'disable_registration_when_oauth_enabled' => 'boolean', + ]); + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + $this->dispatch('success', 'Authentication settings updated successfully!'); + } + + public function submit(): void { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); - } catch (\Throwable $e) { - return handleError($e, $this); + $this->updateOauthSettings($this->selectedProvider); + + if ($this->selectedProvider === null) { + $this->dispatch('success', 'Instance settings updated successfully!'); + } + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + if ($this->selectedProvider !== null) { + $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); + if ($oauth) { + $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); + } + } + + handleError($e, $this); } } } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..02f3e7ed50 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,6 +22,7 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', + 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -88,6 +89,8 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -115,6 +118,19 @@ class InstanceSettings extends Model }); } + public function isPasswordRegistrationAllowed(): bool + { + if (! $this->is_registration_enabled) { + return false; + } + + if (! $this->disable_registration_when_oauth_enabled) { + return true; + } + + return ! OauthSetting::where('enabled', true)->exists(); + } + public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..20541f6139 --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,38 @@ + 'encrypted', + 'capabilities' => 'array', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam() + { + return self::query()->where('team_id', currentTeam()->id); + } +} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php new file mode 100644 index 0000000000..1edf71ad2f --- /dev/null +++ b/app/Models/OauthIdentity.php @@ -0,0 +1,35 @@ + 'array', + 'last_login_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index e7999134a6..7765e41160 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,7 +11,19 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'allow_registration' => 'boolean', + 'auto_join_root_team' => 'boolean', + 'require_email_verified' => 'boolean', + 'use_pkce' => 'boolean', + 'clock_skew_seconds' => 'integer', + ]; + } protected $hidden = [ 'client_secret', @@ -32,9 +44,46 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': + case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } + + /** + * @return array + */ + public function scopeList(): array + { + $scopes = str($this->scopes ?: 'openid email profile') + ->replace(',', ' ') + ->explode(' ') + ->map(fn (string $scope) => trim($scope)) + ->filter() + ->unique() + ->values() + ->all(); + + return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; + } + + public function loginLabel(): string + { + if (filled($this->custom_label)) { + return $this->custom_label; + } + + $envLabel = config("services.{$this->provider}.custom_label"); + if (filled($envLabel)) { + return $envLabel; + } + + return __("auth.login.{$this->provider}"); + } + + public function isOidc(): bool + { + return $this->provider === 'oidc'; + } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 15085203aa..b7664e94d3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 5b38473962..10303422bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } + public function oauthIdentities(): HasMany + { + return $this->hasMany(OauthIdentity::class); + } + + public function hasSsoIdentity(): bool + { + return $this->oauthIdentities()->exists(); + } + /** * Check if the user has a password set. - * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } + + public function requiresPasswordConfirmation(): bool + { + return $this->hasPassword() && ! $this->hasSsoIdentity(); + } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..309c8167f2 --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,34 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5856791662..e4d2b0a851 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Auth\Oidc\OidcDiscoveryService; +use App\Auth\Oidc\OidcTokenValidator; +use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); - $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } + private function configureOidcSocialite(): void + { + if (! $this->app->bound(SocialiteFactory::class)) { + return; + } + + $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { + return new OidcProvider( + $app['request'], + $app->make(OidcDiscoveryService::class), + $app->make(OidcTokenValidator::class), + '', + '', + '', + ); + }); + } + private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -77,16 +98,5 @@ 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 09b2a3e089..e8e6fb42c6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 1b201fb3f9..bf6fa4c4bf 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { + // If there are no users and password registration is allowed, redirect to registration. return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->is_registration_enabled, + 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..2ec8f88e3e --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,228 @@ +email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); + } + + $user = $provider === 'oidc' + ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) + : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); + + Auth::login($user); + $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); + session(['currentTeam' => $user->currentTeam = $team]); + + return $user; + } + + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $provider = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if ( + (! is_string($providerUserId) && ! is_int($providerUserId)) + || (is_string($providerUserId) && trim($providerUserId) === '') + ) { + throw new HttpException(403, 'OAuth provider did not return a valid user ID'); + } + $providerUserId = (string) $providerUserId; + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) + || data_get($oauthUser->user, 'email_verified') === true; + + if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { + throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); + } + + if ($oauthSetting->require_email_verified && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider did not verify the email address'); + } + + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + + // Linking a new OIDC identity to an existing local account by email + // is account takeover unless the provider attests the email. This + // guard is independent of the require_email_verified toggle, which + // only governs the broader login flow. + if ($user && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); + } + + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User + { + if (User::count() === 0) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + $user->save(); + + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + instanceSettings()->update(['is_registration_enabled' => false]); + + return $user; + } + + if ($oauthSetting->auto_join_root_team) { + return $this->createRootTeamOnlyUser($name, $email); + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } + + private function createRootTeamOnlyUser(string $name, string $email): User + { + return DB::transaction(function () use ($name, $email) { + $rootTeam = Team::find(0); + if ($rootTeam === null) { + throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); + } + + $user = User::withoutEvents(fn () => User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ])); + + $user->teams()->attach($rootTeam, ['role' => 'member']); + + return $user; + }); + } +} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40d..461e7c2669 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4566,8 +4566,9 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // Skip if user has no password (OAuth users) - if (! Auth::user()?->hasPassword()) { + // OAuth users may have an unusable generated password, so the linked + // identity is the source of truth for whether confirmation is possible. + if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } @@ -4578,7 +4579,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fd3fbe74ba..f177e6c16f 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,7 +1,13 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -23,7 +29,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new \SocialiteProviders\Manager\Config( + $authentik_clerk_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -34,7 +40,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new \SocialiteProviders\Manager\Config( + $zitadel_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -44,8 +50,12 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } + if ($provider === 'oidc') { + return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); + } + if ($provider == 'google') { - $google_config = new \SocialiteProviders\Manager\Config( + $google_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -63,11 +73,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, - 'discord' => \SocialiteProviders\Discord\Provider::class, - 'github' => \Laravel\Socialite\Two\GithubProvider::class, - 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => BitbucketProvider::class, + 'discord' => Provider::class, + 'github' => GithubProvider::class, + 'gitlab' => GitlabProvider::class, + 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index 871c6f010c..18e125510d 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", diff --git a/composer.lock b/composer.lock index c2c42ba71a..18c5260f32 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "971daeb1b3078a36428c0fb56bb895b7", + "content-hash": "2d511da9e5e82eade5aa7e5094c888ae", "packages": [ { "name": "aws/aws-crt-php", diff --git a/config/services.php b/config/services.php index c5956cf6c9..3a2a0631ef 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_REDIRECT_URI'), + 'base_url' => env('OIDC_BASE_URL'), + 'custom_label' => env('OIDC_LOGIN_LABEL'), + ], + 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 19c4445b26..13fe6b6784 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,6 +8,12 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. + * + * Coolify's own backend runs exclusively on PostgreSQL in production and + * SQLite in testing (see config/database.php — the only configured + * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed + * resources, never Coolify's application database, so no driver path is + * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php new file mode 100644 index 0000000000..3160ef9ddb --- /dev/null +++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php @@ -0,0 +1,40 @@ +string('custom_label')->nullable(); + $table->string('scopes')->nullable(); + $table->boolean('allow_registration')->default(true); + $table->boolean('require_email_verified')->default(true); + $table->boolean('use_pkce')->default(true); + $table->unsignedSmallInteger('clock_skew_seconds')->default(60); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn([ + 'custom_label', + 'scopes', + 'allow_registration', + 'require_email_verified', + 'use_pkce', + 'clock_skew_seconds', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php new file mode 100644 index 0000000000..9f838e5779 --- /dev/null +++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('issuer'); + $table->string('provider_user_id'); + $table->string('email')->nullable()->index(); + $table->json('raw_claims')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php new file mode 100644 index 0000000000..06c0f1dd52 --- /dev/null +++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('disable_registration_when_oauth_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('disable_registration_when_oauth_enabled'); + }); + } +}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 0000000000..b0f5aad18a --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +boolean('auto_join_root_team')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn('auto_join_root_team'); + }); + } +}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e3e63defd..f916c4a9cd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 2ac615cc01..19d3aa42e8 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,12 +15,10 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ - 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ - 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/lang/de.json b/lang/de.json index 7c43300e67..cbc2237a75 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,6 +7,7 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", + "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index 12c21b6665..b97a10d629 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,6 +8,7 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", + "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index bcd8e23937..b05437ac4e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,6 +8,7 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", + "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 0000000000..9c542584ef --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 829a26cad3..12eb57867c 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,11 +80,15 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - {{ __("auth.login.$provider_setting->provider") }} + @if ($provider_setting->provider !== 'oidc') + + @endif + {{ $provider_setting->loginLabel() }} @endforeach
diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index d2b3e30a6f..a17b0b96a6 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,6 +12,12 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, + auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ + 'label' => 'Integration Tokens', + 'route' => 'security.integration-tokens', + 'active' => request()->routeIs('security.integration-tokens'), + 'icon' => 'network', + ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index 0e0de551fd..dbe381e050 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,6 +12,24 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], + [ + 'label' => 'Authentication', + 'route' => 'settings.oauth', + 'active' => $activeMenu === 'oauth', + 'icon' => 'keys', + ], + [ + 'label' => 'Transactional Email', + 'route' => 'settings.email', + 'active' => $activeMenu === 'email', + 'icon' => 'notifications', + ], + [ + 'label' => 'Instance Backup', + 'route' => 'settings.backup', + 'active' => $activeMenu === 'backup', + 'icon' => 'database', + ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index ef54d3e215..33f1b9a98e 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,15 +134,22 @@
+ :disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)"> Change
-
- + + - + @endif
diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php new file mode 100644 index 0000000000..b7e53dbc7c --- /dev/null +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -0,0 +1,52 @@ +
+ +
+ + +
+ +
+
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+ + Create a replacement token in Cloudflare + +
+ @endif + +
+ + + Validate and save + +
+ +
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php new file mode 100644 index 0000000000..d847fff7fb --- /dev/null +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -0,0 +1,49 @@ +
+
+ + +
+ + +
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+

Limit zone resources to the zones Coolify should manage.

+ + Create this token in Cloudflare + +
+ @endif + +
+ + Validate and add + +
+ +
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php new file mode 100644 index 0000000000..b4961551ae --- /dev/null +++ b/resources/views/livewire/security/integration-tokens.blade.php @@ -0,0 +1,84 @@ +
+ + Integration Tokens | Coolify + + + +
+ + + @can('create', App\Models\IntegrationToken::class) + + + + + + + @endcan + + + @if ($tokens->isEmpty()) + + @else +
+ @foreach ($tokens as $savedToken) +
+ + +
+
+

+ +

+
+
+ {{ ucfirst($savedToken->provider) }} +
+
+ +
+ +
+
+ +
+
+ @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index d490b6f1db..f1e4fc3f7a 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications - can be managed from + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status + notifications can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 97822b9251..822c035b31 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,76 +5,126 @@ -
- -
+
+ +
+
- @foreach ($oauth_settings_map as $oauth_setting) - @php - $provider = $oauth_setting['provider']; - $providerLabel = str($provider)->headline(); - @endphp + + + + + @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $oauth_setting['label'] }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- - - +
+ @if ($provider === 'oidc') + + + + + + +
+ +
+ @else + + + + @endif @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif + +
+ +
+ @if ($provider === 'oidc') + + + + @endif +
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d15a1b87ab..d05ac5ac98 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,12 +13,19 @@
- + ]" /> + create(); + $user = User::factory()->create(['email' => 'owner@example.com']); + $user->teams()->attach($team, ['role' => 'owner']); + + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$user, $team]; +} + +function actingAsEnableActionInstanceAdmin(): User +{ + $team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + $user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']); + if (! $user->teams()->whereKey($team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + session(['currentTeam' => $team]); + test()->actingAs($user); + + return $user; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); +}); + +it('renders settings email enable actions instead of enabled checkboxes', function () { + $view = file_get_contents(resource_path('views/livewire/settings-email.blade.php')); + + expect($view)->toContain('Enable SMTP Server') + ->and($view)->toContain('Disable SMTP Server') + ->and($view)->toContain('Enable Resend') + ->and($view)->toContain('Disable Resend') + ->and($view)->not->toContain('id="smtpEnabled" label="Enabled"') + ->and($view)->not->toContain('id="resendEnabled" label="Enabled"'); +}); + +it('keeps transactional smtp disabled when enable validation fails', function () { + actingAsEnableActionInstanceAdmin(); + + Livewire::test(SettingsEmail::class) + ->call('toggleSmtp') + ->assertDispatched('error') + ->assertSet('smtpEnabled', false); + + expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse(); +}); + +it('enables transactional smtp only after required fields validate', function () { + actingAsEnableActionInstanceAdmin(); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromAddress', 'mail@example.com') + ->set('smtpFromName', 'Coolify') + ->set('smtpHost', 'smtp.example.com') + ->set('smtpPort', '587') + ->set('smtpEncryption', 'starttls') + ->call('toggleSmtp') + ->assertHasNoErrors() + ->assertSet('smtpEnabled', true) + ->assertSet('resendEnabled', false); + + expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue() + ->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse(); +}); + +it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) { + $contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php")); + + expect($contents)->toContain($enableLabel) + ->and($contents)->not->toContain($checkboxSnippet); +})->with([ + 'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'], + 'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'], + 'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'], + 'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'], + 'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'], +]); + +it('shows notification provider save buttons while disabled', function (string $component) { + actingAsEnableActionOwner(); + + Livewire::test($component) + ->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false) + ->assertSee('Save'); +})->with([ + 'discord' => [Discord::class], + 'slack' => [Slack::class], + 'telegram' => [Telegram::class], + 'pushover' => [Pushover::class], + 'webhook' => [Webhook::class], +]); + +it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) { + actingAsEnableActionOwner(); + + Livewire::test($component) + ->assertDontSee('Send Test Notification'); + + Livewire::test($component) + ->set($enabledProperty, true) + ->assertSee('Send Test Notification'); +})->with([ + 'discord' => [Discord::class, 'discordEnabled'], + 'slack' => [Slack::class, 'slackEnabled'], + 'telegram' => [Telegram::class, 'telegramEnabled'], + 'pushover' => [Pushover::class, 'pushoverEnabled'], + 'webhook' => [Webhook::class, 'webhookEnabled'], +]); + +it('hides the email test button while email notifications are disabled', function () { + actingAsEnableActionOwner(); + + Livewire::test(Email::class) + ->assertDontSee('Send Test Email'); +}); + +it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) { + [, $team] = actingAsEnableActionOwner(); + + Livewire::test($component) + ->call($method) + ->assertDispatched('error') + ->assertSet($enabledProperty, false); + + expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse(); +})->with([ + 'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'], + 'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'], + 'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'], + 'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'], + 'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'], +]); + +it('renders notification email and log drain enable actions instead of enabled checkboxes', function () { + $notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php')); + $logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php')); + + expect($notificationEmail)->toContain('Enable SMTP Server') + ->and($notificationEmail)->toContain('Enable Resend') + ->and($notificationEmail)->not->toContain('id="smtpEnabled"') + ->and($notificationEmail)->not->toContain('id="resendEnabled"') + ->and($logDrains)->toContain('Enable New Relic') + ->and($logDrains)->toContain('Enable Axiom') + ->and($logDrains)->toContain('Enable Custom FluentBit') + ->and($logDrains)->not->toContain('label="Enabled"'); +}); + +it('keeps notification email smtp disabled when enable validation fails', function () { + actingAsEnableActionOwner(); + + Livewire::test(Email::class) + ->call('toggleSmtp') + ->assertDispatched('error') + ->assertSet('smtpEnabled', false); +}); diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php new file mode 100644 index 0000000000..994c96398b --- /dev/null +++ b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php @@ -0,0 +1,45 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('reverts the persisted enabled flag when starting the log drain fails', function () { + StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainNewRelicLicenseKey', 'abc123') + ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') + ->call('toggleLogDrain', 'newrelic') + ->assertSet('isLogDrainNewRelicEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); +}); + +it('keeps the enabled flag persisted when starting the log drain succeeds', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainNewRelicLicenseKey', 'abc123') + ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') + ->call('toggleLogDrain', 'newrelic') + ->assertSet('isLogDrainNewRelicEnabled', true); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy(); +}); diff --git a/tests/Feature/LoginPageBrandingTest.php b/tests/Feature/LoginPageBrandingTest.php index 5d60290abd..ffe7da67ce 100644 --- a/tests/Feature/LoginPageBrandingTest.php +++ b/tests/Feature/LoginPageBrandingTest.php @@ -37,6 +37,28 @@ test('auth pages use the Coollabs purple background glow', function () { ->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s'); }); +test('external login providers are centered and full width', function () { + $login = file_get_contents(resource_path('views/auth/login.blade.php')); + + expect($login) + ->toContain('class="flex flex-col gap-2"') + ->toContain('class="w-full justify-center"') + ->not->toContain('sm:w-[calc(50%-0.25rem)]'); +}); + +test('external login providers display their icons except oidc', function () { + $login = file_get_contents(resource_path('views/auth/login.blade.php')); + + expect($login) + ->toContain("@if (\$provider_setting->provider !== 'oidc')") + ->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')") + ->toContain('class="size-5 shrink-0 dark:invert"'); + + foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) { + expect(public_path("svgs/{$provider}.svg"))->toBeFile(); + } +}); + test('error pages use the Coollabs purple background glow', function () { $styles = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php index 1388e29808..4671183ae7 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -1,25 +1,35 @@ 0, 'is_registration_enabled' => false, ]); + Once::flush(); + OauthSetting::create([ 'provider' => 'google', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', 'tenant' => 'example.com', + 'enabled' => true, ]); }); @@ -46,6 +56,75 @@ it('logs in an existing user when the oauth provider returns a mixed-case email' $response->assertRedirect('/'); $this->assertAuthenticatedAs($user); expect(User::count())->toBe(1); + expect(OauthIdentity::where([ + 'user_id' => $user->id, + 'provider' => 'google', + 'provider_user_id' => 'google-user-id', + ])->exists())->toBeTrue(); +}); + +it('never moves an existing oauth identity when the provider email changes', function () { + config()->set('app.maintenance.driver', 'file'); + + $identityOwner = User::factory()->create(['email' => 'old@example.com']); + $otherUser = User::factory()->create(['email' => 'new@example.com']); + $identity = OauthIdentity::create([ + 'user_id' => $identityOwner->id, + 'provider' => 'google', + 'issuer' => 'google', + 'provider_user_id' => 'google-user-id', + 'email' => 'old@example.com', + ]); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->once()->andReturnSelf(); + $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf(); + $provider->shouldReceive('user')->once()->andReturn((object) [ + 'email' => 'new@example.com', + 'name' => 'Example User', + 'id' => 'google-user-id', + ]); + + Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider); + + $this->get(route('auth.callback', 'google'))->assertRedirect('/'); + + $this->assertAuthenticatedAs($identityOwner); + expect($identity->refresh()->user_id)->toBe($identityOwner->id) + ->and($identity->email)->toBe('new@example.com') + ->and($identity->user_id)->not->toBe($otherUser->id); +}); + +it('continues oauth login when another request creates the identity first', function () { + $user = User::factory()->create(['email' => 'race@example.com']); + $eventName = 'eloquent.creating: '.OauthIdentity::class; + + Event::listen($eventName, function (OauthIdentity $identity): void { + $attributes = $identity->getAttributes(); + + DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes)); + + throw new UniqueConstraintViolationException( + DB::getDefaultConnection(), + 'insert into oauth_identities', + [], + new PDOException('duplicate identity'), + ); + }); + + try { + $resolvedUser = app(OauthLoginService::class)->login('google', (object) [ + 'email' => 'race@example.com', + 'name' => 'Race User', + 'id' => 'google-race-id', + ], OauthSetting::where('provider', 'google')->firstOrFail()); + } finally { + Event::forget($eventName); + } + + expect($resolvedUser->is($user))->toBeTrue() + ->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1); + $this->assertAuthenticatedAs($user); }); it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) { @@ -76,4 +155,37 @@ it('rejects oauth logins when the provider does not return an email address', fu })->with([ 'null email' => [null], 'blank email' => [' '], + 'malformed email' => ['not-an-email'], + 'missing domain' => ['user@'], +]); + +it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) { + $oauthUser = (object) [ + 'email' => 'user@example.edu', + 'name' => 'Example User', + ]; + + if ($invalidId !== 'missing') { + $oauthUser->id = $invalidId; + } + + try { + app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail()); + } catch (HttpException $exception) { + expect($exception->getStatusCode())->toBe(403) + ->and(OauthIdentity::count())->toBe(0) + ->and(User::count())->toBe(0); + + return; + } + + $this->fail('Expected an invalid OAuth provider user ID to be rejected.'); +})->with([ + 'null id' => [null], + 'missing id' => ['missing'], + 'blank id' => [' '], + 'non-scalar id' => [[]], + 'true id' => [true], + 'false id' => [false], + 'float id' => [1.0], ]); diff --git a/tests/Feature/OauthRegistrationPolicyTest.php b/tests/Feature/OauthRegistrationPolicyTest.php new file mode 100644 index 0000000000..86186cca8c --- /dev/null +++ b/tests/Feature/OauthRegistrationPolicyTest.php @@ -0,0 +1,52 @@ + 0, + 'is_registration_enabled' => true, + 'disable_registration_when_oauth_enabled' => true, + ]); + Once::flush(); +}); + +it('blocks password registration when oauth registration policy disables it', function () { + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => true, + 'client_id' => 'client-id', + 'client_secret' => 'secret', + 'base_url' => 'https://idp.example.com', + ]); + + app(CreateNewUser::class)->create([ + 'name' => 'Password User', + 'email' => 'password@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); +})->throws(HttpException::class); + +it('allows password registration when no oauth provider is enabled', function () { + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => false, + ]); + + $user = app(CreateNewUser::class)->create([ + 'name' => 'Password User', + 'email' => 'password@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + expect($user->email)->toBe('password@example.com'); +}); diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php new file mode 100644 index 0000000000..084347f66d --- /dev/null +++ b/tests/Feature/OidcOauthControllerTest.php @@ -0,0 +1,275 @@ +set('app.maintenance.driver', 'file'); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_registration_enabled' => false, + ]); + + Once::flush(); + + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => true, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'base_url' => 'https://idp.example.com', + 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback', + 'allow_registration' => false, + ]); +}); + +function fakeOidcProvider(array $claims = []): void +{ + $user = (new OidcUser)->setRaw(array_merge([ + 'iss' => 'https://idp.example.com', + 'sub' => 'okta-user-1', + 'email' => 'user@example.com', + 'email_verified' => true, + 'name' => 'Okta User', + ], $claims))->map([ + 'id' => $claims['sub'] ?? 'okta-user-1', + 'name' => $claims['name'] ?? 'Okta User', + 'email' => $claims['email'] ?? 'user@example.com', + ]); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn($user); + + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); +} + +it('logs in a user through an existing oidc identity', function () { + $user = User::factory()->create(['email' => 'existing@example.com']); + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'okta-user-1', + 'email' => 'existing@example.com', + ]); + + fakeOidcProvider(['email' => 'existing@example.com']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); +}); + +it('continues oidc login when another request creates the identity first', function () { + $user = User::factory()->create(['email' => 'race@example.com']); + $eventName = 'eloquent.creating: '.OauthIdentity::class; + + Event::listen($eventName, function (OauthIdentity $identity): void { + $attributes = $identity->getAttributes(); + + DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes)); + + throw new UniqueConstraintViolationException( + DB::getDefaultConnection(), + 'insert into oauth_identities', + [], + new PDOException('duplicate identity'), + ); + }); + + try { + $resolvedUser = app(OauthLoginService::class)->login('oidc', (new OidcUser)->setRaw([ + 'iss' => 'https://idp.example.com', + 'sub' => 'oidc-race-id', + 'email' => 'race@example.com', + 'email_verified' => true, + 'name' => 'Race User', + ])->map([ + 'id' => 'oidc-race-id', + 'name' => 'Race User', + 'email' => 'race@example.com', + ]), OauthSetting::where('provider', 'oidc')->firstOrFail()); + } finally { + Event::forget($eventName); + } + + expect($resolvedUser->is($user))->toBeTrue() + ->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1); + $this->assertAuthenticatedAs($user); +}); + +it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'newuser@example.com']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $user = User::whereEmail('newuser@example.com')->first(); + expect($user)->not->toBeNull() + ->and($user->password)->not->toBeNull(); + $this->assertAuthenticatedAs($user); + $this->assertDatabaseHas('oauth_identities', [ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'okta-user-1', + ]); +}); + +it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () { + Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + (new User)->forceFill([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'root@example.com', + 'password' => 'password', + ])->save(); + + OauthSetting::where('provider', 'oidc')->update([ + 'allow_registration' => true, + 'auto_join_root_team' => true, + ]); + + fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $user = User::whereEmail('root-member@example.com')->first(); + expect($user)->not->toBeNull() + ->and($user->teams()->count())->toBe(1); + + $rootMembership = $user->teams()->where('teams.id', 0)->first(); + expect($rootMembership)->not->toBeNull() + ->and($rootMembership->pivot->role)->toBe('member'); + + $this->assertDatabaseMissing('teams', [ + 'name' => "Root Member's Team", + ]); + expect(session('currentTeam')->id)->toBe(0); + $this->assertAuthenticatedAs($user); +}); + +it('rejects linking an unverified oidc email to an existing local account', function () { + $user = User::factory()->create(['email' => 'victim@example.com']); + + fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); + $this->assertGuest(); + $this->assertDatabaseMissing('oauth_identities', [ + 'user_id' => $user->id, + 'provider' => 'oidc', + ]); +}); + +it('rejects new oidc users when neither normal nor provider registration is enabled', function () { + fakeOidcProvider(['email' => 'blocked@example.com']); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); + expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse(); +}); + +it('creates the root user when oidc provisions the first account', function () { + Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']); + $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']); + expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); +}); + +it('persists raw claims as an array on the oauth identity', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'claims@example.com']); + + $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); + + $identity = OauthIdentity::where('email', 'claims@example.com')->first(); + expect($identity->raw_claims)->toBeArray() + ->and($identity->raw_claims['sub'])->toBe('okta-user-1'); +}); + +it('stores empty raw claims when the provider returns no user payload', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + $user = (new OidcUser)->setIdTokenClaims([ + 'iss' => 'https://idp.example.com', + 'sub' => 'okta-no-payload', + 'email_verified' => true, + ])->map([ + 'id' => 'okta-no-payload', + 'name' => 'No Payload', + 'email' => 'nopayload@example.com', + ]); + $user->user = null; + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn($user); + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); + + $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); + + $identity = OauthIdentity::where('email', 'nopayload@example.com')->first(); + expect($identity->raw_claims)->toBe([]); +}); + +it('rejects callbacks for disabled oidc provider', function () { + OauthSetting::where('provider', 'oidc')->update(['enabled' => false]); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); +}); + +it('logs callback failures with diagnostic context', function () { + Log::spy(); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed')); + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value'])); + + $response->assertRedirect('/login'); + Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) { + return $message === 'OAuth callback failed.' + && $context['provider'] === 'oidc' + && $context['exception_class'] === RuntimeException::class + && $context['exception_message'] === 'Token exchange failed' + && $context['has_code'] === true + && $context['has_state'] === true + && $context['exception'] instanceof RuntimeException; + }); +}); diff --git a/tests/Feature/ProfileSsoIndicatorTest.php b/tests/Feature/ProfileSsoIndicatorTest.php new file mode 100644 index 0000000000..0d48225eb5 --- /dev/null +++ b/tests/Feature/ProfileSsoIndicatorTest.php @@ -0,0 +1,91 @@ +create(['name' => 'Profile User']); + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'idp-user-1', + 'email' => $user->email, + ]); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertSee('Signed in with SSO') + ->assertSee('OIDC'); +}); + +it('does not show sso status for password-only profile users', function () { + $user = User::factory()->create(['name' => 'Profile User']); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertDontSee('Signed in with SSO'); +}); + +it('prevents sso linked users from opening or requesting profile email changes', function () { + $user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']); + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'idp-user-1', + 'email' => $user->email, + ]); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertSee('Email is managed by your SSO provider.') + ->call('showEmailChangeForm') + ->assertSet('show_email_change', false) + ->assertDispatched('error') + ->set('new_email', 'changed@example.com') + ->call('requestEmailChange') + ->assertSet('show_email_change', false) + ->assertSet('show_verification', false) + ->assertDispatched('error'); + + $user->refresh(); + + expect($user->email)->toBe('sso@example.com') + ->and($user->pending_email)->toBeNull() + ->and($user->email_change_code)->toBeNull() + ->and($user->email_change_code_expires_at)->toBeNull(); +}); + +it('keeps profile email changes available for password-only users', function () { + config()->set('constants.coolify.self_hosted', false); + Notification::fake(); + + $user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->call('showEmailChangeForm') + ->assertSet('show_email_change', true) + ->set('new_email', 'changed@example.com') + ->call('requestEmailChange') + ->assertSet('show_verification', true) + ->assertDispatched('success'); + + $user->refresh(); + + expect($user->pending_email)->toBe('changed@example.com') + ->and($user->email_change_code)->not->toBeNull(); +}); diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php new file mode 100644 index 0000000000..113fa4c032 --- /dev/null +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -0,0 +1,253 @@ +whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); +}); + +test('a cloudflare dns token is validated with read only requests before it is saved', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => true, + 'result' => [['id' => 'zone-id']], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ + 'success' => true, + 'result' => [], + ]), + ]); + + Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true]) + ->set('provider', 'cloudflare') + ->set('name', 'Production DNS') + ->set('token', 'cloudflare-token') + ->set('capabilities', ['dns']) + ->call('addToken') + ->assertHasNoErrors() + ->assertDispatched('close-modal'); + + $this->assertDatabaseHas('integration_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + ]); + + Http::assertSentCount(3); + Http::assertSent(fn ($request) => $request->method() === 'GET' + && $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1'); +}); + +test('a cloudflare token is not saved when scope validation fails', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => false, + 'errors' => [['message' => 'Authentication error']], + ], 403), + ]); + + Livewire::test(IntegrationTokenForm::class) + ->set('name', 'Invalid DNS token') + ->set('token', 'cloudflare-token') + ->set('capabilities', ['dns']) + ->call('addToken') + ->assertDispatched('error'); + + $this->assertDatabaseCount('integration_tokens', 0); +}); + +test('at least one capability is required when adding a cloudflare token', function () { + Livewire::test(IntegrationTokenForm::class) + ->set('name', 'Account token') + ->set('token', 'cloudflare-token') + ->set('capabilities', []) + ->call('addToken') + ->assertHasErrors(['capabilities' => 'required']); + + $this->assertDatabaseCount('integration_tokens', 0); + Http::assertNothingSent(); +}); + +test('integration tokens page lists saved provider and capabilities', function () { + IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'secret', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokens::class) + ->assertSee('Production DNS') + ->assertSee('Cloudflare') + ->assertSee('DNS'); +}); + +test('cloudflare dns scope guidance and token creation link are shown', function () { + Livewire::test(IntegrationTokenForm::class) + ->set('capabilities', ['dns']) + ->assertSee('Zone - DNS - Edit') + ->assertSee('Zone - Zone - Read') + ->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management'); + + expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'))) + ->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D'); +}); + +test('capability selection uses the shared checkbox component', function () { + $view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')); + + expect($view) + ->toContain('toContain('class="mt-3 rounded-lg border') + ->not->toContain('toContain('wire:target="addToken" isHighlighted') + ->not->toContain('class="button-highlighted"'); +}); + +test('saved integration token rows render modal editors with a gear button', function () { + IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokens::class) + ->assertSee('Edit Integration Token') + ->assertSee('Production DNS') + ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"'); +}); + +test('an integration token can be rotated after validating its capabilities', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => true, + 'result' => [['id' => 'zone-id']], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ + 'success' => true, + 'result' => [], + ]), + ]); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Rotated DNS') + ->set('newToken', 'rotated-token') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $savedToken->refresh(); + + expect($savedToken->name)->toBe('Rotated DNS') + ->and($savedToken->token)->toBe('rotated-token'); +}); + +test('leaving the token field blank keeps the existing integration token', function () { + Http::fake(); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Renamed DNS') + ->set('newToken', '') + ->call('save') + ->assertHasNoErrors(); + + $savedToken->refresh(); + + expect($savedToken->name)->toBe('Renamed DNS') + ->and($savedToken->token)->toBe('original-token'); + + Http::assertNothingSent(); +}); + +test('an invalid replacement does not rotate the integration token', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => false, + ], 403), + ]); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('newToken', 'invalid-token') + ->call('save') + ->assertDispatched('error'); + + expect($savedToken->fresh()->token)->toBe('original-token'); +}); + +test('editor updates its row without rerendering the teleported parent modal', function () { + $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php')); + + expect($component) + ->toContain("'integration-token-updated'") + ->toContain("'integration-token-deleted'") + ->not->toContain('integrationTokenChanged'); +}); diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php index e89bf8093a..9eab92cc28 100644 --- a/tests/Feature/SecuritySettingsNavigationTest.php +++ b/tests/Feature/SecuritySettingsNavigationTest.php @@ -8,6 +8,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { 'security/private-key/index.blade.php', 'security/private-key/show.blade.php', 'security/cloud-tokens.blade.php', + 'security/integration-tokens.blade.php', 'security/cloud-provider-token/show.blade.php', 'security/cloud-init-scripts.blade.php', 'security/cloud-init-script/show.blade.php', @@ -22,6 +23,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { ->toContain('application-settings-navigation') ->toContain("'label' => 'Private Keys'") ->toContain("'label' => 'Cloud Tokens'") + ->toContain("'label' => 'Integration Tokens'") ->toContain("'label' => 'Cloud-Init Scripts'") ->toContain("'label' => 'API Tokens'"); diff --git a/tests/Feature/SettingsEmailProviderExclusivityTest.php b/tests/Feature/SettingsEmailProviderExclusivityTest.php new file mode 100644 index 0000000000..7e6b6ff232 --- /dev/null +++ b/tests/Feature/SettingsEmailProviderExclusivityTest.php @@ -0,0 +1,64 @@ +settings = new InstanceSettings; + $this->settings->id = 0; + $this->settings->save(); + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->rootTeam, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->rootTeam]); +}); + +test('enabling SMTP disables Resend in storage', function () { + $this->settings->update([ + 'resend_enabled' => true, + 'resend_api_key' => 're_test_key', + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + Livewire::test(SettingsEmail::class) + ->set('smtpHost', 'smtp.example.com') + ->set('smtpPort', '587') + ->set('smtpEncryption', 'starttls') + ->set('smtpFromAddress', 'from@example.com') + ->set('smtpFromName', 'Coolify') + ->call('toggleSmtp'); + + $this->settings->refresh(); + expect($this->settings->smtp_enabled)->toBeTrue(); + expect($this->settings->resend_enabled)->toBeFalse(); +}); + +test('enabling Resend disables SMTP in storage', function () { + $this->settings->update([ + 'smtp_enabled' => true, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => '587', + 'smtp_encryption' => 'starttls', + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + Livewire::test(SettingsEmail::class) + ->set('resendApiKey', 're_test_key') + ->set('smtpFromAddress', 'from@example.com') + ->set('smtpFromName', 'Coolify') + ->call('toggleResend'); + + $this->settings->refresh(); + expect($this->settings->resend_enabled)->toBeTrue(); + expect($this->settings->smtp_enabled)->toBeFalse(); +}); diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php new file mode 100644 index 0000000000..96b01d8d98 --- /dev/null +++ b/tests/Feature/SettingsNavigationTest.php @@ -0,0 +1,52 @@ +blade('') + ->assertSeeText('Configuration') + ->assertSeeText('OAuth') + ->assertSeeText('Scheduled Jobs') + ->assertDontSeeText('Instance Backup') + ->assertDontSeeText('Transactional Email'); +}); + +it('shows backup and transactional email in the settings configuration sidebar', function () { + $view = $this->blade('') + ->assertSeeTextInOrder([ + 'General', + 'Advanced', + 'Instance Backup', + 'Transactional Email', + 'Updates', + ]); + + expect((string) $view) + ->toContain(route('settings.backup')) + ->toContain(route('settings.email')) + ->and(substr_count((string) $view, 'menu-item-active'))->toBe(1); +}); + +it('renders backup and transactional email pages with the settings configuration sidebar', function () { + expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->toContain('') + ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) + ->toContain(''); +}); + +it('uses the same title and description spacing on backup and transactional email settings pages', function () { + expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->not->toContain('class="flex items-center gap-2 pb-2"') + ->toContain('
Instance backup configuration for Coolify instance.
') + ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) + ->not->toContain('class="flex flex-col gap-2 pb-4"') + ->toContain('
Instance wide email settings for password resets, invitations, etc.
'); +}); + +it('uses instance backup as the backup settings label', function () { + expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php'))) + ->toContain('Instance Backup') + ->not->toContain('Backup') + ->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->toContain('

Instance Backup

') + ->toContain('Instance backup configuration for Coolify instance.') + ->not->toContain('

Backup

'); +}); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php new file mode 100644 index 0000000000..95ea47e948 --- /dev/null +++ b/tests/Feature/SettingsOauthTest.php @@ -0,0 +1,277 @@ + 0, 'name' => 'Root Team', 'personal_team' => true]); + $user = User::factory()->create(['id' => 0, 'email' => 'root@example.com', 'email_verified_at' => now()]); + if (! $user->teams()->whereKey($team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + session(['currentTeam' => $team]); + test()->actingAs($user); + + return $user; +} + +beforeEach(function () { + $this->withoutVite(); + config()->set('app.maintenance.driver', 'file'); + + InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]); + Once::flush(); + OauthSetting::create(['provider' => 'oidc']); + OauthSetting::create(['provider' => 'authentik']); + OauthSetting::create(['provider' => 'bitbucket']); +}); + +it('uses the standard settings design and keeps every oauth provider on one page', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Authentication') + ->assertSee('Registration') + ->assertSee('Authentik') + ->assertSee('Bitbucket') + ->assertSee('OpenID Connect') + ->assertSee('Disable password registration when OAuth is enabled') + ->assertSee('Client secret') + ->assertSee('application-settings-form', false) + ->assertDontSee(route('settings.oauth.provider', 'authentik'), false); +}); + +it('lists openid connect before the other oauth providers', function () { + actingAsInstanceAdmin(); + + $providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map')); + + expect($providers[0])->toBe('oidc'); +}); + +it('has an icon for openid connect', function () { + expect(public_path('svgs/oidc.svg'))->toBeFile(); +}); + +it('auto saves registration policy without a general save button', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee("wire:click='saveRegistrationPolicy'", false) + ->assertDontSee('Save', false); + + Livewire::test(SettingsOauth::class) + ->set('disable_registration_when_oauth_enabled', true) + ->call('saveRegistrationPolicy') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('shows oidc fields with a naked okta issuer url example', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('OpenID Connect') + ->assertSee('https://example.okta.com', false) + ->assertDontSee('/oauth2/default', false); +}); + +it('groups oidc fields in the expected desktop order', function () { + $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); + $fields = [ + 'redirect_uri', + 'base_url', + 'client_id', + 'client_secret', + 'scopes', + 'clock_skew_seconds', + 'custom_label', + ]; + $positions = array_map( + fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), + $fields, + ); + + expect($positions)->not->toContain(false) + ->and($positions)->toBe(collect($positions)->sort()->values()->all()) + ->and($view)->toContain('
'); +}); + +it('shows provider enable controls as settings section actions', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Enable') + ->assertDontSee('label="Enabled"', false) + ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); +}); + +it('stacks oidc option checkboxes vertically', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Allow OIDC user creation') + ->assertSee('Require verified email') + ->assertSee('Use PKCE') + ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); +}); + +it('does not show unknown oauth providers', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get('/settings/oauth/unknown') + ->assertNotFound(); +}); + +it('defaults oidc user creation and verified email requirement to enabled', function () { + $setting = OauthSetting::where('provider', 'oidc')->first(); + + expect($setting->allow_registration)->toBeTrue() + ->and($setting->require_email_verified)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeFalse(); +}); + +it('persists oidc oauth settings from livewire', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') + ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') + ->set('oauth_settings_map.oidc.allow_registration', true) + ->set('oauth_settings_map.oidc.auto_join_root_team', true) + ->set('oauth_settings_map.oidc.require_email_verified', true) + ->set('disable_registration_when_oauth_enabled', true) + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') + ->and($setting->base_url)->toBe('https://idp.example.com') + ->and($setting->custom_label)->toBe('Login with Okta') + ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->allow_registration)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeTrue(); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('saves only the selected provider from provider pages', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') + ->set('oauth_settings_map.authentik.enabled', true) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'authentik')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->client_id)->toBe('authentik-client') + ->and($setting->base_url)->toBe('https://authentik.example.com'); +}); + +it('validates oidc url fields before saving', function (string $field, string $value) { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set("oauth_settings_map.oidc.$field", $value) + ->call('submit') + ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->{$field})->toBeNull(); +})->with([ + 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], + 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], + 'invalid issuer url' => ['base_url', 'not-a-url'], + 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], +]); + +it('does not enable oidc without required fields', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->call('instantSave', 'oidc') + ->assertDispatched('error'); + + expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); +}); + +it('keeps provider disabled in the ui when enable validation fails', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->call('toggleProvider', 'authentik') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('disables an enabled provider gracefully when required fields become incomplete', function () { + actingAsInstanceAdmin(); + + OauthSetting::where('provider', 'authentik')->first()->forceFill([ + 'enabled' => true, + 'client_id' => 'authentik-client', + 'client_secret' => 'authentik-secret', + 'base_url' => 'https://authentik.example.com', + ])->save(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_secret', '') + ->call('submit') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('toggles provider enabled state from the action button', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('toggleProvider', 'authentik') + ->assertHasNoErrors(); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); +}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 45e150dfab..272156fbd2 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'bash -se' << \\") + ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php new file mode 100644 index 0000000000..d8ccf86510 --- /dev/null +++ b/tests/Feature/UserSeederTest.php @@ -0,0 +1,16 @@ +seed(UserSeeder::class); + + $user = User::factory()->create(); + + expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) + ->and($user->id)->toBe(3); +}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php new file mode 100644 index 0000000000..d8050c84d9 --- /dev/null +++ b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php @@ -0,0 +1,62 @@ +invoke(new InstallPrerequisites); + + expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); +}); + +it('installs every Docker CLI plugin required on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); + + $command = $method->invoke(new InstallDocker); + + expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); +}); + +it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); + + $action = new InstallDocker; + $commands = $method->invoke($action, true); + + expect($commands) + ->toBe(['rc-update add docker default', 'rc-service docker restart']) + ->each->not->toContain('systemctl') + ->and($method->invoke($action, false)) + ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); +}); + +it('parses Alpine package updates', function () { + $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); + $output = <<<'OUTPUT' +docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] +libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] +OUTPUT; + + $result = $method->invoke(new CheckUpdates, $output); + + expect($result)->toBe([ + 'total_updates' => 2, + 'updates' => [ + [ + 'package' => 'docker-cli-compose', + 'new_version' => '2.31.0-r5', + 'architecture' => 'x86_64', + 'current_version' => '2.31.0-r4', + ], + [ + 'package' => 'libcrypto3', + 'new_version' => '3.3.4-r0', + 'architecture' => 'aarch64', + 'current_version' => '3.3.3-r0', + ], + ], + ]); +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index afad0593f3..1b106a5a82 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -296,7 +296,7 @@ it('accepts the historical environment sorting default in older snapshots', func expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); }); -it('detects environment variable value changes without exposing secret values', function () { +it('detects environment variable value changes for unlocked variables', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ 'key' => 'API_TOKEN', @@ -315,13 +315,13 @@ it('detects environment variable value changes without exposing secret values', $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBe('Changed') - ->and($change['old_display_value'])->toBe('••••••••') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); + ->and($change['display_summary'])->toBeNull() + ->and($change['old_display_value'])->toBe('old-secret') + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); }); -it('describes added environment variables as set without exposing secret values', function () { +it('describes added unlocked environment variables with their value', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -342,6 +342,6 @@ it('describes added environment variables as set without exposing secret values' expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php new file mode 100644 index 0000000000..48fb50c375 --- /dev/null +++ b/tests/Unit/OauthSettingTest.php @@ -0,0 +1,30 @@ + 'oidc']); + expect($setting->couldBeEnabled())->toBeFalse(); + + $setting->fill([ + 'client_id' => 'client-id', + 'client_secret' => 'secret', + 'base_url' => 'https://idp.example.com', + ]); + + expect($setting->couldBeEnabled())->toBeTrue(); +}); + +it('returns configured scopes and custom login label', function () { + $setting = new OauthSetting([ + 'provider' => 'oidc', + 'scopes' => 'openid email profile groups', + 'custom_label' => 'Login with Okta', + ]); + + expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->loginLabel())->toBe('Login with Okta'); +}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php new file mode 100644 index 0000000000..18c358fd13 --- /dev/null +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -0,0 +1,119 @@ + Http::response([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]), + 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), + ]); + + $service = app(OidcDiscoveryService::class); + + $discovery = $service->discover('https://idp.example.com'); + $jwks = $service->jwks($discovery->jwksUri); + + expect($discovery->issuer)->toBe('https://idp.example.com') + ->and($jwks['keys'][0]['kid'])->toBe('one'); + + Http::assertSentCount(2); + + $service->discover('https://idp.example.com'); + $service->jwks('https://idp.example.com/jwks'); + + Http::assertSentCount(2); +}); + +it('does not cache discovery documents with mismatched issuers', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') + ->push([ + 'issuer' => 'https://evil.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]) + ->push([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]); + + $service = app(OidcDiscoveryService::class); + $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); + + expect(fn () => $service->discover('https://idp.example.com')) + ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') + ->and(Cache::has($cacheKey))->toBeFalse() + ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); + + Http::assertSentCount(2); +}); + +it('refetches jwks once on forced refresh to pick up rotated keys', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/jwks') + ->push(['keys' => [['kid' => 'old']]]) + ->push(['keys' => [['kid' => 'new']]]); + + $service = app(OidcDiscoveryService::class); + + expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); + + // Forced refresh bypasses the cache and sees the rotated key. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); + + // Cooldown prevents a second immediate upstream fetch; cached value returned. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); +}); + +it('rejects invalid discovery and jwks payloads', function () { + Cache::flush(); + Http::fake([ + 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), + ]); + + app(OidcDiscoveryService::class)->discover('https://bad.example.com'); +})->throws(OidcDiscoveryException::class); + +it('rejects jwks responses without keys', function () { + Cache::flush(); + Http::fake([ + 'https://idp.example.com/jwks' => Http::response(['empty' => true]), + ]); + + app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); +})->throws(OidcJwksException::class); + +it('rejects non-https issuer urls', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->discover('http://idp.example.com'); +})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); + +it('rejects non-https jwks uris', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); +})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php new file mode 100644 index 0000000000..b92ff58ffe --- /dev/null +++ b/tests/Unit/OidcProviderPkceTest.php @@ -0,0 +1,148 @@ +getAuthUrl($state); + } +} + +function oidc_provider_discovery_document(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +function oidc_provider_session(): Store +{ + $session = new Store('testing', new ArraySessionHandler(1200)); + $session->start(); + + return $session; +} + +function oidc_provider_request(Store $session, string $state = 'state-value'): Request +{ + $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); + $request->setLaravelSession($session); + + return $request; +} + +function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl +{ + /** @var OidcDiscoveryService&MockInterface $discoveryService */ + $discoveryService = Mockery::mock(OidcDiscoveryService::class); + $discoveryService->shouldReceive('discover') + ->byDefault() + ->with('https://idp.example.com') + ->andReturn(oidc_provider_discovery_document()); + + /** @var OidcTokenValidator&MockInterface $tokenValidator */ + $tokenValidator = Mockery::mock(OidcTokenValidator::class); + + return (new TestOidcProviderWithExposedAuthUrl( + $request, + $discoveryService, + $tokenValidator, + 'client-id', + 'client-secret', + 'https://coolify.example.com/auth/oidc/callback', + ))->setConfig(new OidcConfig( + issuerUrl: 'https://idp.example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://coolify.example.com/auth/oidc/callback', + usePkce: true, + )); +} + +it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { + Carbon::setTestNow('2026-06-15 12:00:00'); + + try { + $session = oidc_provider_session(); + $provider = oidc_provider(oidc_provider_request($session)); + + $provider->authUrlForState('state-value'); + + $nonceEntry = $session->get('oidc.nonce.state-value'); + $verifierEntry = $session->get('oidc.code_verifier.state-value'); + + expect($nonceEntry)->toBeArray() + ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() + ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) + ->and($verifierEntry)->toBeArray() + ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() + ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); + } finally { + Carbon::setTestNow(); + } +}); + +it('sends a fresh oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'fresh-verifier', + 'expires_at' => now()->addMinute()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); + + parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); + + expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') + ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); +}); + +it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'expired-verifier', + 'expires_at' => now()->subSecond()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); +})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php new file mode 100644 index 0000000000..9b1d9a24c3 --- /dev/null +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -0,0 +1,187 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($privateKey, $privatePem); + $details = openssl_pkey_get_details($privateKey); + + return [ + 'private_pem' => $privatePem, + 'jwks' => [ + 'keys' => [[ + 'kty' => 'RSA', + 'kid' => $kid, + 'alg' => 'RS256', + 'use' => 'sig', + 'n' => oidc_base64url($details['rsa']['n']), + 'e' => oidc_base64url($details['rsa']['e']), + ]], + ], + ]; +} + +function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string +{ + $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); + $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); + $signatureInput = $header.'.'.$payload; + openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); + + return $signatureInput.'.'.oidc_base64url($signature); +} + +function oidc_discovery(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +it('validates a well formed RS256 id token', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + 'email' => 'User@Example.com', + ], $keyset['private_pem']); + + $claims = app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); + + expect($claims['sub'])->toBe('okta-user-1') + ->and($claims['email'])->toBe('User@Example.com'); +}); + +it('rejects invalid token claims', function (array $claimOverrides, string $message) { + $keyset = oidc_keyset(); + $now = time(); + $claims = array_merge([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $claimOverrides); + + $token = oidc_token($claims, $keyset['private_pem']); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], + 'audience mismatch' => [['aud' => 'other-client'], 'audience'], + 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], + 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], + 'expired token' => [['exp' => time() - 3600], 'expired'], + 'future issued at' => [['iat' => time() + 3600], 'issued'], + 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], + 'missing subject' => [['sub' => null], 'subject'], + 'empty subject' => [['sub' => ''], 'subject'], + 'non-string subject' => [['sub' => 123], 'subject'], +]); + +it('rejects a bad signature and unknown key id', function (string $kid) { + $keyset = oidc_keyset('test-key'); + $otherKeyset = oidc_keyset($kid); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $otherKeyset['private_pem'], $kid); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'same kid with bad signature' => ['test-key'], + 'unknown kid' => ['other-key'], +]); + +it('rejects disallowed algorithms', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem'], algorithm: 'HS256'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); + +it('throws a dedicated exception when the signing key is unknown', function () { + $keyset = oidc_keyset('current-key'); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => time(), + 'exp' => time() + 600, + ], $keyset['private_pem'], 'rotated-key'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcSigningKeyNotFoundException::class); + +it('rejects a jwks key not designated for signing', function () { + $keyset = oidc_keyset(); + $keyset['jwks']['keys'][0]['use'] = 'enc'; + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem']); + + // An encryption-only key is dropped from the keyset, so the kid no longer resolves. + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index d2d4ae600f..4dedc7a768 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase ); } + public function test_remote_shell_prefers_bash_and_falls_back_to_sh() + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); + + $this->assertSame( + 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', + $reflection->invoke(null) + ); + } + public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 7a73f59795..4a275ad484 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,6 +4,7 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -18,7 +19,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -70,6 +71,21 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); +test('delete succeeds without password for an oauth user', function () { + OauthIdentity::create([ + 'user_id' => $this->user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oauth-user-id', + ]); + + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', '') + ->assertHasNoErrors(); + + expect(Application::find($this->application->id))->toBeNull(); +}); + test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From 58861227e0b1fea7641c3cd0e4283e5c75960b30 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:07:07 +0200 Subject: [PATCH 26/86] fix(ci): allow RC workflow to revalidate prereleases --- .github/workflows/coolify-rc-release.yml | 2 +- tests/Unit/ProductionImageWorkflowTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coolify-rc-release.yml b/.github/workflows/coolify-rc-release.yml index 90005a97d2..d7f4dc830f 100644 --- a/.github/workflows/coolify-rc-release.yml +++ b/.github/workflows/coolify-rc-release.yml @@ -165,7 +165,7 @@ jobs: needs: [validate, build] runs-on: ubuntu-24.04 permissions: - contents: read + contents: write steps: - name: Revalidate draft prerelease uses: actions/github-script@v8 diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 5fcada3091..659d233122 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -141,6 +141,7 @@ it('requires a reviewed draft prerelease before publishing an exact rc', functio ->toContain('!release.prerelease') ->toContain('release.body?.trim()') ->toContain('revalidate:') + ->toMatch('/revalidate:.*?permissions:\s+contents: write/s') ->toContain('needs: [validate, build, revalidate]') ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}') ->toContain('--tag "${IMAGE}:${VERSION}"') From 937892b3ea5dfb7885ab36858c233090e6ec56fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:15:15 +0200 Subject: [PATCH 27/86] fix(storage): guard volume names and authorize source removal --- app/Livewire/Project/Service/Storage.php | 7 +++---- .../views/livewire/project/shared/storages/all.blade.php | 1 + tests/Feature/PersistentStorageVolumesLayoutTest.php | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index bb8a39d2b1..6880b5ab09 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -374,10 +374,9 @@ class Storage extends Component private function generateDefaultVolumeName(): string { - return str($this->resource->name ?? 'volume') - ->slug() - ->append('-data') - ->value(); + $name = str($this->resource->name)->slug()->value(); + + return ($name ?: 'volume').'-data'; } public function fileStoragePreviewPath(): string diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 09377dac91..dbe21fd7b8 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -160,6 +160,7 @@
not->toContain('Swarm Mode detected') ->and($volumesView) + ->toMatch('/]*canGate="update"[^>]*:canResource="\$resource"/') ->toContain('The next deployment will use a named Docker volume instead.') ->toContain('Data from the existing host directory will not be copied to the named volume.'); }); @@ -242,6 +243,13 @@ it('uses a resource based default name for new volumes', function () { ->assertSet('name', 'storage-app-data'); }); +it('uses a valid fallback default volume name when the resource name has no slug characters', function () { + [$application] = createApplicationWithVolume(['name' => '---']); + + Livewire::test(Storage::class, ['resource' => $application]) + ->assertSet('name', 'volume-data'); +}); + it('removes existing bind mount source paths from the volume table', function () { [$application, $volume] = createApplicationWithVolume(volumeAttributes: [ 'host_path' => '/srv/storage', From 63d22f13319e3a0e5bf34cc2a7961d286d624d6d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:49:14 +0200 Subject: [PATCH 28/86] chore: remove legacy Dusk tests and configure maintenance driver --- .env.testing | 1 + app/Providers/DuskServiceProvider.php | 21 --- composer.json | 1 - composer.lock | 142 +------------------- config/app.php | 4 +- tests/Browser/LoginTest.php | 27 ---- tests/Browser/Project/ProjectAddNewTest.php | 34 ----- tests/Browser/Project/ProjectSearchTest.php | 29 ---- tests/Browser/Project/ProjectTest.php | 27 ---- tests/Browser/console/.gitignore | 2 - tests/Browser/source/.gitignore | 2 - tests/DuskTestCase.php | 57 -------- 12 files changed, 4 insertions(+), 343 deletions(-) delete mode 100644 app/Providers/DuskServiceProvider.php delete mode 100644 tests/Browser/LoginTest.php delete mode 100644 tests/Browser/Project/ProjectAddNewTest.php delete mode 100644 tests/Browser/Project/ProjectSearchTest.php delete mode 100644 tests/Browser/Project/ProjectTest.php delete mode 100644 tests/Browser/console/.gitignore delete mode 100644 tests/Browser/source/.gitignore delete mode 100644 tests/DuskTestCase.php diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/composer.json b/composer.json index 18e125510d..c0ffc6f07f 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index 18c5260f32..b77aef46f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d511da9e5e82eade5aa7e5094c888ae", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php deleted file mode 100644 index d20e652946..0000000000 --- a/tests/Browser/LoginTest.php +++ /dev/null @@ -1,27 +0,0 @@ -browse(callback: function (Browser $browser) { - $browser->loginWithRootUser() - ->assertPathIs('/') - ->assertSee('Dashboard'); - }); - } -} diff --git a/tests/Browser/Project/ProjectAddNewTest.php b/tests/Browser/Project/ProjectAddNewTest.php deleted file mode 100644 index b03313e4b0..0000000000 --- a/tests/Browser/Project/ProjectAddNewTest.php +++ /dev/null @@ -1,34 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->pressAndWaitFor('+ Add', 1) - ->assertSee('New Project') - ->screenshot('project-add-new-1') - ->type('name', 'Test Project') - ->screenshot('project-add-new-2') - ->press('Continue') - ->assertSee('Test Project.') - ->screenshot('project-add-new-3'); - }); - } -} diff --git a/tests/Browser/Project/ProjectSearchTest.php b/tests/Browser/Project/ProjectSearchTest.php deleted file mode 100644 index 7bc6796d10..0000000000 --- a/tests/Browser/Project/ProjectSearchTest.php +++ /dev/null @@ -1,29 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->type('[x-model="search"]', 'joi43j4oi32j4o2') - ->assertSee('No project found with the search term "joi43j4oi32j4o2".') - ->screenshot('project-search-not-found'); - }); - } -} diff --git a/tests/Browser/Project/ProjectTest.php b/tests/Browser/Project/ProjectTest.php deleted file mode 100644 index 0d360e4604..0000000000 --- a/tests/Browser/Project/ProjectTest.php +++ /dev/null @@ -1,27 +0,0 @@ -browse(function (Browser $browser) { - $browser->loginWithRootUser() - ->visit('/projects') - ->assertSee('Projects'); - }); - } -} diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/Browser/console/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore deleted file mode 100644 index d6b7ef32c8..0000000000 --- a/tests/Browser/source/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php deleted file mode 100644 index 98e90fa79c..0000000000 --- a/tests/DuskTestCase.php +++ /dev/null @@ -1,57 +0,0 @@ -addArguments(collect([ - $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', - ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { - return $items->merge([ - '--disable-gpu', - '--headless=new', - ]); - })->all()); - - return RemoteWebDriver::create( - 'http://localhost:4444', - DesiredCapabilities::chrome()->setCapability( - ChromeOptions::CAPABILITY, - $options - ) - ); - } - - /** - * Determine if the browser window should start maximized. - */ - protected function baseUrl() - { - return 'http://localhost:8000'; - } -} From 13a577a731bebf03441f330acdf06db2d0b34977 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:05:08 +0200 Subject: [PATCH 29/86] fix: align resource access checks --- .../Controllers/Api/ProjectController.php | 2 + .../Controllers/Api/ServersController.php | 6 +- .../Project/Shared/ScheduledTask/Add.php | 9 +- .../Server/DockerCleanupExecutions.php | 5 +- .../Feature/ResourceAccessConsistencyTest.php | 120 ++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/ResourceAccessConsistencyTest.php diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 64bf26c1bb..eb137c5349 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -158,6 +158,8 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); if (! $environment) { $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index d50a5226a9..f7966c71f1 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -550,11 +550,7 @@ class ServersController extends Controller } $foundServer = ModelsServer::whereIp($request->ip)->first(); if ($foundServer) { - if ($foundServer->team_id === $teamId) { - return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); - } - - return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); + return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 2d6b76c25f..61bc6b0fbc 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; +use App\Models\Service; +use App\Models\StandalonePostgresql; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -59,13 +62,13 @@ class Add extends Component // Get the resource based on type and id switch ($this->type) { case 'application': - $this->resource = \App\Models\Application::findOrFail($this->id); + $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id); break; case 'service': - $this->resource = \App\Models\Service::findOrFail($this->id); + $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id); break; case 'standalone-postgresql': - $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id); break; default: throw new \Exception('Invalid resource type'); diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 56d6130644..6a739bc84c 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Models\DockerCleanupExecution; use App\Models\Server; use Illuminate\Support\Collection; use Livewire\Component; @@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component ->get(); if ($this->selectedKey) { - $this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey); if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { $this->isPollingActive = false; } @@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = DockerCleanupExecution::find($key); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key); $this->currentPage = 1; if ($this->selectedExecution && $this->selectedExecution->status === 'running') { diff --git a/tests/Feature/ResourceAccessConsistencyTest.php b/tests/Feature/ResourceAccessConsistencyTest.php new file mode 100644 index 0000000000..2511e14342 --- /dev/null +++ b/tests/Feature/ResourceAccessConsistencyTest.php @@ -0,0 +1,120 @@ + 'file']); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'IDOR test key', + 'private_key' => 'test-private-key', + 'team_id' => $this->team->id, + ])); + + $token = $this->user->createToken('idor-hardening', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $token->plainTextToken; +}); + +test('server creation returns a consistent duplicate address response', function () { + $ownServer = Server::factory()->create([ + 'ip' => '192.0.2.10', + 'team_id' => $this->team->id, + ]); + $otherServer = Server::factory()->create([ + 'ip' => '192.0.2.20', + 'team_id' => $this->otherTeam->id, + ]); + + $payload = fn (Server $server): array => [ + 'name' => 'Duplicate server', + 'ip' => $server->ip, + 'private_key_uuid' => $this->privateKey->uuid, + 'user' => 'root', + ]; + + $ownResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($ownServer)); + $otherResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($otherServer)); + + $ownResponse->assertBadRequest(); + $otherResponse->assertBadRequest(); + expect($ownResponse->json('message')) + ->toBe('A server with this IP/Domain is already in use.') + ->toBe($otherResponse->json('message')); +}); + +test('environment details applies the project view policy', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + Gate::before(fn (User $user, string $ability): ?bool => $ability === 'view' ? false : null); + + $this->withToken($this->token) + ->getJson("/api/v1/projects/{$project->uuid}/{$environment->uuid}") + ->assertForbidden(); +}); + +test('docker cleanup execution selection only uses the mounted server', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $otherExecution = DockerCleanupExecution::create([ + 'server_id' => $otherServer->id, + 'status' => 'success', + 'message' => 'other team cleanup output', + ]); + + Livewire::test(DockerCleanupExecutions::class, ['server' => $server]) + ->call('selectExecution', $otherExecution->id) + ->assertSet('selectedExecution', null); +}); + +test('scheduled task form only mounts applications from the current team', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(Add::class, [ + 'id' => (string) $application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); +})->throws(ModelNotFoundException::class); From a4886f6dfbe9e0a4405454a44dd3f057b536c297 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:29:03 +0200 Subject: [PATCH 30/86] feat(ui): add copy icon --- resources/views/components/reicon.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 04471497f5..a46e4194cf 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,6 +63,7 @@ 'upload' => '', 'x' => '', 'check' => '', + 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', From 4ee59442cef08918aaf5fdc3d99aac0157ace7f4 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:33:31 +0200 Subject: [PATCH 31/86] feat(ui): add shared copy button component --- resources/js/app.js | 2 ++ resources/js/copy-button.js | 35 +++++++++++++++++++ .../views/components/copy-button.blade.php | 28 ++++++--------- 3 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 resources/js/copy-button.js diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..900ef8af71 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +1,4 @@ +import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate @@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); +document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js new file mode 100644 index 0000000000..0ce8d5d67d --- /dev/null +++ b/resources/js/copy-button.js @@ -0,0 +1,35 @@ +// Alpine data provider for the component (x-data="copyButton"). +export function initializeCopyButtonComponent() { + window.Alpine.data('copyButton', () => ({ + copied: false, + async copy(value) { + if (value === null || value === undefined) { + window.toast('Value is not available.', { type: 'warning' }); + return; + } + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + // Deprecated, but the only copy path on plain http (non-secure contexts). + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command was rejected.'); + } + } + this.copied = true; + setTimeout(() => (this.copied = false), 1200); + } catch (e) { + window.toast('Could not copy to clipboard.', { type: 'warning' }); + } + }, + })); +} diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index dfdceef20b..a266a272d8 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,22 +1,16 @@ @props([ - 'value', + 'value' => null, + 'resolve' => null, 'label' => 'Copy to clipboard', ]) - From 8757cd268657630f0f1919e978d8338098e38272 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:25:07 +0200 Subject: [PATCH 32/86] test: update tests for the new copy button component --- tests/Feature/CopyButtonComponentTest.php | 38 ++++++++++++++++--- .../Feature/ResourceDetailsVisibilityTest.php | 14 +++---- tests/Feature/TeamInvitationUiTest.php | 14 ++----- 3 files changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/Feature/CopyButtonComponentTest.php b/tests/Feature/CopyButtonComponentTest.php index a9996a062e..7177da08e8 100644 --- a/tests/Feature/CopyButtonComponentTest.php +++ b/tests/Feature/CopyButtonComponentTest.php @@ -1,16 +1,42 @@ blade(''); $html->assertSee('Copy backup path') ->assertSee('backup\/path.sql', false) - ->assertSee('window.copyToClipboard', false) - ->assertSee('size-6', false); + ->assertSee('x-data="copyButton"', false) + ->assertDontSee('window.copyToClipboard', false); }); -it('uses the reusable copy button for database backup paths', function () { - $view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php')); +it('disables the button when no backend value is available', function () { + $html = $this->blade(''); - expect($view)->toContain(''); + $html->assertSee('disabled', false); +}); + +it('evaluates a resolve expression at click time instead of a static value', function () { + $html = $this->blade(''); + + $html->assertSee('await ($wire.copyValue())', false) + ->assertDontSee('disabled', false); +}); + +it('is the single clipboard implementation shared by its call sites', function () { + expect(file_get_contents(resource_path('js/copy-button.js'))) + ->toContain("window.Alpine.data('copyButton'"); + + expect(file_get_contents(resource_path('js/app.js'))) + ->toContain('initializeCopyButtonComponent'); + + $modalConfirmation = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + $backupExecutions = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php')); + + expect($modalConfirmation) + ->toContain('not->toContain('navigator.clipboard'); + + expect($backupExecutions) + ->toContain('not->toContain('navigator.clipboard'); }); diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 29f611cbaa..4cac570f7e 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -27,7 +27,7 @@ it('keeps the resource details helper text visible below the modal header', func ])->render(); expect($html) - ->toContain('Identifiers for this resource. Read-only') + ->toContain('readonly') ->toContain('pt-1') ->not->toContain('-mt-4'); }); @@ -38,20 +38,18 @@ it('renders copy fields as visible readonly controls with an accessible copy act expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') ->toContain('readonly') - ->toContain('window.copyToClipboard') + ->toContain('x-data="copyButton"') ->toContain('input-with-copy-button') - ->toContain('copy-button') ->toContain('aria-label="Copy to clipboard"') - ->toContain('title="Copy to clipboard"') - ->toContain('class="size-[18px] text-green-500"'); + ->toContain('title="Copy to clipboard"'); }); -it('uses the shared copy field for newly issued api tokens', function () { +it('uses the shared copy button for newly issued api tokens', function () { $blade = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php')); expect($blade) - ->toContain('') - ->not->toContain('navigator.clipboard.writeText(@js(session(\'token\')))'); + ->toContain('not->toContain('navigator.clipboard'); }); it('keeps copy button padding above settings-workspace input overrides', function () { diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 80a4a146df..38c8910986 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,25 +51,19 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain('aria-label="Copy invitation link"') - ->toContain('window.copyToClipboard(@js($invite->link))') - ->toContain('class="button h-7! shrink-0 px-2!"'); + ->toContain(''); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('window.copyToClipboard(') + ->assertSeeHtml('x-data="copyButton"') ->assertSeeHtml('type="button"'); }); -it('exposes a resilient global copyToClipboard helper', function () { +it('keeps clipboard logic in the shared copy button instead of a global helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout) - ->toContain('async function copyToClipboard(text)') - ->toContain('window.copyToClipboard = copyToClipboard') - ->toContain('document.execCommand(\'copy\')') - ->toContain('window.isSecureContext'); + expect($layout)->not->toContain('copyToClipboard'); }); From bd6398e649dca7bf15075a14e9edc9f2840608f9 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:16:56 +0200 Subject: [PATCH 33/86] chore: remove global copyToClipboard helper --- resources/views/layouts/base.blade.php | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a97d8c1df7..82b8cbcbdb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,30 +225,6 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; - async function copyToClipboard(text) { - try { - if (navigator.clipboard?.writeText && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('Copy command was rejected.'); - } - } - window.Livewire.dispatch('success', 'Copied to clipboard.'); - } catch (error) { - window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); - } - } - window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { From 0e35eb2aa3e62a99109127aa3818efbb8af9a20c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:20:38 +0200 Subject: [PATCH 34/86] feat(ui): use the shared copy button component everywhere --- .../components/forms/copy-button.blade.php | 17 +------- .../components/modal-confirmation.blade.php | 13 +----- .../shared/partials/dns-copy-cell.blade.php | 41 +------------------ .../livewire/security/api-tokens.blade.php | 7 +++- .../views/livewire/team/invitations.blade.php | 9 +--- 5 files changed, 13 insertions(+), 74 deletions(-) diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php index e299610eb2..d31fac0bca 100644 --- a/resources/views/components/forms/copy-button.blade.php +++ b/resources/views/components/forms/copy-button.blade.php @@ -1,6 +1,6 @@ @props(['text', 'label' => null]) -
+
@if ($label) @endif @@ -10,19 +10,6 @@ readonly @keydown.prevent @paste.prevent @cut.prevent @drop.prevent @focus="$event.target.select()"> - +
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0e4350f50f..f9b8bd98de 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,17 +287,8 @@
- +
diff --git a/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php b/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php index 7394cbd8bd..be1cbf4ca7 100644 --- a/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php +++ b/resources/views/livewire/project/shared/partials/dns-copy-cell.blade.php @@ -2,44 +2,7 @@ $break = $break ?? false; $label = $label ?? 'Copy'; @endphp -
+
$break])>{{ $text }} - +
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 38db6aa3a6..80647458df 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,7 +109,12 @@ @if (session()->has('token')) - +
+ + +
@endif diff --git a/resources/views/livewire/team/invitations.blade.php b/resources/views/livewire/team/invitations.blade.php index e777ae058e..f9a3f2442e 100644 --- a/resources/views/livewire/team/invitations.blade.php +++ b/resources/views/livewire/team/invitations.blade.php @@ -29,14 +29,7 @@ {{ $invite->link }} - +
From eb9a422d9b19e850c2dcbf6869e9e68d5f60af97 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:27:57 +0200 Subject: [PATCH 36/86] refactor(ui): rename forms.copy-button to forms.copy-input --- ...-button.blade.php => copy-input.blade.php} | 0 .../views/livewire/profile/index.blade.php | 4 ++-- .../application/internal-access.blade.php | 8 ++++---- .../project/shared/resource-details.blade.php | 20 +++++++++---------- .../volume-backups/executions.blade.php | 2 +- .../project/shared/webhooks.blade.php | 6 +++--- .../server/ca-certificate/show.blade.php | 2 +- .../PersistentStorageVolumesLayoutTest.php | 2 +- .../Feature/ResourceDetailsVisibilityTest.php | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) rename resources/views/components/forms/{copy-button.blade.php => copy-input.blade.php} (100%) diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-input.blade.php similarity index 100% rename from resources/views/components/forms/copy-button.blade.php rename to resources/views/components/forms/copy-input.blade.php diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 33f1b9a98e..da5329a475 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -257,9 +257,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 8ab1442ba5..6997b766b8 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 2e92c73146..1a032f6964 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 784843f6f0..40ea7b7e09 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index c8c42763fa..6c87e3098d 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 94d2050dc2..2279e62e39 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index 99133d7945..d906cfb6a1 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -184,7 +184,7 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/'); expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'))) ->toContain('Time') - ->toContain('x-forms.copy-button') + ->toContain('x-forms.copy-input') ->toContain('col-span-6'); $css = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/ResourceDetailsVisibilityTest.php b/tests/Feature/ResourceDetailsVisibilityTest.php index 4cac570f7e..e11c86fe99 100644 --- a/tests/Feature/ResourceDetailsVisibilityTest.php +++ b/tests/Feature/ResourceDetailsVisibilityTest.php @@ -33,7 +33,7 @@ it('keeps the resource details helper text visible below the modal header', func }); it('renders copy fields as visible readonly controls with an accessible copy action', function () { - $html = Blade::render(''); + $html = Blade::render(''); expect($html) ->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"') From afbe4d6fd79ad6fbf70acc32e902ccc9932f0f0e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:13:23 +0200 Subject: [PATCH 37/86] feat(var): add environment variable copy functionality --- .../Shared/EnvironmentVariable/Show.php | 16 ++ .../EnvironmentVariable/ShowHardcoded.php | 19 +++ app/Models/EnvironmentVariable.php | 17 ++ .../shared/environment-variable/all.blade.php | 3 +- .../EnvironmentVariableCopyValueTest.php | 151 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..633c8f04dc 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,6 +161,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..70c9013af2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 923514efcc..87ecd69985 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,7 +219,8 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" + :resourceableType="get_class($resource)" :resourceableId="$resource->id" /> @endif @endforeach
diff --git a/tests/Feature/EnvironmentVariableCopyValueTest.php b/tests/Feature/EnvironmentVariableCopyValueTest.php new file mode 100644 index 0000000000..b12105ea84 --- /dev/null +++ b/tests/Feature/EnvironmentVariableCopyValueTest.php @@ -0,0 +1,151 @@ + 0]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function createEnvironmentVariable(array $attributes = []): EnvironmentVariable +{ + return EnvironmentVariable::create(array_merge([ + 'key' => 'API_KEY', + 'value' => 'secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => test()->application->id, + ], $attributes)); +} + +function assertCopiedValue(EnvironmentVariable|SharedEnvironmentVariable $env, ?string $expected): void +{ + Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('copyValue') + ->assertReturned($expected); +} + +function assertCopiedComposeValue(string $value, ?string $expected): void +{ + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => $value], + 'resourceableType' => Application::class, + 'resourceableId' => test()->application->id, + ]) + ->call('copyValue') + ->assertReturned($expected); +} + +test('copies the plain value', function () { + assertCopiedValue(createEnvironmentVariable(), 'secret-value'); +}); + +test('copies the referenced variable value instead of the reference', function (string $reference) { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedValue(createEnvironmentVariable(['key' => 'MYSQL_USER', 'value' => $reference]), 'classicpress-user'); +})->with(['bare' => '$SERVICE_USER_CLASSICPRESS', 'braced' => '${SERVICE_USER_CLASSICPRESS}']); + +test('copies the resolved shared variable value', function () { + SharedEnvironmentVariable::create([ + 'key' => 'MY_SECRET', + 'value' => 'resolved-secret', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue(createEnvironmentVariable(['value' => '{{team.MY_SECRET}}']), 'resolved-secret'); +}); + +test('copies embedded, literal and unknown references as stored', function () { + createEnvironmentVariable(['key' => 'SERVICE_PASSWORD_MYSQL', 'value' => 'generated-password']); + + assertCopiedValue( + createEnvironmentVariable(['key' => 'DATABASE_URL', 'value' => 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306']), + 'mysql://root:$SERVICE_PASSWORD_MYSQL@db:3306', + ); + assertCopiedValue( + createEnvironmentVariable(['key' => 'LITERAL', 'value' => '$SERVICE_PASSWORD_MYSQL', 'is_literal' => true]), + '$SERVICE_PASSWORD_MYSQL', + ); + assertCopiedValue(createEnvironmentVariable(['key' => 'UNKNOWN', 'value' => '$DOES_NOT_EXIST']), '$DOES_NOT_EXIST'); +}); + +test('copies literal values without .env-style quoting', function () { + $env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]); + + expect($env->real_value)->toBe("'pa\$\$word'"); + assertCopiedValue($env, 'pa$$word'); +}); + +test('copies the value of a shared environment variable row', function () { + $shared = SharedEnvironmentVariable::create([ + 'key' => 'TEAM_WIDE', + 'value' => 'team-wide-value', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + assertCopiedValue($shared, 'team-wide-value'); +}); + +test('members get no copy button and no value', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Show::class, ['env' => createEnvironmentVariable(), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('locked variables get no copy button and no value', function () { + Livewire::test(Show::class, ['env' => createEnvironmentVariable(['is_shown_once' => true]), 'type' => 'application']) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); + +test('compose-managed rows copy the referenced variable value', function () { + createEnvironmentVariable(['key' => 'SERVICE_USER_CLASSICPRESS', 'value' => 'classicpress-user']); + + assertCopiedComposeValue('$SERVICE_USER_CLASSICPRESS', 'classicpress-user'); + assertCopiedComposeValue('production', 'production'); +}); + +test('compose-managed rows hide copying from members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(ShowHardcoded::class, [ + 'env' => ['key' => 'MYSQL_USER', 'value' => '$SERVICE_USER_CLASSICPRESS'], + 'resourceableType' => Application::class, + 'resourceableId' => $this->application->id, + ]) + ->assertDontSeeHtml('Copy value') + ->call('copyValue') + ->assertReturned(null); +}); From 0b811b5ef6b151844551b9c0442ed8127bd9d67e Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:58:34 +0200 Subject: [PATCH 38/86] feat(ui): add copy button to environment variable page --- .../shared/environment-variable/show-hardcoded.blade.php | 5 ++++- .../project/shared/environment-variable/show.blade.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 84d03c0fe8..5492d33f90 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,7 +28,10 @@ - - - -
+
+ @unless (auth()->user()?->isMember() ?? true) + + @endunless diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php new file mode 100644 index 0000000000..e299610eb2 --- /dev/null +++ b/resources/views/components/forms/copy-button.blade.php @@ -0,0 +1,28 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php deleted file mode 100644 index d31fac0bca..0000000000 --- a/resources/views/components/forms/copy-input.blade.php +++ /dev/null @@ -1,15 +0,0 @@ -@props(['text', 'label' => null]) - -
- @if ($label) - - @endif -
- - -
-
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index d0778dce4f..d63c1953f2 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,8 +287,17 @@
- +
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index a46e4194cf..04471497f5 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,7 +63,6 @@ 'upload' => '', 'x' => '', 'check' => '', - 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index a17b0b96a6..d2b3e30a6f 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,12 +12,6 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, - auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ - 'label' => 'Integration Tokens', - 'route' => 'security.integration-tokens', - 'active' => request()->routeIs('security.integration-tokens'), - 'icon' => 'network', - ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index dbe381e050..0e0de551fd 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,24 +12,6 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], - [ - 'label' => 'Authentication', - 'route' => 'settings.oauth', - 'active' => $activeMenu === 'oauth', - 'icon' => 'keys', - ], - [ - 'label' => 'Transactional Email', - 'route' => 'settings.email', - 'active' => $activeMenu === 'email', - 'icon' => 'notifications', - ], - [ - 'label' => 'Instance Backup', - 'route' => 'settings.backup', - 'active' => $activeMenu === 'backup', - 'icon' => 'database', - ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 82b8cbcbdb..a97d8c1df7 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,6 +225,30 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; + async function copyToClipboard(text) { + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!copied) { + throw new Error('Copy command was rejected.'); + } + } + window.Livewire.dispatch('success', 'Copied to clipboard.'); + } catch (error) { + window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); + } + } + window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index da5329a475..ef54d3e215 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,22 +134,15 @@
+ x-bind:disabled="emailModalOpen"> Change
-
- + + - @if ($uses_sso) - - Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider. - - @endif - - @if (! $uses_sso) -
@@ -257,9 +249,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 6997b766b8..8ab1442ba5 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 42ade3da6e..81c19bd3f0 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,9 +116,25 @@

Mount a Docker volume inside the container.

+ @if ($isSwarm) +
Swarm Mode detected: You need to set a shared + volume + (EFS/NFS/etc) on all the worker nodes if you would like to use a + persistent + volumes.
+ @endif
+ @if ($isSwarm) + + @else + + @endif diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 87ecd69985..923514efcc 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,8 +219,7 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" /> @endif @endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 5492d33f90..84d03c0fe8 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,10 +28,7 @@ - - - -
- @unless (auth()->user()?->isMember() ?? true) - - @endunless +
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 1a032f6964..2e92c73146 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index dbe21fd7b8..25a4fd7492 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,24 +154,7 @@
Source Path - @if (filled($form['hostPath'])) -
-
- -
- -
- @else - - - @endif +
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 40ea7b7e09..784843f6f0 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index 6c87e3098d..c8c42763fa 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 80647458df..38db6aa3a6 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,12 +109,7 @@ @if (session()->has('token')) -
- - -
+
@endif diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php deleted file mode 100644 index b7e53dbc7c..0000000000 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ /dev/null @@ -1,52 +0,0 @@ -
-
-
- - -
- -
-
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
- - Create a replacement token in Cloudflare - -
- @endif - -
- - - Validate and save - -
-
-
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php deleted file mode 100644 index d847fff7fb..0000000000 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -
-
- - -
- - -
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
-

Limit zone resources to the zones Coolify should manage.

- - Create this token in Cloudflare - -
- @endif - -
- - Validate and add - -
- -
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php deleted file mode 100644 index b4961551ae..0000000000 --- a/resources/views/livewire/security/integration-tokens.blade.php +++ /dev/null @@ -1,84 +0,0 @@ -
- - Integration Tokens | Coolify - - - -
- - - @can('create', App\Models\IntegrationToken::class) - - - - - - - @endcan - - - @if ($tokens->isEmpty()) - - @else -
- @foreach ($tokens as $savedToken) -
- - -
-
-

- -

-
-
- {{ ucfirst($savedToken->provider) }} -
-
- -
- -
-
- -
-
- @endforeach -
- @endif -
-
-
-
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 2279e62e39..94d2050dc2 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index f1e4fc3f7a..d490b6f1db 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status - notifications can be managed from + Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications + can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 822c035b31..97822b9251 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,126 +5,76 @@ -
- -
+
+ +
-
+ @foreach ($oauth_settings_map as $oauth_setting) + @php + $provider = $oauth_setting['provider']; + $providerLabel = str($provider)->headline(); + @endphp - - - - - @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $providerLabel }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- @if ($provider === 'oidc') - - - - - - -
- -
- @else - - - - @endif + + + + @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif - -
- -
- @if ($provider === 'oidc') - - - - @endif -
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d05ac5ac98..d15a1b87ab 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,19 +13,12 @@
- - + ]" /> {{ $invite->link }} - +
', false); - - Livewire::test(SettingsOauth::class) - ->set('disable_registration_when_oauth_enabled', true) - ->call('saveRegistrationPolicy') - ->assertHasNoErrors() - ->assertDispatched('success'); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('shows oidc fields with a naked okta issuer url example', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('OpenID Connect') - ->assertSee('https://example.okta.com', false) - ->assertDontSee('/oauth2/default', false); -}); - -it('groups oidc fields in the expected desktop order', function () { - $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); - $fields = [ - 'redirect_uri', - 'base_url', - 'client_id', - 'client_secret', - 'scopes', - 'clock_skew_seconds', - 'custom_label', - ]; - $positions = array_map( - fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), - $fields, - ); - - expect($positions)->not->toContain(false) - ->and($positions)->toBe(collect($positions)->sort()->values()->all()) - ->and($view)->toContain('
'); -}); - -it('shows provider enable controls as settings section actions', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Enable') - ->assertDontSee('label="Enabled"', false) - ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); -}); - -it('stacks oidc option checkboxes vertically', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Allow OIDC user creation') - ->assertSee('Require verified email') - ->assertSee('Use PKCE') - ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); -}); - -it('does not show unknown oauth providers', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get('/settings/oauth/unknown') - ->assertNotFound(); -}); - -it('defaults oidc user creation and verified email requirement to enabled', function () { - $setting = OauthSetting::where('provider', 'oidc')->first(); - - expect($setting->allow_registration)->toBeTrue() - ->and($setting->require_email_verified)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeFalse(); -}); - -it('persists oidc oauth settings from livewire', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') - ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') - ->set('oauth_settings_map.oidc.allow_registration', true) - ->set('oauth_settings_map.oidc.auto_join_root_team', true) - ->set('oauth_settings_map.oidc.require_email_verified', true) - ->set('disable_registration_when_oauth_enabled', true) - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') - ->and($setting->base_url)->toBe('https://idp.example.com') - ->and($setting->custom_label)->toBe('Login with Okta') - ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->allow_registration)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeTrue(); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('saves only the selected provider from provider pages', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') - ->set('oauth_settings_map.authentik.enabled', true) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'authentik')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->client_id)->toBe('authentik-client') - ->and($setting->base_url)->toBe('https://authentik.example.com'); -}); - -it('validates oidc url fields before saving', function (string $field, string $value) { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set("oauth_settings_map.oidc.$field", $value) - ->call('submit') - ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->{$field})->toBeNull(); -})->with([ - 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], - 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], - 'invalid issuer url' => ['base_url', 'not-a-url'], - 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], -]); - -it('does not enable oidc without required fields', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->call('instantSave', 'oidc') - ->assertDispatched('error'); - - expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); -}); - -it('keeps provider disabled in the ui when enable validation fails', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->call('toggleProvider', 'authentik') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('disables an enabled provider gracefully when required fields become incomplete', function () { - actingAsInstanceAdmin(); - - OauthSetting::where('provider', 'authentik')->first()->forceFill([ - 'enabled' => true, - 'client_id' => 'authentik-client', - 'client_secret' => 'authentik-secret', - 'base_url' => 'https://authentik.example.com', - ])->save(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_secret', '') - ->call('submit') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('toggles provider enabled state from the action button', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('toggleProvider', 'authentik') - ->assertHasNoErrors(); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); -}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 272156fbd2..45e150dfab 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") + ->toContain("'bash -se' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 949a301923..13b6de23e5 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,21 +51,27 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain(''); + ->toContain('aria-label="Copy invitation link"') + ->toContain('window.copyToClipboard(@js($invite->link))') + ->toContain('class="button h-7! shrink-0 px-2!"'); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('x-data="copyButton"') + ->assertSeeHtml('window.copyToClipboard(') ->assertSeeHtml('type="button"'); }); -it('keeps clipboard logic in the shared copy button instead of a global helper', function () { +it('exposes a resilient global copyToClipboard helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout)->not->toContain('copyToClipboard'); + expect($layout) + ->toContain('async function copyToClipboard(text)') + ->toContain('window.copyToClipboard = copyToClipboard') + ->toContain('document.execCommand(\'copy\')') + ->toContain('window.isSecureContext'); }); it('preserves a provisional user when revoking their invitation fails', function () { diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php deleted file mode 100644 index d8ccf86510..0000000000 --- a/tests/Feature/UserSeederTest.php +++ /dev/null @@ -1,16 +0,0 @@ -seed(UserSeeder::class); - - $user = User::factory()->create(); - - expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) - ->and($user->id)->toBe(3); -}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php deleted file mode 100644 index d8050c84d9..0000000000 --- a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php +++ /dev/null @@ -1,62 +0,0 @@ -invoke(new InstallPrerequisites); - - expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); -}); - -it('installs every Docker CLI plugin required on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); - - $command = $method->invoke(new InstallDocker); - - expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); -}); - -it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); - - $action = new InstallDocker; - $commands = $method->invoke($action, true); - - expect($commands) - ->toBe(['rc-update add docker default', 'rc-service docker restart']) - ->each->not->toContain('systemctl') - ->and($method->invoke($action, false)) - ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); -}); - -it('parses Alpine package updates', function () { - $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); - $output = <<<'OUTPUT' -docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] -libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] -OUTPUT; - - $result = $method->invoke(new CheckUpdates, $output); - - expect($result)->toBe([ - 'total_updates' => 2, - 'updates' => [ - [ - 'package' => 'docker-cli-compose', - 'new_version' => '2.31.0-r5', - 'architecture' => 'x86_64', - 'current_version' => '2.31.0-r4', - ], - [ - 'package' => 'libcrypto3', - 'new_version' => '3.3.4-r0', - 'architecture' => 'aarch64', - 'current_version' => '3.3.3-r0', - ], - ], - ]); -}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 140be57643..b7901abb68 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values', $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBeNull() - ->and($change['old_display_value'])->toBe('old-secret') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); + ->and($change['display_summary'])->toBe('Changed') + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); }); -it('describes added unlocked environment variables with their value', function () { +it('describes added environment variables as set without exposing secret values', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -361,6 +361,6 @@ it('describes added unlocked environment variables with their value', function ( expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php deleted file mode 100644 index 48fb50c375..0000000000 --- a/tests/Unit/OauthSettingTest.php +++ /dev/null @@ -1,30 +0,0 @@ - 'oidc']); - expect($setting->couldBeEnabled())->toBeFalse(); - - $setting->fill([ - 'client_id' => 'client-id', - 'client_secret' => 'secret', - 'base_url' => 'https://idp.example.com', - ]); - - expect($setting->couldBeEnabled())->toBeTrue(); -}); - -it('returns configured scopes and custom login label', function () { - $setting = new OauthSetting([ - 'provider' => 'oidc', - 'scopes' => 'openid email profile groups', - 'custom_label' => 'Login with Okta', - ]); - - expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->loginLabel())->toBe('Login with Okta'); -}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php deleted file mode 100644 index 18c358fd13..0000000000 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ /dev/null @@ -1,119 +0,0 @@ - Http::response([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]), - 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), - ]); - - $service = app(OidcDiscoveryService::class); - - $discovery = $service->discover('https://idp.example.com'); - $jwks = $service->jwks($discovery->jwksUri); - - expect($discovery->issuer)->toBe('https://idp.example.com') - ->and($jwks['keys'][0]['kid'])->toBe('one'); - - Http::assertSentCount(2); - - $service->discover('https://idp.example.com'); - $service->jwks('https://idp.example.com/jwks'); - - Http::assertSentCount(2); -}); - -it('does not cache discovery documents with mismatched issuers', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') - ->push([ - 'issuer' => 'https://evil.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]) - ->push([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]); - - $service = app(OidcDiscoveryService::class); - $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); - - expect(fn () => $service->discover('https://idp.example.com')) - ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') - ->and(Cache::has($cacheKey))->toBeFalse() - ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); - - Http::assertSentCount(2); -}); - -it('refetches jwks once on forced refresh to pick up rotated keys', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/jwks') - ->push(['keys' => [['kid' => 'old']]]) - ->push(['keys' => [['kid' => 'new']]]); - - $service = app(OidcDiscoveryService::class); - - expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); - - // Forced refresh bypasses the cache and sees the rotated key. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); - - // Cooldown prevents a second immediate upstream fetch; cached value returned. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); -}); - -it('rejects invalid discovery and jwks payloads', function () { - Cache::flush(); - Http::fake([ - 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), - ]); - - app(OidcDiscoveryService::class)->discover('https://bad.example.com'); -})->throws(OidcDiscoveryException::class); - -it('rejects jwks responses without keys', function () { - Cache::flush(); - Http::fake([ - 'https://idp.example.com/jwks' => Http::response(['empty' => true]), - ]); - - app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); -})->throws(OidcJwksException::class); - -it('rejects non-https issuer urls', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->discover('http://idp.example.com'); -})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); - -it('rejects non-https jwks uris', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); -})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php deleted file mode 100644 index b92ff58ffe..0000000000 --- a/tests/Unit/OidcProviderPkceTest.php +++ /dev/null @@ -1,148 +0,0 @@ -getAuthUrl($state); - } -} - -function oidc_provider_discovery_document(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -function oidc_provider_session(): Store -{ - $session = new Store('testing', new ArraySessionHandler(1200)); - $session->start(); - - return $session; -} - -function oidc_provider_request(Store $session, string $state = 'state-value'): Request -{ - $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); - $request->setLaravelSession($session); - - return $request; -} - -function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl -{ - /** @var OidcDiscoveryService&MockInterface $discoveryService */ - $discoveryService = Mockery::mock(OidcDiscoveryService::class); - $discoveryService->shouldReceive('discover') - ->byDefault() - ->with('https://idp.example.com') - ->andReturn(oidc_provider_discovery_document()); - - /** @var OidcTokenValidator&MockInterface $tokenValidator */ - $tokenValidator = Mockery::mock(OidcTokenValidator::class); - - return (new TestOidcProviderWithExposedAuthUrl( - $request, - $discoveryService, - $tokenValidator, - 'client-id', - 'client-secret', - 'https://coolify.example.com/auth/oidc/callback', - ))->setConfig(new OidcConfig( - issuerUrl: 'https://idp.example.com', - clientId: 'client-id', - clientSecret: 'client-secret', - redirectUri: 'https://coolify.example.com/auth/oidc/callback', - usePkce: true, - )); -} - -it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { - Carbon::setTestNow('2026-06-15 12:00:00'); - - try { - $session = oidc_provider_session(); - $provider = oidc_provider(oidc_provider_request($session)); - - $provider->authUrlForState('state-value'); - - $nonceEntry = $session->get('oidc.nonce.state-value'); - $verifierEntry = $session->get('oidc.code_verifier.state-value'); - - expect($nonceEntry)->toBeArray() - ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() - ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) - ->and($verifierEntry)->toBeArray() - ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() - ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); - } finally { - Carbon::setTestNow(); - } -}); - -it('sends a fresh oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'fresh-verifier', - 'expires_at' => now()->addMinute()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); - - parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); - - expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') - ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); -}); - -it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'expired-verifier', - 'expires_at' => now()->subSecond()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); -})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php deleted file mode 100644 index 9b1d9a24c3..0000000000 --- a/tests/Unit/OidcTokenValidatorTest.php +++ /dev/null @@ -1,187 +0,0 @@ - 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - openssl_pkey_export($privateKey, $privatePem); - $details = openssl_pkey_get_details($privateKey); - - return [ - 'private_pem' => $privatePem, - 'jwks' => [ - 'keys' => [[ - 'kty' => 'RSA', - 'kid' => $kid, - 'alg' => 'RS256', - 'use' => 'sig', - 'n' => oidc_base64url($details['rsa']['n']), - 'e' => oidc_base64url($details['rsa']['e']), - ]], - ], - ]; -} - -function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string -{ - $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); - $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); - $signatureInput = $header.'.'.$payload; - openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); - - return $signatureInput.'.'.oidc_base64url($signature); -} - -function oidc_discovery(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -it('validates a well formed RS256 id token', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - 'email' => 'User@Example.com', - ], $keyset['private_pem']); - - $claims = app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); - - expect($claims['sub'])->toBe('okta-user-1') - ->and($claims['email'])->toBe('User@Example.com'); -}); - -it('rejects invalid token claims', function (array $claimOverrides, string $message) { - $keyset = oidc_keyset(); - $now = time(); - $claims = array_merge([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $claimOverrides); - - $token = oidc_token($claims, $keyset['private_pem']); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], - 'audience mismatch' => [['aud' => 'other-client'], 'audience'], - 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], - 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], - 'expired token' => [['exp' => time() - 3600], 'expired'], - 'future issued at' => [['iat' => time() + 3600], 'issued'], - 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], - 'missing subject' => [['sub' => null], 'subject'], - 'empty subject' => [['sub' => ''], 'subject'], - 'non-string subject' => [['sub' => 123], 'subject'], -]); - -it('rejects a bad signature and unknown key id', function (string $kid) { - $keyset = oidc_keyset('test-key'); - $otherKeyset = oidc_keyset($kid); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $otherKeyset['private_pem'], $kid); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'same kid with bad signature' => ['test-key'], - 'unknown kid' => ['other-key'], -]); - -it('rejects disallowed algorithms', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem'], algorithm: 'HS256'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); - -it('throws a dedicated exception when the signing key is unknown', function () { - $keyset = oidc_keyset('current-key'); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => time(), - 'exp' => time() + 600, - ], $keyset['private_pem'], 'rotated-key'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcSigningKeyNotFoundException::class); - -it('rejects a jwks key not designated for signing', function () { - $keyset = oidc_keyset(); - $keyset['jwks']['keys'][0]['use'] = 'enc'; - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem']); - - // An encryption-only key is dropped from the keyset, so the kid no longer resolves. - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index 4dedc7a768..d2d4ae600f 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,16 +23,6 @@ class SshMultiplexingDisableTest extends TestCase ); } - public function test_remote_shell_prefers_bash_and_falls_back_to_sh() - { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); - - $this->assertSame( - 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', - $reflection->invoke(null) - ); - } - public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 4a275ad484..7a73f59795 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,7 +4,6 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; -use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -19,7 +18,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::forceCreate(['id' => 0]); + InstanceSettings::create(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -71,21 +70,6 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); -test('delete succeeds without password for an oauth user', function () { - OauthIdentity::create([ - 'user_id' => $this->user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'oauth-user-id', - ]); - - Livewire::test(Danger::class, ['resource' => $this->application]) - ->call('delete', '') - ->assertHasNoErrors(); - - expect(Application::find($this->application->id))->toBeNull(); -}); - test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From ca5fcce39b9590b2f582dee0e3df44b4e0b4d10c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:15:46 +0200 Subject: [PATCH 55/86] Reapply "Merge branch 'next' into main" This reverts commit 7bbd91175f3865018b17aa3273b189c511ce459f. --- .env.testing | 1 + app/Actions/Fortify/CreateNewUser.php | 2 +- app/Actions/Server/CheckUpdates.php | 40 +- app/Actions/Server/InstallDocker.php | 27 +- app/Actions/Server/InstallPrerequisites.php | 16 + app/Actions/Server/UpdatePackage.php | 4 + .../Exceptions/OidcDiscoveryException.php | 5 + app/Auth/Oidc/Exceptions/OidcException.php | 7 + .../Oidc/Exceptions/OidcJwksException.php | 5 + .../OidcSigningKeyNotFoundException.php | 5 + .../Oidc/Exceptions/OidcTokenException.php | 5 + app/Auth/Oidc/OidcConfig.php | 34 ++ app/Auth/Oidc/OidcDiscoveryDocument.php | 61 +++ app/Auth/Oidc/OidcDiscoveryService.php | 97 +++++ app/Auth/Oidc/OidcTokenValidator.php | 199 ++++++++++ app/Auth/Oidc/OidcUser.php | 32 ++ app/Auth/Oidc/Socialite/OidcProvider.php | 299 +++++++++++++++ app/Helpers/SshMultiplexingHelper.php | 8 +- app/Http/Controllers/OauthController.php | 61 +-- app/Livewire/Notifications/Discord.php | 24 ++ app/Livewire/Notifications/Email.php | 121 ++++-- app/Livewire/Notifications/Pushover.php | 28 ++ app/Livewire/Notifications/Slack.php | 26 ++ app/Livewire/Notifications/Telegram.php | 28 ++ app/Livewire/Notifications/Webhook.php | 24 ++ app/Livewire/Profile/Index.php | 59 ++- app/Livewire/Project/Service/Storage.php | 14 +- .../Shared/EnvironmentVariable/Show.php | 22 +- .../EnvironmentVariable/ShowHardcoded.php | 19 + app/Livewire/Project/Shared/Storages/All.php | 19 + .../Security/IntegrationTokenEditor.php | 114 ++++++ .../Security/IntegrationTokenForm.php | 81 ++++ app/Livewire/Security/IntegrationTokens.php | 41 +++ app/Livewire/Server/LogDrains.php | 72 ++++ app/Livewire/Settings/Advanced.php | 6 + app/Livewire/SettingsEmail.php | 124 +++++-- app/Livewire/SettingsOauth.php | 346 ++++++++++++------ app/Models/EnvironmentVariable.php | 17 + app/Models/InstanceSettings.php | 16 + app/Models/IntegrationToken.php | 38 ++ app/Models/OauthIdentity.php | 35 ++ app/Models/OauthSetting.php | 51 ++- app/Models/Team.php | 5 + app/Models/User.php | 17 +- app/Policies/IntegrationTokenPolicy.php | 34 ++ app/Providers/AppServiceProvider.php | 36 +- app/Providers/AuthServiceProvider.php | 3 + app/Providers/DuskServiceProvider.php | 21 -- app/Providers/FortifyServiceProvider.php | 8 +- app/Services/Auth/OauthLoginService.php | 228 ++++++++++++ app/Services/CloudflareTokenValidator.php | 42 +++ bootstrap/helpers/shared.php | 9 +- bootstrap/helpers/socialite.php | 28 +- composer.json | 2 +- composer.lock | 142 +------ config/app.php | 4 +- config/services.php | 8 + ...ation_deployment_configuration_columns.php | 6 + ...dd_oidc_fields_to_oauth_settings_table.php | 40 ++ ...4_091631_create_oauth_identities_table.php | 36 ++ ...tion_policy_to_instance_settings_table.php | 28 ++ ...join_root_team_to_oauth_settings_table.php | 28 ++ ...000000_create_integration_tokens_table.php | 29 ++ database/seeders/OauthSettingSeeder.php | 1 + database/seeders/UserSeeder.php | 2 - lang/de.json | 1 + lang/en.json | 1 + lang/pl.json | 1 + public/svgs/oidc.svg | 5 + resources/js/app.js | 2 + resources/js/copy-button.js | 35 ++ resources/views/auth/login.blade.php | 8 +- .../views/components/copy-button.blade.php | 32 +- .../components/forms/copy-button.blade.php | 28 -- .../components/forms/copy-input.blade.php | 15 + .../components/modal-confirmation.blade.php | 13 +- resources/views/components/reicon.blade.php | 1 + .../security/settings-layout.blade.php | 6 + .../components/settings/sidebar.blade.php | 18 + resources/views/layouts/base.blade.php | 24 -- .../views/livewire/profile/index.blade.php | 22 +- .../application/internal-access.blade.php | 8 +- .../project/service/storage.blade.php | 16 - .../shared/environment-variable/all.blade.php | 3 +- .../show-hardcoded.blade.php | 5 +- .../environment-variable/show.blade.php | 5 +- .../shared/partials/dns-copy-cell.blade.php | 41 +-- .../project/shared/resource-details.blade.php | 20 +- .../project/shared/storages/all.blade.php | 19 +- .../volume-backups/executions.blade.php | 2 +- .../project/shared/webhooks.blade.php | 6 +- .../livewire/security/api-tokens.blade.php | 7 +- .../integration-token-editor.blade.php | 52 +++ .../security/integration-token-form.blade.php | 49 +++ .../security/integration-tokens.blade.php | 84 +++++ .../server/ca-certificate/show.blade.php | 2 +- .../server/security/patches.blade.php | 4 +- .../views/livewire/settings-oauth.blade.php | 142 ++++--- .../livewire/settings/advanced.blade.php | 11 +- .../views/livewire/team/invitations.blade.php | 9 +- routes/web.php | 5 + templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- tests/Browser/LoginTest.php | 27 -- tests/Browser/Project/ProjectAddNewTest.php | 34 -- tests/Browser/Project/ProjectSearchTest.php | 29 -- tests/Browser/Project/ProjectTest.php | 27 -- tests/Browser/console/.gitignore | 2 - tests/Browser/source/.gitignore | 2 - tests/DuskTestCase.php | 57 --- .../EnvironmentVariableValueHidingTest.php | 18 +- tests/Feature/CopyButtonComponentTest.php | 38 +- tests/Feature/EnableActionButtonsTest.php | 179 +++++++++ .../EnvironmentVariableAsyncLoadTest.php | 2 +- .../EnvironmentVariableCopyValueTest.php | 151 ++++++++ .../LogDrain/LogDrainToggleRollbackTest.php | 45 +++ tests/Feature/LoginPageBrandingTest.php | 22 ++ tests/Feature/OauthControllerTest.php | 114 +++++- tests/Feature/OauthRegistrationPolicyTest.php | 52 +++ tests/Feature/OidcOauthControllerTest.php | 275 ++++++++++++++ .../PersistentStorageVolumesLayoutTest.php | 59 ++- tests/Feature/ProfileSsoIndicatorTest.php | 91 +++++ .../Feature/ResourceDetailsVisibilityTest.php | 16 +- .../Security/IntegrationTokenFormTest.php | 253 +++++++++++++ .../SecuritySettingsNavigationTest.php | 2 + .../SettingsEmailProviderExclusivityTest.php | 64 ++++ tests/Feature/SettingsNavigationTest.php | 52 +++ tests/Feature/SettingsOauthTest.php | 277 ++++++++++++++ tests/Feature/SshMultiplexingLockTest.php | 2 +- tests/Feature/TeamInvitationUiTest.php | 14 +- tests/Feature/UserSeederTest.php | 16 + .../Server/AlpinePackageManagerTest.php | 62 ++++ .../ApplicationConfigurationSnapshotTest.php | 14 +- tests/Unit/OauthSettingTest.php | 30 ++ tests/Unit/OidcDiscoveryServiceTest.php | 119 ++++++ tests/Unit/OidcProviderPkceTest.php | 148 ++++++++ tests/Unit/OidcTokenValidatorTest.php | 187 ++++++++++ tests/Unit/SshMultiplexingDisableTest.php | 10 + tests/v4/Feature/DangerDeleteResourceTest.php | 18 +- 139 files changed, 5343 insertions(+), 865 deletions(-) create mode 100644 app/Auth/Oidc/Exceptions/OidcDiscoveryException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcJwksException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcTokenException.php create mode 100644 app/Auth/Oidc/OidcConfig.php create mode 100644 app/Auth/Oidc/OidcDiscoveryDocument.php create mode 100644 app/Auth/Oidc/OidcDiscoveryService.php create mode 100644 app/Auth/Oidc/OidcTokenValidator.php create mode 100644 app/Auth/Oidc/OidcUser.php create mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php create mode 100644 app/Livewire/Security/IntegrationTokenEditor.php create mode 100644 app/Livewire/Security/IntegrationTokenForm.php create mode 100644 app/Livewire/Security/IntegrationTokens.php create mode 100644 app/Models/IntegrationToken.php create mode 100644 app/Models/OauthIdentity.php create mode 100644 app/Policies/IntegrationTokenPolicy.php delete mode 100644 app/Providers/DuskServiceProvider.php create mode 100644 app/Services/Auth/OauthLoginService.php create mode 100644 app/Services/CloudflareTokenValidator.php create mode 100644 database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php create mode 100644 database/migrations/2026_06_04_091631_create_oauth_identities_table.php create mode 100644 database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php create mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php create mode 100644 database/migrations/2026_08_15_000000_create_integration_tokens_table.php create mode 100644 public/svgs/oidc.svg create mode 100644 resources/js/copy-button.js delete mode 100644 resources/views/components/forms/copy-button.blade.php create mode 100644 resources/views/components/forms/copy-input.blade.php create mode 100644 resources/views/livewire/security/integration-token-editor.blade.php create mode 100644 resources/views/livewire/security/integration-token-form.blade.php create mode 100644 resources/views/livewire/security/integration-tokens.blade.php delete mode 100644 tests/Browser/LoginTest.php delete mode 100644 tests/Browser/Project/ProjectAddNewTest.php delete mode 100644 tests/Browser/Project/ProjectSearchTest.php delete mode 100644 tests/Browser/Project/ProjectTest.php delete mode 100644 tests/Browser/console/.gitignore delete mode 100644 tests/Browser/source/.gitignore delete mode 100644 tests/DuskTestCase.php create mode 100644 tests/Feature/EnableActionButtonsTest.php create mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php create mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php create mode 100644 tests/Feature/OauthRegistrationPolicyTest.php create mode 100644 tests/Feature/OidcOauthControllerTest.php create mode 100644 tests/Feature/ProfileSsoIndicatorTest.php create mode 100644 tests/Feature/Security/IntegrationTokenFormTest.php create mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php create mode 100644 tests/Feature/SettingsNavigationTest.php create mode 100644 tests/Feature/SettingsOauthTest.php create mode 100644 tests/Feature/UserSeederTest.php create mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php create mode 100644 tests/Unit/OauthSettingTest.php create mode 100644 tests/Unit/OidcDiscoveryServiceTest.php create mode 100644 tests/Unit/OidcProviderPkceTest.php create mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44a03c17da..d437a3a176 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { abort(403); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -106,6 +107,15 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'apk': + instant_remote_process(['apk update -q'], $server); + $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); + + $out = $this->parseApkOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -266,11 +276,39 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } return $result; } + + private function parseApkOutput(string $output): array + { + $updates = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + // Skip empty lines + if (empty($line)) { + continue; + } + + // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] + if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'new_version' => $matches[2], + 'architecture' => $matches[3], + 'current_version' => $matches[4], + ]; + } + } + + return [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,6 +79,8 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -93,9 +95,8 @@ class InstallDocker "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', ]); + $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -154,6 +155,28 @@ class InstallDocker 'systemctl start docker.service'; } + private function getAlpineDockerInstallCommand(): string + { + return 'apk update && '. + 'apk add docker docker-cli-buildx docker-cli-compose && '. + 'mkdir -p /etc/docker'; + } + + private function getDockerServiceCommands(bool $usesOpenRc): array + { + if ($usesOpenRc) { + return [ + 'rc-update add docker default', + 'rc-service docker restart', + ]; + } + + return [ + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', + ]; + } + private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,6 +53,8 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -61,4 +63,18 @@ class InstallPrerequisites return remote_process($command, $server); } + + private function getAlpinePrerequisiteCommands(): array + { + return [ + "echo 'Installing Prerequisites for Alpine Linux...'", + "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", + 'apk update', + 'command -v bash >/dev/null || apk add bash', + 'command -v curl >/dev/null || apk add curl', + 'command -v wget >/dev/null || apk add wget', + 'command -v git >/dev/null || apk add git', + 'command -v jq >/dev/null || apk add jq', + ]; + } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,6 +58,10 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; + case 'apk': + $commandAll = 'apk update && apk upgrade'; + $commandInstall = 'apk upgrade '.$sanitizedPackage; + break; default: return [ 'error' => 'OS not supported', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php new file mode 100644 index 0000000000..e4a2ba0dfe --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php @@ -0,0 +1,5 @@ + $scopes + */ + public function __construct( + public string $issuerUrl, + public string $clientId, + public string $clientSecret, + public string $redirectUri, + public array $scopes = ['openid', 'email', 'profile'], + public bool $usePkce = true, + public int $clockSkewSeconds = 60, + ) {} + + public static function fromOauthSetting(OauthSetting $setting): self + { + return new self( + issuerUrl: rtrim((string) $setting->base_url, '/'), + clientId: (string) $setting->client_id, + clientSecret: (string) $setting->client_secret, + redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'), + scopes: $setting->scopeList(), + usePkce: $setting->use_pkce ?? true, + clockSkewSeconds: $setting->clock_skew_seconds ?? 60, + ); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php new file mode 100644 index 0000000000..d17061c51d --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryDocument.php @@ -0,0 +1,61 @@ + $supportedScopes + * @param array $supportedClaims + * @param array $idTokenSigningAlgValuesSupported + */ + public function __construct( + public string $issuer, + public string $authorizationEndpoint, + public string $tokenEndpoint, + public string $userinfoEndpoint, + public string $jwksUri, + public ?string $endSessionEndpoint = null, + public array $supportedScopes = [], + public array $supportedClaims = [], + public array $idTokenSigningAlgValuesSupported = [], + ) {} + + /** + * @param array $payload + */ + public static function fromArray(array $payload): self + { + foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) { + if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') { + throw new OidcDiscoveryException("Discovery document is missing required field: {$field}"); + } + } + + return new self( + issuer: $payload['issuer'], + authorizationEndpoint: $payload['authorization_endpoint'], + tokenEndpoint: $payload['token_endpoint'], + userinfoEndpoint: $payload['userinfo_endpoint'], + jwksUri: $payload['jwks_uri'], + endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null, + supportedScopes: self::stringList($payload['scopes_supported'] ?? []), + supportedClaims: self::stringList($payload['claims_supported'] ?? []), + idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []), + ); + } + + /** + * @return array + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map('strval', $value)); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php new file mode 100644 index 0000000000..0847afc9a7 --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,97 @@ +assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); + + $issuerUrl = rtrim($issuerUrl, '/'); + $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); + + return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { + $url = $issuerUrl.'/.well-known/openid-configuration'; + + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); + } catch (Throwable $e) { + throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || $json === []) { + throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); + } + + $discovery = OidcDiscoveryDocument::fromArray($json); + if (rtrim($discovery->issuer, '/') !== $issuerUrl) { + throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); + } + + return $discovery; + }); + } + + /** + * Fetch the JWKS for the given URI. + * + * When $forceRefresh is true the cached document is bypassed so freshly + * rotated signing keys become visible immediately. A short cooldown still + * prevents a flood of upstream requests if many logins miss the same kid. + * + * @return array + */ + public function jwks(string $jwksUri, bool $forceRefresh = false): array + { + $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); + + $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); + + if ($forceRefresh) { + $cooldownKey = $cacheKey.':refresh'; + if (Cache::add($cooldownKey, true, 60)) { + Cache::forget($cacheKey); + } + } + + return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); + } catch (Throwable $e) { + throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || ! is_array($json['keys'] ?? null)) { + throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'."); + } + + return $json; + }); + } + + private function assertHttpsUrl(string $url, Throwable $exception): void + { + $parts = parse_url($url); + + if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') { + throw $exception; + } + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..a8563611dd --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,199 @@ + $jwks + * @return array + */ + public function validate( + string $idToken, + OidcDiscoveryDocument $discovery, + array $jwks, + string $clientId, + ?string $expectedNonce = null, + int $clockSkewSeconds = 60, + ): array { + $kid = $this->extractKid($idToken); + + try { + $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM); + } catch (Throwable $e) { + throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e); + } + + // Surface an unknown signing key distinctly so the caller can refresh + // the JWKS once (key rotation) before giving up. + if (! array_key_exists($kid, $keys)) { + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + $previousLeeway = JWT::$leeway; + JWT::$leeway = $clockSkewSeconds; + + try { + // Validates signature, header alg against the key alg (RS256), + // exp, nbf and iat. Throws on any failure. + $claims = (array) JWT::decode($idToken, $keys); + } catch (OidcTokenException $e) { + throw $e; + } catch (Throwable $e) { + throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e); + } finally { + JWT::$leeway = $previousLeeway; + } + + $this->assertExpiry($claims); + $this->assertIssuer($claims, $discovery->issuer); + $this->assertAudience($claims, $clientId); + $this->assertNonce($claims, $expectedNonce); + $this->assertSubject($claims); + + return $claims; + } + + /** + * Drop JWKS entries explicitly marked for anything other than signing + * (e.g. "use":"enc") so they can never verify an id_token signature. + * firebase/php-jwt does not honour the "use" parameter on its own. + * + * @param array $jwks + * @return array + */ + private function signingKeysOnly(array $jwks): array + { + $keys = array_values(array_filter( + $jwks['keys'] ?? [], + fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'), + )); + + return ['keys' => $keys]; + } + + /** + * Decode just the JWT header to read the kid before signature + * verification, so an unknown key can be reported as a rotation miss. + */ + private function extractKid(string $idToken): string + { + $segments = explode('.', $idToken); + if (count($segments) !== 3) { + throw new OidcTokenException('Malformed id_token.'); + } + + $header = json_decode($this->base64UrlDecode($segments[0]), true); + if (! is_array($header)) { + throw new OidcTokenException('id_token header contains invalid JSON.'); + } + + if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) { + throw new OidcTokenException('id_token uses a disallowed algorithm.'); + } + + $kid = $header['kid'] ?? null; + if (! is_string($kid) || $kid === '') { + throw new OidcTokenException('id_token header is missing kid.'); + } + + return $kid; + } + + private function base64UrlDecode(string $value): string + { + $remainder = strlen($value) % 4; + if ($remainder !== 0) { + $value .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + if ($decoded === false) { + throw new OidcTokenException('Invalid base64url value in id_token header.'); + } + + return $decoded; + } + + /** + * @param array $claims + */ + private function assertExpiry(array $claims): void + { + // Firebase enforces the exp window when present; OIDC requires it to exist. + if (! is_numeric($claims['exp'] ?? null)) { + throw new OidcTokenException('id_token is missing the exp claim.'); + } + } + + /** + * @param array $claims + */ + private function assertSubject(array $claims): void + { + $subject = $claims['sub'] ?? null; + if (! is_string($subject) || $subject === '') { + throw new OidcTokenException('id_token subject is missing or invalid.'); + } + } + + /** + * @param array $claims + */ + private function assertIssuer(array $claims, string $expectedIssuer): void + { + if (($claims['iss'] ?? null) !== $expectedIssuer) { + throw new OidcTokenException('id_token issuer does not match discovery issuer.'); + } + } + + /** + * @param array $claims + */ + private function assertAudience(array $claims, string $clientId): void + { + $audience = $claims['aud'] ?? null; + if (is_string($audience)) { + $audience = [$audience]; + } + + if (! is_array($audience) || ! in_array($clientId, $audience, true)) { + throw new OidcTokenException('id_token audience does not include configured client id.'); + } + + if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) { + throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.'); + } + + if (isset($claims['azp']) && $claims['azp'] !== $clientId) { + throw new OidcTokenException('id_token azp does not match configured client id.'); + } + } + + /** + * @param array $claims + */ + private function assertNonce(array $claims, ?string $expectedNonce): void + { + if ($expectedNonce === null) { + return; + } + + if (($claims['nonce'] ?? null) !== $expectedNonce) { + throw new OidcTokenException('id_token nonce does not match.'); + } + } +} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php new file mode 100644 index 0000000000..645130e019 --- /dev/null +++ b/app/Auth/Oidc/OidcUser.php @@ -0,0 +1,32 @@ + + */ + public array $idTokenClaims = []; + + /** + * @param array $claims + */ + public function setIdTokenClaims(array $claims): self + { + $this->idTokenClaims = $claims; + $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null; + $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null; + $this->emailVerified = ($claims['email_verified'] ?? false) === true; + + return $this; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..383b0cc910 --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,299 @@ + + */ + protected $scopes = ['openid', 'email', 'profile']; + + protected $scopeSeparator = ' '; + + protected ?OidcConfig $oidcConfig = null; + + protected ?OidcDiscoveryDocument $discovery = null; + + public function __construct( + Request $request, + protected OidcDiscoveryService $discoveryService, + protected OidcTokenValidator $tokenValidator, + string $clientId, + string $clientSecret, + string $redirectUrl, + ) { + parent::__construct($request, $clientId, $clientSecret, $redirectUrl); + } + + public function setConfig(OidcConfig $config): self + { + $this->oidcConfig = $config; + $this->clientId = $config->clientId; + $this->clientSecret = $config->clientSecret; + $this->redirectUrl = $config->redirectUri; + $this->scopes = $config->scopes; + $this->discovery = null; + + return $this; + } + + public function getConfig(): OidcConfig + { + if ($this->oidcConfig === null) { + throw new OidcException('OIDC provider config is not set.'); + } + + return $this->oidcConfig; + } + + protected function getAuthUrl($state): string + { + $config = $this->getConfig(); + $nonce = Str::random(40); + $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); + + $extra = ['nonce' => $nonce]; + if ($config->usePkce) { + $verifier = $this->generateCodeVerifier(); + $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); + $extra['code_challenge'] = $this->codeChallenge($verifier); + $extra['code_challenge_method'] = 'S256'; + } + + return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state) + .'&'.http_build_query($extra, '', '&', $this->encodingType); + } + + protected function getTokenUrl(): string + { + return $this->resolveDiscovery()->tokenEndpoint; + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user) + { + return (new OidcUser)->setRaw($user)->map([ + 'id' => $user['sub'] ?? null, + 'nickname' => $user['preferred_username'] ?? null, + 'name' => $this->resolveName($user), + 'email' => $user['email'] ?? null, + 'avatar' => $user['picture'] ?? null, + ]); + } + + public function user() + { + if ($this->user) { + return $this->user; + } + + if ($this->hasInvalidState()) { + throw new InvalidStateException; + } + + $tokenResponse = $this->getAccessTokenResponse($this->getCode()); + $accessToken = Arr::get($tokenResponse, 'access_token'); + $idToken = Arr::get($tokenResponse, 'id_token'); + + if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') { + throw new OidcException('OIDC token endpoint did not return required tokens.'); + } + + $discovery = $this->resolveDiscovery(); + $config = $this->getConfig(); + $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state'))); + if ($expectedNonce === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce); + + $userinfo = $this->getUserByToken($accessToken); + + // OIDC core §5.3.2: the userinfo sub MUST match the id_token sub. + // Reject the response rather than trust unsigned userinfo claims. + $userinfoSub = $userinfo['sub'] ?? null; + if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) { + throw new OidcException('OIDC userinfo subject does not match the id_token subject.'); + } + + $merged = array_merge($userinfo, $claims); + + /** @var OidcUser $user */ + $user = $this->mapUserToObject($merged); + $user->setIdTokenClaims($claims) + ->setToken($accessToken) + ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token')) + ->setExpiresIn(Arr::get($tokenResponse, 'expires_in')); + + return $this->user = $user; + } + + /** + * Validate the id_token, retrying once against a freshly fetched JWKS when + * the signing key is unknown. This keeps logins working immediately after + * the IdP rotates keys instead of failing until the JWKS cache expires. + * + * @return array + */ + protected function validateIdToken( + string $idToken, + OidcDiscoveryDocument $discovery, + OidcConfig $config, + ?string $expectedNonce, + ): array { + foreach ([false, true] as $forceRefresh) { + try { + return $this->tokenValidator->validate( + idToken: $idToken, + discovery: $discovery, + jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh), + clientId: $config->clientId, + expectedNonce: $expectedNonce, + clockSkewSeconds: $config->clockSkewSeconds, + ); + } catch (OidcSigningKeyNotFoundException $e) { + if ($forceRefresh) { + throw $e; + } + } + } + + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + /** + * @return array + */ + public function getAccessTokenResponse($code) + { + $fields = $this->getTokenFields($code); + if ($this->getConfig()->usePkce) { + $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); + if ($verifier === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $fields['code_verifier'] = $verifier; + } + + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + RequestOptions::FORM_PARAMS => $fields, + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + protected function resolveDiscovery(): OidcDiscoveryDocument + { + return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl); + } + + protected function generateCodeVerifier(): string + { + return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); + } + + protected function codeChallenge(string $verifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + /** + * @param array $user + */ + protected function resolveName(array $user): ?string + { + if (is_string($user['name'] ?? null) && $user['name'] !== '') { + return $user['name']; + } + + $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? ''))); + + return $name === '' ? null : $name; + } + + protected function putOidcFlowValue(string $key, string $value): void + { + $this->request->session()->put($key, [ + 'value' => $value, + 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, + ]); + } + + protected function pullOidcFlowValue(string $key): ?string + { + $entry = $this->request->session()->pull($key); + + if (! is_array($entry)) { + return null; + } + + $value = $entry['value'] ?? null; + $expiresAt = $entry['expires_at'] ?? null; + + if (! is_string($value) || $value === '' || ! is_int($expiresAt)) { + return null; + } + + if ($expiresAt < now()->timestamp) { + return null; + } + + return $value; + } + + protected function nonceSessionKey(string $state): string + { + return "oidc.nonce.{$state}"; + } + + protected function verifierSessionKey(string $state): string + { + return "oidc.code_verifier.{$state}"; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index cbb18945e2..e7d6d071b4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -243,12 +243,18 @@ class SshMultiplexingHelper $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); + $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } + private static function remoteShellCommand(): string + { + return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; + } + public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e2..93d27615a7 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,47 +2,60 @@ namespace App\Http\Controllers; -use App\Models\User; -use Illuminate\Support\Facades\Auth; +use App\Models\OauthSetting; +use App\Services\Auth\OauthLoginService; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { public function redirect(string $provider) { - $socialite_provider = get_socialite_provider($provider); + $oauthSetting = $this->enabledProvider($provider); + $socialiteProvider = get_socialite_provider($oauthSetting->provider); - return $socialite_provider->redirect(); + return $socialiteProvider->redirect(); } - public function callback(string $provider) + public function callback(string $provider, OauthLoginService $oauthLoginService) { try { - $oauthUser = get_socialite_provider($provider)->user(); - $email = trim((string) $oauthUser->email); - if ($email === '') { - abort(403, 'OAuth provider did not return an email address'); - } - $email = strtolower($email); - $user = User::whereEmail($email)->first(); - if (! $user) { - $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { - abort(403, 'Registration is disabled'); - } - - $user = User::create([ - 'name' => $oauthUser->name, - 'email' => $email, - ]); - } - Auth::login($user); + $oauthSetting = $this->enabledProvider($provider); + $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); + $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); return redirect('/'); } catch (\Exception $e) { + $this->logCallbackFailure($provider, $e); + $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; return redirect()->route('login')->withErrors([__($errorCode)]); } } + + private function logCallbackFailure(string $provider, \Throwable $exception): void + { + Log::error('OAuth callback failed.', [ + 'provider' => $provider, + 'exception_class' => $exception::class, + 'exception_message' => $exception->getMessage(), + 'request_error' => request()->query('error'), + 'request_error_description' => request()->query('error_description'), + 'has_code' => request()->query->has('code'), + 'has_state' => request()->query->has('state'), + 'ip' => request()->ip(), + 'exception' => $exception, + ]); + } + + private function enabledProvider(string $provider): OauthSetting + { + $oauthSetting = OauthSetting::where('provider', $provider)->first(); + if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) { + throw new HttpException(403, 'OAuth provider is not enabled'); + } + + return $oauthSetting; + } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 797db83629..59ecb06e8e 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -166,6 +166,30 @@ class Discord extends Component } } + public function toggleDiscordEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->discordEnabled) { + $this->discordEnabled = false; + } else { + $this->validate([ + 'discordWebhookUrl' => 'required', + ], [ + 'discordWebhookUrl.required' => 'Discord Webhook URL is required.', + ]); + $this->discordEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 3d95668b91..2a373a5065 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,7 +2,6 @@ namespace App\Livewire\Notifications; -use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -15,7 +14,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests, TogglesNotificationEvents; + use AuthorizesRequests; protected $listeners = ['refresh' => '$refresh']; @@ -252,32 +251,59 @@ class Email extends Component } } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->saveModel(); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->saveModel(); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function submitSmtp() { $this->authorize('update', $this->settings); try { $this->resetErrorBag(); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); if ($this->smtpEnabled) { $this->settings->resend_enabled = $this->resendEnabled = false; @@ -309,17 +335,7 @@ class Email extends Component try { $this->resetErrorBag(); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); if ($this->resendEnabled) { $this->settings->smtp_enabled = $this->smtpEnabled = false; } @@ -336,6 +352,45 @@ class Email extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 3b7c3c6aeb..b1608c5ea2 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -159,6 +159,34 @@ class Pushover extends Component } } + public function togglePushoverEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->pushoverEnabled) { + $this->pushoverEnabled = false; + } else { + $this->validate([ + 'pushoverUserKey' => 'required', + 'pushoverApiToken' => 'required', + ], [ + 'pushoverUserKey.required' => 'Pushover User Key is required.', + 'pushoverApiToken.required' => 'Pushover API Token is required.', + ]); + $this->pushoverEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 9ee3624025..c4ca7da802 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -150,6 +150,32 @@ class Slack extends Component } } + public function toggleSlackEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->slackEnabled) { + $this->slackEnabled = false; + } else { + $this->validate([ + 'slackWebhookUrl' => 'required', + ], [ + 'slackWebhookUrl.required' => 'Slack Webhook URL is required.', + ]); + $this->slackEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index b04d2c73d2..9f19b22f5f 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -252,6 +252,34 @@ class Telegram extends Component } } + public function toggleTelegramEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->telegramEnabled) { + $this->telegramEnabled = false; + } else { + $this->validate([ + 'telegramToken' => 'required', + 'telegramChatId' => 'required', + ], [ + 'telegramToken.required' => 'Telegram Token is required.', + 'telegramChatId.required' => 'Telegram Chat ID is required.', + ]); + $this->telegramEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function saveModel() { $this->syncData(true); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fcf1107781..ee07694767 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -144,6 +144,30 @@ class Webhook extends Component } } + public function toggleWebhookEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->webhookEnabled) { + $this->webhookEnabled = false; + } else { + $this->validate([ + 'webhookUrl' => 'required', + ], [ + 'webhookUrl.required' => 'Webhook URL is required.', + ]); + $this->webhookEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a20a1231b4..ae5d9b3ecd 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,19 +2,15 @@ namespace App\Livewire\Profile; -use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; -use Livewire\WithFileUploads; class Index extends Component { - use WithFileUploads; - public int $userId; public string $email; @@ -36,6 +32,10 @@ class Index extends Component public bool $show_verification = false; + public bool $uses_sso = false; + + public ?string $sso_provider_label = null; + public $avatar; public function uploadAvatar(AvatarStorageService $avatarStorage): bool @@ -75,8 +75,12 @@ class Index extends Component $this->name = Auth::user()->name; $this->email = Auth::user()->email; + $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first(); + $this->uses_sso = $oauthIdentity !== null; + $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null; + // Check if there's a pending email change - if (Auth::user()->hasEmailChangeRequest()) { + if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) { $this->new_email = Auth::user()->pending_email; $this->show_verification = true; } @@ -101,6 +105,10 @@ class Index extends Component public function requestEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -159,6 +167,10 @@ class Index extends Component public function verifyEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -204,7 +216,6 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); - $this->dispatch('close-email-change-modal'); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -216,6 +227,10 @@ class Index extends Component public function resendVerificationCode() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // Check if there's a pending request if (! Auth::user()->hasEmailChangeRequest()) { $this->dispatch('error', 'No pending email change request.'); @@ -269,6 +284,30 @@ class Index extends Component $this->dispatch('success', 'Email change request cancelled.'); } + public function showEmailChangeForm() + { + if ($this->rejectSsoEmailChange()) { + return; + } + + $this->show_email_change = true; + $this->new_email = ''; + } + + private function rejectSsoEmailChange(): bool + { + if (! Auth::user()->hasSsoIdentity()) { + return false; + } + + $this->uses_sso = true; + $this->show_email_change = false; + $this->show_verification = false; + $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.'); + + return true; + } + public function resetPassword() { try { @@ -299,6 +338,14 @@ class Index extends Component } } + private function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OIDC', + default => str($provider)->headline()->toString(), + }; + } + public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b6..6880b5ab09 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,13 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + $name = str($this->resource->name)->slug()->value(); + + return ($name ?: 'volume').'-data'; + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..db80cff801 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,6 +161,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -204,7 +220,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -231,12 +247,12 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || auth()->user()?->isMember()) { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; } public function checkEnvs() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a4..efe54a6a7d 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ class All extends Component $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..453a7e8ae8 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,114 @@ +integrationToken = IntegrationToken::ownedByCurrentTeam() + ->whereUuid($integration_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->integrationToken); + + $this->name = $this->integrationToken->name; + $this->capabilities = $this->integrationToken->capabilities; + } + + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'newToken' => ['nullable', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function save(CloudflareTokenValidator $validator): void + { + $this->authorize('update', $this->integrationToken); + $validated = $this->validate(); + $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() + !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + + try { + if ((filled($validated['newToken']) || $capabilitiesChanged) + && ! $validator->validate($token, $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + $updates = [ + 'name' => $validated['name'], + 'capabilities' => $validated['capabilities'], + ]; + + if (filled($validated['newToken'])) { + $updates['token'] = $validated['newToken']; + } + + $this->integrationToken->update($updates); + $this->newToken = ''; + + auditLog('ui.integration_token.updated', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $this->integrationToken->provider, + 'rotated' => array_key_exists('token', $updates), + ]); + + $this->dispatch( + 'integration-token-updated', + uuid: $this->integrationToken->uuid, + name: $this->integrationToken->name, + capabilities: $this->integrationToken->capabilities, + ); + $this->dispatch('success', 'Integration token updated successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete(string $password = ''): void + { + $this->authorize('delete', $this->integrationToken); + $this->integrationToken->delete(); + + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); + $this->dispatch('close-modal'); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-token-editor'); + } +} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..7a7637bf5e --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,81 @@ +authorize('create', IntegrationToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => ['required', 'in:cloudflare'], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function addToken(CloudflareTokenValidator $validator): void + { + $validated = $this->validate(); + + try { + if (! $validator->validate($validated['token'], $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + IntegrationToken::query()->create([ + ...$validated, + 'team_id' => currentTeam()->id, + ]); + + $this->reset(['name', 'token']); + $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } + + $this->dispatch('success', 'Integration token added successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.integration-token-form'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..39db135b38 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,41 @@ +authorize('viewAny', IntegrationToken::class); + $this->loadTokens(); + } + + #[On('integrationTokenAdded')] + public function loadTokens(): void + { + $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); + } + + public function deleteToken(int $tokenId, string $password = ''): void + { + $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + $token->delete(); + $this->loadTokens(); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-tokens'); + } +} diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 3af0a22610..ae53488bd5 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,6 +177,49 @@ class LogDrains extends Component } } + public function toggleLogDrain(string $type): void + { + $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; + $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; + $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + + try { + $this->authorize('update', $this->server); + $this->resetErrorBag(); + + $enabledProperty = $this->enabledProperty($type); + + if ($this->{$enabledProperty}) { + $this->{$enabledProperty} = false; + } else { + $this->validateLogDrainSettings($type); + $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; + $this->isLogDrainAxiomEnabled = $type === 'axiom'; + $this->isLogDrainCustomEnabled = $type === 'custom'; + } + + $this->syncData(true); + + if ($this->server->isLogDrainEnabled()) { + StartLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service started.'); + } else { + StopLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service stopped.'); + } + } catch (\Throwable $e) { + // Restore the previously persisted enabled flags so the UI/DB never + // claim a runtime state that the Start/StopLogDrain action failed to apply. + $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; + $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; + $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->save(); + $this->syncData(); + + handleError($e, $this); + } + } + public function submit() { try { @@ -192,4 +235,33 @@ class LogDrains extends Component { return view('livewire.server.log-drains'); } + + private function enabledProperty(string $type): string + { + return match ($type) { + 'newrelic' => 'isLogDrainNewRelicEnabled', + 'axiom' => 'isLogDrainAxiomEnabled', + 'custom' => 'isLogDrainCustomEnabled', + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } + + private function validateLogDrainSettings(string $type): void + { + match ($type) { + 'newrelic' => $this->validate([ + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainNewRelicBaseUri' => ['required', 'url'], + ]), + 'axiom' => $this->validate([ + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + ]), + 'custom' => $this->validate([ + 'logDrainCustomConfig' => ['required'], + 'logDrainCustomConfigParser' => ['string', 'nullable'], + ]), + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..38a2f85a73 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,6 +19,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; + #[Validate('boolean')] + public bool $disable_registration_when_oauth_enabled; + #[Validate('boolean')] public bool $do_not_track; @@ -59,6 +62,7 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -84,6 +88,7 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; + $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -199,6 +204,7 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; + $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 9bca0db2e3..1426f61f02 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,30 +160,59 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'SMTP settings updated.'); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'Resend settings updated.'); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function submitSmtp() { try { $this->authorize('update', $this->settings); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); + + if ($this->smtpEnabled) { + $this->settings->resend_enabled = $this->resendEnabled = false; + } $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -210,17 +239,11 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); + + if ($this->resendEnabled) { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -237,6 +260,45 @@ class SettingsEmail extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 4082718191..3b24d0cd2e 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,53 +2,89 @@ namespace App\Livewire; +use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; + public InstanceSettings $settings; + public $oauth_settings_map; - protected function rules() + public ?string $selectedProvider = null; + + public bool $disable_registration_when_oauth_enabled = false; + + protected function rules(): array { - return OauthSetting::all()->reduce(function ($carry, $setting) { - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; + return $this->validationRules(); + } + + private function validationRules(?string $provider = null): array + { + $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { + if ($provider !== null && $setting->provider !== $provider) { + return $carry; + } + + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; + $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; + $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; return $carry; }, []); + + if ($provider === null) { + $rules['disable_registration_when_oauth_enabled'] = 'boolean'; + } + + return $rules; } - public function mount() + public function mount(?string $provider = null): ?RedirectResponse { if (! isInstanceAdmin()) { return redirect()->route('home'); } - $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { - $carry[$setting->provider] = [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - ]; - return $carry; - }, []); + $this->settings = instanceSettings(); + $this->selectedProvider = $provider; + $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; + $this->oauth_settings_map = OauthSetting::all() + ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) + ->reduce(function ($carry, $setting) { + $carry[$setting->provider] = $this->oauthSettingToArray($setting); + + return $carry; + }, []); + + if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { + abort(404); + } + + return null; } - private function updateOauthSettings(?string $provider = null) + private function updateOauthSettings(?string $provider = null): void { + $this->validate($this->validationRules($provider)); + if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -57,78 +93,128 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $oauth->fill([ - 'enabled' => $oauthData['enabled'], - 'client_id' => $oauthData['client_id'], - 'client_secret' => $oauthData['client_secret'], - 'redirect_uri' => $oauthData['redirect_uri'], - 'tenant' => $oauthData['tenant'], - 'base_url' => $oauthData['base_url'], - ]); - - if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } + $this->fillOauthSetting($oauth, $oauthData); + $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); - // Update the array with fresh data - $this->oauth_settings_map[$provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); - } else { - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - - continue; - } - - $oauth->fill([ - 'enabled' => $settingData['enabled'], - 'client_id' => $settingData['client_id'], - 'client_secret' => $settingData['client_secret'], - 'redirect_uri' => $settingData['redirect_uri'], - 'tenant' => $settingData['tenant'], - 'base_url' => $settingData['base_url'], - ]); - - if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; - } - - $oauth->save(); - - // Update the array with fresh data - $this->oauth_settings_map[$oauth->provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; - } - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } + return; } + + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); + + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + + continue; + } + + $this->fillOauthSetting($oauth, $settingData); + + if ($oauth->enabled && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->enabled = false; + $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; + } + + $oauth->save(); + $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); + } + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); + } + } + + private function fillOauthSetting(OauthSetting $oauth, array $data): void + { + $oauth->fill([ + 'enabled' => (bool) ($data['enabled'] ?? false), + 'client_id' => $data['client_id'] ?? null, + 'client_secret' => $data['client_secret'] ?? null, + 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), + 'tenant' => $data['tenant'] ?? null, + 'base_url' => $this->nullableString($data['base_url'] ?? null), + 'custom_label' => $data['custom_label'] ?? null, + 'scopes' => $data['scopes'] ?? null, + 'allow_registration' => (bool) ($data['allow_registration'] ?? false), + 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), + 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), + 'use_pkce' => (bool) ($data['use_pkce'] ?? true), + 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), + ]); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function ensureProviderCanBeEnabled(OauthSetting $oauth): void + { + if (! $oauth->enabled) { + return; + } + + if (! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } + + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->update(['enabled' => false]); + throw new \Exception("OIDC scopes must include 'openid'."); + } + } + + private function oauthSettingToArray(OauthSetting $setting): array + { + return [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + 'custom_label' => $setting->custom_label, + 'scopes' => $setting->scopes ?: 'openid email profile', + 'allow_registration' => $setting->allow_registration, + 'auto_join_root_team' => $setting->auto_join_root_team, + 'require_email_verified' => $setting->require_email_verified ?? true, + 'use_pkce' => $setting->use_pkce ?? true, + 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, + 'label' => $this->providerLabel($setting->provider), + ]; + } + + public function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OpenID Connect', + 'gitlab' => 'GitLab', + default => str($provider)->headline()->toString(), + }; } public function instantSave(string $provider) @@ -141,56 +227,88 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider): mixed + public function toggleProvider(string $provider) { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - throw new \Exception('OAuth provider not found.'); + abort(404); } - $enabling = ! $this->oauth_settings_map[$provider]['enabled']; - if ($enabling) { - $this->validate($this->providerRules($provider)); + if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { + $this->validateProviderCanBeEnabled($provider); } - $this->oauth_settings_map[$provider]['enabled'] = $enabling; + $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; $this->updateOauthSettings($provider); - } catch (\Throwable $e) { + } catch (\Exception $e) { + $oauth = OauthSetting::where('provider', $provider)->first(); + if ($oauth) { + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + } + return handleError($e, $this); } - - return null; } - private function providerRules(string $provider): array + private function validateProviderCanBeEnabled(string $provider): void { - $prefix = "oauth_settings_map.$provider"; - $rules = [ - "$prefix.client_id" => 'required', - "$prefix.client_secret" => 'required', - ]; + $this->validate($this->validationRules($provider)); - if ($provider === 'azure') { - $rules["$prefix.tenant"] = 'required'; + $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); + if (! $oauth) { + throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - if (in_array($provider, ['authentik', 'clerk'], true)) { - $rules["$prefix.base_url"] = 'required'; + $this->fillOauthSetting($oauth, [ + ...$this->oauth_settings_map[$provider], + 'enabled' => true, + ]); + + if (! $oauth->couldBeEnabled()) { + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); } - return $rules; + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + throw new \Exception("OIDC scopes must include 'openid'."); + } } - public function submit() + public function saveRegistrationPolicy(): void + { + $this->authorize('update', instanceSettings()); + $this->validate([ + 'disable_registration_when_oauth_enabled' => 'boolean', + ]); + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + $this->dispatch('success', 'Authentication settings updated successfully!'); + } + + public function submit(): void { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); - } catch (\Throwable $e) { - return handleError($e, $this); + $this->updateOauthSettings($this->selectedProvider); + + if ($this->selectedProvider === null) { + $this->dispatch('success', 'Instance settings updated successfully!'); + } + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + if ($this->selectedProvider !== null) { + $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); + if ($oauth) { + $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); + } + } + + handleError($e, $this); } } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..70c9013af2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..02f3e7ed50 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,6 +22,7 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', + 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -88,6 +89,8 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -115,6 +118,19 @@ class InstanceSettings extends Model }); } + public function isPasswordRegistrationAllowed(): bool + { + if (! $this->is_registration_enabled) { + return false; + } + + if (! $this->disable_registration_when_oauth_enabled) { + return true; + } + + return ! OauthSetting::where('enabled', true)->exists(); + } + public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..20541f6139 --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,38 @@ + 'encrypted', + 'capabilities' => 'array', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam() + { + return self::query()->where('team_id', currentTeam()->id); + } +} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php new file mode 100644 index 0000000000..1edf71ad2f --- /dev/null +++ b/app/Models/OauthIdentity.php @@ -0,0 +1,35 @@ + 'array', + 'last_login_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index e7999134a6..7765e41160 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,7 +11,19 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'allow_registration' => 'boolean', + 'auto_join_root_team' => 'boolean', + 'require_email_verified' => 'boolean', + 'use_pkce' => 'boolean', + 'clock_skew_seconds' => 'integer', + ]; + } protected $hidden = [ 'client_secret', @@ -32,9 +44,46 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': + case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } + + /** + * @return array + */ + public function scopeList(): array + { + $scopes = str($this->scopes ?: 'openid email profile') + ->replace(',', ' ') + ->explode(' ') + ->map(fn (string $scope) => trim($scope)) + ->filter() + ->unique() + ->values() + ->all(); + + return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; + } + + public function loginLabel(): string + { + if (filled($this->custom_label)) { + return $this->custom_label; + } + + $envLabel = config("services.{$this->provider}.custom_label"); + if (filled($envLabel)) { + return $envLabel; + } + + return __("auth.login.{$this->provider}"); + } + + public function isOidc(): bool + { + return $this->provider === 'oidc'; + } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 15085203aa..b7664e94d3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 5b38473962..10303422bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } + public function oauthIdentities(): HasMany + { + return $this->hasMany(OauthIdentity::class); + } + + public function hasSsoIdentity(): bool + { + return $this->oauthIdentities()->exists(); + } + /** * Check if the user has a password set. - * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } + + public function requiresPasswordConfirmation(): bool + { + return $this->hasPassword() && ! $this->hasSsoIdentity(); + } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..309c8167f2 --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,34 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5856791662..e4d2b0a851 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Auth\Oidc\OidcDiscoveryService; +use App\Auth\Oidc\OidcTokenValidator; +use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); - $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } + private function configureOidcSocialite(): void + { + if (! $this->app->bound(SocialiteFactory::class)) { + return; + } + + $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { + return new OidcProvider( + $app['request'], + $app->make(OidcDiscoveryService::class), + $app->make(OidcTokenValidator::class), + '', + '', + '', + ); + }); + } + private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -77,16 +98,5 @@ 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 09b2a3e089..e8e6fb42c6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 65d9687744..dfa3bb3314 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { + // If there are no users and password registration is allowed, redirect to registration. return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->is_registration_enabled, + 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..2ec8f88e3e --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,228 @@ +email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); + } + + $user = $provider === 'oidc' + ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) + : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); + + Auth::login($user); + $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); + session(['currentTeam' => $user->currentTeam = $team]); + + return $user; + } + + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $provider = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if ( + (! is_string($providerUserId) && ! is_int($providerUserId)) + || (is_string($providerUserId) && trim($providerUserId) === '') + ) { + throw new HttpException(403, 'OAuth provider did not return a valid user ID'); + } + $providerUserId = (string) $providerUserId; + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) + || data_get($oauthUser->user, 'email_verified') === true; + + if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { + throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); + } + + if ($oauthSetting->require_email_verified && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider did not verify the email address'); + } + + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + + // Linking a new OIDC identity to an existing local account by email + // is account takeover unless the provider attests the email. This + // guard is independent of the require_email_verified toggle, which + // only governs the broader login flow. + if ($user && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); + } + + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User + { + if (User::count() === 0) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + $user->save(); + + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + instanceSettings()->update(['is_registration_enabled' => false]); + + return $user; + } + + if ($oauthSetting->auto_join_root_team) { + return $this->createRootTeamOnlyUser($name, $email); + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } + + private function createRootTeamOnlyUser(string $name, string $email): User + { + return DB::transaction(function () use ($name, $email) { + $rootTeam = Team::find(0); + if ($rootTeam === null) { + throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); + } + + $user = User::withoutEvents(fn () => User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ])); + + $user->teams()->attach($rootTeam, ['role' => 'member']); + + return $user; + }); + } +} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40d..461e7c2669 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4566,8 +4566,9 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // Skip if user has no password (OAuth users) - if (! Auth::user()?->hasPassword()) { + // OAuth users may have an unusable generated password, so the linked + // identity is the source of truth for whether confirmation is possible. + if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } @@ -4578,7 +4579,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fd3fbe74ba..f177e6c16f 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,7 +1,13 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -23,7 +29,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new \SocialiteProviders\Manager\Config( + $authentik_clerk_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -34,7 +40,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new \SocialiteProviders\Manager\Config( + $zitadel_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -44,8 +50,12 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } + if ($provider === 'oidc') { + return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); + } + if ($provider == 'google') { - $google_config = new \SocialiteProviders\Manager\Config( + $google_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -63,11 +73,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, - 'discord' => \SocialiteProviders\Discord\Provider::class, - 'github' => \Laravel\Socialite\Two\GithubProvider::class, - 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => BitbucketProvider::class, + 'discord' => Provider::class, + 'github' => GithubProvider::class, + 'gitlab' => GitlabProvider::class, + 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index 871c6f010c..c0ffc6f07f 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", @@ -63,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index c2c42ba71a..b77aef46f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "971daeb1b3078a36428c0fb56bb895b7", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/config/services.php b/config/services.php index c5956cf6c9..3a2a0631ef 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_REDIRECT_URI'), + 'base_url' => env('OIDC_BASE_URL'), + 'custom_label' => env('OIDC_LOGIN_LABEL'), + ], + 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 19c4445b26..13fe6b6784 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,6 +8,12 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. + * + * Coolify's own backend runs exclusively on PostgreSQL in production and + * SQLite in testing (see config/database.php — the only configured + * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed + * resources, never Coolify's application database, so no driver path is + * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php new file mode 100644 index 0000000000..3160ef9ddb --- /dev/null +++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php @@ -0,0 +1,40 @@ +string('custom_label')->nullable(); + $table->string('scopes')->nullable(); + $table->boolean('allow_registration')->default(true); + $table->boolean('require_email_verified')->default(true); + $table->boolean('use_pkce')->default(true); + $table->unsignedSmallInteger('clock_skew_seconds')->default(60); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn([ + 'custom_label', + 'scopes', + 'allow_registration', + 'require_email_verified', + 'use_pkce', + 'clock_skew_seconds', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php new file mode 100644 index 0000000000..9f838e5779 --- /dev/null +++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('issuer'); + $table->string('provider_user_id'); + $table->string('email')->nullable()->index(); + $table->json('raw_claims')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php new file mode 100644 index 0000000000..06c0f1dd52 --- /dev/null +++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('disable_registration_when_oauth_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('disable_registration_when_oauth_enabled'); + }); + } +}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 0000000000..b0f5aad18a --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +boolean('auto_join_root_team')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn('auto_join_root_team'); + }); + } +}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e3e63defd..f916c4a9cd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 2ac615cc01..19d3aa42e8 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,12 +15,10 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ - 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ - 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/lang/de.json b/lang/de.json index 7c43300e67..cbc2237a75 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,6 +7,7 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", + "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index 12c21b6665..b97a10d629 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,6 +8,7 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", + "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index bcd8e23937..b05437ac4e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,6 +8,7 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", + "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 0000000000..9c542584ef --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..900ef8af71 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +1,4 @@ +import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate @@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); +document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js new file mode 100644 index 0000000000..0ce8d5d67d --- /dev/null +++ b/resources/js/copy-button.js @@ -0,0 +1,35 @@ +// Alpine data provider for the component (x-data="copyButton"). +export function initializeCopyButtonComponent() { + window.Alpine.data('copyButton', () => ({ + copied: false, + async copy(value) { + if (value === null || value === undefined) { + window.toast('Value is not available.', { type: 'warning' }); + return; + } + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + // Deprecated, but the only copy path on plain http (non-secure contexts). + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command was rejected.'); + } + } + this.copied = true; + setTimeout(() => (this.copied = false), 1200); + } catch (e) { + window.toast('Could not copy to clipboard.', { type: 'warning' }); + } + }, + })); +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 829a26cad3..12eb57867c 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,11 +80,15 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - {{ __("auth.login.$provider_setting->provider") }} + @if ($provider_setting->provider !== 'oidc') + + @endif + {{ $provider_setting->loginLabel() }} @endforeach
diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index dfdceef20b..3333a62bfa 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,22 +1,20 @@ @props([ - 'value', + 'value' => null, + 'resolve' => null, 'label' => 'Copy to clipboard', ]) - diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php deleted file mode 100644 index e299610eb2..0000000000 --- a/resources/views/components/forms/copy-button.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@props(['text', 'label' => null]) - -
- @if ($label) - - @endif -
- - -
-
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php new file mode 100644 index 0000000000..d31fac0bca --- /dev/null +++ b/resources/views/components/forms/copy-input.blade.php @@ -0,0 +1,15 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index d63c1953f2..d0778dce4f 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,17 +287,8 @@
- +
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 04471497f5..a46e4194cf 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,6 +63,7 @@ 'upload' => '', 'x' => '', 'check' => '', + 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index d2b3e30a6f..a17b0b96a6 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,6 +12,12 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, + auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ + 'label' => 'Integration Tokens', + 'route' => 'security.integration-tokens', + 'active' => request()->routeIs('security.integration-tokens'), + 'icon' => 'network', + ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index 0e0de551fd..dbe381e050 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,6 +12,24 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], + [ + 'label' => 'Authentication', + 'route' => 'settings.oauth', + 'active' => $activeMenu === 'oauth', + 'icon' => 'keys', + ], + [ + 'label' => 'Transactional Email', + 'route' => 'settings.email', + 'active' => $activeMenu === 'email', + 'icon' => 'notifications', + ], + [ + 'label' => 'Instance Backup', + 'route' => 'settings.backup', + 'active' => $activeMenu === 'backup', + 'icon' => 'database', + ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a97d8c1df7..82b8cbcbdb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,30 +225,6 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; - async function copyToClipboard(text) { - try { - if (navigator.clipboard?.writeText && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('Copy command was rejected.'); - } - } - window.Livewire.dispatch('success', 'Copied to clipboard.'); - } catch (error) { - window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); - } - } - window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index ef54d3e215..da5329a475 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,15 +134,22 @@
+ :disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)"> Change
-
- + + - + @endif
@@ -249,9 +257,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 8ab1442ba5..6997b766b8 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 81c19bd3f0..42ade3da6e 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@

Mount a Docker volume inside the container.

- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 923514efcc..87ecd69985 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,7 +219,8 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" + :resourceableType="get_class($resource)" :resourceableId="$resource->id" /> @endif @endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 84d03c0fe8..5492d33f90 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,7 +28,10 @@ - - - -
+
+ @unless (auth()->user()?->isMember() ?? true) + + @endunless +
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 2e92c73146..1a032f6964 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 25a4fd7492..dbe21fd7b8 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,7 +154,24 @@
Source Path - + @if (filled($form['hostPath'])) +
+
+ +
+ +
+ @else + - + @endif
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 784843f6f0..40ea7b7e09 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index c8c42763fa..6c87e3098d 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 38db6aa3a6..80647458df 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,7 +109,12 @@ @if (session()->has('token')) - +
+ + +
@endif diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php new file mode 100644 index 0000000000..b7e53dbc7c --- /dev/null +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -0,0 +1,52 @@ +
+
+
+ + +
+ +
+
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+ + Create a replacement token in Cloudflare + +
+ @endif + +
+ + + Validate and save + +
+
+
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php new file mode 100644 index 0000000000..d847fff7fb --- /dev/null +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -0,0 +1,49 @@ +
+
+ + +
+ + +
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+

Limit zone resources to the zones Coolify should manage.

+ + Create this token in Cloudflare + +
+ @endif + +
+ + Validate and add + +
+ +
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php new file mode 100644 index 0000000000..b4961551ae --- /dev/null +++ b/resources/views/livewire/security/integration-tokens.blade.php @@ -0,0 +1,84 @@ +
+ + Integration Tokens | Coolify + + + +
+ + + @can('create', App\Models\IntegrationToken::class) + + + + + + + @endcan + + + @if ($tokens->isEmpty()) + + @else +
+ @foreach ($tokens as $savedToken) +
+ + +
+
+

+ +

+
+
+ {{ ucfirst($savedToken->provider) }} +
+
+ +
+ +
+
+ +
+
+ @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 94d2050dc2..2279e62e39 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index d490b6f1db..f1e4fc3f7a 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications - can be managed from + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status + notifications can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 97822b9251..822c035b31 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,76 +5,126 @@ -
- -
+
+ +
+
- @foreach ($oauth_settings_map as $oauth_setting) - @php - $provider = $oauth_setting['provider']; - $providerLabel = str($provider)->headline(); - @endphp + + + + + @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $oauth_setting['label'] }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- - - +
+ @if ($provider === 'oidc') + + + + + + +
+ +
+ @else + + + + @endif @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif + +
+ +
+ @if ($provider === 'oidc') + + + + @endif +
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d15a1b87ab..d05ac5ac98 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,12 +13,19 @@
- + ]" /> + {{ $invite->link }} - +
', false); + + Livewire::test(SettingsOauth::class) + ->set('disable_registration_when_oauth_enabled', true) + ->call('saveRegistrationPolicy') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('shows oidc fields with a naked okta issuer url example', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('OpenID Connect') + ->assertSee('https://example.okta.com', false) + ->assertDontSee('/oauth2/default', false); +}); + +it('groups oidc fields in the expected desktop order', function () { + $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); + $fields = [ + 'redirect_uri', + 'base_url', + 'client_id', + 'client_secret', + 'scopes', + 'clock_skew_seconds', + 'custom_label', + ]; + $positions = array_map( + fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), + $fields, + ); + + expect($positions)->not->toContain(false) + ->and($positions)->toBe(collect($positions)->sort()->values()->all()) + ->and($view)->toContain('
'); +}); + +it('shows provider enable controls as settings section actions', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Enable') + ->assertDontSee('label="Enabled"', false) + ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); +}); + +it('stacks oidc option checkboxes vertically', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Allow OIDC user creation') + ->assertSee('Require verified email') + ->assertSee('Use PKCE') + ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); +}); + +it('does not show unknown oauth providers', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get('/settings/oauth/unknown') + ->assertNotFound(); +}); + +it('defaults oidc user creation and verified email requirement to enabled', function () { + $setting = OauthSetting::where('provider', 'oidc')->first(); + + expect($setting->allow_registration)->toBeTrue() + ->and($setting->require_email_verified)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeFalse(); +}); + +it('persists oidc oauth settings from livewire', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') + ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') + ->set('oauth_settings_map.oidc.allow_registration', true) + ->set('oauth_settings_map.oidc.auto_join_root_team', true) + ->set('oauth_settings_map.oidc.require_email_verified', true) + ->set('disable_registration_when_oauth_enabled', true) + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') + ->and($setting->base_url)->toBe('https://idp.example.com') + ->and($setting->custom_label)->toBe('Login with Okta') + ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->allow_registration)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeTrue(); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('saves only the selected provider from provider pages', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') + ->set('oauth_settings_map.authentik.enabled', true) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'authentik')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->client_id)->toBe('authentik-client') + ->and($setting->base_url)->toBe('https://authentik.example.com'); +}); + +it('validates oidc url fields before saving', function (string $field, string $value) { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set("oauth_settings_map.oidc.$field", $value) + ->call('submit') + ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->{$field})->toBeNull(); +})->with([ + 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], + 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], + 'invalid issuer url' => ['base_url', 'not-a-url'], + 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], +]); + +it('does not enable oidc without required fields', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->call('instantSave', 'oidc') + ->assertDispatched('error'); + + expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); +}); + +it('keeps provider disabled in the ui when enable validation fails', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->call('toggleProvider', 'authentik') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('disables an enabled provider gracefully when required fields become incomplete', function () { + actingAsInstanceAdmin(); + + OauthSetting::where('provider', 'authentik')->first()->forceFill([ + 'enabled' => true, + 'client_id' => 'authentik-client', + 'client_secret' => 'authentik-secret', + 'base_url' => 'https://authentik.example.com', + ])->save(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_secret', '') + ->call('submit') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('toggles provider enabled state from the action button', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('toggleProvider', 'authentik') + ->assertHasNoErrors(); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); +}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 45e150dfab..272156fbd2 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'bash -se' << \\") + ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 13b6de23e5..949a301923 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,27 +51,21 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain('aria-label="Copy invitation link"') - ->toContain('window.copyToClipboard(@js($invite->link))') - ->toContain('class="button h-7! shrink-0 px-2!"'); + ->toContain(''); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('window.copyToClipboard(') + ->assertSeeHtml('x-data="copyButton"') ->assertSeeHtml('type="button"'); }); -it('exposes a resilient global copyToClipboard helper', function () { +it('keeps clipboard logic in the shared copy button instead of a global helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout) - ->toContain('async function copyToClipboard(text)') - ->toContain('window.copyToClipboard = copyToClipboard') - ->toContain('document.execCommand(\'copy\')') - ->toContain('window.isSecureContext'); + expect($layout)->not->toContain('copyToClipboard'); }); it('preserves a provisional user when revoking their invitation fails', function () { diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php new file mode 100644 index 0000000000..d8ccf86510 --- /dev/null +++ b/tests/Feature/UserSeederTest.php @@ -0,0 +1,16 @@ +seed(UserSeeder::class); + + $user = User::factory()->create(); + + expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) + ->and($user->id)->toBe(3); +}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php new file mode 100644 index 0000000000..d8050c84d9 --- /dev/null +++ b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php @@ -0,0 +1,62 @@ +invoke(new InstallPrerequisites); + + expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); +}); + +it('installs every Docker CLI plugin required on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); + + $command = $method->invoke(new InstallDocker); + + expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); +}); + +it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); + + $action = new InstallDocker; + $commands = $method->invoke($action, true); + + expect($commands) + ->toBe(['rc-update add docker default', 'rc-service docker restart']) + ->each->not->toContain('systemctl') + ->and($method->invoke($action, false)) + ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); +}); + +it('parses Alpine package updates', function () { + $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); + $output = <<<'OUTPUT' +docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] +libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] +OUTPUT; + + $result = $method->invoke(new CheckUpdates, $output); + + expect($result)->toBe([ + 'total_updates' => 2, + 'updates' => [ + [ + 'package' => 'docker-cli-compose', + 'new_version' => '2.31.0-r5', + 'architecture' => 'x86_64', + 'current_version' => '2.31.0-r4', + ], + [ + 'package' => 'libcrypto3', + 'new_version' => '3.3.4-r0', + 'architecture' => 'aarch64', + 'current_version' => '3.3.3-r0', + ], + ], + ]); +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index b7901abb68..140be57643 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values', $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBe('Changed') - ->and($change['old_display_value'])->toBe('••••••••') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); + ->and($change['display_summary'])->toBeNull() + ->and($change['old_display_value'])->toBe('old-secret') + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); }); -it('describes added environment variables as set without exposing secret values', function () { +it('describes added unlocked environment variables with their value', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -361,6 +361,6 @@ it('describes added environment variables as set without exposing secret values' expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php new file mode 100644 index 0000000000..48fb50c375 --- /dev/null +++ b/tests/Unit/OauthSettingTest.php @@ -0,0 +1,30 @@ + 'oidc']); + expect($setting->couldBeEnabled())->toBeFalse(); + + $setting->fill([ + 'client_id' => 'client-id', + 'client_secret' => 'secret', + 'base_url' => 'https://idp.example.com', + ]); + + expect($setting->couldBeEnabled())->toBeTrue(); +}); + +it('returns configured scopes and custom login label', function () { + $setting = new OauthSetting([ + 'provider' => 'oidc', + 'scopes' => 'openid email profile groups', + 'custom_label' => 'Login with Okta', + ]); + + expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->loginLabel())->toBe('Login with Okta'); +}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php new file mode 100644 index 0000000000..18c358fd13 --- /dev/null +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -0,0 +1,119 @@ + Http::response([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]), + 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), + ]); + + $service = app(OidcDiscoveryService::class); + + $discovery = $service->discover('https://idp.example.com'); + $jwks = $service->jwks($discovery->jwksUri); + + expect($discovery->issuer)->toBe('https://idp.example.com') + ->and($jwks['keys'][0]['kid'])->toBe('one'); + + Http::assertSentCount(2); + + $service->discover('https://idp.example.com'); + $service->jwks('https://idp.example.com/jwks'); + + Http::assertSentCount(2); +}); + +it('does not cache discovery documents with mismatched issuers', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') + ->push([ + 'issuer' => 'https://evil.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]) + ->push([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]); + + $service = app(OidcDiscoveryService::class); + $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); + + expect(fn () => $service->discover('https://idp.example.com')) + ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') + ->and(Cache::has($cacheKey))->toBeFalse() + ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); + + Http::assertSentCount(2); +}); + +it('refetches jwks once on forced refresh to pick up rotated keys', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/jwks') + ->push(['keys' => [['kid' => 'old']]]) + ->push(['keys' => [['kid' => 'new']]]); + + $service = app(OidcDiscoveryService::class); + + expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); + + // Forced refresh bypasses the cache and sees the rotated key. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); + + // Cooldown prevents a second immediate upstream fetch; cached value returned. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); +}); + +it('rejects invalid discovery and jwks payloads', function () { + Cache::flush(); + Http::fake([ + 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), + ]); + + app(OidcDiscoveryService::class)->discover('https://bad.example.com'); +})->throws(OidcDiscoveryException::class); + +it('rejects jwks responses without keys', function () { + Cache::flush(); + Http::fake([ + 'https://idp.example.com/jwks' => Http::response(['empty' => true]), + ]); + + app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); +})->throws(OidcJwksException::class); + +it('rejects non-https issuer urls', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->discover('http://idp.example.com'); +})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); + +it('rejects non-https jwks uris', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); +})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php new file mode 100644 index 0000000000..b92ff58ffe --- /dev/null +++ b/tests/Unit/OidcProviderPkceTest.php @@ -0,0 +1,148 @@ +getAuthUrl($state); + } +} + +function oidc_provider_discovery_document(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +function oidc_provider_session(): Store +{ + $session = new Store('testing', new ArraySessionHandler(1200)); + $session->start(); + + return $session; +} + +function oidc_provider_request(Store $session, string $state = 'state-value'): Request +{ + $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); + $request->setLaravelSession($session); + + return $request; +} + +function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl +{ + /** @var OidcDiscoveryService&MockInterface $discoveryService */ + $discoveryService = Mockery::mock(OidcDiscoveryService::class); + $discoveryService->shouldReceive('discover') + ->byDefault() + ->with('https://idp.example.com') + ->andReturn(oidc_provider_discovery_document()); + + /** @var OidcTokenValidator&MockInterface $tokenValidator */ + $tokenValidator = Mockery::mock(OidcTokenValidator::class); + + return (new TestOidcProviderWithExposedAuthUrl( + $request, + $discoveryService, + $tokenValidator, + 'client-id', + 'client-secret', + 'https://coolify.example.com/auth/oidc/callback', + ))->setConfig(new OidcConfig( + issuerUrl: 'https://idp.example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://coolify.example.com/auth/oidc/callback', + usePkce: true, + )); +} + +it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { + Carbon::setTestNow('2026-06-15 12:00:00'); + + try { + $session = oidc_provider_session(); + $provider = oidc_provider(oidc_provider_request($session)); + + $provider->authUrlForState('state-value'); + + $nonceEntry = $session->get('oidc.nonce.state-value'); + $verifierEntry = $session->get('oidc.code_verifier.state-value'); + + expect($nonceEntry)->toBeArray() + ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() + ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) + ->and($verifierEntry)->toBeArray() + ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() + ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); + } finally { + Carbon::setTestNow(); + } +}); + +it('sends a fresh oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'fresh-verifier', + 'expires_at' => now()->addMinute()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); + + parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); + + expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') + ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); +}); + +it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'expired-verifier', + 'expires_at' => now()->subSecond()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); +})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php new file mode 100644 index 0000000000..9b1d9a24c3 --- /dev/null +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -0,0 +1,187 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($privateKey, $privatePem); + $details = openssl_pkey_get_details($privateKey); + + return [ + 'private_pem' => $privatePem, + 'jwks' => [ + 'keys' => [[ + 'kty' => 'RSA', + 'kid' => $kid, + 'alg' => 'RS256', + 'use' => 'sig', + 'n' => oidc_base64url($details['rsa']['n']), + 'e' => oidc_base64url($details['rsa']['e']), + ]], + ], + ]; +} + +function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string +{ + $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); + $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); + $signatureInput = $header.'.'.$payload; + openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); + + return $signatureInput.'.'.oidc_base64url($signature); +} + +function oidc_discovery(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +it('validates a well formed RS256 id token', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + 'email' => 'User@Example.com', + ], $keyset['private_pem']); + + $claims = app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); + + expect($claims['sub'])->toBe('okta-user-1') + ->and($claims['email'])->toBe('User@Example.com'); +}); + +it('rejects invalid token claims', function (array $claimOverrides, string $message) { + $keyset = oidc_keyset(); + $now = time(); + $claims = array_merge([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $claimOverrides); + + $token = oidc_token($claims, $keyset['private_pem']); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], + 'audience mismatch' => [['aud' => 'other-client'], 'audience'], + 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], + 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], + 'expired token' => [['exp' => time() - 3600], 'expired'], + 'future issued at' => [['iat' => time() + 3600], 'issued'], + 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], + 'missing subject' => [['sub' => null], 'subject'], + 'empty subject' => [['sub' => ''], 'subject'], + 'non-string subject' => [['sub' => 123], 'subject'], +]); + +it('rejects a bad signature and unknown key id', function (string $kid) { + $keyset = oidc_keyset('test-key'); + $otherKeyset = oidc_keyset($kid); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $otherKeyset['private_pem'], $kid); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'same kid with bad signature' => ['test-key'], + 'unknown kid' => ['other-key'], +]); + +it('rejects disallowed algorithms', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem'], algorithm: 'HS256'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); + +it('throws a dedicated exception when the signing key is unknown', function () { + $keyset = oidc_keyset('current-key'); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => time(), + 'exp' => time() + 600, + ], $keyset['private_pem'], 'rotated-key'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcSigningKeyNotFoundException::class); + +it('rejects a jwks key not designated for signing', function () { + $keyset = oidc_keyset(); + $keyset['jwks']['keys'][0]['use'] = 'enc'; + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem']); + + // An encryption-only key is dropped from the keyset, so the kid no longer resolves. + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index d2d4ae600f..4dedc7a768 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase ); } + public function test_remote_shell_prefers_bash_and_falls_back_to_sh() + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); + + $this->assertSame( + 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', + $reflection->invoke(null) + ); + } + public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 7a73f59795..4a275ad484 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,6 +4,7 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -18,7 +19,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -70,6 +71,21 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); +test('delete succeeds without password for an oauth user', function () { + OauthIdentity::create([ + 'user_id' => $this->user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oauth-user-id', + ]); + + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', '') + ->assertHasNoErrors(); + + expect(Application::find($this->application->id))->toBeNull(); +}); + test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From dd90926583e21fbc872203fd1a6d24e6c4ca434d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:19:05 +0200 Subject: [PATCH 56/86] fix(ci): protect main during sync conflicts --- .github/workflows/sync-main-to-next.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 614175d9b4..595a21e799 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -45,15 +45,17 @@ jobs: exit 1 fi - existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url') + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') if [ -n "$existing_pr" ]; then echo "A main to next pull request already exists: $existing_pr" else + git push --force origin origin/main:"refs/heads/$sync_branch" gh pr create \ --base next \ - --head main \ + --head "$sync_branch" \ --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts.' + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' fi echo 'main could not be merged into next without conflicts.' From e82843ac3979979fa761045635f62d6d43fbaf4d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:55:54 +0200 Subject: [PATCH 57/86] fix(terminal): distinguish containers across servers Namespace terminal container targets by server UUID and bump Coolify to 4.3.11. --- app/Livewire/Terminal/Index.php | 2 +- config/constants.php | 2 +- other/nightly/versions.json | 2 +- tests/Feature/TerminalContainerListTest.php | 39 +++++++++++++++++++++ tests/Unit/ProductionImageWorkflowTest.php | 4 +-- versions.json | 2 +- 6 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/TerminalContainerListTest.php diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e908..116db1eed1 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -47,7 +47,7 @@ class Index extends Component return [ 'name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'), - 'uuid' => data_get($container, 'Names'), + 'uuid' => $server->uuid.':'.data_get($container, 'Names'), 'status' => data_get_str($container, 'State')->lower(), 'server' => $server, 'server_uuid' => $server->uuid, diff --git a/config/constants.php b/config/constants.php index a406dd0ea7..ca05db48f4 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.10', + 'version' => env('COOLIFY_VERSION') ?: '4.3.11', 'helper_version' => '1.0.15', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 440ad36160..d4e5b5c8c9 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.10" + "version": "4.3.11" }, "nightly": { "version": "4.4-rc.1" diff --git a/tests/Feature/TerminalContainerListTest.php b/tests/Feature/TerminalContainerListTest.php new file mode 100644 index 0000000000..ec64da51c9 --- /dev/null +++ b/tests/Feature/TerminalContainerListTest.php @@ -0,0 +1,39 @@ +makePartial(); + $server->forceFill(['uuid' => $uuid, 'name' => $name]); + $server->shouldReceive('isFunctional')->once()->andReturnTrue(); + $server->shouldReceive('loadAllContainers')->once()->andReturn(collect($containers)); + + return $server; +} + +it('keeps containers with the same name on different servers as distinct terminal targets', function () { + $component = new Index; + $component->servers = new Collection([ + terminalServer('pulse-uuid', 'Pulse', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + terminalServer('forge-uuid', 'Forge', [ + ['Names' => 'coolify-proxy', 'State' => 'running'], + ['Names' => 'coolify-sentinel', 'State' => 'running'], + ]), + ]); + + $component->loadContainers(); + + expect($component->containers)->toHaveCount(4) + ->and(collect($component->containers)->pluck('uuid')->all())->toBe([ + 'pulse-uuid:coolify-proxy', + 'forge-uuid:coolify-proxy', + 'pulse-uuid:coolify-sentinel', + 'forge-uuid:coolify-sentinel', + ]); +}); diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 659d233122..2fafa678e8 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve ->toContain('ARG COOLIFY_VERSION') ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}') ->and($constants) - ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.10'") - ->and($versions['coolify']['v4']['version'])->toBe('4.3.10') + ->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'") + ->and($versions['coolify']['v4']['version'])->toBe('4.3.11') ->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1') ->and($nightlyVersions)->toBe($versions); }); diff --git a/versions.json b/versions.json index 440ad36160..d4e5b5c8c9 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.10" + "version": "4.3.11" }, "nightly": { "version": "4.4-rc.1" From eb57a691cac2faceb0f9c0aa91bcd25e9bed8939 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:04 +0200 Subject: [PATCH 58/86] feat(domains): add asynchronous DNS validation Add queued DNS checks with polling, status indicators, and notifications for application and service domains. Improve volume backup target labels and names. --- app/Actions/Shared/CheckDomainDns.php | 142 ++++++++++ app/Jobs/CheckDomainDnsJob.php | 90 ++++++ app/Livewire/Project/Application/Domains.php | 266 ++++++++++++++---- app/Livewire/Project/Service/Domains.php | 225 ++++++++++++--- .../Project/Service/VolumeBackup/Create.php | 8 +- .../project/application/domains.blade.php | 4 + .../application/partials/domain-row.blade.php | 1 + .../project/service/domains.blade.php | 4 + .../service/partials/domain-table.blade.php | 1 + tests/Feature/ApplicationDomainsTest.php | 130 +++++++-- tests/Feature/CheckDomainDnsJobTest.php | 119 ++++++++ tests/Feature/DnsValidationTest.php | 55 ++++ tests/Feature/ServiceDomainsTest.php | 60 +++- tests/Feature/VolumeBackupTest.php | 41 +++ 14 files changed, 1016 insertions(+), 130 deletions(-) create mode 100644 app/Actions/Shared/CheckDomainDns.php create mode 100644 app/Jobs/CheckDomainDnsJob.php create mode 100644 tests/Feature/CheckDomainDnsJobTest.php diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 0000000000..d0cea0fb1c --- /dev/null +++ b/app/Actions/Shared/CheckDomainDns.php @@ -0,0 +1,142 @@ + $entries + * @return array + */ + public function handle( + array $entries, + ?Server $server, + ?string $expectedIp, + bool $skipForMultipleServers = false, + int $timeoutSeconds = 5, + ): array { + if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) { + return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp); + } + + if (! $server) { + return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null); + } + + if ($skipForMultipleServers) { + return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp); + } + + $deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000); + $dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers')) + ->explode(',') + ->map(fn ($dnsServer) => trim((string) $dnsServer)) + ->filter() + ->values(); + $results = []; + + foreach ($entries as $key => $url) { + $results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline); + } + + return $results; + } + + /** + * @param array $dnsServers + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array + { + try { + $host = Url::fromString($url)->getHost(); + } catch (\Throwable) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + if (str($host)->contains('sslip.io')) { + return $this->result('ok', 'DNS looks correct.', $expectedIp); + } + + $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + + foreach ($dnsServers as $dnsServer) { + $remainingNanoseconds = $deadline - hrtime(true); + if ($remainingNanoseconds < 1_000_000_000) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + + try { + $query = app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), + ]); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() !== $type) { + continue; + } + + if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } catch (\Throwable) { + continue; + } + } + + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + + private function successMessage(Server $server, ?string $expectedIp): string + { + if ( + filled($expectedIp) + && filled($server->ip) + && $server->ip !== $expectedIp + && filter_var($server->ip, FILTER_VALIDATE_IP) === false + ) { + return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare)."; + } + + return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.'; + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function result(string $status, string $message, ?string $expectedIp): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $entries + * @return array + */ + private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array + { + $result = $this->result($status, $message, $expectedIp); + + return array_fill_keys(array_keys($entries), $result); + } +} diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 0000000000..1a7ceaeabc --- /dev/null +++ b/app/Jobs/CheckDomainDnsJob.php @@ -0,0 +1,90 @@ +persistResults(CheckDomainDns::run( + [$this->statusKey => $this->url], + $this->server, + $this->expectedIp, + $this->skipForMultipleServers, + )); + } + + public function failed(?\Throwable $exception): void + { + $this->persistResults([ + $this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'), + ]); + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function status(string $status, string $message): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $this->expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $results + */ + private function persistResults(array $results): void + { + DB::transaction(function () use ($results): void { + $resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey()); + if (! $resource) { + return; + } + + $statuses = $resource->domain_dns_statuses ?? []; + + foreach ($results as $key => $result) { + if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) { + continue; + } + + $statuses[$key] = $result; + } + + $resource->domain_dns_statuses = $statuses === [] ? null : $statuses; + $resource->save(); + }); + } +} diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 45a76a4c33..2f9370871c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Application; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; @@ -10,6 +12,7 @@ use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Domains extends Component @@ -141,6 +144,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->application); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); @@ -464,6 +500,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -478,6 +515,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -533,6 +571,8 @@ class Domains extends Component || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +588,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +619,50 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; + } + + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + + // Keep suggested-row copy short after DNS checks (no role badge). + if ($this->domainRows[$index]['is_suggested'] ?? false) { + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; - } - // Keep suggested-row copy short after DNS checks (no role badge). - if ($this->domainRows[$index]['is_suggested'] ?? false) { - $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $serviceName = $this->domainRows[$index]['service'] ?? null; - $meta = $this->suggestedDomainMeta( - $isWww, - $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) - ); - $this->domainRows[$index]['dns_message'] = $meta['pending_message']; - $this->domainRows[$index]['suggestion_label'] = null; - $this->domainRows[$index]['suggestion_role'] = $meta['role']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; } - - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** @@ -647,11 +696,34 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } + DB::transaction(function () use (&$statuses): void { + $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; - $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void @@ -804,16 +876,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; if (! $this->saveDomainList($merged, $this->newDomainService)) { @@ -825,14 +887,110 @@ class Domains extends Component $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + ->map(fn (string $url, string $statusKey) => [ + 'status_key' => $statusKey, + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + $this->persistDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $this->application, + $dnsCheck['status_key'], + $dnsCheck['url'], + $this->application->destination?->server, + $this->serverIp, + $dnsCheck['check_id'], + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $service, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + + /** + * @param array $urls + * @return array + */ + protected function dnsEntriesForUrls(array $urls, ?string $service = null): array + { + $entries = []; + + foreach ($urls as $url) { + $entries[$this->domainDnsStatusKey($url, $service)] = $url; + } + + return $entries; + } + /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * @@ -875,7 +1033,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1071,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4690335d86..d932e76494 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Service; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; @@ -131,6 +133,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->service); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void { $application = $this->service->applications()->findOrFail($serviceApplicationId); @@ -282,6 +317,7 @@ class Domains extends Component 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'checked_at' => data_get($entry, 'checked_at'), + 'check_id' => data_get($entry, 'check_id'), 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -298,6 +334,7 @@ class Domains extends Component 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, @@ -404,6 +441,8 @@ class Domains extends Component $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $this->domainRows[$index]['dns_status'] = 'skipped'; @@ -415,7 +454,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +486,37 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistAllDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; } - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; + $this->decorateSuggestedDomainAfterDnsCheck($index); + } } /** @@ -516,6 +563,7 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } @@ -528,8 +576,30 @@ class Domains extends Component ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); + DB::transaction(function () use ($app, &$statuses): void { + $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $app->domain_dns_statuses = $statuses === [] ? null : $statuses; - $app->save(); } $this->service->load('applications'); @@ -928,16 +998,6 @@ class Domains extends Component } } - if (! $this->forceSaveDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; @@ -955,14 +1015,93 @@ class Domains extends Component $this->forceRemovePort = false; $this->pendingAction = null; $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $serviceApplicationId = (int) $app->id; + $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + $this->persistAllDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $app, + $dnsCheck['url'], + $dnsCheck['url'], + $this->service->server, + $this->serverIp, + $dnsCheck['check_id'], + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { @@ -1309,7 +1448,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1473,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index adb1234e69..430e89a2c1 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ class Create extends Component $label = str($resource->name)->headline(); $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ 'key' => 'volume:'.$volume->id, - 'type' => 'Volume · '.$label, - 'name' => $volume->name, + 'type' => $label, + 'name' => str($volume->name)->after($this->service->uuid.'_')->value(), ])); $targets->push(...$resource->fileStorages() ->where('is_directory', true) @@ -109,8 +109,8 @@ class Create extends Component ->get() ->map(fn (LocalFileVolume $directory): array => [ 'key' => 'directory:'.$directory->id, - 'type' => 'Directory · '.$label, - 'name' => $directory->fs_path, + 'type' => $label, + 'name' => $directory->fs_path.' (directory)', ])); } diff --git a/resources/views/livewire/project/application/domains.blade.php b/resources/views/livewire/project/application/domains.blade.php index 4adbcf8ff1..128f2f2627 100644 --- a/resources/views/livewire/project/application/domains.blade.php +++ b/resources/views/livewire/project/application/domains.blade.php @@ -2,6 +2,7 @@ $configuredCount = collect($domainRows)->where('is_suggested', false)->count(); $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; + $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $composeDomainGroups = collect($domainRows) ->groupBy(fn ($row) => $row['service'] ?? '__unknown') ->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false))); @@ -36,6 +37,9 @@ }" @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> + @if ($hasDnsChecksInProgress) + + @endif @can('update', $application) diff --git a/resources/views/livewire/project/application/partials/domain-row.blade.php b/resources/views/livewire/project/application/partials/domain-row.blade.php index 10aa21200a..6c20d0a46d 100644 --- a/resources/views/livewire/project/application/partials/domain-row.blade.php +++ b/resources/views/livewire/project/application/partials/domain-row.blade.php @@ -10,6 +10,7 @@ 'ok' => 'DNS OK', 'failed' => 'DNS mismatch', 'skipped' => 'DNS skipped', + 'checking' => 'Checking DNS...', 'pending' => 'DNS pending', default => 'DNS unknown', }; diff --git a/resources/views/livewire/project/service/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index 05e5792fe2..c228f39e59 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -2,6 +2,7 @@ $configuredCount = collect($domainRows)->where('is_suggested', false)->count(); $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; + $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $serviceAppCount = count($serviceApps); $domainGroups = collect($domainRows) ->groupBy('service_application_id') @@ -37,6 +38,9 @@ }" @open-edit-domain.window="openEditDomain()" @edit-domain-saved.window="closeEditDomain()"> + @if ($hasDnsChecksInProgress) + + @endif @can('update', $service) diff --git a/resources/views/livewire/project/service/partials/domain-table.blade.php b/resources/views/livewire/project/service/partials/domain-table.blade.php index 81daa100fe..f856285adb 100644 --- a/resources/views/livewire/project/service/partials/domain-table.blade.php +++ b/resources/views/livewire/project/service/partials/domain-table.blade.php @@ -35,6 +35,7 @@ 'ok' => 'DNS OK', 'failed' => 'DNS mismatch', 'skipped' => 'DNS skipped', + 'checking' => 'Checking DNS...', 'pending' => 'DNS pending', default => 'DNS unknown', }; diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 50c71fe958..cac3b29a39 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -1,5 +1,6 @@ call('addDomain') ->assertHasNoErrors() ->assertSet('addDomainDnsFailed', false) - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') ->assertDispatched('close-modal'); $this->application->refresh(); @@ -290,7 +292,9 @@ it('adds multiple domains without replacing existing ones', function () { ->toContain('https://api.example.com'); }); -it('blocks adding a domain with bad dns until the user continues', function () { +it('saves a domain before checking dns in a separate request', function () { + Queue::fake(); + $settings = InstanceSettings::get(); $settings->is_dns_validation_enabled = true; $settings->save(); @@ -298,15 +302,8 @@ it('blocks adding a domain with bad dns until the user continues', function () { $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) - ->assertSee('DNS is not pointing to the right IP') - ->assertSee('Are you sure you want to add it anyway'); - - $this->application->refresh(); - expect($this->application->fqdn)->toBeNull(); - - $component->call('confirmAddDomainDespiteDns') ->assertSet('addDomainDnsFailed', false) + ->assertSet('domainRows.0.dns_status', 'checking') ->assertDispatched('success') ->assertDispatched('close-modal'); @@ -315,17 +312,24 @@ it('blocks adding a domain with bad dns until the user continues', function () { 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', ]); + + expect($this->application->domain_dns_statuses['https://this-domain-should-not-resolve-for-coolify-tests.invalid']['status'] ?? null) + ->toBe('checking'); + + Queue::assertPushed(CheckDomainDnsJob::class, 2); + + $jobs = Queue::pushed(CheckDomainDnsJob::class); + + expect($jobs->pluck('statusKey')->all())->toEqualCanonicalizing([ + 'https://this-domain-should-not-resolve-for-coolify-tests.invalid', + 'https://www.this-domain-should-not-resolve-for-coolify-tests.invalid', + ])->and($jobs->pluck('checkId')->unique())->toHaveCount(2); }); it('resets the dns gate when the domain input changes', function () { - $settings = InstanceSettings::get(); - $settings->is_dns_validation_enabled = true; - $settings->save(); - Livewire::test(Domains::class, ['application' => $this->application->fresh()]) - ->set('newDomain', 'https://this-domain-should-not-resolve-for-coolify-tests.invalid') - ->call('addDomain') - ->assertSet('addDomainDnsFailed', true) + ->set('addDomainDnsFailed', true) + ->set('forceSaveDns', true) ->set('newDomain', 'https://another.example.com') ->assertSet('addDomainDnsFailed', false) ->assertSet('forceSaveDns', false); @@ -727,6 +731,98 @@ it('persists dns status after checking a domain', function () { ->and($entry['checked_at'] ?? null)->not->toBeNull(); }); +it('polls a queued dns check and notifies about a mismatch', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->application->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'failed', + 'message' => 'Required DNS record type A pointing to 203.0.113.10', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows.0.dns_status', 'failed') + ->assertDispatched('error', 'DNS is not configured for app.example.com. Review the required DNS record.'); +}); + +it('does not overwrite a completed queued dns result with stale checking state', function () { + $domain = 'https://app.example.com'; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $this->application->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['status'])->toBe('ok'); +}); + +it('does not overwrite a newer queued dns check with stale completed component state', function () { + $domain = 'https://app.example.com'; + $status = [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + 'check_id' => null, + ]; + $this->application->update([ + 'fqdn' => $domain, + 'domain_dns_statuses' => [$domain => $status], + ]); + + $component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]); + + $status['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => [$domain => $status]]); + + $method = new ReflectionMethod($component->instance(), 'persistDomainDnsStatuses'); + $method->invoke($component->instance()); + + expect($this->application->fresh()->domain_dns_statuses[$domain]['check_id'])->toBe('newer-check'); +}); + it('resolves hostname server addresses to a real ip for dns messages', function () { $this->server->update(['ip' => 'localhost']); $this->application->update([ diff --git a/tests/Feature/CheckDomainDnsJobTest.php b/tests/Feature/CheckDomainDnsJobTest.php new file mode 100644 index 0000000000..74502268a0 --- /dev/null +++ b/tests/Feature/CheckDomainDnsJobTest.php @@ -0,0 +1,119 @@ + CheckDomainDns::clearFake()); + +beforeEach(function () { + InstanceSettings::unguarded(fn () => InstanceSettings::create([ + 'id' => 0, + 'is_dns_validation_enabled' => false, + ])); + + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => 1, + 'destination_type' => 'App\\Models\\StandaloneDocker', + 'fqdn' => 'https://app.example.com', + 'domain_dns_statuses' => [ + 'https://app.example.com' => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => null, + 'checked_at' => null, + 'check_id' => 'test-check', + ], + ], + ]); +}); + +it('persists a skipped result when dns validation is disabled', function () { + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('skipped') + ->and($status['message'])->toBe('DNS validation is disabled in instance settings.') + ->and($status['checked_at'])->not->toBeNull(); +}); + +it('does not restore a dns status removed before the job finishes', function () { + $this->application->update(['domain_dns_statuses' => null]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses)->toBeNull(); +}); + +it('uses the shared dns action', function () { + CheckDomainDns::shouldRun() + ->once() + ->andReturn([ + 'https://app.example.com' => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => null, + 'checked_at' => now()->toIso8601String(), + ], + ]); + + (new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ))->handle(); + + expect($this->application->fresh()->domain_dns_statuses['https://app.example.com']['status'])->toBe('ok'); +}); + +it('does not let an older job overwrite a newer check for the same domain', function () { + $oldJob = new CheckDomainDnsJob( + $this->application, + 'https://app.example.com', + 'https://app.example.com', + null, + null, + 'test-check', + ); + + $statuses = $this->application->domain_dns_statuses; + $statuses['https://app.example.com']['check_id'] = 'newer-check'; + $this->application->update(['domain_dns_statuses' => $statuses]); + + $oldJob->handle(); + + $status = $this->application->fresh()->domain_dns_statuses['https://app.example.com']; + + expect($status['status'])->toBe('checking') + ->and($status['check_id'])->toBe('newer-check'); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index aed8eb85c8..a9ac104c71 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -1,5 +1,6 @@ InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_dns_validation_enabled' => false] + )); + + $result = CheckDomainDns::run( + ['https://example.com' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + ); + + expect($result['https://example.com']) + ->toMatchArray([ + 'status' => 'skipped', + 'message' => 'DNS validation is disabled in instance settings.', + 'expected_ip' => '203.0.113.10', + ]) + ->and($result['https://example.com']['checked_at'])->not->toBeNull(); +}); + it('stops querying DNS servers after finding a matching IP', function (string $resolvedIp) { InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( ['id' => 0], @@ -51,7 +73,40 @@ it('stops querying DNS servers after finding a matching IP', function (string $r expect(validateDNSEntry('https://example.com', $server))->toBeTrue() ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1']); + + $result = CheckDomainDns::run(['example' => 'https://example.com'], $server, $targetIp); + + expect($result['example']['status'])->toBe('ok') + ->and($queriedServers->getArrayCopy())->toBe(['192.0.2.1', '192.0.2.1']); })->with([ 'target server IP' => '203.0.113.10', 'Cloudflare IP' => '104.16.0.1', ]); + +it('does not start another resolver query after the total dns budget is exhausted', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + [ + 'is_dns_validation_enabled' => true, + 'custom_dns_servers' => '192.0.2.1,192.0.2.2', + ] + )); + + $queryCount = new ArrayObject; + app()->bind(DNSQuery::class, function () use ($queryCount) { + $queryCount->append(true); + + return new DNSQuery('192.0.2.1'); + }); + + $result = CheckDomainDns::run( + ['example' => 'https://example.com'], + new Server(['ip' => '203.0.113.10']), + '203.0.113.10', + timeoutSeconds: 0, + ); + + expect($result['example']['status'])->toBe('failed') + ->and($result['example']['message'])->toBe('Could not validate DNS for this domain.') + ->and($queryCount)->toHaveCount(0); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 899aa521f8..76ca4f2a8a 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1,5 +1,6 @@ webApp->update(['redirect' => 'both']); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success'); + + $component->call('pollDnsChecks') ->assertSee('DNS skipped'); expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com'); }); it('adds a domain to a selected service application', function () { + Queue::fake(); + Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->set('newServiceApplicationId', $this->webApp->id) ->set('newDomain', 'https://web.example.com') ->call('addDomain') ->assertHasNoErrors() - ->assertDispatched('success') + ->assertDispatched('success', 'Domain added. DNS check started.') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', 'https://web.example.com')['dns_status'] === 'checking') ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://web.example.com')) ->assertSee('https://web.example.com'); $this->webApp->refresh(); expect($this->webApp->fqdn)->toBe('https://web.example.com'); - $dnsStatuses = $this->webApp->domain_dns_statuses; + expect($this->webApp->domain_dns_statuses['https://web.example.com']['status'] ?? null)->toBe('checking'); - expect($dnsStatuses) - ->toHaveKey('https://web.example.com') - ->not->toHaveKey('https://www.web.example.com') - ->and($dnsStatuses['https://web.example.com']['status']) - ->toBe('skipped') - ->and($dnsStatuses['https://web.example.com']['checked_at']) - ->not->toBeNull(); + Queue::assertPushed(CheckDomainDnsJob::class, 1); }); it('adds a domain when the compose service has an empty environment section', function () { @@ -457,7 +458,7 @@ it('does not restore stale dns status when a removed service domain is re-added' ], ]); - Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->call('removeDomain', 0) ->set('newServiceApplicationId', $this->apiApp->id) ->set('newDomain', 'https://api.example.com') @@ -465,6 +466,8 @@ it('does not restore stale dns status when a removed service domain is re-added' ->assertHasNoErrors() ->assertDispatched('success'); + $component->call('pollDnsChecks'); + $this->apiApp->refresh(); expect(explode(',', (string) $this->apiApp->fqdn)) @@ -564,6 +567,39 @@ it('hides dns message text when service domain dns status is ok', function () { ->assertDontSee('DNS points to 203.0.113.10'); }); +it('polls a queued service dns check and notifies about success', function () { + $domain = 'https://api.example.com'; + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'checking', + 'message' => 'Checking DNS...', + 'expected_ip' => '203.0.113.10', + 'checked_at' => null, + ], + ], + ]); + + $component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) + ->assertSee('Checking DNS...') + ->assertSee('wire:poll.2000ms="pollDnsChecks"', false); + + $this->apiApp->update([ + 'domain_dns_statuses' => [ + $domain => [ + 'status' => 'ok', + 'message' => 'DNS looks correct.', + 'expected_ip' => '203.0.113.10', + 'checked_at' => now()->toIso8601String(), + ], + ], + ]); + + $component->call('pollDnsChecks') + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->firstWhere('url', $domain)['dns_status'] === 'ok') + ->assertDispatched('success', 'DNS is configured correctly for api.example.com.'); +}); + it('forbids read-only users from checking service domain dns', function (string $action, array $parameters) { $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index b4f78d9e0a..aa7da543be 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -6,6 +6,7 @@ use App\Jobs\VolumeBackupJob; use App\Jobs\VolumeBackupRecoveryJob; use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup; use App\Livewire\Project\Service\FileStorage; +use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\Show; use App\Livewire\Project\Shared\Storages\VolumeBackups; use App\Models\Application; @@ -20,6 +21,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; @@ -187,6 +189,45 @@ it('creates a scheduled backup with a preselected volume from the shared modal', ->and($backup->s3_storage_id)->toBeNull(); }); +it('shows readable service storage backup target labels', function () { + $team = Team::factory()->create(); + signInForVolumeBackups($this, $team); + [$application] = createVolumeBackupApplication($team); + $service = Service::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $resource = ServiceApplication::create([ + 'uuid' => new_public_id(), + 'name' => 'directus', + 'service_id' => $service->id, + ]); + LocalPersistentVolume::create([ + 'name' => $service->uuid.'_directus-templates', + 'mount_path' => '/directus/templates', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([ + 'uuid' => new_public_id(), + 'fs_path' => './uploads', + 'mount_path' => '/directus/uploads', + 'is_directory' => true, + 'is_based_on_git' => false, + 'is_preview_suffix_enabled' => true, + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]))); + + Livewire::test(CreateServiceVolumeBackup::class, ['service' => $service]) + ->assertSet('targets.0.name', 'directus-templates') + ->assertSet('targets.0.type', 'Directus') + ->assertSet('targets.1.name', './uploads (directory)') + ->assertSet('targets.1.type', 'Directus') + ->assertSee('Directus: directus-templates'); +}); + it('handles scheduled backup persistence failures', function () { $team = Team::factory()->create(); signInForVolumeBackups($this, $team); From 59cd5b0d72efbe8b2e64ce0346ef1463e0fc4c45 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:35:24 +0200 Subject: [PATCH 59/86] fix(api): allow system-wide GitHub Apps across teams (#11453) --- .../Api/ApplicationsController.php | 7 ++- .../ApplicationBuildSecretsSettingApiTest.php | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..285cf8e2f1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); + $githubApp = GithubApp::where('uuid', $githubAppUuid) + ->where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + }) + ->first(); if (! $githubApp) { return response()->json(['message' => 'Github App not found.'], 404); } diff --git a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php index 3b3721545f..1847c00221 100644 --- a/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php +++ b/tests/Feature/Api/ApplicationBuildSecretsSettingApiTest.php @@ -254,4 +254,58 @@ describe('other application creation endpoints use_build_secrets', function () { expect($application->settings->use_build_secrets)->toBeTrue(); }); + + test('creates an application from a system-wide GitHub App owned by another team', function () { + $ownerTeam = Team::factory()->create(); + $privateKey = PrivateKey::create([ + 'name' => 'System-wide GitHub App Key', + 'private_key' => buildSecretsGithubPrivateKey(), + 'team_id' => $ownerTeam->id, + ]); + $githubApp = GithubApp::create([ + 'name' => 'System-wide GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 54321, + 'installation_id' => 9876, + 'client_id' => 'system-wide-client-id', + 'client_secret' => 'system-wide-client-secret', + 'webhook_secret' => 'system-wide-webhook-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $ownerTeam->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [ + 'Date' => now()->toRfc7231String(), + ]), + 'https://api.github.com/app/installations/9876/access_tokens' => Http::response([ + 'token' => 'github-installation-token', + ], 201), + 'https://api.github.com/repos/coolify/system-wide-test' => Http::response([ + 'id' => 654321, + ]), + ]); + + $response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/private-github-app', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'github_app_uuid' => $githubApp->uuid, + 'git_repository' => 'coolify/system-wide-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->source_id)->toBe($githubApp->id) + ->and($application->environment_id)->toBe($this->environment->id); + }); }); From 60eff6fb6ef8522a3fdb5fdbc97b2984dbf626f6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:53:20 +0200 Subject: [PATCH 60/86] fix(docker): update helper CLI for registry pushes (#11461) --- CONTRIBUTING.md | 29 ++ app/Livewire/Settings/Index.php | 2 +- bootstrap/helpers/shared.php | 9 +- docker/coolify-helper/Dockerfile | 6 +- scripts/dev-helper | 280 ++++++++++++++++++ .../DevHelperVersionValidationTest.php | 19 ++ tests/Unit/DevHelperScriptTest.php | 19 ++ tests/Unit/DockerCliPackagingTest.php | 12 + 8 files changed, 368 insertions(+), 8 deletions(-) create mode 100755 scripts/dev-helper create mode 100644 tests/Unit/DevHelperScriptTest.php create mode 100644 tests/Unit/DockerCliPackagingTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b6..626f6d7b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 40705617f1..829fbda800 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -212,7 +212,7 @@ class Index extends Component return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40d..c3bd4a2238 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4152,11 +4152,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeebe..94330bbcec 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/scripts/dev-helper b/scripts/dev-helper new file mode 100755 index 0000000000..1e1bfae27b --- /dev/null +++ b/scripts/dev-helper @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper} +DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)" +COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify} + +usage() { + cat < [tag] + +Commands: + build [tag] Build the local helper image + use [tag] Configure local Coolify to use an existing helper image + verify [tag] Verify tools and run a Docker Compose socket smoke test + deploy [tag] Deploy seeded Dockerfile, Compose, and Nixpacks applications + reset Clear the local helper version override + test [tag] Build, select, verify, and run real seeded deployments + +Default tag: $DEFAULT_TAG +EOF +} + +require_local_coolify() { + local is_dev + + if ! docker inspect "$COOLIFY_CONTAINER" >/dev/null 2>&1; then + echo "Coolify container '$COOLIFY_CONTAINER' is not running." >&2 + exit 1 + fi + + is_dev=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo isDev() ? "yes" : "no";' 2>/dev/null | tail -1) + if [[ $is_dev != yes ]]; then + echo "The running Coolify instance is not in development mode." >&2 + exit 1 + fi +} + +validate_tag() { + local tag=$1 + + if [[ ! $tag =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + echo "Invalid Docker tag: $tag" >&2 + exit 1 + fi +} + +image_for() { + echo "${REPOSITORY}:$1" +} + +build_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + echo "Building $image" + docker build --progress=plain -f "$ROOT_DIR/docker/coolify-helper/Dockerfile" -t "$image" "$ROOT_DIR" +} + +use_helper() { + local tag=$1 + local image + image=$(image_for "$tag") + + require_local_coolify + docker image inspect "$image" >/dev/null + docker exec -e DEV_HELPER_VERSION="$tag" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => getenv("DEV_HELPER_VERSION"), + ]); + ' >/dev/null + + local selected + selected=$(docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute 'echo getHelperVersion();' 2>/dev/null | tail -1) + if [[ $selected != "$tag" ]]; then + echo "Coolify selected helper '$selected' instead of '$tag'." >&2 + exit 1 + fi + + echo "Coolify now uses $image" +} + +verify_helper() { + local tag=$1 + local image project compose + image=$(image_for "$tag") + project="coolify-helper-${tag//[^A-Za-z0-9_-]/-}-smoke" + compose=$'services:\n app:\n image: alpine:3.21\n command: ["sh", "-c", "sleep 30"]\n' + + docker image inspect "$image" >/dev/null + + docker run --rm "$image" docker --version + docker run --rm "$image" docker compose version + docker run --rm "$image" docker buildx version + docker run --rm "$image" pack version + docker run --rm "$image" nixpacks --version + docker run --rm "$image" railpack --version + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker version --format 'client={{.Client.Version}} server={{.Server.Version}} api={{.Client.APIVersion}}/{{.Server.APIVersion}}' + + cleanup_compose() { + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - down --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup_compose RETURN + + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - up -d --wait + printf '%s' "$compose" | docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock "$image" \ + docker compose -p "$project" -f - ps --format json + cleanup_compose + trap - RETURN + + if [[ -n $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]]; then + echo "Compose smoke-test resources were not removed." >&2 + exit 1 + fi + + echo "Helper verification passed for $image" +} + +queue_deployment() { + local application_uuid=$1 + + docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $application = App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->firstOrFail(); + $deploymentUuid = (string) Illuminate\Support\Str::uuid(); + queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + force_rebuild: true, + no_questions_asked: true, + ); + echo "DEPLOYMENT_UUID={$deploymentUuid}"; + ' 2>/dev/null | sed -n 's/^DEPLOYMENT_UUID=//p' | tail -1 +} + +wait_for_deployment() { + local deployment_uuid=$1 + local expected_image=$2 + local status + + for _ in $(seq 1 150); do + status=$(docker exec -e DEPLOYMENT_UUID="$deployment_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->first(); + echo $deployment?->status ?? "missing"; + ' 2>/dev/null | tail -1) + + case "$status" in + finished) + break + ;; + failed|cancelled|missing) + echo "Deployment $deployment_uuid ended with status: $status" >&2 + return 1 + ;; + esac + + sleep 4 + done + + if [[ $status != finished ]]; then + echo "Deployment $deployment_uuid timed out with status: $status" >&2 + return 1 + fi + + local used_expected_helper + used_expected_helper=$(docker exec \ + -e DEPLOYMENT_UUID="$deployment_uuid" \ + -e EXPECTED_HELPER_IMAGE="$expected_image" \ + "$COOLIFY_CONTAINER" php artisan tinker --execute ' + $deployment = App\Models\ApplicationDeploymentQueue::query() + ->where("deployment_uuid", getenv("DEPLOYMENT_UUID")) + ->firstOrFail(); + $logs = collect(json_decode($deployment->logs, true)) + ->pluck("output") + ->implode("\n"); + echo str_contains( + $logs, + "Preparing container with helper image: ".getenv("EXPECTED_HELPER_IMAGE"), + ) ? "yes" : "no"; + ' 2>/dev/null | tail -1) + + if [[ $used_expected_helper != yes ]]; then + echo "Deployment $deployment_uuid did not use $expected_image." >&2 + return 1 + fi +} + +deploy_examples() { + local tag=$1 + local image application_uuid deployment_uuid fqdn + image=$(image_for "$tag") + + require_local_coolify + use_helper "$tag" + + for application_uuid in dockerfile docker-compose nodejs; do + echo "Deploying seeded application: $application_uuid" + deployment_uuid=$(queue_deployment "$application_uuid") + if [[ -z $deployment_uuid ]]; then + echo "Could not queue $application_uuid." >&2 + exit 1 + fi + + wait_for_deployment "$deployment_uuid" "$image" + + fqdn=$(docker exec -e APPLICATION_UUID="$application_uuid" "$COOLIFY_CONTAINER" php artisan tinker --execute ' + echo App\Models\Application::query() + ->where("uuid", getenv("APPLICATION_UUID")) + ->value("fqdn") ?? ""; + ' 2>/dev/null | tail -1) + + if [[ -n $fqdn ]]; then + curl --fail --silent --show-error --output /dev/null "$fqdn" + fi + + echo "Deployment passed: $application_uuid ($deployment_uuid)" + done +} + +reset_helper() { + require_local_coolify + docker exec "$COOLIFY_CONTAINER" php artisan tinker --execute ' + App\Models\InstanceSettings::findOrFail(0)->update([ + "dev_helper_version" => null, + ]); + ' >/dev/null + echo "Development helper override cleared." +} + +command=${1:-help} +tag=${2:-$DEFAULT_TAG} + +case "$command" in + build) + validate_tag "$tag" + build_helper "$tag" + ;; + use) + validate_tag "$tag" + use_helper "$tag" + ;; + verify) + validate_tag "$tag" + verify_helper "$tag" + ;; + deploy) + validate_tag "$tag" + deploy_examples "$tag" + ;; + reset) + reset_helper + ;; + test) + validate_tag "$tag" + build_helper "$tag" + use_helper "$tag" + verify_helper "$tag" + deploy_examples "$tag" + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/tests/Feature/DevHelperVersionValidationTest.php b/tests/Feature/DevHelperVersionValidationTest.php index 03316598c3..0a91141850 100644 --- a/tests/Feature/DevHelperVersionValidationTest.php +++ b/tests/Feature/DevHelperVersionValidationTest.php @@ -88,3 +88,22 @@ test('buildHelperImage refuses previously stored invalid version', function () { ->call('buildHelperImage') ->assertDispatched('error'); }); + +test('development helper version is read fresh for queue workers', function () { + config(['app.env' => 'local']); + + InstanceSettings::findOrFail(0)->update(['dev_helper_version' => 'first']); + expect(getHelperVersion())->toBe('first'); + + InstanceSettings::query()->whereKey(0)->update(['dev_helper_version' => 'second']); + + expect(getHelperVersion())->toBe('second'); +}); + +test('development helper build uses the configured helper repository', function () { + $component = file_get_contents(app_path('Livewire/Settings/Index.php')); + + expect($component) + ->toContain('$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");') + ->not->toContain('"ghcr.io/coollabsio/coolify-helper:{$version}"'); +}); diff --git a/tests/Unit/DevHelperScriptTest.php b/tests/Unit/DevHelperScriptTest.php new file mode 100644 index 0000000000..eee1803826 --- /dev/null +++ b/tests/Unit/DevHelperScriptTest.php @@ -0,0 +1,19 @@ +toBeFile() + ->and(is_executable($script))->toBeTrue(); + + exec('bash -n '.escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0) + ->and(file_get_contents($script)) + ->toContain('build)') + ->toContain('use)') + ->toContain('verify)') + ->toContain('deploy)') + ->toContain('reset)') + ->toContain('test)'); +}); diff --git a/tests/Unit/DockerCliPackagingTest.php b/tests/Unit/DockerCliPackagingTest.php new file mode 100644 index 0000000000..71fa7078e1 --- /dev/null +++ b/tests/Unit/DockerCliPackagingTest.php @@ -0,0 +1,12 @@ +toContain('ARG DOCKER_VERSION=29.7.2') + ->toContain('ARG DOCKER_COMPOSE_VERSION=5.5.0') + ->toContain('ARG DOCKER_BUILDX_VERSION=0.36.1') + ->toContain('https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz') + ->toContain('https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz') + ->toMatch('/chmod \+x [^\n]*\/usr\/bin\/docker/'); +}); From 7fd678d9073428dc315267ce0c1ab6d8b9582340 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:16:44 +0200 Subject: [PATCH 61/86] fix(previews): remove persistent volumes on deletion (#11455) --- app/Models/ApplicationPreview.php | 22 +++- .../ApplicationPreviewVolumeCleanupTest.php | 120 ++++++++++++++++++ 2 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewVolumeCleanupTest.php diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6998211eea..0905242753 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; +use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel @@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel 'pull_request_id' => 'integer', ]; - protected static function booted() + protected static function booted(): void { - static::forceDeleting(function ($preview) { + static::forceDeleting(function (ApplicationPreview $preview): void { $server = $preview->application->destination->server; $application = $preview->application; @@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel }); } else { // Regular application volume cleanup - $persistentStorages = $preview->persistentStorages()->get() ?? collect(); - if ($persistentStorages->count() > 0) { - foreach ($persistentStorages as $storage) { - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $application->persistentStorages() + ->get() + ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path) + && $storage->is_preview_suffix_enabled); + + foreach ($persistentStorages as $storage) { + $volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id); + try { + instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server); + } catch (RuntimeException $exception) { + if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) { + throw $exception; + } } } } diff --git a/tests/Feature/ApplicationPreviewVolumeCleanupTest.php b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php new file mode 100644 index 0000000000..7f59f689fc --- /dev/null +++ b/tests/Feature/ApplicationPreviewVolumeCleanupTest.php @@ -0,0 +1,120 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockerfile', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->preview = ApplicationPreview::create([ + 'uuid' => 'preview-volume-cleanup-test', + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/42', + ]); +}); + +it('deletes only named volumes that have the preview suffix enabled', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'shared-cache', + 'mount_path' => '/cache', + 'host_path' => null, + 'is_preview_suffix_enabled' => false, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'seed-data', + 'mount_path' => '/seed', + 'host_path' => '/srv/seed', + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(output: '')]); + + $this->preview->forceDelete(); + + Process::assertRanTimes(fn ($process) => str_contains($process->command, 'docker volume rm'), 1); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker volume rm -f 'app-data-pr-42'")); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'shared-cache')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'seed-data')); +}); + +it('reports Docker volume removal failures and keeps the preview record', function () { + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(['*' => Process::result(errorOutput: 'volume is in use', exitCode: 1)]); + + expect(fn () => $this->preview->forceDelete()) + ->toThrow(RuntimeException::class, 'volume is in use'); + + expect(ApplicationPreview::find($this->preview->id))->not->toBeNull(); +}); + +it('continues removing preview volumes when an earlier volume is already absent', function () { + $this->application->persistentStorages()->create([ + 'name' => 'already-removed', + 'mount_path' => '/removed', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + $this->application->persistentStorages()->create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'host_path' => null, + 'is_preview_suffix_enabled' => true, + ]); + Process::fake(function ($process) { + if (str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")) { + return Process::result( + errorOutput: 'Error response from daemon: volume already-removed-pr-42 not found', + exitCode: 1, + ); + } + + return Process::result(output: 'app-data-pr-42'); + }); + + $this->preview->forceDelete(); + + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'already-removed-pr-42'")); + Process::assertRan(fn ($process) => str_contains($process->command, "docker volume rm -f 'app-data-pr-42'")); + expect(ApplicationPreview::find($this->preview->id))->toBeNull(); +}); From 40e5fd8521a5e5706bc27e288f1b35faca0c3f49 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:18:14 +0200 Subject: [PATCH 62/86] feat(audit): add team activity tracking and audit log --- app/Console/Commands/CleanupDatabase.php | 7 + .../Api/ApplicationsController.php | 8 - .../Controllers/Api/ProjectController.php | 21 - app/Http/Kernel.php | 2 + .../Project/Application/DeploymentNavbar.php | 1 - app/Livewire/Project/Application/Heading.php | 5 + app/Livewire/Project/Application/Previews.php | 6 + app/Livewire/Project/CloneMe.php | 8 + app/Livewire/Project/Database/BackupEdit.php | 21 +- app/Livewire/Project/Database/BackupNow.php | 7 + app/Livewire/Project/Database/Heading.php | 12 + app/Livewire/Project/Database/ImportForm.php | 13 + app/Livewire/Project/Service/Heading.php | 12 + app/Livewire/Project/Shared/Destination.php | 7 + .../Project/Shared/ResourceOperations.php | 8 + .../Project/Shared/ScheduledTask/Show.php | 7 + .../Project/Shared/Storages/VolumeBackups.php | 6 + app/Livewire/Security/ApiTokens.php | 11 + app/Livewire/Server/DockerCleanup.php | 7 + app/Livewire/Server/Navbar.php | 16 + app/Livewire/Server/TransferImport.php | 9 + app/Livewire/Team/AuditLog.php | 70 ++ app/Livewire/Team/Invitations.php | 7 + app/Livewire/Team/InviteLink.php | 7 + app/Livewire/Team/Member.php | 20 + app/Models/Application.php | 7 +- app/Models/ApplicationDeploymentQueue.php | 39 ++ app/Models/AuditEvent.php | 171 +++++ app/Models/Environment.php | 3 +- app/Models/EnvironmentVariable.php | 3 + app/Models/GithubApp.php | 3 + app/Models/GitlabApp.php | 3 + app/Models/PrivateKey.php | 3 +- app/Models/Project.php | 3 +- app/Models/S3Storage.php | 3 +- app/Models/Server.php | 3 +- app/Models/Service.php | 3 +- app/Models/SharedEnvironmentVariable.php | 3 + app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Models/Tag.php | 3 +- app/Models/Team.php | 3 +- app/Traits/Auditable.php | 82 +++ bootstrap/helpers/applications.php | 10 + bootstrap/helpers/audit.php | 48 +- config/logging.php | 7 - database/factories/AuditEventFactory.php | 29 + ...08_20_000000_create_audit_events_table.php | 43 ++ .../components/team/settings-layout.blade.php | 6 + .../views/livewire/team/audit-log.blade.php | 116 ++++ routes/web.php | 2 + tests/Feature/AuditEventsTest.php | 614 ++++++++++++++++++ tests/Feature/Proxy/RestartProxyTest.php | 21 + .../QueueApplicationDeploymentCommitTest.php | 34 + 60 files changed, 1486 insertions(+), 101 deletions(-) create mode 100644 app/Livewire/Team/AuditLog.php create mode 100644 app/Models/AuditEvent.php create mode 100644 app/Traits/Auditable.php create mode 100644 database/factories/AuditEventFactory.php create mode 100644 database/migrations/2026_08_20_000000_create_audit_events_table.php create mode 100644 resources/views/livewire/team/audit-log.blade.php create mode 100644 tests/Feature/AuditEventsTest.php diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Models\AuditEvent; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -49,6 +50,12 @@ class CleanupDatabase extends Command $activity_log->delete(); } + $count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count(); + echo "Delete $count entries from audit_events.\n"; + if ($this->option('yes')) { + AuditEvent::pruneExpired(); + } + // Cleanup application_deployment_queues table $application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10); $count = $application_deployment_queues->count(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..b47db0e26f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -5630,14 +5630,6 @@ class ApplicationsController extends Controller return response()->json(['message' => $result['message']], 200); } - auditLog('api.application.rollback', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid, - 'commit' => $commit, - ]); - return response()->json([ 'message' => 'Rollback deployment queued.', 'deployment_uuid' => $deployment_uuid, diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index eb137c5349..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -271,12 +271,6 @@ class ProjectController extends Controller 'team_id' => $teamId, ]); - auditLog('api.project.created', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - ]); - return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -396,13 +390,6 @@ class ProjectController extends Controller $project->update($request->only($allowedFields)); - auditLog('api.project.updated', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), - ]); - return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -482,16 +469,8 @@ class ProjectController extends Controller return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } - $projectUuid = $project->uuid; - $projectName = $project->name; $project->delete(); - auditLog('api.project.deleted', [ - 'team_id' => $teamId, - 'project_uuid' => $projectUuid, - 'project_name' => $projectName, - ]); - return response()->json(['message' => 'Project deleted.']); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\SetCacheHeaders; @@ -59,6 +60,7 @@ class Kernel extends HttpKernel ValidatePostSize::class, TrimStrings::class, ConvertEmptyStringsToNull::class, + InvokeDeferredCallbacks::class, ]; diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -104,7 +104,6 @@ class DeploymentNavbar extends Component $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); - try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); StopApplication::dispatch($this->application, false, $this->docker_cleanup); + auditLog('ui.application.stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b40..3944bbe09d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -377,6 +377,12 @@ class Previews extends Component ApplicationPreview::where('application_id', $this->application->id) ->where('pull_request_id', $pull_request_id) ->update(['status' => 'exited']); + auditLog('ui.application.preview_stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'pull_request_id' => $pull_request_id, + ]); ServiceStatusChanged::dispatch($this->application->environment->project->team->id); GetContainersStatus::run($server); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -102,6 +102,14 @@ class CloneMe extends Component if (! $selectedDestination) { throw new \Exception('Destination not found.'); } + auditLog('ui.project.clone_started', [ + 'team_id' => $this->project->team_id, + 'project_uuid' => $this->project->uuid, + 'project_name' => $this->project->name, + 'clone_type' => $type, + 'new_name' => $this->newName, + 'destination_uuid' => $selectedDestination->uuid, + ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); if ($foundProject) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 2c04f5ba9b..608d153ebc 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -207,10 +207,18 @@ class BackupEdit extends Component } } + $database = $this->backup->database; + $backupUuid = $this->backup->uuid; $this->backup->delete(); + auditLog('ui.database.backup_schedule_deleted', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $backupUuid, + ]); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; + if ($database->getMorphClass() === ServiceDatabase::class) { + $serviceDatabase = $database; return redirect()->route('project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], @@ -238,9 +246,14 @@ class BackupEdit extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); - $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); if ($database instanceof ServiceDatabase) { return redirect()->route('project.service.database.backup.executions', [ diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..e45c797d1e 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -18,6 +18,13 @@ class BackupNow extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); + $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f227021..4b34c5e4ee 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -83,6 +83,7 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); + $this->auditDatabaseAction('ui.database.stopped'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } @@ -94,6 +95,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -107,6 +109,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.started'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -122,4 +125,13 @@ class Heading extends Component ], ]); } + + private function auditDatabaseAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index ccd3435106..d6d713d801 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -510,6 +510,12 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.import_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 'file', + ]); } } catch (\Throwable $e) { handleError($e, $this); @@ -768,6 +774,13 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.restore_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 's3', + 'storage_id' => $this->s3StorageId, + ]); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 34bb46ff19..9390fe2a58 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -113,6 +113,7 @@ class Heading extends Component try { $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); + $this->auditServiceAction('ui.service.started'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -146,6 +147,7 @@ class Heading extends Component try { $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); + $this->auditServiceAction('ui.service.stopped'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -162,6 +164,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -196,6 +199,15 @@ class Heading extends Component $this->authorize($ability, $this->service); } + private function auditServiceAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->service->team()?->id, + 'service_uuid' => $this->service->uuid, + 'service_name' => $this->service->name, + ]); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -64,6 +64,13 @@ class Destination extends Component $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); + auditLog('ui.application.destination_stopped', [ + 'team_id' => $this->resource->team()?->id, + 'application_uuid' => $this->resource->uuid, + 'application_name' => $this->resource->name, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); $this->refreshServers(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..1389b583b2 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -81,6 +81,14 @@ class ResourceOperations extends Component if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } + auditLog('ui.resource.clone_started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'resource_type' => class_basename($this->resource), + 'destination_uuid' => $new_destination->uuid, + 'environment_id' => $new_environment->id, + ]); $uuid = new_public_id(); $server = $new_destination->server; if (! $server->canHostResources()) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 11df001531..14777724e5 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -184,6 +184,13 @@ class Show extends Component $this->authorize('update', $this->resource); $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); + auditLog('ui.scheduled_task.executed', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'scheduled_task_uuid' => $this->task->uuid, + 'scheduled_task_name' => $this->task->name, + ]); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..ef7b36ff72 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -204,6 +204,12 @@ class VolumeBackups extends Component } VolumeBackupJob::dispatch($this->backup); + auditLog('ui.volume_backup.started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Storage backup queued.'); return redirect()->route($this->routeName('executions'), $this->routeParameters()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5a978ac84f..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -140,6 +140,12 @@ class ApiTokens extends Component ]); $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); + auditLog('ui.api_token.created', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $this->description, + 'abilities' => array_values($this->permissions), + 'expires_at' => $expiresAt?->toIso8601String(), + ]); $this->getTokens(); // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. session()->flash('token', $token->plainTextToken); @@ -156,7 +162,12 @@ class ApiTokens extends Component ->where('id', $id) ->firstOrFail(); $this->authorize('delete', $token); + $tokenName = $token->name; $token->delete(); + auditLog('ui.api_token.revoked', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $tokenName, + ]); $this->getTokens(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 12d111d219..24acdecad1 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -134,6 +134,13 @@ class DockerCleanup extends Component try { $this->authorize('update', $this->server); DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); + auditLog('ui.server.docker_cleanup_started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'delete_unused_volumes' => $this->deleteUnusedVolumes, + 'delete_unused_networks' => $this->deleteUnusedNetworks, + ]); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -101,6 +101,11 @@ class Navbar extends Component // Always use background job for all servers RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); } catch (\Throwable $e) { $this->restartInitiated = false; @@ -125,6 +130,11 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); $activity = StartProxy::run($this->server, force: true); + auditLog('ui.proxy.started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -136,6 +146,12 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); StopProxy::dispatch($this->server, $forceStop); + auditLog('ui.proxy.stopped', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'force' => $forceStop, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -123,6 +123,15 @@ class TransferImport extends Component $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + if (! $dryRun) { + auditLog('ui.server.imported', [ + 'team_id' => $teamId, + 'server_uuid' => $this->importedServerUuid, + 'claimed' => (bool) data_get($result, 'claimed'), + 'adopt_mode' => $this->adoptMode, + ]); + } + if ($dryRun) { $this->dispatch('success', 'Dry run completed — nothing was written.'); } elseif (data_get($result, 'claimed')) { diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..7dddcfcfb5 --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,70 @@ +resetPage(); + } + + public function updatedAction(): void + { + $this->resetPage(); + } + + public function updatedSource(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(10, min(100, $this->perPage)); + $this->resetPage(); + } + + public function render(): View + { + $search = trim($this->search); + $teamId = currentTeam()->id; + $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin(); + $events = AuditEvent::query() + ->where(function ($query) use ($canViewInstanceEvents, $teamId): void { + $query->where('team_id', $teamId) + ->when($canViewInstanceEvents, fn ($query) => $query->orWhereNull('team_id')); + }) + ->when($this->action !== 'all', fn ($query) => $query->where('action', $this->action)) + ->when($this->source !== 'all', fn ($query) => $query->where('source', $this->source)) + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($query) use ($search): void { + $query->where('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->orWhere('actor_email', 'like', "%{$search}%") + ->orWhere('event', 'like', "%{$search}%"); + }); + }) + ->latest('created_at') + ->latest('id') + ->paginate($this->perPage); + + return view('livewire.team.audit-log', ['events' => $events]); + } +} diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8ecafc417c..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -22,6 +22,8 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); + $invitationEmail = $invitation->email; + $invitationUuid = $invitation->uuid; DB::transaction(function () use ($invitation): void { $user = User::whereEmail($invitation->email)->first(); if (filled($user)) { @@ -30,6 +32,11 @@ class Invitations extends Component $invitation->delete(); }); + auditLog('ui.team_invitation.revoked', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitationUuid, + 'invitation_email' => $invitationEmail, + ]); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -103,6 +103,13 @@ class InviteLink extends Component 'link' => $link, 'via' => $sendEmail ? 'email' : 'link', ]); + auditLog('ui.team_invitation.created', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitation->uuid, + 'invitation_email' => $invitation->email, + 'role' => $invitation->role, + 'via' => $invitation->via, + ]); if ($sendEmail) { $mail = new MailMessage; $mail->view('emails.invitation-link', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 38c932c39d..d99fd2eb1b 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -30,6 +30,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -50,6 +51,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -70,6 +72,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -90,6 +93,12 @@ class Member extends Component $this->member->teams()->detach($teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + auditLog('ui.team_member.removed', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + ]); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); @@ -103,4 +112,15 @@ class Member extends Component { return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } + + private function auditRoleUpdate(int $teamId, Role $role): void + { + auditLog('ui.team_member.role_updated', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + 'role' => $role->value, + ]); + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..824e58a154 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,6 +7,7 @@ use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -121,10 +122,10 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ - use HasFactory; + use Auditable, HasFactory; + + use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; +use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -44,6 +45,44 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + protected static function booted(): void + { + static::created(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() || ! $deployment->rollback) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.application.rollback", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + 'commit' => $deployment->commit, + ]); + }); + + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() + || ! $deployment->wasChanged('status') + || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.deployment.cancelled", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + }); + } + protected $fillable = [ 'application_id', 'deployment_uuid', diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..8ef1a3dbac --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,171 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + /** + * @param array $context + */ + public static function record(string $event, array $context = []): void + { + try { + $attributes = self::attributesFor($event, $context); + + if ($attributes === null) { + return; + } + + DB::afterCommit(function () use ($attributes): void { + defer(function () use ($attributes): void { + try { + self::query()->create($attributes); + } catch (Throwable) { + } + })->always(); + }); + } catch (Throwable) { + } + } + + /** + * @param array $context + * @return array + */ + private static function attributesFor(string $event, array $context): array + { + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') + ?? data_get($context, 'team_id') + ?? currentTeam()?->id + ?? self::teamIdFromContext($context); + + $parts = explode('.', $event); + $source = $parts[0] ?? 'system'; + $resourceType = $parts[1] ?? null; + $action = end($parts) ?: 'event'; + $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); + $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $actorType = match (true) { + in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, + $token !== null => 'api_token', + $user !== null => 'user', + default => 'system', + }; + + return [ + 'team_id' => $teamId, + 'event' => $event, + 'source' => $source, + 'action' => $action, + 'actor_type' => $actorType, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + 'actor_token_id' => $token?->id, + 'actor_token_name' => $token?->name, + 'resource_type' => $resourceType, + 'resource_uuid' => $resourceUuid, + 'resource_name' => $resourceName, + 'description' => data_get($context, 'audit_description') + ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), + 'metadata' => self::redact($context), + 'ip_address' => app()->bound('request') ? request()->ip() : null, + 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, + ]; + } + + /** + * @param array $context + */ + private static function teamIdFromContext(array $context): ?int + { + $applicationUuid = data_get($context, 'application_uuid'); + if (! is_string($applicationUuid) || $applicationUuid === '') { + return null; + } + + return Application::query() + ->where('uuid', $applicationUuid) + ->first()?->team()?->id; + } + + public static function pruneExpired(): int + { + return self::query() + ->where('created_at', '<', now()->subDays(90)) + ->delete(); + } + + /** + * @param array $context + */ + private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed + { + if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { + return data_get($context, $preferredKey); + } + + $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); + + return $key ? data_get($context, $key) : null; + } + + private static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential/i', $key)) { + return '[REDACTED]'; + } + + if (! is_array($value)) { + return $value; + } + + return collect($value) + ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ + $itemKey => self::redact($item, (string) $itemKey), + ]) + ->all(); + } +} diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,8 +22,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..f4872e5c14 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -34,6 +35,8 @@ use OpenApi\Attributes as OA; )] class EnvironmentVariable extends BaseModel { + use Auditable; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; protected $attributes = [ diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 564fbcf6a4..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + use Auditable; + public function delete(): ?bool { return DB::transaction(fn () => parent::delete()); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index c6c2b84095..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\Crypt; class GitlabApp extends BaseModel { + use Auditable; + protected $fillable = [ 'name', 'organization', diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasFactory, HasSafeStringAttribute, WithRateLimiting; + use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..677af58666 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,8 +21,8 @@ use OpenApi\Attributes as OA; )] class Project extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { - use HasFactory, HasSafeStringAttribute; + use Auditable, HasFactory, HasSafeStringAttribute; private const CONNECTION_TIMEOUT_SECONDS = 15; diff --git a/app/Models/Server.php b/app/Models/Server.php index f7a4bf20c0..b6e1b92d99 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..16d5673a86 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -43,7 +44,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model { + use Auditable; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..e3e1c249f3 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..9b0ec923b2 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..7b1b20b2fe 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..7ac68aa597 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..33fb862164 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..ec3c7b5795 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..92796aea6a 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..f9877a16c7 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; @@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..b42f7daf29 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..433ea13a23 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,82 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, ['updated_at', 'order', 'status'], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..b6c24e0b32 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,16 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'team_id' => $application->team()?->id, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..f3a268f00a --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,43 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['team_id', 'created_at']); + $table->index(['team_id', 'action', 'created_at']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php index e9a5fbd9b0..80bf767273 100644 --- a/resources/views/components/team/settings-layout.blade.php +++ b/resources/views/components/team/settings-layout.blade.php @@ -12,6 +12,12 @@ 'active' => request()->routeIs('team.member.index'), 'icon' => 'teams', ], + [ + 'label' => 'Audit log', + 'route' => 'team.audit-log', + 'active' => request()->routeIs('team.audit-log'), + 'icon' => 'time-back', + ], isInstanceAdmin() ? [ 'label' => 'Admin View', 'route' => 'team.admin-view', diff --git a/resources/views/livewire/team/audit-log.blade.php b/resources/views/livewire/team/audit-log.blade.php new file mode 100644 index 0000000000..2dcb544785 --- /dev/null +++ b/resources/views/livewire/team/audit-log.blade.php @@ -0,0 +1,116 @@ +
+ + Team Audit Log | Coolify + + + +
+ +
+
+ + +
+
+
+ +
+
+ +
+
+
+ + @if ($events->isNotEmpty()) +
+
+
+ Actor + Activity + Source + Time +
+ @foreach ($events as $event) +
+
+
+ {{ $event->actor_name ?: Str::headline($event->actor_type) }} +
+ @if ($event->actor_email) +
+ {{ $event->actor_email }} +
+ @endif + @if ($event->actor_token_name) +
+ Token: {{ $event->actor_token_name }} +
+ @endif +
+
+
+ {{ $event->description }} +
+
+ {{ $event->event }} +
+
+
+ + {{ Str::upper($event->source) }} + + {{ Str::headline($event->action) }} +
+ +
+ @endforeach +
+
+ + + + + + + @else + + @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index c81c46b92d..3aa32dc661 100644 --- a/routes/web.php +++ b/routes/web.php @@ -97,6 +97,7 @@ use App\Livewire\Subscription\Index as SubscriptionIndex; use App\Livewire\Subscription\Show as SubscriptionShow; use App\Livewire\Tags\Show as TagsShow; use App\Livewire\Team\AdminView as TeamAdminView; +use App\Livewire\Team\AuditLog as TeamAuditLog; use App\Livewire\Team\DangerZone as TeamDangerZone; use App\Livewire\Team\Index as TeamIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex; @@ -206,6 +207,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::prefix('team')->group(function () { Route::get('/', TeamIndex::class)->name('team.index'); Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); }); diff --git a/tests/Feature/AuditEventsTest.php b/tests/Feature/AuditEventsTest.php new file mode 100644 index 0000000000..e3fb8395df --- /dev/null +++ b/tests/Feature/AuditEventsTest.php @@ -0,0 +1,614 @@ +withoutDefer(); + + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->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]); + + Log::spy(); +}); + +test('audit inserts are deferred until after the response', function () { + $this->withDefer(); + + auditLog('ui.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'project_name' => 'Website', + ]); + + expect(AuditEvent::query()->count())->toBe(0); + + defer()->invoke(); + + expect(AuditEvent::query()->count())->toBe(1); +}); + +test('multiple audit inserts in one request are all deferred', function () { + $this->withDefer(); + + auditLog('ui.application.deployed', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + ]); + auditLog('ui.application.updated', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + ]); + + defer()->invoke(); + + expect(AuditEvent::query()->pluck('event')->all())->toBe([ + 'ui.application.deployed', + 'ui.application.updated', + ]); +}); + +test('http kernel invokes deferred callbacks', function () { + $kernel = app(Kernel::class); + $middleware = (new ReflectionClass($kernel))->getProperty('middleware')->getValue($kernel); + + expect($middleware)->toContain(InvokeDeferredCallbacks::class); +}); + +test('audit persistence failures do not fail the action', function () { + Schema::drop('audit_events'); + + auditLog('ui.project.updated', ['team_id' => $this->team->id]); + + expect(true)->toBeTrue(); +}); + +test('audit log persists a structured event for the current team', function () { + auditLog('ui.application.updated', [ + 'application_uuid' => 'app-123', + 'application_name' => 'Website', + 'changed' => ['name'], + ]); + + $event = AuditEvent::query()->sole(); + + expect($event->team_id)->toBe($this->team->id) + ->and($event->actor_id)->toBe($this->user->id) + ->and($event->actor_email)->toBe($this->user->email) + ->and($event->source)->toBe('ui') + ->and($event->action)->toBe('updated') + ->and($event->resource_type)->toBe('application') + ->and($event->resource_uuid)->toBe('app-123') + ->and($event->resource_name)->toBe('Website') + ->and($event->metadata['changed'])->toBe(['name']); +}); + +test('auditable models record authenticated create update and delete actions', function () { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Website project', + ]); + $project->update(['name' => 'Renamed project']); + $project->delete(); + + $events = AuditEvent::query()->where('resource_type', 'project')->orderBy('id')->get(); + + expect($events->pluck('event')->all())->toBe([ + 'ui.project.created', + 'ui.project.updated', + 'ui.project.deleted', + ])->and($events[1]->metadata['changed_fields'])->toBe(['name']); +}); + +test('auditable model mutations succeed when audit persistence fails', function () { + Schema::rename('audit_events', 'unavailable_audit_events'); + + try { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Persisted project', + ]); + } finally { + Schema::rename('unavailable_audit_events', 'audit_events'); + } + + expect($project->exists)->toBeTrue() + ->and(Project::query()->whereKey($project->id)->exists())->toBeTrue(); +}); + +test('repeated events for the same resource are each persisted', function () { + auditLog('api.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'changed_fields' => ['name'], + ]); + auditLog('api.project.updated', [ + 'team_id' => $this->team->id, + 'project_uuid' => 'project-123', + 'changed_fields' => ['description'], + ]); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events[0]->metadata['changed_fields'])->toBe(['name']) + ->and($events[1]->metadata['changed_fields'])->toBe(['description']); +}); + +test('automatic and explicit auditing both preserve their events', function () { + $project = Project::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Website project', + ]); + + auditLog('ui.project.created', [ + 'team_id' => $this->team->id, + 'project_uuid' => $project->uuid, + 'project_name' => $project->name, + 'audit_description' => 'Project created through the API', + 'request_field' => 'preserved', + ]); + + $events = AuditEvent::query()->where('event', 'ui.project.created')->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events[1]->description)->toBe('Project created through the API') + ->and($events[1]->metadata['request_field'])->toBe('preserved'); +}); + +test('auditable models ignore unauthenticated mutations', function () { + auth()->logout(); + + Project::factory()->create(['team_id' => $this->team->id]); + + expect(AuditEvent::query()->count())->toBe(0); +}); + +test('webhook audits resolve the team from the application', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + auth()->logout(); + session()->forget('currentTeam'); + + auditLog('webhook.deployment.queued', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'webhook.deployment.queued', + 'resource_uuid' => $application->uuid, + ]); +}); + +test('unauthenticated webhook failures without a team are preserved', function () { + auth()->logout(); + session()->forget('currentTeam'); + + auditLogWebhookFailure('sentinel', 'token_missing'); + auditLogWebhookFailure('stripe', 'invalid_signature'); + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('event')->all())->toBe([ + 'webhook.sentinel.signature_failed', + 'webhook.stripe.signature_failed', + ]) + ->and($events->pluck('team_id')->all())->toBe([null, null]); +}); + +test('early Sentinel and Stripe rejections persist unscoped audit events', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $this->postJson('/api/v1/sentinel/push', [])->assertUnauthorized(); + + config(['subscription.stripe_webhook_secret' => 'whsec_test']); + $this->withHeader('Stripe-Signature', 'invalid') + ->call('POST', '/webhooks/payments/stripe/events', [], [], [], [], '{}') + ->assertBadRequest(); + + expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([ + 'webhook.sentinel.signature_failed', + 'webhook.stripe.signature_failed', + ])->and(AuditEvent::query()->whereNotNull('team_id')->doesntExist())->toBeTrue(); +}); + +test('unscoped audit events are only visible to the instance team', function () { + AuditEvent::factory()->create([ + 'team_id' => null, + 'description' => 'Unscoped security failure', + ]); + + Livewire::test(AuditLog::class) + ->assertDontSee('Unscoped security failure'); + + $instanceTeam = Team::factory()->create(['id' => 0]); + $instanceTeam->members()->attach($this->user->id, ['role' => 'owner']); + $this->user->unsetRelation('teams'); + session(['currentTeam' => $instanceTeam]); + + Livewire::test(AuditLog::class) + ->assertSee('Unscoped security failure'); +}); + +test('auditable models identify personal access token mutations as api events', function () { + $newToken = $this->user->createToken('audit-api'); + $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->actingAs($this->user->withAccessToken($newToken->accessToken->fresh())); + + Project::factory()->create(['team_id' => $this->team->id]); + + expect(AuditEvent::query()->where('resource_type', 'project')->firstOrFail()->event) + ->toBe('api.project.created'); +}); + +test('API audit events identify the responsible access token', function () { + $firstToken = $this->user->createToken('first-token'); + $firstToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $secondToken = $this->user->createToken('second-token'); + $secondToken->accessToken->forceFill(['team_id' => $this->team->id])->save(); + + foreach ([$firstToken->accessToken->fresh(), $secondToken->accessToken->fresh()] as $token) { + $this->actingAs($this->user->withAccessToken($token)); + auditLog('api.project.updated', ['team_id' => $this->team->id]); + } + + $events = AuditEvent::query()->orderBy('id')->get(); + + expect($events->pluck('actor_token_id')->all())->toBe([ + $firstToken->accessToken->id, + $secondToken->accessToken->id, + ])->and($events->pluck('actor_token_name')->all())->toBe([ + 'first-token', + 'second-token', + ]); + + Livewire::test(AuditLog::class) + ->assertSee('Token: first-token') + ->assertSee('Token: second-token'); +}); + +test('API model mutations produce one audit event', function () { + $this->withoutExceptionHandling(); + $token = $this->user->createToken('audit-api', ['root']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + auth()->logout(); + auth()->forgetGuards(); + + $response = $this->withToken($token->plainTextToken)->postJson('/api/v1/projects', [ + 'name' => 'Single API audit event', + ]); + + $response->assertCreated(); + + expect(AuditEvent::query() + ->where('event', 'api.project.created') + ->where('resource_uuid', $response->json('uuid')) + ->count())->toBe(1); +}); + +test('deployment queue records rollback and cancellation operations', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + AuditEvent::query()->delete(); + + $deployment = ApplicationDeploymentQueue::query()->create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'rollback-deployment', + 'commit' => 'abc123', + 'rollback' => true, + 'status' => 'queued', + ]); + + $deployment->update(['status' => 'cancelled-by-user']); + + expect(AuditEvent::query()->orderBy('id')->pluck('event')->all())->toBe([ + 'ui.application.rollback', + 'ui.deployment.cancelled', + ]); +}); + +test('team resource models opt in to automatic auditing', function (string $model) { + expect(class_uses_recursive($model))->toContain(Auditable::class); +})->with([ + Application::class, + Service::class, + Server::class, + Project::class, + Environment::class, + EnvironmentVariable::class, + SharedEnvironmentVariable::class, + PrivateKey::class, + StandalonePostgresql::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneRedis::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, +]); + +test('audit log redacts sensitive metadata', function () { + auditLog('api.application.updated', [ + 'team_id' => $this->team->id, + 'application_uuid' => 'app-123', + 'token' => 'secret-token', + 'nested' => ['password' => 'secret-password', 'safe' => 'visible'], + ]); + + $metadata = AuditEvent::query()->sole()->metadata; + + expect($metadata['token'])->toBe('[REDACTED]') + ->and($metadata['nested']['password'])->toBe('[REDACTED]') + ->and($metadata['nested']['safe'])->toBe('visible'); +}); + +test('audit log page only shows events for the current team', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'description' => 'Website created', + ]); + AuditEvent::factory()->create([ + 'team_id' => Team::factory()->create()->id, + 'description' => 'Private app deleted', + ]); + + Livewire::test(AuditLog::class) + ->assertSee('Website created') + ->assertDontSee('Private app deleted'); +}); + +test('audit log is available under team settings', function () { + $this->get('/team/audit-log') + ->assertSuccessful() + ->assertSeeLivewire(AuditLog::class); +}); + +test('audit source filter omits the unused system source', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->not->toContain("['value' => 'system', 'label' => 'System']"); +}); + +test('critical UI operations have explicit audit events', function (string $path, string $event) { + expect(file_get_contents(base_path($path)))->toContain("'{$event}'"); +})->with([ + ['app/Livewire/Project/Application/Heading.php', 'ui.application.stopped'], + ['app/Livewire/Project/Application/Previews.php', 'ui.application.preview_stopped'], + ['app/Livewire/Project/Shared/Destination.php', 'ui.application.destination_stopped'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.started'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.stopped'], + ['app/Livewire/Project/Service/Heading.php', 'ui.service.restarted'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.started'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.stopped'], + ['app/Livewire/Project/Database/Heading.php', 'ui.database.restarted'], + ['app/Livewire/Server/Navbar.php', 'ui.proxy.stopped'], + ['app/Livewire/Server/Navbar.php', 'ui.proxy.restarted'], + ['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_started'], + ['app/Livewire/Project/Database/BackupEdit.php', 'ui.database.backup_schedule_deleted'], + ['app/Livewire/Project/Database/ImportForm.php', 'ui.database.import_started'], + ['app/Livewire/Project/Database/ImportForm.php', 'ui.database.restore_started'], + ['app/Livewire/Project/Shared/ScheduledTask/Show.php', 'ui.scheduled_task.executed'], + ['app/Livewire/Security/ApiTokens.php', 'ui.api_token.created'], + ['app/Livewire/Security/ApiTokens.php', 'ui.api_token.revoked'], + ['app/Livewire/Team/Member.php', 'ui.team_member.role_updated'], + ['app/Livewire/Team/Member.php', 'ui.team_member.removed'], + ['app/Livewire/Team/InviteLink.php', 'ui.team_invitation.created'], + ['app/Livewire/Team/Invitations.php', 'ui.team_invitation.revoked'], + ['app/Livewire/Server/DockerCleanup.php', 'ui.server.docker_cleanup_started'], + ['app/Livewire/Server/TransferImport.php', 'ui.server.imported'], + ['app/Livewire/Project/CloneMe.php', 'ui.project.clone_started'], + ['app/Livewire/Project/Shared/ResourceOperations.php', 'ui.resource.clone_started'], +]); + +test('critical operational events persist with their source action and actor', function (string $event) { + auditLog($event, [ + 'team_id' => $this->team->id, + 'resource_uuid' => 'resource-123', + 'resource_name' => 'Test resource', + ]); + + $auditEvent = AuditEvent::query()->sole(); + + expect($auditEvent->event)->toBe($event) + ->and($auditEvent->source)->toBe(str($event)->before('.')->value()) + ->and($auditEvent->action)->toBe(str($event)->afterLast('.')->value()) + ->and($auditEvent->actor_email)->toBe($this->user->email); +})->with([ + 'ui.application.stopped', + 'ui.application.preview_stopped', + 'ui.application.destination_stopped', + 'ui.application.rollback', + 'ui.deployment.cancelled', + 'ui.service.started', + 'ui.service.stopped', + 'ui.service.restarted', + 'ui.database.started', + 'ui.database.stopped', + 'ui.database.restarted', + 'ui.proxy.stopped', + 'ui.proxy.restarted', + 'ui.database.backup_started', + 'ui.database.backup_schedule_deleted', + 'ui.database.import_started', + 'ui.database.restore_started', + 'ui.scheduled_task.executed', + 'ui.api_token.created', + 'ui.api_token.revoked', + 'ui.team_member.role_updated', + 'ui.team_member.removed', + 'ui.team_invitation.created', + 'ui.team_invitation.revoked', + 'ui.server.docker_cleanup_started', + 'ui.server.imported', + 'ui.project.clone_started', + 'ui.resource.clone_started', + 'api.database.started', + 'api.database.stopped', + 'api.database.restarted', +]); + +test('audit log table keeps actor details visible in a mobile scroll area', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->toContain('overflow-x-auto') + ->toContain('min-w-[760px]') + ->not->toContain('hidden lg:block">Actor'); +}); + +test('audit log displays source abbreviations in uppercase', function () { + $view = file_get_contents(resource_path('views/livewire/team/audit-log.blade.php')); + + expect($view)->toContain('Str::upper($event->source)'); +}); + +test('audit log page filters events by search and action', function () { + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'action' => 'created', + 'description' => 'Website created', + 'resource_name' => 'Website', + ]); + AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'event' => 'api.server.deleted', + 'action' => 'deleted', + 'description' => 'Build server deleted', + 'resource_name' => 'Build server', + ]); + + Livewire::test(AuditLog::class) + ->set('search', 'Website') + ->assertSee('Website created') + ->assertDontSee('Build server deleted') + ->set('search', '') + ->set('action', 'deleted') + ->assertDontSee('Website created') + ->assertSee('Build server deleted'); +}); + +test('updating team settings records an audit event', function () { + Livewire::test(TeamIndex::class) + ->set('name', 'Renamed team') + ->call('submit') + ->assertHasNoErrors(); + + $event = AuditEvent::query()->where('action', 'updated')->sole(); + + expect($event->event)->toBe('ui.team.updated') + ->and($event->team_id)->toBe($this->team->id) + ->and($event->resource_name)->toBe('Renamed team'); +}); + +test('updating an environment variable records an event without its value', function () { + $variable = SharedEnvironmentVariable::create([ + 'team_id' => $this->team->id, + 'type' => 'team', + 'key' => 'API_SECRET', + 'value' => 'old-secret', + ]); + + Livewire::test(Show::class, [ + 'env' => $variable, + 'type' => 'team', + ]) + ->call('loadValues') + ->set('value', 'new-secret') + ->call('submit') + ->assertHasNoErrors(); + + $event = AuditEvent::query() + ->where('resource_type', 'shared_environment_variable') + ->where('action', 'updated') + ->sole(); + + expect($event->event)->toBe('ui.shared_environment_variable.updated') + ->and($event->resource_name)->toBe('API_SECRET') + ->and(json_encode($event->metadata))->not->toContain('new-secret'); +}); + +test('creating an application environment variable records an audit event', function () { + $this->withDefer(); + + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + + $application->environment_variables()->create([ + 'key' => 'API_SECRET', + 'value' => 'secret-value', + ]); + + defer()->invoke(); + + $event = AuditEvent::query() + ->where('resource_type', 'environment_variable') + ->where('action', 'created') + ->where('resource_name', 'API_SECRET') + ->firstOrFail(); + + expect($event->team_id)->toBe($this->team->id) + ->and($event->resource_name)->toBe('API_SECRET') + ->and(json_encode($event->metadata))->not->toContain('secret-value'); +}); + +test('database cleanup removes audit events older than 90 days', function () { + $old = AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'created_at' => now()->subDays(91), + ]); + $recent = AuditEvent::factory()->create([ + 'team_id' => $this->team->id, + 'created_at' => now()->subDays(89), + ]); + + AuditEvent::pruneExpired(); + + expect($old->fresh())->toBeNull() + ->and($recent->fresh())->not->toBeNull(); +}); diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php index 16cddd36ea..0c393c03b5 100644 --- a/tests/Feature/Proxy/RestartProxyTest.php +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -1,16 +1,20 @@ withoutDefer(); InstanceSettings::forceCreate(['id' => 0]); }); @@ -187,3 +191,20 @@ test('start proxy button shows a loading state while proxy startup actions run', ->assertSeeHtml('wire:loading.class="is-loading"') ->assertSeeHtml('wire:target="checkProxy,startProxy"'); }); + +test('starting a proxy records a team audit event', function () { + [$user, $team, $server] = setupProxyUser('admin'); + $activity = Activity::create([ + 'description' => 'proxy start', + 'properties' => ['team_id' => $team->id], + ]); + StartProxy::shouldRun()->andReturn($activity); + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Livewire::test('server.navbar', ['server' => $server]) + ->call('startProxy'); + + expect(AuditEvent::query()->sole()->event)->toBe('ui.proxy.started'); +}); diff --git a/tests/Feature/QueueApplicationDeploymentCommitTest.php b/tests/Feature/QueueApplicationDeploymentCommitTest.php index ac6be5c9e9..6b4c766fb0 100644 --- a/tests/Feature/QueueApplicationDeploymentCommitTest.php +++ b/tests/Feature/QueueApplicationDeploymentCommitTest.php @@ -8,12 +8,14 @@ use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; uses(RefreshDatabase::class); beforeEach(function () { + $this->withoutDefer(); Bus::fake([ApplicationDeploymentJob::class]); $this->team = Team::factory()->create(); @@ -42,6 +44,38 @@ function makeApplication(int $environmentId, int $destinationId, ?string $gitCom } describe('queue_application_deployment commit resolution', function () { + test('records a team audit event when a user queues a deployment', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD'); + + queue_application_deployment($application, 'audit-deploy-uuid'); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.deployed', + 'resource_uuid' => $application->uuid, + ]); + }); + + test('uses the deployed application team for the audit event', function () { + $user = User::factory()->create(); + $this->team->members()->attach($user, ['role' => 'owner']); + $this->actingAs($user); + session()->forget('currentTeam'); + $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD'); + + queue_application_deployment($application, 'resource-team-audit-deploy'); + + $this->assertDatabaseHas('audit_events', [ + 'team_id' => $this->team->id, + 'event' => 'ui.application.deployed', + 'resource_uuid' => $application->uuid, + ]); + }); + test('uses application git_commit_sha when commit parameter omitted', function () { $pinnedSha = 'abc123def456abc123def456abc123def456abc1'; $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha); From 379abb252621f34b318190bd49b614aed9818716 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:22:50 +0200 Subject: [PATCH 63/86] fix(docker): make cleanup commands idempotent (#11463) --- .../Application/StopApplicationOneServer.php | 2 +- app/Actions/Database/StopDatabaseProxy.php | 4 +- .../RemoveStandaloneDockerNetwork.php | 2 +- app/Jobs/DatabaseBackupJob.php | 2 +- .../ProxyStatusChangedNotification.php | 2 +- app/Livewire/Destination/Show.php | 2 +- bootstrap/helpers/docker.php | 23 +++- tests/Unit/DockerRemoveWithTimeoutTest.php | 118 ++++++++++++++++++ 8 files changed, 147 insertions(+), 8 deletions(-) diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 10f5b85f21..b25eb481b6 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -29,7 +29,7 @@ class StopApplicationOneServer instant_remote_process( [ dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", + dockerRemoveCommand($containerName), ], $server ); diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a1097662..e6789202f3 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -24,10 +24,10 @@ class StopDatabaseProxy { $server = data_get($database, 'destination.server'); $uuid = $database->uuid; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $server = data_get($database, 'service.server'); } - instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); + instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server); $database->save(); diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php index 21c40a50ad..3e1b5380b6 100644 --- a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork $safeNetwork = escapeshellarg($destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server); } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 1838feb9e7..0b73ed0cf5 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; + $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}"); instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 30ecb2d8d5..9b117d4e13 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'created') { instant_remote_process([ - 'docker rm -f coolify-proxy', + dockerRemoveCommand('coolify-proxy'), ], $server); } } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 1b344c9056..03fa2b5109 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -81,7 +81,7 @@ class Show extends Component } $safeNetwork = escapeshellarg($this->destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); + instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server); } $this->destination->delete(); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 210c84a86a..d152ba58f8 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -267,7 +267,28 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string { $container = escapeShellValue($container); - $script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code"; + $script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + +function dockerRemoveCommand(string $container): string +{ + $command = 'docker rm -f '.escapeShellValue($container); + + return dockerCommandIgnoringError($command, 'No such container:'); +} + +function dockerNetworkRemoveCommand(string $network): string +{ + $command = 'docker network rm '.escapeShellValue($network); + + return dockerCommandIgnoringError($command, 'network .* not found'); +} + +function dockerCommandIgnoringError(string $command, string $ignoredError): string +{ + $script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; return 'bash -c '.escapeShellValue($script); } diff --git a/tests/Unit/DockerRemoveWithTimeoutTest.php b/tests/Unit/DockerRemoveWithTimeoutTest.php index a3b9f15dc5..1f09ae06cb 100644 --- a/tests/Unit/DockerRemoveWithTimeoutTest.php +++ b/tests/Unit/DockerRemoveWithTimeoutTest.php @@ -58,6 +58,124 @@ it('escapes container names in bounded removal commands', function () { rmdir($directory); }); +it('succeeds when the container was already removed', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho 'Error response from daemon: No such container: container-name' >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->isSuccessful())->toBeTrue(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('reports a timeout when timeout output also says the container is missing', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/timeout', "#!/bin/sh\necho 'Error response from daemon: No such container: container-name'\nexit 124\n"); + chmod($directory.'/timeout', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->getExitCode())->toBe(124) + ->and($process->getOutput())->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); + + unlink($directory.'/timeout'); + rmdir($directory); +}); + +it('fails when Docker cannot remove an existing container', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho 'Error response from daemon: removal already in progress' >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout('container-name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + ]); + $process->run(); + + expect($process->isSuccessful())->toBeFalse() + ->and($process->getErrorOutput())->toContain('removal already in progress'); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('makes regular container removal idempotent without hiding other failures', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho \"\$DOCKER_ERROR\" >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $missingContainer = new Process(['/bin/sh', '-c', dockerRemoveCommand('container name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: No such container: container name', + ]); + $missingContainer->run(); + $realFailure = new Process(['/bin/sh', '-c', dockerRemoveCommand('container name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: removal already in progress', + ]); + $realFailure->run(); + + expect($missingContainer->isSuccessful())->toBeTrue() + ->and($realFailure->isSuccessful())->toBeFalse(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('makes network removal idempotent without hiding other failures', function () { + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\necho \"\$DOCKER_ERROR\" >&2\nexit 1\n"); + chmod($directory.'/docker', 0755); + + $missingNetwork = new Process(['/bin/sh', '-c', dockerNetworkRemoveCommand('network name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: network network name not found', + ]); + $missingNetwork->run(); + $realFailure = new Process(['/bin/sh', '-c', dockerNetworkRemoveCommand('network name')], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'DOCKER_ERROR' => 'Error response from daemon: network has active endpoints', + ]); + $realFailure->run(); + + expect($missingNetwork->isSuccessful())->toBeTrue() + ->and($realFailure->isSuccessful())->toBeFalse(); + + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('uses idempotent commands in strict cleanup paths', function () { + $root = dirname(__DIR__, 2); + + expect(file_get_contents($root.'/app/Jobs/DatabaseBackupJob.php')) + ->toContain('dockerRemoveCommand("backup-of-{$this->backup_log_uuid}")') + ->and(file_get_contents($root.'/app/Actions/Database/StopDatabaseProxy.php')) + ->toContain('dockerRemoveCommand("{$uuid}-proxy")') + ->and(file_get_contents($root.'/app/Actions/Destination/RemoveStandaloneDockerNetwork.php')) + ->toContain('dockerNetworkRemoveCommand($destination->network)') + ->and(file_get_contents($root.'/app/Livewire/Destination/Show.php')) + ->toContain('dockerNetworkRemoveCommand($this->destination->network)') + ->and(file_get_contents($root.'/app/Listeners/ProxyStatusChangedNotification.php')) + ->toContain("dockerRemoveCommand('coolify-proxy')") + ->and(file_get_contents($root.'/app/Actions/Application/StopApplicationOneServer.php')) + ->toContain('dockerRemoveCommand($containerName)'); +}); + it('configures deferred removal attempts to outlive the shell timeout', function () { $job = new RemoveContainerJob(123, 'container-name'); From c1219576cfe77aa3e3c15f655be146ec471cffbd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:51:22 +0200 Subject: [PATCH 64/86] feat(livewire): handle infrastructure request failures with toast Add a local preview route and test coverage for proxy-style failures, suppressing raw Livewire error responses and showing a user-friendly toast after gestures. --- AGENTS.md | 23 ++- .../Dev/LivewireRequestFailurePreview.php | 31 ++++ resources/js/app.js | 5 + resources/js/livewire-request-failure.js | 68 ++++++++ resources/js/livewire-request-failure.test.js | 146 ++++++++++++++++++ resources/views/layouts/simple.blade.php | 3 + ...livewire-request-failure-preview.blade.php | 23 +++ routes/web.php | 4 + .../LivewireRequestFailurePreviewTest.php | 47 ++++++ .../LivewireRequestFailurePreviewTest.php | 48 ++++++ 10 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/Dev/LivewireRequestFailurePreview.php create mode 100644 resources/js/livewire-request-failure.js create mode 100644 resources/js/livewire-request-failure.test.js create mode 100644 resources/views/livewire/dev/livewire-request-failure-preview.blade.php create mode 100644 tests/Feature/LivewireRequestFailurePreviewTest.php create mode 100644 tests/v4/Browser/LivewireRequestFailurePreviewTest.php diff --git a/AGENTS.md b/AGENTS.md index 5563a18ec1..4d78dfd938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,9 +99,30 @@ function loginAsRoot(): mixed ``` - See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. -- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). - Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. +### How Browser Tests Actually Run (no Docker, no display needed) + +`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin: + +1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`). +2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself. + +Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres. + +`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements). + +### Browser Test Gotchas + +- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`. +- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache. +- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`. +- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up. +- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds. +- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions. +- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible. +- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner. + ## Architecture ### Backend Structure (app/) diff --git a/app/Livewire/Dev/LivewireRequestFailurePreview.php b/app/Livewire/Dev/LivewireRequestFailurePreview.php new file mode 100644 index 0000000000..5cdda5d781 --- /dev/null +++ b/app/Livewire/Dev/LivewireRequestFailurePreview.php @@ -0,0 +1,31 @@ + + */ + public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]; + + public function fail(int $status): never + { + abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND); + + throw new HttpResponseException(response( + '

Gateway time-out

cloudflare proxy error '.$status.'

', + $status, + ['Content-Type' => 'text/html'] + )); + } + + public function render(): mixed + { + return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple'); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..11681efa03 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,9 @@ import { initializeTerminalComponent } from './terminal.js'; +import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; + +document.addEventListener('livewire:init', () => { + registerLivewireRequestFailureHandler(window.Livewire); +}); // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate // (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper, diff --git a/resources/js/livewire-request-failure.js b/resources/js/livewire-request-failure.js new file mode 100644 index 0000000000..e1bd82583c --- /dev/null +++ b/resources/js/livewire-request-failure.js @@ -0,0 +1,68 @@ +export const INFRASTRUCTURE_FAILURE_STATUSES = new Set([502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]); + +const USER_GESTURE_EVENTS = ['click', 'submit', 'keydown', 'input', 'change']; + +// A request sent within this window of a trusted user gesture is treated as +// user-initiated. It must cover Alpine $nextTick deferrals and wire:model +// debounces, while staying short enough to exclude most wire:poll requests. +export const GESTURE_WINDOW_MS = 2_000; + +const WARN_COOLDOWN_MS = 10_000; +const WARN_CONTENT_MAX_LENGTH = 2_000; + +export function createLivewireRequestFailureHandler({ now = Date.now } = {}) { + let lastWarnAt = Number.NEGATIVE_INFINITY; + let lastToastGestureAt = Number.NEGATIVE_INFINITY; + + return ({ status, content, preventDefault, gestureAt = Number.NEGATIVE_INFINITY }) => { + if (!INFRASTRUCTURE_FAILURE_STATUSES.has(status)) { + return; + } + + preventDefault(); + + const currentTime = now(); + if (currentTime - lastWarnAt >= WARN_COOLDOWN_MS) { + lastWarnAt = currentTime; + console.warn('Livewire request failed', { + status, + content: typeof content === 'string' ? content.slice(0, WARN_CONTENT_MAX_LENGTH) : content, + }); + } + + // One toast per user gesture: a single click that fails several + // component requests toasts once, while a retry (a new gesture) + // always toasts again. Background requests carry no gesture. + if (gestureAt > lastToastGestureAt) { + lastToastGestureAt = gestureAt; + window.toast?.('Action could not be completed', { + type: 'danger', + description: 'Coolify did not receive a response. Please try again.', + }); + } + }; +} + +export function registerLivewireRequestFailureHandler(Livewire, documentObject = document, { now = Date.now } = {}) { + let lastGestureAt = Number.NEGATIVE_INFINITY; + + const markUserGesture = (event) => { + if (event.isTrusted) { + lastGestureAt = now(); + } + }; + + USER_GESTURE_EVENTS.forEach((eventName) => { + documentObject.addEventListener(eventName, markUserGesture, true); + }); + + const handleFailure = createLivewireRequestFailureHandler({ now }); + + Livewire.hook('request', ({ fail }) => { + // Classify at send time: infrastructure failures (522/524) can arrive + // long after the gesture, so the failure timestamp is meaningless. + const gestureAt = now() - lastGestureAt <= GESTURE_WINDOW_MS ? lastGestureAt : Number.NEGATIVE_INFINITY; + + fail((failure) => handleFailure({ ...failure, gestureAt })); + }); +} diff --git a/resources/js/livewire-request-failure.test.js b/resources/js/livewire-request-failure.test.js new file mode 100644 index 0000000000..64a5cebbce --- /dev/null +++ b/resources/js/livewire-request-failure.test.js @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + GESTURE_WINDOW_MS, + INFRASTRUCTURE_FAILURE_STATUSES, + createLivewireRequestFailureHandler, + registerLivewireRequestFailureHandler, +} from './livewire-request-failure.js'; + +function createHarness({ start = 100_000 } = {}) { + let currentTime = start; + let requestHook = null; + let toasts = 0; + let warnings = []; + const listeners = {}; + + global.window = { toast: () => toasts++ }; + global.console = { warn: (...args) => warnings.push(args) }; + + registerLivewireRequestFailureHandler({ + hook(name, callback) { + assert.equal(name, 'request'); + requestHook = callback; + }, + }, { + addEventListener(name, callback) { + listeners[name] = callback; + }, + }, { now: () => currentTime }); + + return { + listeners, + advance: (ms) => currentTime += ms, + gesture: (event = { isTrusted: true }) => listeners.click(event), + fail(status) { + let prevented = false; + let failureCallback = null; + requestHook({ fail: (callback) => failureCallback = callback }); + failureCallback({ status, content: 'proxy error', preventDefault: () => prevented = true }); + return prevented; + }, + toasts: () => toasts, + warnings: () => warnings, + }; +} + +test('a failure after a trusted gesture suppresses the response and shows a toast', () => { + const harness = createHarness(); + + harness.gesture(); + const prevented = harness.fail(504); + + assert.equal(prevented, true); + assert.equal(harness.toasts(), 1); +}); + +test('background failures are suppressed and logged without a toast', () => { + const harness = createHarness(); + + const prevented = harness.fail(524); + + assert.equal(prevented, true); + assert.equal(harness.toasts(), 0); + assert.equal(harness.warnings().length, 1); + assert.deepEqual(harness.warnings()[0], ['Livewire request failed', { + status: 524, + content: 'proxy error', + }]); +}); + +test('one gesture toasts once, but a retry gesture toasts again', () => { + const harness = createHarness(); + + harness.gesture(); + harness.fail(504); + harness.fail(504); + assert.equal(harness.toasts(), 1); + + harness.advance(8_000); + harness.gesture(); + harness.fail(504); + assert.equal(harness.toasts(), 2); +}); + +test('requests sent outside the gesture window count as background', () => { + const harness = createHarness(); + + harness.gesture(); + harness.advance(GESTURE_WINDOW_MS + 1); + harness.fail(504); + + assert.equal(harness.toasts(), 0); +}); + +test('untrusted synthetic events do not count as gestures', () => { + const harness = createHarness(); + + harness.gesture({ isTrusted: false }); + harness.fail(504); + + assert.equal(harness.toasts(), 0); +}); + +test('a missing window.toast does not throw', () => { + const harness = createHarness(); + delete global.window.toast; + + harness.gesture(); + assert.doesNotThrow(() => harness.fail(504)); +}); + +test('console warnings are throttled and truncated', () => { + const harness = createHarness(); + + harness.fail(502); + harness.fail(504); + assert.equal(harness.warnings().length, 1); + + harness.advance(10_000); + harness.fail(504); + assert.equal(harness.warnings().length, 2); + + const handler = createLivewireRequestFailureHandler({ now: () => 0 }); + let logged = null; + global.console = { warn: (message, details) => logged = details }; + handler({ status: 502, content: 'x'.repeat(5_000), preventDefault() {} }); + assert.equal(logged.content.length, 2_000); +}); + +test('all supported infrastructure status codes are handled', () => { + const harness = createHarness(); + + for (const status of INFRASTRUCTURE_FAILURE_STATUSES) { + assert.equal(harness.fail(status), true, `expected ${status} to be handled`); + } +}); + +test('other failures keep Livewire default handling', () => { + const harness = createHarness(); + + harness.gesture(); + for (const status of [401, 419, 422, 429, 500]) { + assert.equal(harness.fail(status), false, `expected ${status} to be untouched`); + } + assert.equal(harness.toasts(), 0); +}); diff --git a/resources/views/layouts/simple.blade.php b/resources/views/layouts/simple.blade.php index 27248f4ece..e927e300c6 100644 --- a/resources/views/layouts/simple.blade.php +++ b/resources/views/layouts/simple.blade.php @@ -1,5 +1,8 @@ @extends('layouts.base') @section('body') + {{-- Manual @livewireScripts disables Livewire's asset auto-injection, so the + styles (e.g. [wire\:loading] { display: none }) must be rendered manually too. --}} + @livewireStyles @livewireScripts
{{ $slot }} diff --git a/resources/views/livewire/dev/livewire-request-failure-preview.blade.php b/resources/views/livewire/dev/livewire-request-failure-preview.blade.php new file mode 100644 index 0000000000..f829b64016 --- /dev/null +++ b/resources/views/livewire/dev/livewire-request-failure-preview.blade.php @@ -0,0 +1,23 @@ +
+
+

Development tool

+

Livewire request failure preview

+

+ Each button returns proxy-style HTML from a failed Livewire request. The page should remain visible and + Coolify should show a toast instead of Livewire's raw response modal. +

+
+ +
+ @foreach ($statuses as $status) + + {{ in_array($status, [504, 522, 524], true) ? 'Gateway timeout' : 'Proxy unavailable' }} + {{ $status }} + + @endforeach +
+ +

+ This route is registered only when APP_ENV is local or testing. +

+
diff --git a/routes/web.php b/routes/web.php index d9b43a790a..dddb0353bf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Livewire\Dashboard; use App\Livewire\Destination\Index as DestinationIndex; use App\Livewire\Destination\Resources as DestinationResources; use App\Livewire\Destination\Show as DestinationShow; +use App\Livewire\Dev\LivewireRequestFailurePreview; use App\Livewire\ForcePasswordReset; use App\Livewire\Notifications\Discord as NotificationDiscord; use App\Livewire\Notifications\Email as NotificationEmail; @@ -120,6 +121,9 @@ Route::get('/auth/{provider}/callback', [OauthController::class, 'callback'])->n // Local/testing previews for HTTP error pages and the Laravel debug renderer (never in production). if (app()->environment(['local', 'testing'])) { + Route::get('/__livewire-request-failure', LivewireRequestFailurePreview::class) + ->name('dev.livewire-request-failure-preview'); + Route::get('/__exception', function () { throw new RuntimeException('Testing Laravel exception page'); })->name('dev.exception-preview'); diff --git a/tests/Feature/LivewireRequestFailurePreviewTest.php b/tests/Feature/LivewireRequestFailurePreviewTest.php new file mode 100644 index 0000000000..8877d28ce7 --- /dev/null +++ b/tests/Feature/LivewireRequestFailurePreviewTest.php @@ -0,0 +1,47 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); +}); + +it('registers the Livewire request failure preview in testing', function () { + expect(Route::has('dev.livewire-request-failure-preview'))->toBeTrue(); + + $this->get('/__livewire-request-failure') + ->assertSuccessful() + ->assertSee('Livewire request failure preview') + ->assertSee('Gateway timeout') + ->assertSee('504'); +}); + +it('returns proxy-style html for supported statuses', function () { + Livewire::test(LivewireRequestFailurePreview::class) + ->call('fail', 504) + ->assertStatus(504) + ->assertContent('

Gateway time-out

cloudflare proxy error 504

'); +}); + +it('rejects statuses outside the supported list', function () { + Livewire::test(LivewireRequestFailurePreview::class) + ->call('fail', 500) + ->assertStatus(404); +}); + +it('keeps the preview statuses in sync with the JS handler', function () { + $source = file_get_contents(resource_path('js/livewire-request-failure.js')); + + expect(preg_match('/INFRASTRUCTURE_FAILURE_STATUSES = new Set\(\[([\d,\s]+)\]\)/', $source, $matches))->toBe(1); + + $jsStatuses = array_map('intval', array_map('trim', explode(',', $matches[1]))); + + expect((new LivewireRequestFailurePreview)->statuses)->toBe($jsStatuses); +}); diff --git a/tests/v4/Browser/LivewireRequestFailurePreviewTest.php b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php new file mode 100644 index 0000000000..f89893bc9f --- /dev/null +++ b/tests/v4/Browser/LivewireRequestFailurePreviewTest.php @@ -0,0 +1,48 @@ +set('app.maintenance.store', 'array'); + seedBrowserInstanceSettings(); + createBrowserRootUser(); +}); + +it('suppresses proxy error responses and shows a toast', function () { + $page = visit('/login') + ->fill('email', 'test@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertSee('Welcome to Coolify'); + + // Boarding redirects every other path; finish it so the preview page loads. + // User::currentTeam() caches the team, so flush after the update. + Team::query()->update(['show_boarding' => false]); + Cache::flush(); + + $page->navigate('/__livewire-request-failure') + ->assertSee('Livewire request failure preview') + ->click('502') + ->assertSee('Action could not be completed') + ->assertSee('Coolify did not receive a response. Please try again.') + ->assertSee('Livewire request failure preview') + ->assertDontSee('cloudflare proxy error') + ->screenshot(filename: 'livewire-request-failure-toast'); +}); + +it('shows the preview page without a toast before any failure', function () { + $page = visit('/__livewire-request-failure'); + + $page->assertSee('Livewire request failure preview') + ->assertDontSee('Action could not be completed') + ->screenshot(filename: 'livewire-request-failure-initial'); + + // layouts.simple must render Livewire's styles, or wire:loading spinners leak. + $spinnerDisplay = $page->script('getComputedStyle(document.querySelector("[wire\\\\:loading]")).display'); + expect($spinnerDisplay)->toBe('none'); +}); From 51461456f6e2b6e55414c0aff50f9bb2548dcf13 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:09:31 +0200 Subject: [PATCH 65/86] feat(secrets): resolve remote secret references at deployment Add Doppler, Infisical, and Vault integrations with per-resource secret links, autocomplete, and deploy-time resolution for applications, services, and databases without persisting remote values. --- .ai/lessons.md | 6 + .ai/todo.md | 93 ++++ app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- app/Actions/Database/StartRedis.php | 13 +- app/Jobs/ApplicationDeploymentJob.php | 172 +++++++- .../Shared/EnvironmentVariable/Add.php | 14 +- .../Shared/EnvironmentVariable/Show.php | 8 +- .../Project/Shared/SecretManagerLinks.php | 261 +++++++++++ .../Security/IntegrationTokenEditor.php | 42 +- .../Security/IntegrationTokenForm.php | 55 ++- app/Livewire/Security/IntegrationTokens.php | 7 + app/Models/Application.php | 5 +- app/Models/EnvironmentVariable.php | 9 +- app/Models/IntegrationToken.php | 40 ++ app/Models/SecretManagerLink.php | 122 ++++++ app/Models/Service.php | 5 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Services/DopplerService.php | 57 +++ app/Services/InfisicalService.php | 81 ++++ app/Services/IntegrationTokenValidator.php | 39 ++ app/Services/VaultService.php | 60 +++ app/Support/RemoteSecretReferences.php | 64 +++ app/Traits/HasSecretManager.php | 69 +++ app/Traits/HasSecretManagerAutocomplete.php | 58 +++ app/View/Components/Forms/EnvVarInput.php | 1 + ...000000_add_secret_manager_integrations.php | 35 ++ docker/coolify-realtime/terminal-utils.js | 2 +- .../coolify-realtime/terminal-utils.test.js | 8 + .../components/forms/env-var-input.blade.php | 41 +- .../application/configuration.blade.php | 1 + .../project/database/configuration.blade.php | 1 + .../project/service/configuration.blade.php | 1 + .../shared/environment-variable/add.blade.php | 1 + .../shared/environment-variable/all.blade.php | 2 +- .../environment-variable/show.blade.php | 1 + .../shared/secret-manager-links.blade.php | 125 ++++++ .../integration-token-editor.blade.php | 53 ++- .../security/integration-token-form.blade.php | 93 +++- .../security/integration-tokens.blade.php | 4 +- ...ationDeploymentControlVarFilteringTest.php | 1 + tests/Feature/EnvVarInputDesignTest.php | 29 ++ .../SecretManagers/SecretManagerLinkTest.php | 408 ++++++++++++++++++ .../SecretManagerLinksComponentTest.php | 262 +++++++++++ .../SecretManagerServicesTest.php | 186 ++++++++ .../IntegrationTokenSecretProvidersTest.php | 172 ++++++++ tests/Unit/RemoteSecretReferencesTest.php | 39 ++ 59 files changed, 2685 insertions(+), 99 deletions(-) create mode 100644 .ai/todo.md create mode 100644 app/Livewire/Project/Shared/SecretManagerLinks.php create mode 100644 app/Models/SecretManagerLink.php create mode 100644 app/Services/DopplerService.php create mode 100644 app/Services/InfisicalService.php create mode 100644 app/Services/IntegrationTokenValidator.php create mode 100644 app/Services/VaultService.php create mode 100644 app/Support/RemoteSecretReferences.php create mode 100644 app/Traits/HasSecretManager.php create mode 100644 app/Traits/HasSecretManagerAutocomplete.php create mode 100644 database/migrations/2026_08_23_000000_add_secret_manager_integrations.php create mode 100644 resources/views/livewire/project/shared/secret-manager-links.blade.php create mode 100644 tests/Feature/SecretManagers/SecretManagerLinkTest.php create mode 100644 tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php create mode 100644 tests/Feature/SecretManagers/SecretManagerServicesTest.php create mode 100644 tests/Feature/Security/IntegrationTokenSecretProvidersTest.php create mode 100644 tests/Unit/RemoteSecretReferencesTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 0c08f5d495..7d25e5f1fc 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -5,3 +5,9 @@ - Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. - Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. - Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. + +## Secret-manager integration: fetch at deploy time, do not sync into the DB +- Context: 2026-08-23, third-party-secret-manager-integration branch. +- Correction: I proposed to sync remote secrets (Doppler/Vault/Infisical) into the `environment_variables` table. The user rejected this. The purpose of the integration is that Coolify does NOT store the env values. Coolify must fetch them at deployment time. +- Rule: for this feature, secrets from external managers must only exist in memory during a deployment and in the generated `.env` on the target server. Never persist them in Coolify's database. +- Rule: when a feature's stated purpose is "external system is the source of truth", do not recommend a local copy for convenience. Design for the pull model first, then list its trade-offs. diff --git a/.ai/todo.md b/.ai/todo.md new file mode 100644 index 0000000000..84a0351528 --- /dev/null +++ b/.ai/todo.md @@ -0,0 +1,93 @@ +# Third-party secret manager integration (fetch-at-deploy) + +Branch: third-party-secret-manager-integration + +## Design (agreed with user) +- Coolify stores ONLY the integration token (encrypted) + link settings. Secret values are never persisted in the DB. +- Secrets are fetched in memory during deployment and merged into the generated `.env`. +- Local Coolify env vars override remote secrets on key conflict. +- Fetch failure fails the deployment with a clear error (no stale fallback in phase 1). +- Secret change => redeploy (webhook later), not sync. + +## Phase 1 scope +Providers: doppler (service token), infisical (universal auth), vault (static token auth, KV v2). +Resources: Applications only. + +## Tasks +- [x] Migration: add nullable `metadata` json to `integration_tokens` +- [x] Migration: create `secret_manager_links` (morph resourceable, integration_token_id FK cascade, settings json, is_runtime, is_buildtime) +- [x] Model: SecretManagerLink (+ fetchSecrets()); IntegrationToken metadata cast + links relation +- [x] Services: DopplerService, InfisicalService, VaultService (validate + fetchSecrets, timeouts like CloudflareTokenValidator) +- [x] Extend IntegrationTokenForm/Editor: providers doppler/infisical/vault, capability `secrets`, metadata fields, per-provider validation +- [x] Block token deletion while secret_manager_links exist +- [x] ApplicationDeploymentJob: merge remote secrets into runtime + buildtime env generation (local wins); fail deploy on fetch error +- [x] Livewire UI: Project/Shared/SecretManagerLinks on env var page (add/delete link, preview key names on demand) +- [x] Tests: token form (new providers), services (Http::fake), SecretManagerLink fetch, links Livewire component, deploy merge helper +- [x] Pint + run tests + +## Review + +Implemented fetch-at-deploy secret manager integration (Doppler, Infisical, Vault): + +- DB: `integration_tokens.metadata` (json, non-secret config: base_url/client_id/namespace) + new `secret_manager_links` table (resource morph + token FK + settings json + runtime/buildtime flags). No secret values stored anywhere. +- Services: DopplerService (/v3/configs/config/secrets/download), InfisicalService (universal-auth login -> /api/v4/secrets, v3 raw fallback for older self-hosted), VaultService (KV v2, X-Vault-Token, optional namespace). Shared IntegrationTokenValidator dispatches per provider. +- Token UI: Keys & Tokens > Integration Tokens supports the 3 new providers with capability `secrets`, provider-specific fields, pre-save API validation, deletion blocked while links exist. +- Deploy: ApplicationDeploymentJob::remote_secrets() fetches once per deployment (cached), merges into runtime .env (dotenv-literal formatting, local vars win, COOLIFY_/SERVICE_ prefixes blocked), buildtime .env dict, and env_args. Fetch failure throws DeploymentException -> deployment fails with a clear log line. +- Link UI: "Secret managers" section under the app's Environment Variables page (add/remove link, runtime/buildtime flags, on-demand key-name preview that never stores values). +- Tests: 44 new tests pass (services, link model, job remote_secrets via reflection, dotenv formatting, token form, links component, delete guard). Unit suite baseline identical with/without changes (102 pre-existing env failures, unrelated). Pint clean, all blades compile. + +Follow-ups (next phases): Doppler webhook -> auto-redeploy, Services support, Vault AppRole, stale-.env opt-in fallback, REST API for links. + + +# Iteration 2: reference model ({{secret.KEY}}) + +Agreed with user (brainstorm accepted): +- One secret source (API key + coordinates) per app, selected in the env variable view. +- Env vars are normal rows; values reference remote secrets: {{secret.KEY}} (aliases: {{vault.KEY}}, {{doppler.KEY}}, {{infisical.KEY}}). All aliases resolve against the app single source. +- Search remote keys + "Import all keys" (creates KEY={{secret.KEY}} rows, skips existing). Values never stored. +- Resolution ONLY in the deploy job (one cached bulk fetch); never in realValue/UI. +- Changing the API key does not re-check existing references; missing keys fail the deploy with a list. +- Bulk-inject model removed. + +## Tasks +- [x] App\Support\RemoteSecretReferences (pattern, containsReference, referencedKeys, substitute) +- [x] Migration: secret_manager_links drop is_runtime/is_buildtime, unique per resource +- [x] Models: SecretManagerLink (flags out, importMissingReferences), Application morphOne secretManagerLink +- [x] EnvironmentVariable::isShared restricted to SHARED_VARIABLE_TYPES +- [x] Job: flat remote_secrets (fetch only when refs exist), substitution in runtime/buildtime/env_args, remove bulk-inject merges +- [x] UI: SecretManagerLinks -> source selector + key search/browse + import all + add single reference +- [x] Tests: references unit, substitution/missing-key via reflection, component rewrite, isShared regression +- [x] Pint + tests + baseline compare +- [x] Live dev test with real Doppler token (migrate, import, deploy, verify container + DB) + +## Iteration 2 review + +Implemented and live-tested the reference model: + +- `App\Support\RemoteSecretReferences`: pattern for {{secret.KEY}} + provider aliases, key extraction, substitution, missing-key detection. +- `secret_manager_links`: one source per resource (unique constraint), runtime/buildtime flags dropped (now per-variable via normal env rows). +- Job: lazy cached fetch (only when a value references a secret), substitution in runtime .env (dotenv-literal), buildtime .env, env_args, railpack/nixpacks normalizer, Dockerfile ARG injection, and secrets hash. Missing key or fetch error -> DeploymentException with exact key + variable names. No source + references -> clear error. +- EnvironmentVariable::isShared restricted to SHARED_VARIABLE_TYPES via anchored regex (also fixes {{ project.x }} spaced form; {{secret.*}} no longer mislabeled shared). +- UI: "Secret manager" card on env page — source selector, Browse keys (names only), search filter, "Add as variable", "Import all keys" (via SecretManagerLink::importMissingReferences), remove source with warning. +- Tests: 57 secret-manager/token tests + 5 parser unit tests pass; Unit suite matches pre-existing baseline (102 env-related failures, unrelated); pint clean; blades compile. +- Live dev test (real Doppler service token, app 3 Dockerfile Example): import created 4 reference rows (values = {{secret.KEY}} strings only in DB), deploy fetched once ("Fetched 4 secrets from Doppler"), container had substituted values incl. composed value url-{{secret.SECRET}}-end, missing-key deploy failed with "Missing secret keys: DOES_NOT_EXIST (referenced by BROKEN)", cleanup redeploy healthy. + +Follow-ups: Doppler webhook -> redeploy, Services support, Vault AppRole, key picker inside the Add-variable dialog, provider badge on reference rows. + +## Iteration 2.1 (UX tweak) +- [x] Token selector: dropdown auto-saves on select (updatedIntegrationTokenUuid hook; provider change clears settings) +- [x] Provider settings fields auto-save on blur (wire:blur="saveSettings") +- [x] "Save source" button and editing state removed; Remove button kept next to the dropdown +- [x] Component tests updated (34 pass), pint clean, blades compile + +## Iteration 2.2 (namespace rename) +- [x] Canonical reference namespace is {{vault.KEY}} (user request: differentiate from shared variables); {{doppler.KEY}} / {{infisical.KEY}} stay as aliases; {{secret.KEY}} removed and no longer parses +- [x] Import / Add-as-variable / UI texts / job error messages use {{vault.KEY}} +- [x] Tests updated (39 pass incl. negative assertion that {{secret.KEY}} is ignored) +- [x] Dev data migrated via tinker ({{secret.* -> {{vault.*), redeploy verified (container OK) + +## Iteration 2.3 (UI bug fixes from user screenshots) +- [x] Key browser snippet rendered a raw Blade artifact ("{{vault.{{ $key }}}}") — now renders the exact reference, e.g. {{vault.DOPPLER_CONFIG}} (Blade escape fixed via PHP string concat; regression-asserted in component test) +- [x] Env value autocomplete ({{ typing) now offers a "vault" scope whenever the app has a secret manager source; keys are lazy-fetched from the provider on first use via $wire.fetchSecretManagerKeys() (names only, never persisted) +- [x] Autocomplete now also works in the edit-variable modal: Show (and Add) use the new HasSecretManagerAutocomplete trait and pass hasVaultSource to env-var-input; previously the dropdown never appeared when no shared variables existed +- [x] 41 tests pass; pint clean; blades compile. Browser click-through not verified (Chrome extension permission unavailable) — user to smoke-test. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..cc0ff9fe81 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -148,7 +148,7 @@ class StartClickhouse { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..e683bb5177 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -252,7 +252,7 @@ class StartDragonfly { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..45ce414bf9 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -253,7 +253,7 @@ class StartKeydb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..09512ee7b3 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -255,7 +255,7 @@ class StartMariadb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..03bc2f48e4 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -304,7 +304,7 @@ class StartMongodb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..20ee3a6e18 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -257,7 +257,7 @@ class StartMysql { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..a1f95e8a97 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -266,7 +266,7 @@ class StartPostgresql { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..61172a00cd 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -5,6 +5,7 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Support\RemoteSecretReferences; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -250,22 +251,22 @@ class StartRedis foreach ($this->database->runtime_environment_variables as $env) { if ($env->is_shared) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); if ($env->key === 'REDIS_PASSWORD') { - $this->database->update(['redis_password' => $env->real_value]); + $this->database->update(['redis_password' => $this->database->resolveSecretManagerEnvironmentVariable($env)]); } if ($env->key === 'REDIS_USERNAME') { - $this->database->update(['redis_username' => $env->real_value]); + $this->database->update(['redis_username' => $this->database->resolveSecretManagerEnvironmentVariable($env)]); } } else { - if ($env->key === 'REDIS_PASSWORD') { + if ($env->key === 'REDIS_PASSWORD' && ! RemoteSecretReferences::containsReference($env->value)) { $env->update(['value' => $this->database->redis_password]); - } elseif ($env->key === 'REDIS_USERNAME') { + } elseif ($env->key === 'REDIS_USERNAME' && ! RemoteSecretReferences::containsReference($env->value)) { $env->update(['value' => $this->database->redis_username]); } - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1e8450c1b9..92af1c4921 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Support\RemoteSecretReferences; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; @@ -143,6 +144,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; + /** @var array{runtime: array, buildtime: array}|null */ + private ?array $remote_secrets_cache = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -1275,6 +1279,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return true; } + if ($this->has_remote_buildtime_secret_references()) { + $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.'); + + return false; + } $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); if (! $configurationDiff->requiresBuild()) { $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); @@ -1302,6 +1311,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return false; } + private function has_remote_buildtime_secret_references(): bool + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview(); + + return $environmentVariables + ->where('is_buildtime', true) + ->get(['value']) + ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + } + private function check_image_locally_or_remotely() { $this->execute_remote_command([ @@ -1323,6 +1344,106 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Fetch the secrets from the application's secret manager source. Values + * live only in memory during the deployment and in the generated .env on + * the server — they are never persisted in the Coolify database. Fetched + * lazily (only when a variable references a secret), once per deployment. + * A fetch failure fails the deployment. + * + * @return array + */ + private function remote_secrets(): array + { + if ($this->remote_secrets_cache !== null) { + return $this->remote_secrets_cache; + } + + $link = $this->application->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.'); + } + + $provider = $link->integrationToken->providerName(); + $tokenName = $link->integrationToken->name; + + try { + $secrets = $link->fetchSecrets(); + } catch (Throwable $e) { + $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr'); + + throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); + } + + $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); + + return $this->remote_secrets_cache = $secrets; + } + + /** + * Replace {{vault.KEY}} references with values from the configured secret + * manager source. Missing keys fail the deployment with a + * list — changing the source never re-checks references, so this is the + * moment problems surface. + */ + private function substitute_remote_secrets(string $value, string $envKey): string + { + $secrets = $this->remote_secrets(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey})."; + $this->application_deployment_queue->addLogEntry($message, 'stderr'); + + throw new DeploymentException($message.' Check the secret manager source of this application.'); + } + + return RemoteSecretReferences::substitute($value, $secrets); + } + + /** + * Resolve shared variables, then secret references, in a raw variable value. + */ + private function resolve_environment_variable_raw(EnvironmentVariable $env): string + { + $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + + return $this->substitute_remote_secrets($value ?? '', $env->key); + } + + /** + * Resolve a runtime variable to its dotenv representation. Values with + * secret references are substituted and written as literals. + */ + private function resolve_environment_variable(EnvironmentVariable $env): ?string + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return $env->getResolvedValueWithServer($this->mainServer); + } + + return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env)); + } + + /** + * Format a remote secret value for the runtime .env file (dotenv syntax read + * by docker compose). Values are treated as literals — no interpolation. + */ + private function format_remote_secret_value(string $value): string + { + // Keep valid JSON objects/arrays unquoted, matching EnvironmentVariable::realValue(). + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + if (! str_contains($value, "'")) { + return "'".$value."'"; + } + + // Fall back to double quotes; $$ escapes compose interpolation. + return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"'; + } + private function generate_runtime_environment_variables() { $envs = collect([]); @@ -1391,7 +1512,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1458,7 +1579,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Fall back to production env vars for keys not overridden by preview vars, @@ -1472,7 +1593,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } } @@ -1728,6 +1849,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1783,6 +1910,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -2651,6 +2784,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { + if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + $resolved = $this->resolve_environment_variable_raw($environmentVariable); + + return $resolved === '' ? null : $resolved; + } + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); if (is_null($resolvedValue) || $resolvedValue === '') { return null; @@ -3194,7 +3333,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -3210,7 +3351,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -4268,7 +4411,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { $secrets_string = $variables ->map(function ($env) { - return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"; + return "{$env->key}={$this->resolve_environment_variable($env)}"; }) ->sort() ->implode('|'); @@ -4334,7 +4477,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}"); } } // Add Coolify variables as ARGs @@ -4356,7 +4499,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}"); } } // Add Coolify variables as ARGs @@ -4370,6 +4513,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + if ($argsToInsert->isNotEmpty()) { + $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) { + return [$environmentVariable->key => $this->resolve_environment_variable($environmentVariable)]; + }); + $secretsHash = $this->generate_secrets_hash($environmentVariables); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"); + } + // Development logging to show what ARGs are being injected if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); @@ -4391,11 +4542,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); } } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->getResolvedValueWithServer($this->mainServer)]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..37f9a7ad84 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; @@ -16,7 +17,18 @@ use Livewire\Component; class Add extends Component { - use AuthorizesRequests, EnvironmentVariableAnalyzer; + use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete; + + protected function secretManagerResource() + { + if ($this->shared || ! $this->resource) { + return null; + } + + return $this->resource; + } + + public $resource; public $parameters; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index db80cff801..7231602ab0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -12,6 +12,7 @@ use App\Models\SharedEnvironmentVariable; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; @@ -21,7 +22,12 @@ class Show extends Component { public bool $showEnvironmentType = true; - use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; + + protected function secretManagerResource() + { + return $this->isSharedVariable ? null : $this->env->resourceable; + } public $parameters; diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..0096654fbb --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,261 @@ + Remote key names only — values are never stored. */ + public array $keys = []; + + public bool $keysLoaded = false; + + public string $search = ''; + + public function mount(): void + { + $this->loadData(); + } + + private function loadData(): void + { + $this->link = $this->resource->secretManagerLink()->with('integrationToken')->first(); + $this->availableTokens = IntegrationToken::ownedByCurrentTeam() + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->get() + ->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true)) + ->values(); + + if ($this->link) { + $this->integration_token_uuid = $this->link->integrationToken->uuid; + $this->settings = $this->link->settings ?? []; + } + } + + public function getSelectedTokenProperty(): ?IntegrationToken + { + if (blank($this->integration_token_uuid)) { + return null; + } + + return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid); + } + + protected function rules(): array + { + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + ]; + + $rules += match ($this->selectedToken?->provider) { + 'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account' + ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] + : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + return $rules; + } + + /** + * Auto-save when a token is selected in the dropdown. Existing {{vault.*}} + * references are intentionally NOT re-checked — missing keys surface at + * the next deployment. + */ + public function updatedIntegrationTokenUuid(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->selectedToken; + + if (! $token) { + return; + } + + if ($this->link?->integrationToken?->provider !== $token->provider + || $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) { + $this->settings = []; + } + + $settings = array_filter($this->settings, fn ($value) => filled($value)); + + $this->resource->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Auto-save of the provider-specific settings fields (called on blur). + */ + public function saveSettings(): void + { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $validated = $this->validate(); + + try { + + $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); + + $this->link->update(['settings' => $settings ?: null]); + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager settings saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeSource(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->secretManagerLink()->delete(); + $this->link = null; + $this->integration_token_uuid = ''; + $this->settings = []; + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function loadKeys(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + // Values are fetched into memory, reduced to key names, and discarded. + $keys = array_keys($this->link->fetchSecrets()); + sort($keys); + $this->keys = $keys; + $this->keysLoaded = true; + } catch (\Throwable $e) { + $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage()); + } + } + + public function addReference(string $key): void + { + try { + $this->authorize('update', $this->resource); + + if (! in_array($key, $this->keys, true)) { + return; + } + + if ($this->resource->environment_variables()->where('key', $key)->exists()) { + $this->dispatch('error', "A variable with the key {$key} already exists."); + + return; + } + + $this->resource->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', "Added {$key} as {{vault.{$key}}}."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function importAll(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $imported = $this->link->importMissingReferences(); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', $imported === [] + ? 'All remote keys already exist as variables.' + : 'Imported '.count($imported).' keys as {{vault.KEY}} references.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function resetKeys(): void + { + $this->keys = []; + $this->keysLoaded = false; + $this->search = ''; + } + + public function getFilteredKeysProperty(): array + { + if (blank($this->search)) { + return $this->keys; + } + + return array_values(array_filter( + $this->keys, + fn (string $key) => stripos($key, $this->search) !== false, + )); + } + + public function render() + { + return view('livewire.project.shared.secret-manager-links', [ + 'selectedToken' => $this->selectedToken, + 'filteredKeys' => $this->filteredKeys, + ]); + } +} diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 453a7e8ae8..8c00027e4b 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component public array $capabilities = []; + public array $metadata = []; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component $this->name = $this->integrationToken->name; $this->capabilities = $this->integrationToken->capabilities; + $this->metadata = $this->integrationToken->metadata ?? []; } protected function rules(): array { - return [ + $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->integrationToken->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->integrationToken->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component ]; } - public function save(CloudflareTokenValidator $validator): void + public function save(IntegrationTokenValidator $validator): void { $this->authorize('update', $this->integrationToken); $validated = $this->validate(); + $provider = $this->integrationToken->provider; $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); try { - if ((filled($validated['newToken']) || $capabilitiesChanged) - && ! $validator->validate($token, $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) + && ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($provider)); return; } @@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component $updates = [ 'name' => $validated['name'], 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, ]; if (filled($validated['newToken'])) { @@ -100,6 +121,13 @@ class IntegrationTokenEditor extends Component public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); + + if ($this->integrationToken->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + $this->integrationToken->delete(); $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 7a7637bf5e..cf54ff60e2 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component public array $capabilities = ['dns']; + public array $metadata = []; + public function mount(): void { $this->authorize('create', IntegrationToken::class); } + public function updatedProvider(): void + { + if ($this->provider === 'cloudflare') { + $this->capabilities = ['dns']; + $this->metadata = []; + } else { + $this->capabilities = ['secrets']; + $this->metadata = $this->provider === 'infisical' + ? ['base_url' => 'https://app.infisical.com'] + : []; + } + } + protected function rules(): array { - return [ - 'provider' => ['required', 'in:cloudflare'], + $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ + 'provider' => ['required', 'in:cloudflare,doppler,infisical,vault'], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->provider === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } + + if ($this->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -42,22 +75,28 @@ class IntegrationTokenForm extends Component return [ 'capabilities.required' => 'Select at least one capability.', 'capabilities.min' => 'Select at least one capability.', + 'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).', ]; } - public function addToken(CloudflareTokenValidator $validator): void + public function addToken(IntegrationTokenValidator $validator): void { $validated = $this->validate(); + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($validated['provider'])); return; } IntegrationToken::query()->create([ - ...$validated, + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 39db135b38..c0b6541cc3 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -29,6 +29,13 @@ class IntegrationTokens extends Component { $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + + if ($token->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + $token->delete(); $this->loadTokens(); $this->dispatch('success', 'Integration token deleted successfully.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..f802a65972 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -12,6 +12,7 @@ use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -122,10 +123,11 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ use HasFactory; + use HasSecretManager; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; private static $parserVersion = '5'; @@ -382,6 +384,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->secretManagerLink()->delete(); foreach ($application->scheduled_tasks as $task) { $task->delete(); } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..cbe2ceabdc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -250,12 +250,13 @@ class EnvironmentVariable extends BaseModel { return Attribute::make( get: function () { - $type = str($this->value)->after('{{')->before('.')->value; - if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) { - return true; + if (blank($this->value)) { + return false; } - return false; + $types = implode('|', SHARED_VARIABLE_TYPES); + + return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; } ); } diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 20541f6139..53b4dd6f4a 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -3,15 +3,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class IntegrationToken extends BaseModel { + public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault']; + + public const PROVIDER_NAMES = [ + 'cloudflare' => 'Cloudflare', + 'doppler' => 'Doppler', + 'infisical' => 'Infisical', + 'vault' => 'HashiCorp Vault', + ]; + protected $fillable = [ 'team_id', 'provider', 'name', 'token', 'capabilities', + 'metadata', ]; protected $hidden = [ @@ -23,6 +34,7 @@ class IntegrationToken extends BaseModel return [ 'token' => 'encrypted', 'capabilities' => 'array', + 'metadata' => 'array', ]; } @@ -31,6 +43,34 @@ class IntegrationToken extends BaseModel return $this->belongsTo(Team::class); } + public function secretManagerLinks(): HasMany + { + return $this->hasMany(SecretManagerLink::class); + } + + public function isSecretManager(): bool + { + return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); + } + + public function providerName(): string + { + return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); + } + + public function dopplerTokenType(): ?string + { + if ($this->provider !== 'doppler') { + return null; + } + + return match (true) { + str_starts_with($this->token, 'dp.st.') => 'service', + str_starts_with($this->token, 'dp.sa.') => 'service_account', + default => null, + }; + } + public static function ownedByCurrentTeam() { return self::query()->where('team_id', currentTeam()->id); diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + 'array', + ]; + } + + public function resourceable(): MorphTo + { + return $this->morphTo(); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + /** + * Fetch the secrets from the remote manager. Values live only in memory. + * + * @return array + */ + public function fetchSecrets(): array + { + $token = $this->integrationToken; + $settings = $this->settings ?? []; + $metadata = $token->metadata ?? []; + + return match ($token->provider) { + 'doppler' => (new DopplerService($token->token))->fetchSecrets( + data_get($settings, 'project'), + data_get($settings, 'config'), + ), + 'infisical' => (new InfisicalService( + data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token->token, + ))->fetchSecrets( + (string) data_get($settings, 'project_id'), + (string) data_get($settings, 'environment'), + (string) data_get($settings, 'secret_path', '/'), + ), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token->token, + data_get($metadata, 'namespace'), + ))->fetchSecrets( + (string) data_get($settings, 'mount', 'secret'), + (string) data_get($settings, 'path'), + ), + default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."), + }; + } + + /** + * Create one {{vault.KEY}} reference variable per remote key that has no + * variable with that key yet. Only key names touch the database. + * + * @return list The keys that were imported + */ + public function importMissingReferences(): array + { + $keys = array_keys($this->fetchSecrets()); + sort($keys); + + $existing = $this->resourceable->environment_variables()->pluck('key')->flip(); + $imported = []; + + foreach ($keys as $key) { + if (isset($existing[$key])) { + continue; + } + + $this->resourceable->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $imported[] = $key; + } + + return $imported; + } + + /** Short human-readable description of the remote source for the UI. */ + public function sourceSummary(): string + { + $settings = $this->settings ?? []; + + return match ($this->integrationToken->provider) { + 'doppler' => trim(implode('/', array_filter([ + data_get($settings, 'project'), + data_get($settings, 'config'), + ])), '/') ?: 'token scope', + 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'), + 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'), + default => '', + }; + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..2a30fb846e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -6,6 +6,7 @@ use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +44,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -1631,7 +1632,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$env->real_value}"); + $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { $commands[] = 'touch .env'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..979c0ede80 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..e9b7a3ffe0 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..1f66f2591e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..18fc8868ce 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -6,6 +6,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..22c12b0677 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..cad1813436 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..adf0b38965 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..25b53f78db 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..3a684cb93e --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,81 @@ +baseUrl = rtrim($baseUrl, '/'); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..bcb6e92c76 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,60 @@ +baseUrl = rtrim($baseUrl, '/'); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/RemoteSecretReferences.php b/app/Support/RemoteSecretReferences.php new file mode 100644 index 0000000000..530c29a28c --- /dev/null +++ b/app/Support/RemoteSecretReferences.php @@ -0,0 +1,64 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is — collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..df3f28369f --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,69 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..6f41273284 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + return []; + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..b68d39ba81 --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,35 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->morphs('resourceable'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 8769d62d9d..61f82f6265 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -20,7 +20,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index bf863099b4..d3b639ba5f 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -34,6 +34,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 378a3947e3..4eb217c4fa 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -20,13 +20,33 @@ cursorPosition: 0, currentScope: null, availableVars: @js($availableVars), + hasVaultSource: @js($hasVaultSource), + vaultKeysLoading: false, get availableScopes() { // Only include scopes that have at least one variable const allScopes = ['team', 'project', 'environment', 'server']; - return allScopes.filter(scope => { + const scopes = allScopes.filter(scope => { const vars = this.availableVars[scope]; return vars && vars.length > 0; }); + // The vault scope is offered whenever a secret manager source is + // configured; its keys are fetched lazily on first use. + if (this.hasVaultSource) { + scopes.push('vault'); + } + return scopes; + }, + loadVaultKeys() { + if (this.vaultKeysLoading) return; + this.vaultKeysLoading = true; + this.$wire.fetchSecretManagerKeys().then(keys => { + this.availableVars['vault'] = keys || []; + this.vaultKeysLoading = false; + this.handleInput(); + }).catch(() => { + this.availableVars['vault'] = []; + this.vaultKeysLoading = false; + }); }, scopeUrls: @js($scopeUrls), @@ -84,6 +104,15 @@ } this.currentScope = scope; + + // Vault keys are fetched from the secret manager on first use. + if (scope === 'vault' && this.availableVars['vault'] === undefined) { + this.loadVaultKeys(); + this.suggestions = []; + this.showDropdown = true; + return; + } + const scopeVars = this.availableVars[scope] || []; const filtered = scopeVars.filter(v => v.toLowerCase().includes((partial || '').toLowerCase()) @@ -214,6 +243,7 @@ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" + wire:target.except="fetchSecretManagerKeys" @disabled($disabled) @if ($type !== 'password') type="{{ $type }}" @@ -236,7 +266,14 @@
-