From 51461456f6e2b6e55414c0aff50f9bb2548dcf13 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:09:31 +0200 Subject: [PATCH] feat(secrets): resolve remote secret references at deployment Add Doppler, Infisical, and Vault integrations with per-resource secret links, autocomplete, and deploy-time resolution for applications, services, and databases without persisting remote values. --- .ai/lessons.md | 6 + .ai/todo.md | 93 ++++ app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- app/Actions/Database/StartRedis.php | 13 +- app/Jobs/ApplicationDeploymentJob.php | 172 +++++++- .../Shared/EnvironmentVariable/Add.php | 14 +- .../Shared/EnvironmentVariable/Show.php | 8 +- .../Project/Shared/SecretManagerLinks.php | 261 +++++++++++ .../Security/IntegrationTokenEditor.php | 42 +- .../Security/IntegrationTokenForm.php | 55 ++- app/Livewire/Security/IntegrationTokens.php | 7 + app/Models/Application.php | 5 +- app/Models/EnvironmentVariable.php | 9 +- app/Models/IntegrationToken.php | 40 ++ app/Models/SecretManagerLink.php | 122 ++++++ app/Models/Service.php | 5 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Services/DopplerService.php | 57 +++ app/Services/InfisicalService.php | 81 ++++ app/Services/IntegrationTokenValidator.php | 39 ++ app/Services/VaultService.php | 60 +++ app/Support/RemoteSecretReferences.php | 64 +++ app/Traits/HasSecretManager.php | 69 +++ app/Traits/HasSecretManagerAutocomplete.php | 58 +++ app/View/Components/Forms/EnvVarInput.php | 1 + ...000000_add_secret_manager_integrations.php | 35 ++ docker/coolify-realtime/terminal-utils.js | 2 +- .../coolify-realtime/terminal-utils.test.js | 8 + .../components/forms/env-var-input.blade.php | 41 +- .../application/configuration.blade.php | 1 + .../project/database/configuration.blade.php | 1 + .../project/service/configuration.blade.php | 1 + .../shared/environment-variable/add.blade.php | 1 + .../shared/environment-variable/all.blade.php | 2 +- .../environment-variable/show.blade.php | 1 + .../shared/secret-manager-links.blade.php | 125 ++++++ .../integration-token-editor.blade.php | 53 ++- .../security/integration-token-form.blade.php | 93 +++- .../security/integration-tokens.blade.php | 4 +- ...ationDeploymentControlVarFilteringTest.php | 1 + tests/Feature/EnvVarInputDesignTest.php | 29 ++ .../SecretManagers/SecretManagerLinkTest.php | 408 ++++++++++++++++++ .../SecretManagerLinksComponentTest.php | 262 +++++++++++ .../SecretManagerServicesTest.php | 186 ++++++++ .../IntegrationTokenSecretProvidersTest.php | 172 ++++++++ tests/Unit/RemoteSecretReferencesTest.php | 39 ++ 59 files changed, 2685 insertions(+), 99 deletions(-) create mode 100644 .ai/todo.md create mode 100644 app/Livewire/Project/Shared/SecretManagerLinks.php create mode 100644 app/Models/SecretManagerLink.php create mode 100644 app/Services/DopplerService.php create mode 100644 app/Services/InfisicalService.php create mode 100644 app/Services/IntegrationTokenValidator.php create mode 100644 app/Services/VaultService.php create mode 100644 app/Support/RemoteSecretReferences.php create mode 100644 app/Traits/HasSecretManager.php create mode 100644 app/Traits/HasSecretManagerAutocomplete.php create mode 100644 database/migrations/2026_08_23_000000_add_secret_manager_integrations.php create mode 100644 resources/views/livewire/project/shared/secret-manager-links.blade.php create mode 100644 tests/Feature/SecretManagers/SecretManagerLinkTest.php create mode 100644 tests/Feature/SecretManagers/SecretManagerLinksComponentTest.php create mode 100644 tests/Feature/SecretManagers/SecretManagerServicesTest.php create mode 100644 tests/Feature/Security/IntegrationTokenSecretProvidersTest.php create mode 100644 tests/Unit/RemoteSecretReferencesTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 0c08f5d495..7d25e5f1fc 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -5,3 +5,9 @@ - Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. - Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. - Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. + +## Secret-manager integration: fetch at deploy time, do not sync into the DB +- Context: 2026-08-23, third-party-secret-manager-integration branch. +- Correction: I proposed to sync remote secrets (Doppler/Vault/Infisical) into the `environment_variables` table. The user rejected this. The purpose of the integration is that Coolify does NOT store the env values. Coolify must fetch them at deployment time. +- Rule: for this feature, secrets from external managers must only exist in memory during a deployment and in the generated `.env` on the target server. Never persist them in Coolify's database. +- Rule: when a feature's stated purpose is "external system is the source of truth", do not recommend a local copy for convenience. Design for the pull model first, then list its trade-offs. diff --git a/.ai/todo.md b/.ai/todo.md new file mode 100644 index 0000000000..84a0351528 --- /dev/null +++ b/.ai/todo.md @@ -0,0 +1,93 @@ +# Third-party secret manager integration (fetch-at-deploy) + +Branch: third-party-secret-manager-integration + +## Design (agreed with user) +- Coolify stores ONLY the integration token (encrypted) + link settings. Secret values are never persisted in the DB. +- Secrets are fetched in memory during deployment and merged into the generated `.env`. +- Local Coolify env vars override remote secrets on key conflict. +- Fetch failure fails the deployment with a clear error (no stale fallback in phase 1). +- Secret change => redeploy (webhook later), not sync. + +## Phase 1 scope +Providers: doppler (service token), infisical (universal auth), vault (static token auth, KV v2). +Resources: Applications only. + +## Tasks +- [x] Migration: add nullable `metadata` json to `integration_tokens` +- [x] Migration: create `secret_manager_links` (morph resourceable, integration_token_id FK cascade, settings json, is_runtime, is_buildtime) +- [x] Model: SecretManagerLink (+ fetchSecrets()); IntegrationToken metadata cast + links relation +- [x] Services: DopplerService, InfisicalService, VaultService (validate + fetchSecrets, timeouts like CloudflareTokenValidator) +- [x] Extend IntegrationTokenForm/Editor: providers doppler/infisical/vault, capability `secrets`, metadata fields, per-provider validation +- [x] Block token deletion while secret_manager_links exist +- [x] ApplicationDeploymentJob: merge remote secrets into runtime + buildtime env generation (local wins); fail deploy on fetch error +- [x] Livewire UI: Project/Shared/SecretManagerLinks on env var page (add/delete link, preview key names on demand) +- [x] Tests: token form (new providers), services (Http::fake), SecretManagerLink fetch, links Livewire component, deploy merge helper +- [x] Pint + run tests + +## Review + +Implemented fetch-at-deploy secret manager integration (Doppler, Infisical, Vault): + +- DB: `integration_tokens.metadata` (json, non-secret config: base_url/client_id/namespace) + new `secret_manager_links` table (resource morph + token FK + settings json + runtime/buildtime flags). No secret values stored anywhere. +- Services: DopplerService (/v3/configs/config/secrets/download), InfisicalService (universal-auth login -> /api/v4/secrets, v3 raw fallback for older self-hosted), VaultService (KV v2, X-Vault-Token, optional namespace). Shared IntegrationTokenValidator dispatches per provider. +- Token UI: Keys & Tokens > Integration Tokens supports the 3 new providers with capability `secrets`, provider-specific fields, pre-save API validation, deletion blocked while links exist. +- Deploy: ApplicationDeploymentJob::remote_secrets() fetches once per deployment (cached), merges into runtime .env (dotenv-literal formatting, local vars win, COOLIFY_/SERVICE_ prefixes blocked), buildtime .env dict, and env_args. Fetch failure throws DeploymentException -> deployment fails with a clear log line. +- Link UI: "Secret managers" section under the app's Environment Variables page (add/remove link, runtime/buildtime flags, on-demand key-name preview that never stores values). +- Tests: 44 new tests pass (services, link model, job remote_secrets via reflection, dotenv formatting, token form, links component, delete guard). Unit suite baseline identical with/without changes (102 pre-existing env failures, unrelated). Pint clean, all blades compile. + +Follow-ups (next phases): Doppler webhook -> auto-redeploy, Services support, Vault AppRole, stale-.env opt-in fallback, REST API for links. + + +# Iteration 2: reference model ({{secret.KEY}}) + +Agreed with user (brainstorm accepted): +- One secret source (API key + coordinates) per app, selected in the env variable view. +- Env vars are normal rows; values reference remote secrets: {{secret.KEY}} (aliases: {{vault.KEY}}, {{doppler.KEY}}, {{infisical.KEY}}). All aliases resolve against the app single source. +- Search remote keys + "Import all keys" (creates KEY={{secret.KEY}} rows, skips existing). Values never stored. +- Resolution ONLY in the deploy job (one cached bulk fetch); never in realValue/UI. +- Changing the API key does not re-check existing references; missing keys fail the deploy with a list. +- Bulk-inject model removed. + +## Tasks +- [x] App\Support\RemoteSecretReferences (pattern, containsReference, referencedKeys, substitute) +- [x] Migration: secret_manager_links drop is_runtime/is_buildtime, unique per resource +- [x] Models: SecretManagerLink (flags out, importMissingReferences), Application morphOne secretManagerLink +- [x] EnvironmentVariable::isShared restricted to SHARED_VARIABLE_TYPES +- [x] Job: flat remote_secrets (fetch only when refs exist), substitution in runtime/buildtime/env_args, remove bulk-inject merges +- [x] UI: SecretManagerLinks -> source selector + key search/browse + import all + add single reference +- [x] Tests: references unit, substitution/missing-key via reflection, component rewrite, isShared regression +- [x] Pint + tests + baseline compare +- [x] Live dev test with real Doppler token (migrate, import, deploy, verify container + DB) + +## Iteration 2 review + +Implemented and live-tested the reference model: + +- `App\Support\RemoteSecretReferences`: pattern for {{secret.KEY}} + provider aliases, key extraction, substitution, missing-key detection. +- `secret_manager_links`: one source per resource (unique constraint), runtime/buildtime flags dropped (now per-variable via normal env rows). +- Job: lazy cached fetch (only when a value references a secret), substitution in runtime .env (dotenv-literal), buildtime .env, env_args, railpack/nixpacks normalizer, Dockerfile ARG injection, and secrets hash. Missing key or fetch error -> DeploymentException with exact key + variable names. No source + references -> clear error. +- EnvironmentVariable::isShared restricted to SHARED_VARIABLE_TYPES via anchored regex (also fixes {{ project.x }} spaced form; {{secret.*}} no longer mislabeled shared). +- UI: "Secret manager" card on env page — source selector, Browse keys (names only), search filter, "Add as variable", "Import all keys" (via SecretManagerLink::importMissingReferences), remove source with warning. +- Tests: 57 secret-manager/token tests + 5 parser unit tests pass; Unit suite matches pre-existing baseline (102 env-related failures, unrelated); pint clean; blades compile. +- Live dev test (real Doppler service token, app 3 Dockerfile Example): import created 4 reference rows (values = {{secret.KEY}} strings only in DB), deploy fetched once ("Fetched 4 secrets from Doppler"), container had substituted values incl. composed value url-{{secret.SECRET}}-end, missing-key deploy failed with "Missing secret keys: DOES_NOT_EXIST (referenced by BROKEN)", cleanup redeploy healthy. + +Follow-ups: Doppler webhook -> redeploy, Services support, Vault AppRole, key picker inside the Add-variable dialog, provider badge on reference rows. + +## Iteration 2.1 (UX tweak) +- [x] Token selector: dropdown auto-saves on select (updatedIntegrationTokenUuid hook; provider change clears settings) +- [x] Provider settings fields auto-save on blur (wire:blur="saveSettings") +- [x] "Save source" button and editing state removed; Remove button kept next to the dropdown +- [x] Component tests updated (34 pass), pint clean, blades compile + +## Iteration 2.2 (namespace rename) +- [x] Canonical reference namespace is {{vault.KEY}} (user request: differentiate from shared variables); {{doppler.KEY}} / {{infisical.KEY}} stay as aliases; {{secret.KEY}} removed and no longer parses +- [x] Import / Add-as-variable / UI texts / job error messages use {{vault.KEY}} +- [x] Tests updated (39 pass incl. negative assertion that {{secret.KEY}} is ignored) +- [x] Dev data migrated via tinker ({{secret.* -> {{vault.*), redeploy verified (container OK) + +## Iteration 2.3 (UI bug fixes from user screenshots) +- [x] Key browser snippet rendered a raw Blade artifact ("{{vault.{{ $key }}}}") — now renders the exact reference, e.g. {{vault.DOPPLER_CONFIG}} (Blade escape fixed via PHP string concat; regression-asserted in component test) +- [x] Env value autocomplete ({{ typing) now offers a "vault" scope whenever the app has a secret manager source; keys are lazy-fetched from the provider on first use via $wire.fetchSecretManagerKeys() (names only, never persisted) +- [x] Autocomplete now also works in the edit-variable modal: Show (and Add) use the new HasSecretManagerAutocomplete trait and pass hasVaultSource to env-var-input; previously the dropdown never appeared when no shared variables existed +- [x] 41 tests pass; pint clean; blades compile. Browser click-through not verified (Chrome extension permission unavailable) — user to smoke-test. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..cc0ff9fe81 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -148,7 +148,7 @@ class StartClickhouse { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..e683bb5177 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -252,7 +252,7 @@ class StartDragonfly { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..45ce414bf9 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -253,7 +253,7 @@ class StartKeydb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..09512ee7b3 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -255,7 +255,7 @@ class StartMariadb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..03bc2f48e4 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -304,7 +304,7 @@ class StartMongodb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..20ee3a6e18 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -257,7 +257,7 @@ class StartMysql { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..a1f95e8a97 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -266,7 +266,7 @@ class StartPostgresql { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..61172a00cd 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -5,6 +5,7 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Support\RemoteSecretReferences; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -250,22 +251,22 @@ class StartRedis foreach ($this->database->runtime_environment_variables as $env) { if ($env->is_shared) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); if ($env->key === 'REDIS_PASSWORD') { - $this->database->update(['redis_password' => $env->real_value]); + $this->database->update(['redis_password' => $this->database->resolveSecretManagerEnvironmentVariable($env)]); } if ($env->key === 'REDIS_USERNAME') { - $this->database->update(['redis_username' => $env->real_value]); + $this->database->update(['redis_username' => $this->database->resolveSecretManagerEnvironmentVariable($env)]); } } else { - if ($env->key === 'REDIS_PASSWORD') { + if ($env->key === 'REDIS_PASSWORD' && ! RemoteSecretReferences::containsReference($env->value)) { $env->update(['value' => $this->database->redis_password]); - } elseif ($env->key === 'REDIS_USERNAME') { + } elseif ($env->key === 'REDIS_USERNAME' && ! RemoteSecretReferences::containsReference($env->value)) { $env->update(['value' => $this->database->redis_username]); } - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1e8450c1b9..92af1c4921 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Support\RemoteSecretReferences; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; @@ -143,6 +144,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; + /** @var array{runtime: array, buildtime: array}|null */ + private ?array $remote_secrets_cache = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -1275,6 +1279,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return true; } + if ($this->has_remote_buildtime_secret_references()) { + $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.'); + + return false; + } $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); if (! $configurationDiff->requiresBuild()) { $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); @@ -1302,6 +1311,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return false; } + private function has_remote_buildtime_secret_references(): bool + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview(); + + return $environmentVariables + ->where('is_buildtime', true) + ->get(['value']) + ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + } + private function check_image_locally_or_remotely() { $this->execute_remote_command([ @@ -1323,6 +1344,106 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Fetch the secrets from the application's secret manager source. Values + * live only in memory during the deployment and in the generated .env on + * the server — they are never persisted in the Coolify database. Fetched + * lazily (only when a variable references a secret), once per deployment. + * A fetch failure fails the deployment. + * + * @return array + */ + private function remote_secrets(): array + { + if ($this->remote_secrets_cache !== null) { + return $this->remote_secrets_cache; + } + + $link = $this->application->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.'); + } + + $provider = $link->integrationToken->providerName(); + $tokenName = $link->integrationToken->name; + + try { + $secrets = $link->fetchSecrets(); + } catch (Throwable $e) { + $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr'); + + throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); + } + + $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); + + return $this->remote_secrets_cache = $secrets; + } + + /** + * Replace {{vault.KEY}} references with values from the configured secret + * manager source. Missing keys fail the deployment with a + * list — changing the source never re-checks references, so this is the + * moment problems surface. + */ + private function substitute_remote_secrets(string $value, string $envKey): string + { + $secrets = $this->remote_secrets(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey})."; + $this->application_deployment_queue->addLogEntry($message, 'stderr'); + + throw new DeploymentException($message.' Check the secret manager source of this application.'); + } + + return RemoteSecretReferences::substitute($value, $secrets); + } + + /** + * Resolve shared variables, then secret references, in a raw variable value. + */ + private function resolve_environment_variable_raw(EnvironmentVariable $env): string + { + $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + + return $this->substitute_remote_secrets($value ?? '', $env->key); + } + + /** + * Resolve a runtime variable to its dotenv representation. Values with + * secret references are substituted and written as literals. + */ + private function resolve_environment_variable(EnvironmentVariable $env): ?string + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return $env->getResolvedValueWithServer($this->mainServer); + } + + return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env)); + } + + /** + * Format a remote secret value for the runtime .env file (dotenv syntax read + * by docker compose). Values are treated as literals — no interpolation. + */ + private function format_remote_secret_value(string $value): string + { + // Keep valid JSON objects/arrays unquoted, matching EnvironmentVariable::realValue(). + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + if (! str_contains($value, "'")) { + return "'".$value."'"; + } + + // Fall back to double quotes; $$ escapes compose interpolation. + return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"'; + } + private function generate_runtime_environment_variables() { $envs = collect([]); @@ -1391,7 +1512,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1458,7 +1579,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Fall back to production env vars for keys not overridden by preview vars, @@ -1472,7 +1593,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } } @@ -1728,6 +1849,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1783,6 +1910,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -2651,6 +2784,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { + if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + $resolved = $this->resolve_environment_variable_raw($environmentVariable); + + return $resolved === '' ? null : $resolved; + } + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); if (is_null($resolvedValue) || $resolvedValue === '') { return null; @@ -3194,7 +3333,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -3210,7 +3351,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -4268,7 +4411,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { $secrets_string = $variables ->map(function ($env) { - return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"; + return "{$env->key}={$this->resolve_environment_variable($env)}"; }) ->sort() ->implode('|'); @@ -4334,7 +4477,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}"); } } // Add Coolify variables as ARGs @@ -4356,7 +4499,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}"); } } // Add Coolify variables as ARGs @@ -4370,6 +4513,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + if ($argsToInsert->isNotEmpty()) { + $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) { + return [$environmentVariable->key => $this->resolve_environment_variable($environmentVariable)]; + }); + $secretsHash = $this->generate_secrets_hash($environmentVariables); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"); + } + // Development logging to show what ARGs are being injected if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); @@ -4391,11 +4542,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); } } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->getResolvedValueWithServer($this->mainServer)]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..37f9a7ad84 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -9,6 +9,7 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; @@ -16,7 +17,18 @@ use Livewire\Component; class Add extends Component { - use AuthorizesRequests, EnvironmentVariableAnalyzer; + use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete; + + protected function secretManagerResource() + { + if ($this->shared || ! $this->resource) { + return null; + } + + return $this->resource; + } + + public $resource; public $parameters; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index db80cff801..7231602ab0 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -12,6 +12,7 @@ use App\Models\SharedEnvironmentVariable; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; @@ -21,7 +22,12 @@ class Show extends Component { public bool $showEnvironmentType = true; - use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; + + protected function secretManagerResource() + { + return $this->isSharedVariable ? null : $this->env->resourceable; + } public $parameters; diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..0096654fbb --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,261 @@ + Remote key names only — values are never stored. */ + public array $keys = []; + + public bool $keysLoaded = false; + + public string $search = ''; + + public function mount(): void + { + $this->loadData(); + } + + private function loadData(): void + { + $this->link = $this->resource->secretManagerLink()->with('integrationToken')->first(); + $this->availableTokens = IntegrationToken::ownedByCurrentTeam() + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->get() + ->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true)) + ->values(); + + if ($this->link) { + $this->integration_token_uuid = $this->link->integrationToken->uuid; + $this->settings = $this->link->settings ?? []; + } + } + + public function getSelectedTokenProperty(): ?IntegrationToken + { + if (blank($this->integration_token_uuid)) { + return null; + } + + return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid); + } + + protected function rules(): array + { + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + ]; + + $rules += match ($this->selectedToken?->provider) { + 'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account' + ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] + : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + return $rules; + } + + /** + * Auto-save when a token is selected in the dropdown. Existing {{vault.*}} + * references are intentionally NOT re-checked — missing keys surface at + * the next deployment. + */ + public function updatedIntegrationTokenUuid(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->selectedToken; + + if (! $token) { + return; + } + + if ($this->link?->integrationToken?->provider !== $token->provider + || $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) { + $this->settings = []; + } + + $settings = array_filter($this->settings, fn ($value) => filled($value)); + + $this->resource->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Auto-save of the provider-specific settings fields (called on blur). + */ + public function saveSettings(): void + { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $validated = $this->validate(); + + try { + + $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); + + $this->link->update(['settings' => $settings ?: null]); + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager settings saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeSource(): void + { + try { + $this->authorize('update', $this->resource); + $this->resource->secretManagerLink()->delete(); + $this->link = null; + $this->integration_token_uuid = ''; + $this->settings = []; + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function loadKeys(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + // Values are fetched into memory, reduced to key names, and discarded. + $keys = array_keys($this->link->fetchSecrets()); + sort($keys); + $this->keys = $keys; + $this->keysLoaded = true; + } catch (\Throwable $e) { + $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage()); + } + } + + public function addReference(string $key): void + { + try { + $this->authorize('update', $this->resource); + + if (! in_array($key, $this->keys, true)) { + return; + } + + if ($this->resource->environment_variables()->where('key', $key)->exists()) { + $this->dispatch('error', "A variable with the key {$key} already exists."); + + return; + } + + $this->resource->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', "Added {$key} as {{vault.{$key}}}."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function importAll(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $imported = $this->link->importMissingReferences(); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', $imported === [] + ? 'All remote keys already exist as variables.' + : 'Imported '.count($imported).' keys as {{vault.KEY}} references.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function resetKeys(): void + { + $this->keys = []; + $this->keysLoaded = false; + $this->search = ''; + } + + public function getFilteredKeysProperty(): array + { + if (blank($this->search)) { + return $this->keys; + } + + return array_values(array_filter( + $this->keys, + fn (string $key) => stripos($key, $this->search) !== false, + )); + } + + public function render() + { + return view('livewire.project.shared.secret-manager-links', [ + 'selectedToken' => $this->selectedToken, + 'filteredKeys' => $this->filteredKeys, + ]); + } +} diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 453a7e8ae8..8c00027e4b 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component public array $capabilities = []; + public array $metadata = []; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component $this->name = $this->integrationToken->name; $this->capabilities = $this->integrationToken->capabilities; + $this->metadata = $this->integrationToken->metadata ?? []; } protected function rules(): array { - return [ + $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->integrationToken->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->integrationToken->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component ]; } - public function save(CloudflareTokenValidator $validator): void + public function save(IntegrationTokenValidator $validator): void { $this->authorize('update', $this->integrationToken); $validated = $this->validate(); + $provider = $this->integrationToken->provider; $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); 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.'); + if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) + && ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($provider)); return; } @@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component $updates = [ 'name' => $validated['name'], 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, ]; if (filled($validated['newToken'])) { @@ -100,6 +121,13 @@ class IntegrationTokenEditor extends Component public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); + + if ($this->integrationToken->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + $this->integrationToken->delete(); $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 7a7637bf5e..cf54ff60e2 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component public array $capabilities = ['dns']; + public array $metadata = []; + public function mount(): void { $this->authorize('create', IntegrationToken::class); } + public function updatedProvider(): void + { + if ($this->provider === 'cloudflare') { + $this->capabilities = ['dns']; + $this->metadata = []; + } else { + $this->capabilities = ['secrets']; + $this->metadata = $this->provider === 'infisical' + ? ['base_url' => 'https://app.infisical.com'] + : []; + } + } + protected function rules(): array { - return [ - 'provider' => ['required', 'in:cloudflare'], + $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ + 'provider' => ['required', 'in:cloudflare,doppler,infisical,vault'], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->provider === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } + + if ($this->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -42,22 +75,28 @@ class IntegrationTokenForm extends Component return [ 'capabilities.required' => 'Select at least one capability.', 'capabilities.min' => 'Select at least one capability.', + 'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).', ]; } - public function addToken(CloudflareTokenValidator $validator): void + public function addToken(IntegrationTokenValidator $validator): void { $validated = $this->validate(); + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($validated['provider'])); return; } IntegrationToken::query()->create([ - ...$validated, + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 39db135b38..c0b6541cc3 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -29,6 +29,13 @@ class IntegrationTokens extends Component { $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + + if ($token->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + $token->delete(); $this->loadTokens(); $this->dispatch('success', 'Integration token deleted successfully.'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..f802a65972 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -12,6 +12,7 @@ use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -122,10 +123,11 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ use HasFactory; + use HasSecretManager; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; private static $parserVersion = '5'; @@ -382,6 +384,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->secretManagerLink()->delete(); foreach ($application->scheduled_tasks as $task) { $task->delete(); } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..cbe2ceabdc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -250,12 +250,13 @@ class EnvironmentVariable extends BaseModel { return Attribute::make( get: function () { - $type = str($this->value)->after('{{')->before('.')->value; - if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) { - return true; + if (blank($this->value)) { + return false; } - return false; + $types = implode('|', SHARED_VARIABLE_TYPES); + + return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; } ); } diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 20541f6139..53b4dd6f4a 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -3,15 +3,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class IntegrationToken extends BaseModel { + public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault']; + + public const PROVIDER_NAMES = [ + 'cloudflare' => 'Cloudflare', + 'doppler' => 'Doppler', + 'infisical' => 'Infisical', + 'vault' => 'HashiCorp Vault', + ]; + protected $fillable = [ 'team_id', 'provider', 'name', 'token', 'capabilities', + 'metadata', ]; protected $hidden = [ @@ -23,6 +34,7 @@ class IntegrationToken extends BaseModel return [ 'token' => 'encrypted', 'capabilities' => 'array', + 'metadata' => 'array', ]; } @@ -31,6 +43,34 @@ class IntegrationToken extends BaseModel return $this->belongsTo(Team::class); } + public function secretManagerLinks(): HasMany + { + return $this->hasMany(SecretManagerLink::class); + } + + public function isSecretManager(): bool + { + return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); + } + + public function providerName(): string + { + return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); + } + + public function dopplerTokenType(): ?string + { + if ($this->provider !== 'doppler') { + return null; + } + + return match (true) { + str_starts_with($this->token, 'dp.st.') => 'service', + str_starts_with($this->token, 'dp.sa.') => 'service_account', + default => null, + }; + } + public static function ownedByCurrentTeam() { return self::query()->where('team_id', currentTeam()->id); diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + 'array', + ]; + } + + public function resourceable(): MorphTo + { + return $this->morphTo(); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + /** + * Fetch the secrets from the remote manager. Values live only in memory. + * + * @return array + */ + public function fetchSecrets(): array + { + $token = $this->integrationToken; + $settings = $this->settings ?? []; + $metadata = $token->metadata ?? []; + + return match ($token->provider) { + 'doppler' => (new DopplerService($token->token))->fetchSecrets( + data_get($settings, 'project'), + data_get($settings, 'config'), + ), + 'infisical' => (new InfisicalService( + data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token->token, + ))->fetchSecrets( + (string) data_get($settings, 'project_id'), + (string) data_get($settings, 'environment'), + (string) data_get($settings, 'secret_path', '/'), + ), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token->token, + data_get($metadata, 'namespace'), + ))->fetchSecrets( + (string) data_get($settings, 'mount', 'secret'), + (string) data_get($settings, 'path'), + ), + default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."), + }; + } + + /** + * Create one {{vault.KEY}} reference variable per remote key that has no + * variable with that key yet. Only key names touch the database. + * + * @return list The keys that were imported + */ + public function importMissingReferences(): array + { + $keys = array_keys($this->fetchSecrets()); + sort($keys); + + $existing = $this->resourceable->environment_variables()->pluck('key')->flip(); + $imported = []; + + foreach ($keys as $key) { + if (isset($existing[$key])) { + continue; + } + + $this->resourceable->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $imported[] = $key; + } + + return $imported; + } + + /** Short human-readable description of the remote source for the UI. */ + public function sourceSummary(): string + { + $settings = $this->settings ?? []; + + return match ($this->integrationToken->provider) { + 'doppler' => trim(implode('/', array_filter([ + data_get($settings, 'project'), + data_get($settings, 'config'), + ])), '/') ?: 'token scope', + 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'), + 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'), + default => '', + }; + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..2a30fb846e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -6,6 +6,7 @@ use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +44,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -1631,7 +1632,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$env->real_value}"); + $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { $commands[] = 'touch .env'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..979c0ede80 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..e9b7a3ffe0 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..1f66f2591e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..18fc8868ce 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -6,6 +6,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..22c12b0677 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..cad1813436 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..adf0b38965 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..25b53f78db 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -6,13 +6,14 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes; protected $fillable = [ 'uuid', diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..3a684cb93e --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,81 @@ +baseUrl = rtrim($baseUrl, '/'); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..bcb6e92c76 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,60 @@ +baseUrl = rtrim($baseUrl, '/'); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/RemoteSecretReferences.php b/app/Support/RemoteSecretReferences.php new file mode 100644 index 0000000000..530c29a28c --- /dev/null +++ b/app/Support/RemoteSecretReferences.php @@ -0,0 +1,64 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is — collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..df3f28369f --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,69 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..6f41273284 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + return []; + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..b68d39ba81 --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,35 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->morphs('resourceable'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 8769d62d9d..61f82f6265 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -20,7 +20,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index bf863099b4..d3b639ba5f 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -34,6 +34,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index 378a3947e3..4eb217c4fa 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -20,13 +20,33 @@ cursorPosition: 0, currentScope: null, availableVars: @js($availableVars), + hasVaultSource: @js($hasVaultSource), + vaultKeysLoading: false, get availableScopes() { // Only include scopes that have at least one variable const allScopes = ['team', 'project', 'environment', 'server']; - return allScopes.filter(scope => { + const scopes = allScopes.filter(scope => { const vars = this.availableVars[scope]; return vars && vars.length > 0; }); + // The vault scope is offered whenever a secret manager source is + // configured; its keys are fetched lazily on first use. + if (this.hasVaultSource) { + scopes.push('vault'); + } + return scopes; + }, + loadVaultKeys() { + if (this.vaultKeysLoading) return; + this.vaultKeysLoading = true; + this.$wire.fetchSecretManagerKeys().then(keys => { + this.availableVars['vault'] = keys || []; + this.vaultKeysLoading = false; + this.handleInput(); + }).catch(() => { + this.availableVars['vault'] = []; + this.vaultKeysLoading = false; + }); }, scopeUrls: @js($scopeUrls), @@ -84,6 +104,15 @@ } this.currentScope = scope; + + // Vault keys are fetched from the secret manager on first use. + if (scope === 'vault' && this.availableVars['vault'] === undefined) { + this.loadVaultKeys(); + this.suggestions = []; + this.showDropdown = true; + return; + } + const scopeVars = this.availableVars[scope] || []; const filtered = scopeVars.filter(v => v.toLowerCase().includes((partial || '').toLowerCase()) @@ -214,6 +243,7 @@ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" + wire:target.except="fetchSecretManagerKeys" @disabled($disabled) @if ($type !== 'password') type="{{ $type }}" @@ -236,7 +266,14 @@
-