From e200d881f55d03ccf371780987839e5dafcb17e1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:58:27 +0200 Subject: [PATCH] feat(security): add editable cloud credential pages Add dedicated show/edit pages for cloud provider tokens and cloud-init scripts, including descriptions and UUID routes. Generate private keys directly from the index and surface cloud provider API loading errors in server creation flows. --- .../Security/CloudInitScript/Show.php | 97 ++++++++++ app/Livewire/Security/CloudInitScripts.php | 7 + .../Security/CloudProviderToken/Show.php | 177 ++++++++++++++++++ .../Security/CloudProviderTokenForm.php | 8 +- app/Livewire/Security/PrivateKey/Create.php | 24 --- app/Livewire/Security/PrivateKey/Index.php | 25 +++ app/Livewire/Server/New/ByDigitalOcean.php | 18 +- app/Livewire/Server/New/ByHetzner.php | 18 +- app/Livewire/Server/New/ByVultr.php | 18 +- app/Models/CloudInitScript.php | 4 +- app/Models/CloudProviderToken.php | 1 + app/Models/PrivateKey.php | 2 +- ...ription_to_cloud_provider_tokens_table.php | 28 +++ ...4_add_uuid_to_cloud_init_scripts_table.php | 33 ++++ .../security/cloud-init-script-form.blade.php | 7 +- .../security/cloud-init-script/show.blade.php | 28 +++ .../security/cloud-init-scripts.blade.php | 46 ++--- .../cloud-provider-token-form.blade.php | 17 +- .../cloud-provider-token/show.blade.php | 39 ++++ .../security/cloud-provider-tokens.blade.php | 113 +++++++---- .../security/private-key/create.blade.php | 5 - .../security/private-key/index.blade.php | 54 +++++- .../security/private-key/show.blade.php | 16 +- .../cloud-provider-token/show.blade.php | 7 +- .../server/new/by-digital-ocean.blade.php | 16 ++ .../livewire/server/new/by-hetzner.blade.php | 16 ++ .../livewire/server/new/by-vultr.blade.php | 16 ++ routes/web.php | 4 + .../SecurityPageAuthorizationTest.php | 6 +- tests/Feature/CloudProviderTokenShowTest.php | 48 +++++ .../Feature/Security/CloudInitScriptsTest.php | 122 ++++++++++++ .../Security/CloudProviderTokenFormTest.php | 35 ++++ .../Security/CloudProviderTokenShowTest.php | 106 +++++++++++ .../Security/PrivateKeyDropdownTest.php | 97 ++++++++++ .../Server/NewServerProviderTokenUrlTest.php | 27 +++ tests/Unit/CloudProviderTokenFormViewTest.php | 37 ++++ 36 files changed, 1196 insertions(+), 126 deletions(-) create mode 100644 app/Livewire/Security/CloudInitScript/Show.php create mode 100644 app/Livewire/Security/CloudProviderToken/Show.php create mode 100644 database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php create mode 100644 database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php create mode 100644 resources/views/livewire/security/cloud-init-script/show.blade.php create mode 100644 resources/views/livewire/security/cloud-provider-token/show.blade.php create mode 100644 tests/Feature/CloudProviderTokenShowTest.php create mode 100644 tests/Feature/Security/CloudInitScriptsTest.php create mode 100644 tests/Feature/Security/CloudProviderTokenShowTest.php create mode 100644 tests/Feature/Security/PrivateKeyDropdownTest.php diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php new file mode 100644 index 000000000..6e2a3937d --- /dev/null +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -0,0 +1,97 @@ + 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Script name is required.', + 'name.max' => 'Script name cannot exceed 255 characters.', + 'script.required' => 'Cloud-init script content is required.', + ]; + } + + public function mount(string $cloud_init_script_uuid): void + { + try { + $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() + ->whereUuid($cloud_init_script_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudInitScript); + + $this->name = $this->cloudInitScript->name; + $this->script = $this->cloudInitScript->script; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud-init script.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudInitScript); + $this->validate(); + + $this->cloudInitScript->update([ + 'name' => $this->name, + 'script' => $this->script, + ]); + + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $this->cloudInitScript->id, + 'cloud_init_script_name' => $this->cloudInitScript->name, + ]); + + $this->dispatch('success', 'Cloud-init script updated successfully.'); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudInitScript); + + $scriptId = $this->cloudInitScript->id; + $scriptName = $this->cloudInitScript->name; + + $this->cloudInitScript->delete(); + + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + + return redirectRoute($this, 'security.cloud-init-scripts'); + } + + public function render() + { + return view('livewire.security.cloud-init-script.show'); + } +} diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 57b7324d3..e66a749cf 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -27,6 +27,13 @@ public function getListeners() public function loadScripts() { + CloudInitScript::ownedByCurrentTeam() + ->whereNull('uuid') + ->get() + ->each(function (CloudInitScript $script): void { + $script->forceFill(['uuid' => new_public_id()])->save(); + }); + $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php new file mode 100644 index 000000000..aa9270be0 --- /dev/null +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -0,0 +1,177 @@ + 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + ]; + } + + protected function messages(): array + { + return [ + 'name.required' => 'Token name is required.', + ]; + } + + public function mount(string $cloud_token_uuid): void + { + try { + $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() + ->whereUuid($cloud_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->cloudProviderToken); + + $this->name = $this->cloudProviderToken->name; + $this->description = $this->cloudProviderToken->description; + } catch (AuthorizationException) { + abort(403, 'You do not have permission to view this cloud token.'); + } catch (\Throwable) { + abort(404); + } + } + + public function save(): void + { + $this->authorize('update', $this->cloudProviderToken); + $this->validate(); + + $description = trim($this->description ?? ''); + + $this->cloudProviderToken->update([ + 'name' => $this->name, + 'description' => $description === '' ? null : $description, + ]); + + auditLog('ui.cloud_token.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->dispatch('success', 'Cloud provider token updated.'); + } + + public function validateToken(): void + { + $this->authorize('view', $this->cloudProviderToken); + + $isValid = match ($this->cloudProviderToken->provider) { + 'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token), + 'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token), + 'vultr' => $this->validateVultrToken($this->cloudProviderToken->token), + default => false, + }; + + $providerName = $this->providerName(); + + $this->dispatch( + $isValid ? 'success' : 'error', + $isValid + ? "{$providerName} token is valid." + : "{$providerName} token validation failed. Please check the token." + ); + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + 'valid' => $isValid, + ]); + } + + public function delete(): mixed + { + $this->authorize('delete', $this->cloudProviderToken); + + if ($this->cloudProviderToken->hasServers()) { + $serverCount = $this->cloudProviderToken->servers()->count(); + $this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first."); + + return null; + } + + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $this->cloudProviderToken->uuid, + 'cloud_token_name' => $this->cloudProviderToken->name, + 'provider' => $this->cloudProviderToken->provider, + ]); + + $this->cloudProviderToken->delete(); + + return redirectRoute($this, 'security.cloud-tokens'); + } + + public function providerName(): string + { + return match ($this->cloudProviderToken->provider) { + 'digitalocean' => 'DigitalOcean', + 'vultr' => 'Vultr', + default => 'Hetzner', + }; + } + + private function validateHetznerToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.hetzner.cloud/v1/servers?per_page=1') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateDigitalOceanToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + private function validateVultrToken(string $token): bool + { + try { + return Http::withToken($token) + ->timeout(10) + ->get('https://api.vultr.com/v2/account') + ->successful(); + } catch (\Throwable) { + return false; + } + } + + public function render() + { + return view('livewire.security.cloud-provider-token.show'); + } +} diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 0a7235447..c2466c622 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -22,6 +22,8 @@ class CloudProviderTokenForm extends Component public string $name = ''; + public ?string $description = null; + public function mount() { try { @@ -37,6 +39,7 @@ protected function rules(): array 'provider' => 'required|string|in:hetzner,digitalocean,vultr', 'token' => 'required|string', 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', ]; } @@ -94,11 +97,14 @@ public function addToken() return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); } + $description = trim($this->description ?? ''); + $savedToken = CloudProviderToken::create([ 'team_id' => currentTeam()->id, 'provider' => $this->provider, 'token' => $this->token, 'name' => $this->name, + 'description' => $description === '' ? null : $description, ]); auditLog('ui.cloud_token.created', [ @@ -108,7 +114,7 @@ public function addToken() 'provider' => $savedToken->provider, ]); - $this->reset(['token', 'name']); + $this->reset(['token', 'name', 'description']); // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 8b7ba73dd..8be1011d5 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -43,22 +43,6 @@ protected function messages(): array ); } - public function generateNewRSAKey() - { - $this->generateNewKey('rsa'); - } - - public function generateNewEDKey() - { - $this->generateNewKey('ed25519'); - } - - private function generateNewKey($type) - { - $keyData = PrivateKey::generateNewKeyPair($type); - $this->setKeyData($keyData); - } - public function updated($property) { if ($property === 'value') { @@ -93,14 +77,6 @@ public function createPrivateKey() } } - private function setKeyData(array $keyData) - { - $this->name = $keyData['name']; - $this->description = $keyData['description']; - $this->value = $keyData['private_key']; - $this->publicKey = $keyData['public_key']; - } - private function validatePrivateKey() { $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 0362b65fa..540ef5fa1 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,31 @@ class Index extends Component { use AuthorizesRequests; + public function generatePrivateKey(string $type) + { + try { + $this->authorize('create', PrivateKey::class); + + if (! in_array($type, ['ed25519', 'rsa'], true)) { + $this->dispatch('error', 'Invalid private key type.'); + + return; + } + + $keyData = PrivateKey::generateNewKeyPair($type); + $privateKey = PrivateKey::createAndStore([ + 'name' => $keyData['name'], + 'description' => $keyData['description'], + 'private_key' => $keyData['private_key'], + 'team_id' => currentTeam()->id, + ]); + + return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index c131e096a..7b0151851 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\DigitalOceanService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -57,6 +58,8 @@ class ByDigitalOcean extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $monitoring = true; @@ -264,6 +267,7 @@ public function loadDigitalOceanData(): void } $this->loading_data = true; + $this->provider_data_error = null; try { $digitalOceanService = new DigitalOceanService($token); @@ -278,10 +282,22 @@ public function loadDigitalOceanData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + public function getAvailableSizesProperty(): array { if (! $this->selected_region) { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 31cd89dae..5ef88acee 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Locked; @@ -70,6 +71,8 @@ class ByHetzner extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv4 = true; public bool $enable_ipv6 = true; @@ -310,6 +313,7 @@ public function loadHetznerData(): void } $this->loading_data = true; + $this->provider_data_error = null; $this->selectedHetznerSshKeyIds = []; $this->selectedHetznerFirewallIds = []; $this->selectedHetznerNetworkIds = []; @@ -353,10 +357,22 @@ public function loadHetznerData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function getCpuVendorInfo(array $serverType): ?string { $name = strtolower($serverType['name'] ?? ''); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 124644845..9e6bc3cf2 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -12,6 +12,7 @@ use App\Rules\ValidHostname; use App\Services\VultrService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; @@ -57,6 +58,8 @@ class ByVultr extends Component public bool $loading_data = false; + public ?string $provider_data_error = null; + public bool $enable_ipv6 = true; public bool $disable_public_ipv4 = false; @@ -334,6 +337,7 @@ public function loadVultrData(): void } $this->loading_data = true; + $this->provider_data_error = null; try { $vultrService = new VultrService($token); @@ -357,10 +361,22 @@ public function loadVultrData(): void $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; - throw $e; + $this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error'); + $this->dispatch('error', $this->provider_data_error); } } + private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string + { + $details = $e->getMessage(); + + if ($e instanceof RequestException && $e->response) { + $details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details; + } + + return "{$providerName} API error: {$details}"; + } + private function createVultrServer(string $token): array { $vultrService = new VultrService($token); diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 9d2676aca..671c5e76c 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -2,9 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; - -class CloudInitScript extends Model +class CloudInitScript extends BaseModel { protected $fillable = [ 'team_id', diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 27214f1d1..ab9897f9a 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -13,6 +13,7 @@ class CloudProviderToken extends BaseModel 'provider', 'token', 'name', + 'description', ]; protected $hidden = [ diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index d16213ad7..3f72642a5 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -291,7 +291,7 @@ protected function ensureStorageDirectoryExists() public function getKeyLocation() { - return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + return Storage::disk('ssh-keys')->path("ssh_key@{$this->uuid}"); } public function updatePrivateKey(array $data) diff --git a/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php new file mode 100644 index 000000000..5d194cd7d --- /dev/null +++ b/database/migrations/2026_07_08_102340_add_description_to_cloud_provider_tokens_table.php @@ -0,0 +1,28 @@ +text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('cloud_provider_tokens', function (Blueprint $table) { + $table->dropColumn('description'); + }); + } +}; diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php new file mode 100644 index 000000000..500fa1fcf --- /dev/null +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -0,0 +1,33 @@ +string('uuid')->nullable()->unique()->after('id'); + }); + + DB::table('cloud_init_scripts') + ->whereNull('uuid') + ->orderBy('id') + ->each(function (object $script): void { + DB::table('cloud_init_scripts') + ->where('id', $script->id) + ->update(['uuid' => new_public_id()]); + }); + } + + public function down(): void + { + Schema::table('cloud_init_scripts', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropColumn('uuid'); + }); + } +}; diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php index 83bedffab..2e1eb86eb 100644 --- a/resources/views/livewire/security/cloud-init-script-form.blade.php +++ b/resources/views/livewire/security/cloud-init-script-form.blade.php @@ -5,13 +5,8 @@ helper="Enter your cloud-init script. Supports cloud-config YAML format." required />