fix(secrets): require the secret again when a secret manager connection changes

Changing the Vault base URL or namespace, or the Infisical base URL or
client ID, sent the stored secret to the new settings. The secret must now
be entered again, and base URL changes are audit logged.
This commit is contained in:
Andras Bacsai
2026-09-30 18:10:10 +02:00
parent 9a4469a42d
commit f53a883a60
3 changed files with 190 additions and 4 deletions
@@ -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<int, string>
*/
private function connectionFields(string $provider): array
{
return match ($provider) {
'vault' => ['base_url', 'namespace'],
'infisical' => ['base_url', 'client_id'],
default => [],
};
}
/**
* @param array<string, mixed> $storedMetadata
* @param array<string, mixed> $metadata
* @return array<int, string>
*/
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);
@@ -7,7 +7,11 @@
<x-forms.input type="password" id="newToken"
label="{{ $integrationToken->provider === 'infisical' ? 'New client secret' : 'New API token' }}"
placeholder="Leave blank to keep the current token"
helper="Paste a replacement token to rotate this credential." />
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.',
} }}" />
</div>
</div>
@@ -311,3 +311,129 @@ test('new integration token form controls declare authorization matching server-
expect($form)
->toMatch('/<x-forms\.checkbox(?=[^>]*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']);
});