diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index d10ebb1c14..d8dc685666 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -58,12 +58,12 @@ class IntegrationTokenEditor extends Component ]; if ($this->integrationToken->provider === 'infisical') { - $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; } if ($this->integrationToken->provider === 'vault') { - $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; } @@ -92,9 +92,16 @@ class IntegrationTokenEditor extends Component $metadata['automatic_dns'] = false; } } + $storedMetadata = $this->integrationToken->metadata ?? []; + $changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata); + if ($changedConnectionFields !== [] && blank($validated['newToken'])) { + $this->addError('newToken', $this->reenterSecretMessage($provider)); + + return; + } $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); - $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); + $metadataChanged = $metadata != $storedMetadata; try { if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) @@ -129,8 +136,20 @@ class IntegrationTokenEditor extends Component 'integration_token_name' => $this->integrationToken->name, 'provider' => $this->integrationToken->provider, 'rotated' => array_key_exists('token', $updates), + 'connection_changed_fields' => $changedConnectionFields, ]); + if (in_array('base_url', $changedConnectionFields, true)) { + auditLog('ui.integration_token.base_url_changed', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $provider, + 'previous_base_url' => data_get($storedMetadata, 'base_url'), + 'base_url' => data_get($metadata, 'base_url'), + ], 'warning'); + } + $this->dispatch( 'integration-token-updated', uuid: $this->integrationToken->uuid, @@ -143,6 +162,43 @@ class IntegrationTokenEditor extends Component } } + /** + * Metadata keys that decide where or how the stored secret is sent. + * Changing any of them must require the secret to be entered again. + * + * @return array + */ + private function connectionFields(string $provider): array + { + return match ($provider) { + 'vault' => ['base_url', 'namespace'], + 'infisical' => ['base_url', 'client_id'], + default => [], + }; + } + + /** + * @param array $storedMetadata + * @param array $metadata + * @return array + */ + private function changedConnectionFields(string $provider, array $storedMetadata, array $metadata): array + { + return array_values(array_filter( + $this->connectionFields($provider), + fn (string $field): bool => (string) data_get($storedMetadata, $field, '') !== (string) data_get($metadata, $field, ''), + )); + } + + private function reenterSecretMessage(string $provider): string + { + return match ($provider) { + 'infisical' => 'Enter the client secret again when you change the base URL or the client ID.', + 'vault' => 'Enter the token again when you change the base URL or the namespace.', + default => 'Enter the token again when you change the connection settings.', + }; + } + public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index e99106418f..9a558939ba 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -7,7 +7,11 @@ + helper="{{ match ($integrationToken->provider) { + 'infisical' => 'Paste a replacement client secret to rotate this credential. Required when you change the base URL or the client ID.', + 'vault' => 'Paste a replacement token to rotate this credential. Required when you change the base URL or the namespace.', + default => 'Paste a replacement token to rotate this credential.', + } }}" /> diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 6853a33623..0c70c868b5 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -311,3 +311,129 @@ test('new integration token form controls declare authorization matching server- expect($form) ->toMatch('/]*id="automatic-dns")(?=[^>]*canGate="create")(?=[^>]*:canResource="\\\\App\\\\Models\\\\IntegrationToken::class")[^>]*>/'); }); + +function vaultIntegrationToken(Team $team, array $metadata = ['base_url' => 'https://example.com:8200']): IntegrationToken +{ + return IntegrationToken::query()->create([ + 'team_id' => $team->id, + 'provider' => 'vault', + 'name' => 'Production Vault', + 'token' => 'hvs.stored-secret', + 'capabilities' => ['secrets'], + 'metadata' => $metadata, + ]); +} + +test('changing the vault base url without re-entering the token does not send the stored token to the new host', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->set('newToken', '') + ->call('save') + ->assertHasErrors(['newToken']) + ->assertNotDispatched('success'); + + Http::assertNothingSent(); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.net')); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200']) + ->and($savedToken->fresh()->token)->toBe('hvs.stored-secret'); +}); + +test('changing the vault namespace without re-entering the token is rejected', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.namespace', 'team-b') + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); +}); + +test('changing the infisical base url or client id without re-entering the client secret is rejected', function (string $field, string $value) { + Http::fake(); + $savedToken = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'infisical', + 'name' => 'Production Infisical', + 'token' => 'stored-client-secret', + 'capabilities' => ['secrets'], + 'metadata' => ['base_url' => 'https://example.com', 'client_id' => 'client-a'], + ]); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set("metadata.{$field}", $value) + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); + expect($savedToken->fresh()->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-a']); +})->with([ + 'base url' => ['base_url', 'https://example.net'], + 'client id' => ['client_id', 'client-b'], +]); + +test('an admin changing the vault base url without the token gets a validation error', function () { + Http::fake(); + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->call('save') + ->assertHasErrors(['newToken']); + + Http::assertNothingSent(); +}); + +test('changing the vault base url with a re-entered token validates against the new host and is audited', function () { + Http::fake([ + 'https://example.net:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]), + ]); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.base_url', 'https://example.net:8200') + ->set('newToken', 'hvs.new-secret') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + Http::assertSent(fn ($request) => $request->url() === 'https://example.net:8200/v1/auth/token/lookup-self' + && $request->hasHeader('X-Vault-Token', 'hvs.new-secret')); + Http::assertNotSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.stored-secret')); + + $savedToken->refresh(); + expect($savedToken->metadata)->toBe(['base_url' => 'https://example.net:8200']) + ->and($savedToken->token)->toBe('hvs.new-secret'); + + $event = AuditEvent::query()->where('event', 'ui.integration_token.base_url_changed')->first(); + expect($event)->not->toBeNull() + ->and($event->team_id)->toBe($this->team->id); + expect(json_encode($event->toArray()))->toContain('example.net') + ->not->toContain('hvs.new-secret'); +}); + +test('saving a vault token with unchanged connection settings and a blank token keeps working', function () { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team, ['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('name', 'Renamed Vault') + ->set('newToken', '') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + Http::assertNothingSent(); + $savedToken->refresh(); + expect($savedToken->name)->toBe('Renamed Vault') + ->and($savedToken->token)->toBe('hvs.stored-secret') + ->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); +});