diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..7a7637bf5e --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,81 @@ +authorize('create', IntegrationToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => ['required', 'in:cloudflare'], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function addToken(CloudflareTokenValidator $validator): void + { + $validated = $this->validate(); + + try { + if (! $validator->validate($validated['token'], $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + IntegrationToken::query()->create([ + ...$validated, + 'team_id' => currentTeam()->id, + ]); + + $this->reset(['name', 'token']); + $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } + + $this->dispatch('success', 'Integration token added successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.integration-token-form'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..39db135b38 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,41 @@ +authorize('viewAny', IntegrationToken::class); + $this->loadTokens(); + } + + #[On('integrationTokenAdded')] + public function loadTokens(): void + { + $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); + } + + public function deleteToken(int $tokenId, string $password = ''): void + { + $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + $token->delete(); + $this->loadTokens(); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-tokens'); + } +} diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..20541f6139 --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,38 @@ + 'encrypted', + 'capabilities' => 'array', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam() + { + return self::query()->where('team_id', currentTeam()->id); + } +} diff --git a/app/Models/Team.php b/app/Models/Team.php index 15085203aa..b7664e94d3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..8dfc98eb8d --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,24 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 0adf9e2329..008544beaa 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -51,6 +52,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -127,6 +129,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/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/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..e4f1ba1d6e --- /dev/null +++ b/resources/views/livewire/security/integration-tokens.blade.php @@ -0,0 +1,68 @@ +
+ + Integration Tokens | Coolify + + + +
+ + + @can('create', App\Models\IntegrationToken::class) + + + + + + + @endcan + + + @if ($tokens->isEmpty()) + + @else +
+ @foreach ($tokens as $savedToken) +
+
+

+ {{ $savedToken->name }} +

+
+
+ {{ ucfirst($savedToken->provider) }} +
+
+ @foreach ($savedToken->capabilities ?? [] as $capability) + + {{ $capability }} + + @endforeach +
+ + + + + +
+ @endforeach +
+ @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 0d9e83ceda..086f85e137 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ use App\Livewire\Security\CloudInitScript\Show as SecurityCloudInitScriptShow; use App\Livewire\Security\CloudInitScripts; use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenShow; use App\Livewire\Security\CloudTokens; +use App\Livewire\Security\IntegrationTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; use App\Livewire\Server\Advanced as ServerAdvanced; @@ -388,6 +389,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show'); Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens'); + Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens'); Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show'); Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts'); Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show'); diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php new file mode 100644 index 0000000000..ba22641499 --- /dev/null +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -0,0 +1,143 @@ +whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); +}); + +test('a cloudflare dns token is validated with read only requests before it is saved', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => true, + 'result' => [['id' => 'zone-id']], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ + 'success' => true, + 'result' => [], + ]), + ]); + + Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true]) + ->set('provider', 'cloudflare') + ->set('name', 'Production DNS') + ->set('token', 'cloudflare-token') + ->set('capabilities', ['dns']) + ->call('addToken') + ->assertHasNoErrors() + ->assertDispatched('close-modal'); + + $this->assertDatabaseHas('integration_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + ]); + + Http::assertSentCount(3); + Http::assertSent(fn ($request) => $request->method() === 'GET' + && $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1'); +}); + +test('a cloudflare token is not saved when scope validation fails', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => false, + 'errors' => [['message' => 'Authentication error']], + ], 403), + ]); + + Livewire::test(IntegrationTokenForm::class) + ->set('name', 'Invalid DNS token') + ->set('token', 'cloudflare-token') + ->set('capabilities', ['dns']) + ->call('addToken') + ->assertDispatched('error'); + + $this->assertDatabaseCount('integration_tokens', 0); +}); + +test('at least one capability is required when adding a cloudflare token', function () { + Livewire::test(IntegrationTokenForm::class) + ->set('name', 'Account token') + ->set('token', 'cloudflare-token') + ->set('capabilities', []) + ->call('addToken') + ->assertHasErrors(['capabilities' => 'required']); + + $this->assertDatabaseCount('integration_tokens', 0); + Http::assertNothingSent(); +}); + +test('integration tokens page lists saved provider and capabilities', function () { + IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'secret', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokens::class) + ->assertSee('Production DNS') + ->assertSee('Cloudflare') + ->assertSee('DNS'); +}); + +test('cloudflare dns scope guidance and token creation link are shown', function () { + Livewire::test(IntegrationTokenForm::class) + ->set('capabilities', ['dns']) + ->assertSee('Zone - DNS - Edit') + ->assertSee('Zone - Zone - Read') + ->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management'); + + expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'))) + ->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D'); +}); + +test('capability selection uses the shared checkbox component', function () { + $view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')); + + expect($view) + ->toContain('toContain('class="mt-3 rounded-lg border') + ->not->toContain('toContain('wire:target="addToken" isHighlighted') + ->not->toContain('class="button-highlighted"'); +}); diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php index e89bf8093a..9eab92cc28 100644 --- a/tests/Feature/SecuritySettingsNavigationTest.php +++ b/tests/Feature/SecuritySettingsNavigationTest.php @@ -8,6 +8,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { 'security/private-key/index.blade.php', 'security/private-key/show.blade.php', 'security/cloud-tokens.blade.php', + 'security/integration-tokens.blade.php', 'security/cloud-provider-token/show.blade.php', 'security/cloud-init-scripts.blade.php', 'security/cloud-init-script/show.blade.php', @@ -22,6 +23,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () { ->toContain('application-settings-navigation') ->toContain("'label' => 'Private Keys'") ->toContain("'label' => 'Cloud Tokens'") + ->toContain("'label' => 'Integration Tokens'") ->toContain("'label' => 'Cloud-Init Scripts'") ->toContain("'label' => 'API Tokens'");