Files
Andras BacsaiandClaude Opus 5.5 3379fa448a fix(audit): keep personal data and credentials out of audit events
Anonymize the audit events of a deleted user, store only the ID and
name of other users, and drop the metadata copy of actor_email.
Store URLs without query strings, registries without user info, and
the exception class instead of raw SSH errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:31:44 +01:00

158 lines
6.1 KiB
PHP

<?php
namespace App\Actions\IntegrationToken;
use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
/**
* Updates an integration token. The provider checks the token again when the secret, the capabilities, or the
* settings change; a Cloudflare token gets its zones again when the secret or the capabilities change.
* The security page and the API use this action.
*/
class UpdateIntegrationToken
{
use AsAction;
public function __construct(
private IntegrationTokenValidator $validator,
private CloudflareDnsProvider $cloudflare,
) {}
/**
* Validation rules for an update. `token` is the new secret and is optional.
*
* @return array<string, array<int, mixed>>
*/
public static function rules(string $provider): array
{
$rules = CreateIntegrationToken::rules($provider);
unset($rules['provider']);
$rules['token'][0] = 'nullable';
return $rules;
}
/**
* @param array<int, string> $capabilities
* @param array<string, mixed> $metadata The complete provider settings; empty values are dropped.
*
* @throws ValidationException when a connection setting changes without a new secret
* @throws RuntimeException when the provider rejects the token or Cloudflare zones cannot be read
*/
public function handle(
IntegrationToken $integrationToken,
string $name,
?string $newToken,
array $capabilities,
array $metadata,
bool $automaticDns,
bool $proxiedDns,
string $auditSource,
): IntegrationToken {
$provider = $integrationToken->provider;
$newToken = filled($newToken) ? $newToken : null;
$metadata = array_filter($metadata, fn ($value) => filled($value));
if ($provider === 'cloudflare') {
// Cloudflare has no user settings in metadata; keep system keys such as zones_synced_at.
$metadata = $integrationToken->metadata ?? [];
unset($metadata['automatic_dns'], $metadata['proxied_dns']);
if (! $automaticDns) {
$metadata['automatic_dns'] = false;
}
if ($proxiedDns) {
$metadata['proxied_dns'] = true;
}
}
$storedMetadata = $integrationToken->metadata ?? [];
$changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata);
if ($changedConnectionFields !== [] && $newToken === null) {
throw ValidationException::withMessages(['token' => $this->reenterSecretMessage($provider)]);
}
$capabilitiesChanged = collect($capabilities)->sort()->values()->all()
!== collect($integrationToken->capabilities)->sort()->values()->all();
if (($newToken !== null || $capabilitiesChanged || $metadata != $storedMetadata)
&& ! $this->validator->validate($provider, $newToken ?? $integrationToken->token, $capabilities, $metadata)) {
throw new RuntimeException($this->validator->errorMessage($provider));
}
$updates = ['name' => $name, 'capabilities' => $capabilities, 'metadata' => $metadata ?: null];
if ($newToken !== null) {
$updates['token'] = $newToken;
}
DB::transaction(function () use ($integrationToken, $updates, $provider, $newToken, $capabilitiesChanged): void {
$integrationToken->update($updates);
if ($provider === 'cloudflare' && ($newToken !== null || $capabilitiesChanged)) {
$this->cloudflare->syncZones($integrationToken);
}
});
auditLog("{$auditSource}.integration_token.updated", [
'team_id' => $integrationToken->team_id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $provider,
'rotated' => $newToken !== null,
'connection_changed_fields' => $changedConnectionFields,
]);
if (in_array('base_url', $changedConnectionFields, true)) {
auditLog("{$auditSource}.integration_token.base_url_changed", [
'team_id' => $integrationToken->team_id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $provider,
'previous_base_url' => auditUrl(data_get($storedMetadata, 'base_url')),
'base_url' => auditUrl(data_get($metadata, 'base_url')),
], 'warning');
}
return $integrationToken;
}
/**
* 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.',
};
}
}