diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 31b74d6c76..8054462e9a 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4038,7 +4038,7 @@ class ApplicationsController extends Controller 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), @@ -4191,7 +4191,7 @@ class ApplicationsController extends Controller 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ], @@ -4381,7 +4381,7 @@ class ApplicationsController extends Controller 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 43afd00019..ddc09fb1c8 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3924,7 +3924,7 @@ class DatabasesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), @@ -4067,7 +4067,7 @@ class DatabasesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ], @@ -4199,7 +4199,7 @@ class DatabasesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index c939964cdf..90720773b5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1490,7 +1490,7 @@ class ServicesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), @@ -1633,7 +1633,7 @@ class ServicesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ], @@ -1765,7 +1765,7 @@ class ServicesController extends Controller 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], ], ), ), diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php index e3951796d7..b8eb220640 100644 --- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -22,7 +22,7 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'value', type: 'string', nullable: true), new OA\Property(property: 'is_literal', type: 'boolean'), new OA\Property(property: 'is_multiline', type: 'boolean'), - new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'), new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), ], )] @@ -35,7 +35,7 @@ use OpenApi\Attributes as OA; new OA\Property(property: 'value', type: 'string', nullable: true), new OA\Property(property: 'is_literal', type: 'boolean'), new OA\Property(property: 'is_multiline', type: 'boolean'), - new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'), + new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'), new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true), ], )] diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index b5dbdf5007..e12006a964 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -96,6 +96,11 @@ class Show extends Component */ public bool $editorOpen = false; + /** + * A new value for a locked variable. The locked value is never sent to the browser, but it can be replaced. + */ + public ?string $newLockedValue = null; + public array $problematicVariables = []; /** @@ -120,6 +125,7 @@ class Show extends Component 'is_runtime' => 'required|boolean', 'is_buildtime' => 'required|boolean', 'real_value' => 'nullable', + 'newLockedValue' => 'nullable|string', 'is_required' => 'required|boolean', ]; } @@ -222,6 +228,7 @@ class Show extends Component 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', 'real_value' => 'nullable', + 'newLockedValue' => 'nullable|string', ]); } else { $this->validate(); @@ -235,11 +242,14 @@ class Show extends Component if (! $this->env->is_shown_once) { $this->env->value = $this->value; $this->env->is_shown_once = $this->is_shown_once; + } elseif (filled($this->newLockedValue)) { + $this->env->value = $this->newLockedValue; } $this->env->comment = $this->comment; $this->env->is_multiline = $this->is_multiline; $this->env->is_literal = $this->is_literal; $this->env->save(); + $this->newLockedValue = null; $this->valuesLoaded = true; } else { // Table metadata only — never decrypt here. Values load via loadValues(). @@ -366,7 +376,9 @@ class Show extends Component } } - $submittedValue = $this->env->is_shown_once ? $this->env->value : $this->value; + $submittedValue = $this->env->is_shown_once + ? (filled($this->newLockedValue) ? $this->newLockedValue : $this->env->value) + : $this->value; if (! $this->isSharedVariable && $this->is_required && str($submittedValue)->isEmpty()) { if (! $this->env->is_shown_once) { $this->value = $this->env->getOriginal('value'); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index d829fe8d20..fed2c3fa17 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -32,7 +32,7 @@ use OpenApi\Attributes as OA; 'is_runtime' => ['type' => 'boolean'], 'is_buildtime' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'], - 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'], + 'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.'], 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'real_value' => ['type' => 'string'], @@ -173,6 +173,10 @@ class EnvironmentVariable extends BaseModel }); static::saving(function (ModelsEnvironmentVariable $environmentVariable) { + // A lock is permanent: no UI, API, or MCP path can unlock a variable and show its value again. + if ($environmentVariable->exists && $environmentVariable->getOriginal('is_shown_once')) { + $environmentVariable->is_shown_once = true; + } if ($environmentVariable->preserve_compose_null && $environmentVariable->isDirty('value') && $environmentVariable->value !== null) { $environmentVariable->preserve_compose_null = false; } diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index e0f40a4542..41c6bcc1ca 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -51,6 +51,16 @@ class SharedEnvironmentVariable extends Model 'is_literal' => 'boolean', ]; + protected static function booted(): void + { + // A lock is permanent: no UI, API, or MCP path can unlock a variable and show its value again. + static::saving(function (SharedEnvironmentVariable $sharedVariable) { + if ($sharedVariable->exists && $sharedVariable->getOriginal('is_shown_once')) { + $sharedVariable->is_shown_once = true; + } + }); + } + /** * Scope shared environment variables to a team (API token team_id). */ diff --git a/openapi.json b/openapi.json index 6c318c2156..72dd1dbd79 100644 --- a/openapi.json +++ b/openapi.json @@ -4594,7 +4594,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -4686,7 +4686,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -4777,7 +4777,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -9816,7 +9816,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -9907,7 +9907,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -9997,7 +9997,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -22498,7 +22498,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -22589,7 +22589,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -22679,7 +22679,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." } }, "type": "object" @@ -26558,7 +26558,7 @@ "type": "boolean" }, "is_shown_once": { - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.", "type": "boolean" }, "comment": { @@ -26590,7 +26590,7 @@ "type": "boolean" }, "is_shown_once": { - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.", + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.", "type": "boolean" }, "comment": { @@ -27535,7 +27535,7 @@ }, "is_shown_once": { "type": "boolean", - "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values." + "description": "If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values." }, "key": { "type": "string" diff --git a/openapi.yaml b/openapi.yaml index 94b03a9c2c..770cb82f08 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3062,7 +3062,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -3123,7 +3123,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -3167,7 +3167,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' } }, type: object } type: object responses: '201': @@ -6452,7 +6452,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -6512,7 +6512,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -6558,7 +6558,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' } }, type: object } type: object responses: '201': @@ -14186,7 +14186,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -14246,7 +14246,7 @@ paths: description: 'The flag to indicate if the environment variable is multiline.' is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: object responses: '201': @@ -14292,7 +14292,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' } }, type: object } type: object responses: '201': @@ -16876,7 +16876,7 @@ components: is_multiline: type: boolean is_shown_once: - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: boolean comment: type: @@ -16898,7 +16898,7 @@ components: is_multiline: type: boolean is_shown_once: - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' type: boolean comment: type: @@ -17603,7 +17603,7 @@ components: type: boolean is_shown_once: type: boolean - description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' + description: 'If true, the saved value is hidden in the UI and API responses. A lock is permanent: false does not unlock a locked variable, but a new value can still replace the locked one. MCP never returns environment variable values.' key: type: string value: diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 44f7c664fc..032d5b5f38 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -126,11 +126,10 @@ class="input w-full italic !text-neutral-500 dark:!text-neutral-500" /> @elseif ($isLocked) -
- - -
+ @else {{-- Multiline: same shell while loading/loaded to avoid layout jump. --}}
@endif @if ($canUpdate && !$isLocked && !$isMagicVariable) - Lock +
+ +
@endif @if ($canUpdate && !$isDisabled)
diff --git a/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php b/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php index 910e5b8f78..49958203e3 100644 --- a/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php +++ b/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php @@ -413,7 +413,7 @@ describe('application env PATCH keeps omitted flags', function () { ->and((bool) $env->is_shown_once)->toBeTrue(); }); - test('explicit flags are still applied', function () { + test('explicit flags are still applied, but a locked variable stays locked', function () { $this->withHeaders($this->headers) ->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [ 'key' => 'FLAGGED_KEY', @@ -427,7 +427,7 @@ describe('application env PATCH keeps omitted flags', function () { $env = $this->env->fresh(); expect((bool) $env->is_literal)->toBeFalse() ->and($env->is_multiline)->toBeFalse() - ->and((bool) $env->is_shown_once)->toBeFalse(); + ->and((bool) $env->is_shown_once)->toBeTrue(); }); }); @@ -645,6 +645,60 @@ describe('env PATCH keeps the value when value is omitted', function () { ]); }); +describe('env API cannot unlock a locked variable', function () { + test('is_shown_once false keeps the lock and the value can still be replaced', function (string $resourceType, string $endpoint, bool $bulk) { + $resource = match ($resourceType) { + 'application' => Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]), + 'service' => Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]), + 'database' => StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'app', + 'image' => 'postgres:16', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]), + }; + + $env = EnvironmentVariable::create([ + 'key' => 'LOCKED_KEY', + 'value' => 'locked-secret', + 'is_shown_once' => true, + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + 'is_preview' => false, + ]); + + $payload = ['key' => 'LOCKED_KEY', 'value' => 'rotated-secret', 'is_shown_once' => false]; + $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json']) + ->patchJson("/api/v1/{$endpoint}/{$resource->uuid}/envs".($bulk ? '/bulk' : ''), $bulk ? ['data' => [$payload]] : $payload) + ->assertStatus(201) + ->assertDontSee('rotated-secret'); + + $env->refresh(); + expect((bool) $env->is_shown_once)->toBeTrue() + ->and($env->value)->toBe('rotated-secret'); + })->with([ + 'application single' => ['application', 'applications', false], + 'application bulk' => ['application', 'applications', true], + 'service single' => ['service', 'services', false], + 'service bulk' => ['service', 'services', true], + 'database single' => ['database', 'databases', false], + 'database bulk' => ['database', 'databases', true], + ]); +}); + describe('env API keeps multiline values exact', function () { test('create and bulk update keep the trailing newline of a private key', function () { $application = Application::factory()->create([ diff --git a/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php b/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php index 0097389817..501fb22a2d 100644 --- a/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php +++ b/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php @@ -85,6 +85,23 @@ describe('Team shared environment variables API', function () { expect(SharedEnvironmentVariable::query()->whereKey($envId)->exists())->toBeFalse(); }); + test('cannot unlock a locked team shared env', function () { + $env = SharedEnvironmentVariable::create([ + 'key' => 'LOCKED_TEAM_SECRET', + 'value' => 'locked-secret', + 'is_shown_once' => true, + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->patchJson("/api/v1/team/envs/{$env->id}", ['is_shown_once' => false]) + ->assertOk() + ->assertDontSee('locked-secret'); + + expect((bool) $env->fresh()->is_shown_once)->toBeTrue(); + }); + test('hides value without can_read_sensitive', function () { SharedEnvironmentVariable::create([ 'key' => 'HIDDEN_SECRET', diff --git a/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php index 57de2cba32..12986e0b89 100644 --- a/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php +++ b/tests/Feature/EnvironmentVariable/AutocompleteResourceTeamTest.php @@ -43,8 +43,8 @@ beforeEach(function () { }); test('add suggests shared variables of the resource team when the session team differs', function () { - $component = Livewire::test(Add::class, ['resource' => $this->application]) - ->set('parameters', $this->parameters); + $component = Livewire::test(Add::class, ['resource' => $this->application]); + $component->instance()->parameters = $this->parameters; $suggestions = $component->instance()->availableSharedVariables(); @@ -60,8 +60,8 @@ test('show suggests shared variables of the resource team when the session team 'resourceable_id' => $this->application->id, ]); - $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) - ->set('parameters', $this->parameters); + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']); + $component->instance()->parameters = $this->parameters; $suggestions = $component->instance()->availableSharedVariables(); diff --git a/tests/Feature/LockedEnvironmentVariableUpdateTest.php b/tests/Feature/LockedEnvironmentVariableUpdateTest.php index 75d4cc5289..4a35be3412 100644 --- a/tests/Feature/LockedEnvironmentVariableUpdateTest.php +++ b/tests/Feature/LockedEnvironmentVariableUpdateTest.php @@ -116,3 +116,51 @@ test('changing the comment of a locked shared variable keeps its value', functio expect($env->value)->toBe('locked-shared-secret') ->and($env->comment)->toBe('rotated monthly'); }); + +test('a locked variable can get a new value and stays locked', function () { + $env = createLockedVariableForUpdateTest(); + + Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('loadValues') + ->set('newLockedValue', 'rotated-secret') + ->call('submit') + ->assertDispatched('success') + ->assertSet('newLockedValue', null) + ->assertSet('value', null); + + $env->refresh(); + expect($env->value)->toBe('rotated-secret') + ->and((bool) $env->is_shown_once)->toBeTrue(); +}); + +test('a locked variable that Docker Compose uses can get a new value', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n - API_TOKEN=\${API_TOKEN}\n", + ]); + $env = createLockedVariableForUpdateTest(required: true, value: null); + + Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->call('loadValues') + ->set('newLockedValue', 'rotated-secret') + ->call('submit') + ->assertNotDispatched('error') + ->assertDispatched('success'); + + $env->refresh(); + expect($env->value)->toBe('rotated-secret') + ->and((bool) $env->is_shown_once)->toBeTrue(); +}); + +test('a member cannot replace a locked value', function () { + $env = createLockedVariableForUpdateTest(); + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + $this->actingAs($member); + + expect(fn () => Livewire::test(Show::class, ['env' => $env, 'type' => 'application']) + ->set('newLockedValue', 'attacker') + ->call('submit'))->toThrow(Exception::class, 'This action is unauthorized.'); + + expect($env->refresh()->value)->toBe('locked-secret'); +});