From e71648d01ba30cc44f4d766e313ae6fcf961a58c Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:54:43 +0200 Subject: [PATCH 01/21] feat(ui): add split action button component --- resources/css/app.css | 66 +++++++++++++++++++ .../views/components/split-action.blade.php | 19 ++++++ ...bleLivewireComponentsAuthorizationTest.php | 2 +- .../ResourceHeadingUnifiedNavbarTest.php | 43 ++++++------ 4 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 resources/views/components/split-action.blade.php diff --git a/resources/css/app.css b/resources/css/app.css index 30ccf4ac02..09f3e18de8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1870,6 +1870,72 @@ html[data-theme="custom"] textarea:disabled { flex-shrink: 0; } +.split-action { + display: inline-flex; + align-items: stretch; +} + +.split-action-main, +.split-action-caret { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + border: none; + background: linear-gradient(to bottom, var(--color-coollabs-100), var(--color-coollabs-200)); + color: #ffffff; + transition: background-color 0.15s, color 0.15s, filter 0.15s; +} + +.split-action-main { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.625rem; + border-radius: 6px 0 0 6px; +} + +.split-action-caret { + flex-shrink: 0; + width: 1.75rem; + border-left: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0 6px 6px 0; +} + +.split-action > .split-action-main:only-of-type { + border-radius: 6px; +} + +.split-action-main:hover:not(:disabled), +.split-action-caret:hover:not(:disabled) { + background: linear-gradient(to bottom, var(--color-coollabs-100), var(--color-coollabs)); + color: #ffffff; +} + +.split-action-main:disabled, +.split-action-caret:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.split-action-main:focus-visible, +.split-action-caret:focus-visible { + outline: none; + position: relative; + z-index: 1; + box-shadow: 0 0 0 1px var(--color-accent); +} + +/* Compact height inside the fixed heading action bar */ +.application-heading-actions .split-action-main, +.application-heading-actions .split-action-caret { + height: 1.75rem; +} + /* Custom listbox (replaces native - - - 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 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 05/21] 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 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 06/21] 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 @@
-