From da8665330bc46ec8e13d650ab1dbc2bedf19b05a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:24:53 +0200 Subject: [PATCH] feat(security): add integration token editing and rotation --- .../Security/IntegrationTokenEditor.php | 114 ++++++++++++++++++ app/Policies/IntegrationTokenPolicy.php | 10 ++ .../integration-token-editor.blade.php | 52 ++++++++ .../security/integration-tokens.blade.php | 70 ++++++----- .../Security/IntegrationTokenFormTest.php | 110 +++++++++++++++++ 5 files changed, 329 insertions(+), 27 deletions(-) create mode 100644 app/Livewire/Security/IntegrationTokenEditor.php create mode 100644 resources/views/livewire/security/integration-token-editor.blade.php diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..453a7e8ae8 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,114 @@ +integrationToken = IntegrationToken::ownedByCurrentTeam() + ->whereUuid($integration_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->integrationToken); + + $this->name = $this->integrationToken->name; + $this->capabilities = $this->integrationToken->capabilities; + } + + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'newToken' => ['nullable', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function save(CloudflareTokenValidator $validator): void + { + $this->authorize('update', $this->integrationToken); + $validated = $this->validate(); + $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() + !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + + try { + if ((filled($validated['newToken']) || $capabilitiesChanged) + && ! $validator->validate($token, $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + $updates = [ + 'name' => $validated['name'], + 'capabilities' => $validated['capabilities'], + ]; + + if (filled($validated['newToken'])) { + $updates['token'] = $validated['newToken']; + } + + $this->integrationToken->update($updates); + $this->newToken = ''; + + auditLog('ui.integration_token.updated', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $this->integrationToken->provider, + 'rotated' => array_key_exists('token', $updates), + ]); + + $this->dispatch( + 'integration-token-updated', + uuid: $this->integrationToken->uuid, + name: $this->integrationToken->name, + capabilities: $this->integrationToken->capabilities, + ); + $this->dispatch('success', 'Integration token updated successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete(string $password = ''): void + { + $this->authorize('delete', $this->integrationToken); + $this->integrationToken->delete(); + + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); + $this->dispatch('close-modal'); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-token-editor'); + } +} diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php index 8dfc98eb8d..309c8167f2 100644 --- a/app/Policies/IntegrationTokenPolicy.php +++ b/app/Policies/IntegrationTokenPolicy.php @@ -17,6 +17,16 @@ class IntegrationTokenPolicy return $user->isAdmin(); } + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + public function delete(User $user, IntegrationToken $integrationToken): bool { return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; diff --git a/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-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php index e4f1ba1d6e..b4961551ae 100644 --- a/resources/views/livewire/security/integration-tokens.blade.php +++ b/resources/views/livewire/security/integration-tokens.blade.php @@ -30,34 +30,50 @@
@foreach ($tokens as $savedToken)
-
-

- {{ $savedToken->name }} -

-
-
- {{ ucfirst($savedToken->provider) }} -
-
- @foreach ($savedToken->capabilities ?? [] as $capability) - - {{ $capability }} - - @endforeach -
- - - - - +
+ + +
@endforeach diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index ba22641499..113fa4c032 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -1,5 +1,6 @@ toContain('wire:target="addToken" isHighlighted') ->not->toContain('class="button-highlighted"'); }); + +test('saved integration token rows render modal editors with a gear button', function () { + IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokens::class) + ->assertSee('Edit Integration Token') + ->assertSee('Production DNS') + ->assertSeeHtml(':aria-label="`Edit ${tokenName}`"'); +}); + +test('an integration token can be rotated after validating its capabilities', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => true, + 'result' => ['status' => 'active'], + ]), + 'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([ + 'success' => true, + 'result' => [['id' => 'zone-id']], + ]), + 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([ + 'success' => true, + 'result' => [], + ]), + ]); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Rotated DNS') + ->set('newToken', 'rotated-token') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $savedToken->refresh(); + + expect($savedToken->name)->toBe('Rotated DNS') + ->and($savedToken->token)->toBe('rotated-token'); +}); + +test('leaving the token field blank keeps the existing integration token', function () { + Http::fake(); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Renamed DNS') + ->set('newToken', '') + ->call('save') + ->assertHasNoErrors(); + + $savedToken->refresh(); + + expect($savedToken->name)->toBe('Renamed DNS') + ->and($savedToken->token)->toBe('original-token'); + + Http::assertNothingSent(); +}); + +test('an invalid replacement does not rotate the integration token', function () { + Http::fake([ + 'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([ + 'success' => false, + ], 403), + ]); + + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'cloudflare', + 'name' => 'Production DNS', + 'token' => 'original-token', + 'capabilities' => ['dns'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('newToken', 'invalid-token') + ->call('save') + ->assertDispatched('error'); + + expect($savedToken->fresh()->token)->toBe('original-token'); +}); + +test('editor updates its row without rerendering the teleported parent modal', function () { + $component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php')); + + expect($component) + ->toContain("'integration-token-updated'") + ->toContain("'integration-token-deleted'") + ->not->toContain('integrationTokenChanged'); +});