Skip to main content

alien_core/
deployer_secrets.rs

1//! Deployer secrets that live only in the customer's own secret store.
2//!
3//! A secret stack input the deployer provides is *vault-native*: the deployer
4//! writes the value into their cloud's secret store, in the stack's `deployer-secrets`
5//! vault, under a key Alien derives from the input id. Alien tells them where
6//! (a console link and a CLI command with a hidden prompt), checks whether the
7//! slot is filled using metadata-only calls, and the workload reads the value
8//! at start. Setup paths never carry the value.
9//!
10//! A secret input that the developer may also provide keeps today's path when
11//! the developer gave a value; otherwise it is vault-native too.
12
13use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    vault_naming, BindingValue, EnvironmentVariable, EnvironmentVariableType, Platform,
19    StackInputDefinition, StackInputEnvironmentVariableType, StackInputKind, StackInputProvider,
20    VaultBinding,
21};
22
23/// Placeholder for the secret value in the CLI commands Alien shows.
24pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
25
26/// The `deployer-secrets` vault key is the input id in lowercase kebab case
27/// (`databasePassword` → `database-password`).
28///
29/// Only `[a-z0-9-]` survive, so the key is valid unchanged in every backend
30/// (SSM, Secret Manager, Key Vault and Kubernetes object names).
31pub fn deployer_secret_vault_key(input_id: &str) -> String {
32    let mut key = String::new();
33    let mut previous_lower_or_digit = false;
34    let mut pending_separator = false;
35    for character in input_id.chars() {
36        if character.is_ascii_alphanumeric() {
37            let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
38            if (pending_separator || starts_word) && !key.is_empty() && !key.ends_with('-') {
39                key.push('-');
40            }
41            key.push(character.to_ascii_lowercase());
42            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
43            pending_separator = false;
44        } else {
45            pending_separator = true;
46            previous_lower_or_digit = false;
47        }
48    }
49    key.trim_end_matches('-').to_string()
50}
51
52/// Whether the deployer may provide this secret input.
53pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
54    input.kind == StackInputKind::Secret
55        && input.provided_by.contains(&StackInputProvider::Deployer)
56}
57
58/// Why a setup path refuses a value for the deployer secret `name` (its label
59/// or id), with what to do instead.
60pub fn deployer_secret_value_refusal(name: &str) -> String {
61    format!(
62        "'{name}' is a deployer secret: its value goes into your own secret store and never \
63         through Alien. Do not pass it here; write it into the deployment's deployer-secrets vault \
64         instead (the deployment status shows the secret's name and the command that writes it)."
65    )
66}
67
68/// A deployer secret input whose value lives in the customer's secret store.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct DeployerSecretSlot<'a> {
71    /// The stack input.
72    pub input: &'a StackInputDefinition,
73    /// Key of the value in the `deployer-secrets` vault.
74    pub vault_key: String,
75    /// The deployment still stores a value for this input from before slots
76    /// were vault-native. It is used until the control plane drops it.
77    pub has_stored_value: bool,
78}
79
80/// The vault-native deployer secret slots of a deployment on `platform`.
81///
82/// `values` are the deployment's stored input values. A secret input the
83/// developer may also provide is a slot only when it has no stored value: a
84/// developer value keeps today's path. `stored_secret_input_ids` carries trusted
85/// presence when stored secret values are intentionally absent from `values`.
86/// Presence never supplies a value for gates, defaults, or environment variables.
87/// Only omitted legacy metadata may use complete Secret delivery in the trusted
88/// target snapshot. Explicit empty metadata disables this compatibility path.
89pub fn deployer_secret_slots<'a>(
90    inputs: &'a [StackInputDefinition],
91    values: &HashMap<String, serde_json::Value>,
92    stored_secret_input_ids: Option<&[String]>,
93    environment: &[EnvironmentVariable],
94    platform: Platform,
95) -> Vec<DeployerSecretSlot<'a>> {
96    inputs
97        .iter()
98        .filter(|input| is_deployer_secret_input(input))
99        .filter(|input| {
100            input
101                .platforms
102                .as_ref()
103                .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
104        })
105        .filter_map(|input| {
106            let has_stored_value = stored_secret_input_ids
107                .is_some_and(|ids| ids.contains(&input.id))
108                || values.get(&input.id).is_some_and(|value| !value.is_null());
109            let developer_value = input.provided_by.contains(&StackInputProvider::Developer)
110                && (has_stored_value
111                    || (stored_secret_input_ids.is_none()
112                        && legacy_secret_delivery_complete(input, environment)));
113            (!developer_value).then(|| DeployerSecretSlot {
114                input,
115                vault_key: deployer_secret_vault_key(&input.id),
116                has_stored_value,
117            })
118        })
119        .collect()
120}
121
122// Only trusted legacy delivery can replace omitted presence metadata. No values
123// are read here, and scope coverage follows the same exact-id/prefix rules as delivery.
124fn legacy_secret_delivery_complete(
125    input: &StackInputDefinition,
126    environment: &[EnvironmentVariable],
127) -> bool {
128    !input.env.is_empty()
129        && input.env.iter().all(|mapping| {
130            let mut matches = environment
131                .iter()
132                .filter(|variable| variable.name == mapping.name);
133            let Some(variable) = matches.next() else {
134                return false;
135            };
136            // Duplicate names have no trusted precedence, even when their scopes differ.
137            if matches.next().is_some() {
138                return false;
139            }
140            !mapping.name.is_empty()
141                && mapping
142                    .target_resources
143                    .as_ref()
144                    .is_none_or(|targets| !targets.is_empty())
145                && !matches!(
146                    mapping.var_type,
147                    Some(StackInputEnvironmentVariableType::Plain)
148                )
149                && variable.var_type == EnvironmentVariableType::Secret
150                && match (&mapping.target_resources, &variable.target_resources) {
151                    (_, None) => true,
152                    (None, Some(delivered)) => delivered.iter().any(|pattern| pattern == "*"),
153                    (Some(required), Some(delivered)) => {
154                        !required.is_empty()
155                            && required.iter().all(|target| {
156                                delivered.iter().any(|pattern| {
157                                    pattern == target
158                                        || pattern
159                                            .strip_suffix('*')
160                                            .is_some_and(|prefix| target.starts_with(prefix))
161                                })
162                            })
163                    }
164                }
165        })
166}
167
168/// The secret store a deployer writes a vault-native secret into.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
171#[serde(rename_all = "kebab-case")]
172pub enum DeployerSecretStore {
173    /// AWS Systems Manager Parameter Store, as a `SecureString` parameter.
174    AwsParameterStore,
175    /// GCP Secret Manager.
176    GcpSecretManager,
177    /// Azure Key Vault.
178    AzureKeyVault,
179    /// A Kubernetes Secret with the value under the `value` key.
180    KubernetesSecret,
181    /// The local development vault (`alien dev vault set`).
182    LocalVault,
183}
184
185/// Commands for a shell family. Values are never sent to the control plane.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
188#[serde(rename_all = "camelCase")]
189pub struct DeployerSecretCommandVariants {
190    /// Command with a visible value placeholder; shell history can retain it.
191    pub inline: String,
192    /// Hidden-input command, without the value in shell history or process arguments.
193    pub prompt: String,
194}
195
196/// Shell-specific instructions generated beside the secret location.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199#[serde(rename_all = "camelCase")]
200pub struct DeployerSecretCommands {
201    /// Bash (including 3.2) and zsh on Linux and macOS.
202    pub unix: DeployerSecretCommandVariants,
203    /// Windows PowerShell 5.1 and PowerShell 7.
204    pub windows: DeployerSecretCommandVariants,
205    /// Expected account, project, subscription or Kubernetes namespace.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub target: Option<String>,
208    /// Read-only identity check, valid in both shell families.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub check_command: Option<String>,
211}
212
213/// Where a deployer writes a vault-native secret.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216#[serde(rename_all = "camelCase")]
217pub struct DeployerSecretLocation {
218    /// The secret store.
219    pub store: DeployerSecretStore,
220    /// Full name of the secret in that store.
221    pub name: String,
222    /// The Azure Key Vault that holds the secret. Other stores resolve `name`
223    /// in the stack's own account or project.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub vault_name: Option<String>,
226    /// Cloud console page where the secret is created, when the store has one.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub console_url: Option<String>,
229    /// Command that prompts without echoing and writes the value through stdin.
230    pub cli_command: String,
231    /// Optional for compatibility with reports from older engines.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub commands: Option<DeployerSecretCommands>,
234    /// Command that deletes the secret. Deleting a deployment keeps the
235    /// secrets the deployer wrote, since Alien never owned their values.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub delete_command: Option<String>,
238}
239
240/// What a location needs beyond the vault binding.
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct DeployerSecretLocationContext {
243    /// Display label used by the hidden prompt.
244    pub secret_label: Option<String>,
245    /// AWS account that owns the deployment.
246    pub aws_account_id: Option<String>,
247    /// AWS region of the stack.
248    pub aws_region: Option<String>,
249    /// GCP project id of the stack.
250    pub gcp_project_id: Option<String>,
251    /// Azure subscription id of the stack.
252    pub azure_subscription_id: Option<String>,
253    /// Azure resource group that holds the Key Vault.
254    pub azure_resource_group: Option<String>,
255    /// Deployment name, which `alien dev vault set` selects with `--deployment`.
256    pub deployment_name: Option<String>,
257}
258
259/// Where the deployer writes the value for `vault_key` of the `deployer-secrets` vault
260/// described by `binding`.
261pub fn deployer_secret_location(
262    binding: &VaultBinding,
263    vault_key: &str,
264    context: &DeployerSecretLocationContext,
265) -> crate::Result<DeployerSecretLocation> {
266    let label = context.secret_label.as_deref().unwrap_or(vault_key);
267    let mut prepare = String::new();
268    let mut location = match binding {
269        VaultBinding::ParameterStore(binding) => {
270            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
271            let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
272            let region = context.aws_region.as_deref();
273            let region_flag = region
274                .map(|region| format!(" --region {}", shell_quote(region)))
275                .unwrap_or_default();
276            DeployerSecretLocation {
277                commands: None,
278                store: DeployerSecretStore::AwsParameterStore,
279                vault_name: None,
280                console_url: region.map(|region| {
281                    format!(
282                        "https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
283                    )
284                }),
285                cli_command: format!(
286                    "aws ssm put-parameter{region_flag} --name {} --type SecureString --overwrite --value file:///dev/stdin", shell_quote(&name)
287                ),
288                delete_command: Some(format!(
289                    "aws ssm delete-parameter{region_flag} --name '{name}'"
290                )),
291                name,
292            }
293        }
294        VaultBinding::SecretManager(binding) => {
295            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
296            let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
297            let project = context.gcp_project_id.as_deref();
298            let project_flag = project
299                .map(|project| format!(" --project {}", shell_quote(project)))
300                .unwrap_or_default();
301            prepare = format!(
302                "(gcloud secrets describe {0}{project_flag} >/dev/null 2>&1 || gcloud secrets create {0}{project_flag} --replication-policy=automatic) &&\n",
303                shell_quote(&name)
304            );
305            DeployerSecretLocation {
306                commands: None,
307                store: DeployerSecretStore::GcpSecretManager,
308                vault_name: None,
309                console_url: project.map(|project| {
310                    format!(
311                        "https://console.cloud.google.com/security/secret-manager/create?project={project}"
312                    )
313                }),
314                // Creates the secret the first time; every run adds the value
315                // as a new version, so the same command rotates it.
316                cli_command: format!(
317                    "gcloud secrets versions add {}{project_flag} --data-file=-", shell_quote(&name)
318                ),
319                delete_command: Some(format!(
320                    "gcloud secrets delete {name}{project_flag} --quiet"
321                )),
322                name,
323            }
324        }
325        VaultBinding::KeyVault(binding) => {
326            let vault_name = concrete(&binding.vault_name, "vaultName")?;
327            let name = vault_naming::key_vault_secret_name(vault_key);
328            DeployerSecretLocation {
329                commands: None,
330                store: DeployerSecretStore::AzureKeyVault,
331                vault_name: Some(vault_name.clone()),
332                console_url: match (
333                    context.azure_subscription_id.as_deref(),
334                    context.azure_resource_group.as_deref(),
335                ) {
336                    (Some(subscription), Some(resource_group)) => Some(format!(
337                        "https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
338                    )),
339                    _ => None,
340                },
341                cli_command: format!(
342                    "az keyvault secret set --vault-name {} --name {} --file /dev/stdin --encoding utf-8 --output none",
343                    shell_quote(&vault_name),
344                    shell_quote(&name)
345                ),
346                delete_command: Some(format!(
347                    "az keyvault secret delete --vault-name {vault_name} --name {name}"
348                )),
349                name,
350            }
351        }
352        VaultBinding::KubernetesSecret(binding) => {
353            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
354            let namespace = concrete(&binding.namespace, "namespace")?;
355            let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
356            DeployerSecretLocation {
357                commands: None,
358                store: DeployerSecretStore::KubernetesSecret,
359                vault_name: None,
360                console_url: None,
361                cli_command: format!(
362                    "kubectl create secret generic {} --namespace {} --from-file={}=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -",
363                    shell_quote(&name),
364                    shell_quote(&namespace),
365                    vault_naming::KUBERNETES_SECRET_VALUE_KEY
366                ),
367                delete_command: Some(format!(
368                    "kubectl delete secret {name} --namespace {namespace}"
369                )),
370                name,
371            }
372        }
373        VaultBinding::Local(binding) => {
374            let deployment_flag = context
375                .deployment_name
376                .as_deref()
377                .map(|name| format!(" --deployment {}", shell_quote(name)))
378                .unwrap_or_default();
379            DeployerSecretLocation {
380                commands: None,
381                store: DeployerSecretStore::LocalVault,
382                vault_name: None,
383                console_url: None,
384                cli_command: format!(
385                    "alien dev vault{deployment_flag} set {} {} --stdin",
386                    shell_quote(&binding.vault_name),
387                    shell_quote(vault_key)
388                ),
389                delete_command: Some(format!(
390                    "alien dev vault{deployment_flag} delete {} {vault_key}",
391                    binding.vault_name
392                )),
393                name: vault_key.to_string(),
394            }
395        }
396    };
397    let unix_inline = match location.store {
398        DeployerSecretStore::AwsParameterStore => location
399            .cli_command
400            .replace("file:///dev/stdin", "'<VALUE>'"),
401        DeployerSecretStore::AzureKeyVault => location
402            .cli_command
403            .replace("--file /dev/stdin --encoding utf-8", "--value '<VALUE>'"),
404        DeployerSecretStore::KubernetesSecret => location.cli_command.replace(
405            "--from-file=value=/dev/stdin",
406            "--from-literal=value='<VALUE>'",
407        ),
408        DeployerSecretStore::LocalVault => format!(
409            "{} '<VALUE>'",
410            location.cli_command.trim_end_matches(" --stdin")
411        ),
412        DeployerSecretStore::GcpSecretManager => format!(
413            "{}printf '%s' '<VALUE>' | {}",
414            prepare.replace('\n', " "),
415            location.cli_command
416        ),
417    };
418    let windows = windows_secret_commands(binding, vault_key, context)?;
419    // Secret inputs have no multiline declaration; Enter completes a hidden single-line read.
420    // A subshell prevents values or shell options from leaking into the caller's session.
421    let prompt = shell_quote(&format!("{label}: "));
422    location.cli_command = format!(
423        "(set +x; set -o pipefail; printf '%s' {prompt} >&2; IFS= read -rs secret || [ -n \"$secret\" ]\n[ -n \"$secret\" ] && printf '\\n' >&2 && {prepare}printf '%s' \"$secret\" | {}\n)",
424        location.cli_command,
425        prepare = prepare.replace('\n', " "),
426    );
427    let (target, check_command) = match binding {
428        VaultBinding::ParameterStore(_) => (
429            context.aws_account_id.clone(),
430            Some("aws sts get-caller-identity --query Account --output text".to_string()),
431        ),
432        VaultBinding::SecretManager(_) => (
433            context.gcp_project_id.clone(),
434            Some("gcloud auth list --filter=status:ACTIVE --format=\"value(account)\"".to_string()),
435        ),
436        VaultBinding::KeyVault(_) => (
437            context.azure_subscription_id.clone(),
438            Some("az account show --query id --output tsv".to_string()),
439        ),
440        VaultBinding::KubernetesSecret(binding) => (
441            Some(concrete(&binding.namespace, "namespace")?),
442            Some("kubectl config current-context".to_string()),
443        ),
444        VaultBinding::Local(_) => (context.deployment_name.clone(), None),
445    };
446    location.commands = Some(DeployerSecretCommands {
447        unix: DeployerSecretCommandVariants {
448            inline: unix_inline,
449            prompt: location.cli_command.clone(),
450        },
451        windows,
452        target,
453        check_command,
454    });
455    Ok(location)
456}
457
458// PowerShell 5.1 string pipelines add a newline and default to ASCII. A UTF-8
459// file avoids both transformations and native argument quoting of secret values.
460fn windows_secret_commands(
461    binding: &VaultBinding,
462    vault_key: &str,
463    context: &DeployerSecretLocationContext,
464) -> crate::Result<DeployerSecretCommandVariants> {
465    let mut prepare = String::new();
466    let write = match binding {
467        VaultBinding::ParameterStore(binding) => {
468            let name = vault_naming::parameter_store_parameter_name(
469                &concrete(&binding.vault_prefix, "vaultPrefix")?,
470                vault_key,
471            );
472            let region = context
473                .aws_region
474                .as_deref()
475                .map(|value| format!(" --region {}", powershell_quote(value)))
476                .unwrap_or_default();
477            format!(
478                "aws ssm put-parameter{region} --name {} --type SecureString --overwrite --value \"file://$path\"",
479                powershell_quote(&name)
480            )
481        }
482        VaultBinding::SecretManager(binding) => {
483            let name = powershell_quote(&vault_naming::secret_manager_secret_id(
484                &concrete(&binding.vault_prefix, "vaultPrefix")?,
485                vault_key,
486            ));
487            let project = context
488                .gcp_project_id
489                .as_deref()
490                .map(|value| format!(" --project {}", powershell_quote(value)))
491                .unwrap_or_default();
492            prepare = format!(
493                "gcloud secrets describe {name}{project} 2>$null | Out-Null; if ($LASTEXITCODE -ne 0) {{ gcloud secrets create {name}{project} --replication-policy=automatic; if ($LASTEXITCODE -ne 0) {{ throw 'Could not create secret' }} }}; "
494            );
495            format!("gcloud secrets versions add {name}{project} --data-file=$path")
496        }
497        VaultBinding::KeyVault(binding) => format!(
498            "az keyvault secret set --vault-name {} --name {} --file $path --encoding utf-8 --output none",
499            powershell_quote(&concrete(&binding.vault_name, "vaultName")?),
500            powershell_quote(&vault_naming::key_vault_secret_name(vault_key))
501        ),
502        VaultBinding::KubernetesSecret(binding) => format!(
503            "kubectl create secret generic {} --namespace {} --from-file=\"value=$path\" --dry-run=client -o yaml | kubectl apply -f -",
504            powershell_quote(&vault_naming::kubernetes_secret_name(
505                &concrete(&binding.vault_prefix, "vaultPrefix")?,
506                vault_key
507            )),
508            powershell_quote(&concrete(&binding.namespace, "namespace")?)
509        ),
510        VaultBinding::Local(binding) => {
511            let deployment = context
512                .deployment_name
513                .as_deref()
514                .map(|value| format!(" --deployment {}", powershell_quote(value)))
515                .unwrap_or_default();
516            format!(
517                "alien dev vault{deployment} set {} {} --file $path",
518                powershell_quote(&binding.vault_name),
519                powershell_quote(vault_key)
520            )
521        }
522    };
523    let file = "$path = [IO.Path]::GetTempFileName()";
524    let save = "[IO.File]::WriteAllText($path, $value, (New-Object Text.UTF8Encoding $false))";
525    let cleanup = "Remove-Item -LiteralPath $path -Force; $value = $null";
526    let result = "; if ($LASTEXITCODE -ne 0) { throw 'Could not write secret' }";
527    let inline = format!(
528        "& {{ $value = '<VALUE>'; {file}; try {{ {save}; {prepare}{write}{result} }} finally {{ {cleanup} }} }}"
529    );
530    let inline = match binding {
531        VaultBinding::ParameterStore(_) => write.replace("\"file://$path\"", "'<VALUE>'"),
532        VaultBinding::KeyVault(_) => {
533            write.replace("--file $path --encoding utf-8", "--value '<VALUE>'")
534        }
535        VaultBinding::KubernetesSecret(_) => write.replace(
536            "--from-file=\"value=$path\"",
537            "--from-literal=value='<VALUE>'",
538        ),
539        VaultBinding::Local(binding) => {
540            let deployment = context
541                .deployment_name
542                .as_deref()
543                .map(|value| format!(" --deployment {}", powershell_quote(value)))
544                .unwrap_or_default();
545            format!(
546                "alien dev vault{deployment} set {} {} '<VALUE>'",
547                powershell_quote(&binding.vault_name),
548                powershell_quote(vault_key)
549            )
550        }
551        VaultBinding::SecretManager(_) => inline,
552    };
553    let label = powershell_quote(context.secret_label.as_deref().unwrap_or(vault_key));
554    // NetworkCredential unwraps SecureString on both Windows PowerShell 5.1 and PowerShell 7.
555    // Keep the native file transport: Windows string pipelines alter encoding and newlines.
556    let prompt = format!(
557        "& {{ $value = [Net.NetworkCredential]::new('', (Read-Host {label} -AsSecureString)).Password\nif (!$value) {{ return }}\n{file}\ntry {{ {save}\n{prepare}{write}{result}\n}} finally {{ {cleanup} }} }}"
558    );
559    Ok(DeployerSecretCommandVariants { inline, prompt })
560}
561
562fn powershell_quote(value: &str) -> String {
563    format!("'{}'", value.replace('\'', "''"))
564}
565
566fn shell_quote(value: &str) -> String {
567    if !value.is_empty()
568        && value
569            .bytes()
570            .all(|byte| byte.is_ascii_alphanumeric() || b"-_./:@".contains(&byte))
571    {
572        value.to_string()
573    } else {
574        format!("'{}'", value.replace('\'', "'\"'\"'"))
575    }
576}
577
578fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
579    value
580        .clone()
581        .into_value(crate::DEPLOYER_SECRETS_VAULT_ID, field)
582}
583
584/// Whether a deployer secret slot holds a usable value. Alien learns this from
585/// metadata only and never reads the value.
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
587#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
588#[serde(rename_all = "kebab-case")]
589pub enum DeployerSecretStatus {
590    /// The secret exists and can be read by the workload.
591    Present,
592    /// The secret does not exist.
593    Missing,
594    /// The secret exists but cannot be used as is (wrong type, disabled, no
595    /// enabled version); the report's message says why.
596    Invalid,
597}
598
599/// The state of one deployer secret slot, reported with the deployment.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
602#[serde(rename_all = "camelCase")]
603pub struct DeployerSecretReport {
604    /// Stack input id.
605    pub input_id: String,
606    /// The input's label.
607    pub label: String,
608    /// Whether the workload cannot start without it.
609    pub required: bool,
610    /// Whether the slot is filled.
611    pub status: DeployerSecretStatus,
612    /// Why the slot is invalid.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub message: Option<String>,
615    /// The secret store's version of the present value (never the value or a
616    /// hash of it). A new version reaches workloads with the next update.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub version: Option<String>,
619    /// Where the deployer writes the value.
620    pub location: DeployerSecretLocation,
621}
622
623impl DeployerSecretReport {
624    /// Whether this slot keeps the workload from starting.
625    pub fn blocks_start(&self) -> bool {
626        self.required && self.status != DeployerSecretStatus::Present
627    }
628
629    /// One line for a person: `missing: Database password` or
630    /// `invalid: Database password (must be a SecureString)`.
631    pub fn summary(&self) -> String {
632        match self.status {
633            DeployerSecretStatus::Present => format!("present: {}", self.label),
634            DeployerSecretStatus::Missing => format!("missing: {}", self.label),
635            DeployerSecretStatus::Invalid => match &self.message {
636                Some(message) => format!("invalid: {} ({message})", self.label),
637                None => format!("invalid: {}", self.label),
638            },
639        }
640    }
641}
642
643/// Env var that carries a natively projected workload's
644/// [`DeployerSecretEnv`] list to its hosting controller, which resolves each
645/// entry before the process starts (a `secretKeyRef` on Kubernetes, a vault
646/// read on the local platform). It holds names only, never values.
647pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
648
649/// An environment variable a workload reads from a vault-native deployer
650/// secret when it starts.
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653pub struct DeployerSecretEnv {
654    /// Environment variable name.
655    pub name: String,
656    /// Key in the `deployer-secrets` vault.
657    pub vault_key: String,
658    /// Full name in the secret store (the Kubernetes Secret on Kubernetes).
659    pub secret_name: String,
660    /// The Azure Key Vault that holds it. Other stores resolve `secret_name`
661    /// in the workload's own account or project.
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub vault_name: Option<String>,
664    /// The input's label, for the "missing: <label>" a failed start reports.
665    pub label: String,
666    /// Whether the workload must not start without it.
667    pub required: bool,
668    /// The secret store's version of the value when the workload was
669    /// configured. Overwriting the secret changes it, so the next update
670    /// restarts the workload, which then reads the new value.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub version: Option<String>,
673}
674
675/// The environment variables workloads read from vault-native deployer
676/// secrets, each with the resources its mapping targets (`None` = all).
677///
678/// A slot is read from the vault once Alien has a report for it, unless the
679/// deployment still stores a value from before slots were vault-native: that
680/// value keeps today's path until the control plane drops it. The workload's
681/// profile gains the vault read grant at the same point, so a workload never
682/// reads a slot it may not read.
683pub fn deployer_secret_environment(
684    inputs: &[StackInputDefinition],
685    values: &HashMap<String, serde_json::Value>,
686    stored_secret_input_ids: Option<&[String]>,
687    environment: &[EnvironmentVariable],
688    platform: Platform,
689    reports: &[DeployerSecretReport],
690) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
691    deployer_secret_slots(
692        inputs,
693        values,
694        stored_secret_input_ids,
695        environment,
696        platform,
697    )
698    .into_iter()
699    // Stored presence selects existing delivery even when plaintext inputs are
700    // omitted. Preflight does not grant these inputs access to the native vault.
701    .filter(|slot| !slot.has_stored_value)
702    .filter_map(|slot| {
703        reports
704            .iter()
705            .find(|report| report.input_id == slot.input.id)
706            .map(|report| (slot, report))
707    })
708    .flat_map(|(slot, report)| {
709        slot.input.env.iter().map(move |mapping| {
710            (
711                DeployerSecretEnv {
712                    name: mapping.name.clone(),
713                    vault_key: slot.vault_key.clone(),
714                    secret_name: report.location.name.clone(),
715                    vault_name: report.location.vault_name.clone(),
716                    label: slot.input.label.clone(),
717                    required: slot.input.required,
718                    version: report.version.clone(),
719                },
720                mapping.target_resources.clone(),
721            )
722        })
723    })
724    .collect()
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::StackInputEnvironmentMapping;
731
732    fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
733        StackInputDefinition {
734            id: id.to_string(),
735            kind: StackInputKind::Secret,
736            provided_by,
737            required: true,
738            label: "Database password".to_string(),
739            description: String::new(),
740            placeholder: None,
741            default: None,
742            platforms: None,
743            validation: None,
744            generate: None,
745            env: vec![StackInputEnvironmentMapping {
746                name: "DATABASE_PASSWORD".to_string(),
747                target_resources: None,
748                var_type: None,
749            }],
750        }
751    }
752
753    #[test]
754    fn legacy_omitted_presence_uses_only_complete_scoped_secret_delivery() {
755        let base = serde_json::json!({
756            "environmentVariables": {
757                "variables": [{"name": "DATABASE_PASSWORD", "value": "fixture-value", "type": "secret"}],
758                "hash": "fixture", "createdAt": "2026-01-01T00:00:00Z"
759            }
760        });
761        let legacy: crate::DeploymentConfig = serde_json::from_value(base.clone()).unwrap();
762        assert_eq!(legacy.stored_secret_input_ids, None);
763        let input = secret(
764            "databasePassword",
765            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
766        );
767        for case in [
768            "legacy",
769            "null",
770            "empty",
771            "unrelated",
772            "missing",
773            "plain",
774            "wrong-name",
775            "partial",
776            "narrow",
777            "matching-scope",
778            "broader-scope",
779            "deployer-only",
780            "no-mappings",
781            "plain-mapping",
782            "duplicate-plain",
783            "duplicate-scoped-secret",
784        ] {
785            let mut wire = base.clone();
786            let mut input = input.clone();
787            match case {
788                "null" => wire["storedSecretInputIds"] = serde_json::Value::Null,
789                "empty" => wire["storedSecretInputIds"] = serde_json::json!([]),
790                "unrelated" => wire["storedSecretInputIds"] = serde_json::json!(["other"]),
791                "missing" => wire["environmentVariables"]["variables"] = serde_json::json!([]),
792                "plain" => {
793                    wire["environmentVariables"]["variables"][0]["type"] =
794                        serde_json::json!("plain")
795                }
796                "duplicate-plain" | "duplicate-scoped-secret" => {
797                    let mut duplicate = wire["environmentVariables"]["variables"][0].clone();
798                    if case == "duplicate-plain" {
799                        duplicate["type"] = serde_json::json!("plain");
800                    } else {
801                        input.env[0].target_resources = Some(vec!["app".to_string()]);
802                        wire["environmentVariables"]["variables"][0]["targetResources"] =
803                            serde_json::json!(["app"]);
804                        duplicate["targetResources"] = serde_json::json!(["other"]);
805                    }
806                    wire["environmentVariables"]["variables"]
807                        .as_array_mut()
808                        .unwrap()
809                        .push(duplicate);
810                }
811                "wrong-name" => {
812                    wire["environmentVariables"]["variables"][0]["name"] =
813                        serde_json::json!("OTHER")
814                }
815                "partial" => {
816                    let mut mapping = input.env[0].clone();
817                    mapping.name = "SECOND".to_string();
818                    input.env.push(mapping);
819                }
820                "narrow" => {
821                    wire["environmentVariables"]["variables"][0]["targetResources"] =
822                        serde_json::json!(["app"])
823                }
824                "matching-scope" => {
825                    input.env[0].target_resources = Some(vec!["app*".to_string()]);
826                    wire["environmentVariables"]["variables"][0]["targetResources"] =
827                        serde_json::json!(["app*"]);
828                }
829                "broader-scope" => input.env[0].target_resources = Some(vec!["app".to_string()]),
830                "deployer-only" => input.provided_by = vec![StackInputProvider::Deployer],
831                "no-mappings" => input.env.clear(),
832                "plain-mapping" => {
833                    input.env[0].var_type = Some(StackInputEnvironmentVariableType::Plain)
834                }
835                _ => {}
836            }
837            let config: crate::DeploymentConfig = serde_json::from_value(wire).unwrap();
838            let inputs = [input];
839            let delivered = matches!(case, "legacy" | "null" | "matching-scope" | "broader-scope");
840            let slots = deployer_secret_slots(
841                &inputs,
842                &config.input_values,
843                config.stored_secret_input_ids.as_deref(),
844                &config.environment_variables.variables,
845                Platform::Aws,
846            );
847            assert_eq!(slots.is_empty(), delivered, "{case}");
848            let reports = [report("databasePassword", DeployerSecretStatus::Present)];
849            let pointers = deployer_secret_environment(
850                &inputs,
851                &config.input_values,
852                config.stored_secret_input_ids.as_deref(),
853                &config.environment_variables.variables,
854                Platform::Aws,
855                &reports,
856            );
857            assert_eq!(
858                pointers.len(),
859                if delivered || case == "no-mappings" {
860                    0
861                } else {
862                    inputs[0].env.len()
863                },
864                "{case}"
865            );
866            assert!(config.input_values.is_empty());
867            assert!(is_deployer_secret_input(&inputs[0]));
868        }
869    }
870
871    #[test]
872    fn vault_keys_are_kebab_case_and_backend_safe() {
873        assert_eq!(
874            deployer_secret_vault_key("databasePassword"),
875            "database-password"
876        );
877        assert_eq!(deployer_secret_vault_key("api_key"), "api-key");
878        assert_eq!(deployer_secret_vault_key("OAuth2Token"), "oauth2-token");
879        assert_eq!(deployer_secret_vault_key("a--b__c"), "a-b-c");
880    }
881
882    #[test]
883    fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
884        let inputs = vec![secret(
885            "databasePassword",
886            vec![StackInputProvider::Deployer],
887        )];
888        let stored = HashMap::from([(
889            "databasePassword".to_string(),
890            serde_json::json!("from-before"),
891        )]);
892
893        let slots = deployer_secret_slots(&inputs, &stored, Some(&[]), &[], Platform::Aws);
894        assert_eq!(slots.len(), 1);
895        assert_eq!(slots[0].vault_key, "database-password");
896        assert!(slots[0].has_stored_value);
897    }
898
899    #[test]
900    fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
901        let inputs = vec![secret(
902            "databasePassword",
903            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
904        )];
905        let with_developer_value =
906            HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
907
908        assert!(deployer_secret_slots(
909            &inputs,
910            &with_developer_value,
911            Some(&[]),
912            &[],
913            Platform::Aws
914        )
915        .is_empty());
916        assert_eq!(
917            deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Aws).len(),
918            1
919        );
920    }
921
922    #[test]
923    fn developer_secrets_and_other_platforms_are_not_slots() {
924        let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
925        aws_only.platforms = Some(vec![Platform::Aws]);
926        let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
927
928        assert!(
929            deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Gcp)
930                .is_empty()
931        );
932    }
933
934    #[test]
935    fn locations_name_the_same_secret_the_vault_reads() {
936        let context = DeployerSecretLocationContext {
937            aws_region: Some("us-east-1".to_string()),
938            gcp_project_id: Some("acme-prod".to_string()),
939            deployment_name: Some("default".to_string()),
940            ..Default::default()
941        };
942        let key = "database-password";
943
944        let aws = deployer_secret_location(
945            &VaultBinding::parameter_store("stack-deployer-secrets"),
946            key,
947            &context,
948        )
949        .unwrap();
950        assert_eq!(aws.name, "stack-deployer-secrets-database-password");
951        assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
952        assert!(aws.cli_command.contains("--type SecureString"));
953
954        assert_eq!(
955            aws.delete_command.as_deref(),
956            Some(
957                "aws ssm delete-parameter --region us-east-1 --name 'stack-deployer-secrets-database-password'"
958            )
959        );
960
961        let gcp = deployer_secret_location(
962            &VaultBinding::secret_manager("stack-deployer-secrets"),
963            key,
964            &context,
965        )
966        .unwrap();
967        assert_eq!(gcp.name, "stack-deployer-secrets-database-password");
968        assert_eq!(gcp.vault_name, None);
969        assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
970        assert_eq!(
971            gcp.delete_command.as_deref(),
972            Some(
973                "gcloud secrets delete stack-deployer-secrets-database-password --project acme-prod --quiet"
974            )
975        );
976
977        // A Key Vault secret name alone does not say which vault holds it, so
978        // the location carries the vault for whoever reads the slot.
979        let azure =
980            deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
981                .unwrap();
982        assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
983        assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
984        assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
985        assert_eq!(
986            azure.delete_command,
987            Some(format!(
988                "az keyvault secret delete --vault-name stacksecrets7f3a --name {}",
989                azure.name
990            ))
991        );
992
993        let kubernetes = deployer_secret_location(
994            &VaultBinding::kubernetes_secret("apps", "Stack-Deployer-Secrets"),
995            key,
996            &context,
997        )
998        .unwrap();
999        assert_eq!(kubernetes.name, "stack-deployer-secrets-database-password");
1000        assert!(kubernetes.cli_command.contains("--namespace apps"));
1001        assert_eq!(
1002            kubernetes.delete_command.as_deref(),
1003            Some("kubectl delete secret stack-deployer-secrets-database-password --namespace apps")
1004        );
1005
1006        let local = deployer_secret_location(
1007            &VaultBinding::local("deployer-secrets", "/tmp/x"),
1008            key,
1009            &context,
1010        )
1011        .unwrap();
1012
1013        assert_eq!(
1014            local.delete_command.as_deref(),
1015            Some("alien dev vault --deployment default delete deployer-secrets database-password")
1016        );
1017    }
1018
1019    #[test]
1020    fn a_template_expression_has_no_location() {
1021        let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
1022            vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
1023        });
1024        assert!(
1025            deployer_secret_location(&binding, "x", &DeployerSecretLocationContext::default())
1026                .is_err()
1027        );
1028    }
1029
1030    #[test]
1031    fn only_required_unfilled_slots_block_start() {
1032        let location = deployer_secret_location(
1033            &VaultBinding::local("deployer-secrets", "/tmp/x"),
1034            "x",
1035            &DeployerSecretLocationContext::default(),
1036        )
1037        .unwrap();
1038        let mut report = DeployerSecretReport {
1039            input_id: "x".to_string(),
1040            label: "Database password".to_string(),
1041            required: true,
1042            status: DeployerSecretStatus::Missing,
1043            message: None,
1044            version: None,
1045            location,
1046        };
1047        assert!(report.blocks_start());
1048        assert_eq!(report.summary(), "missing: Database password");
1049
1050        report.required = false;
1051        assert!(!report.blocks_start());
1052
1053        report.required = true;
1054        report.status = DeployerSecretStatus::Present;
1055        assert!(!report.blocks_start());
1056    }
1057
1058    fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
1059        DeployerSecretReport {
1060            input_id: input_id.to_string(),
1061            label: "Database password".to_string(),
1062            required: true,
1063            status,
1064            message: None,
1065            version: None,
1066            location: DeployerSecretLocation {
1067                commands: None,
1068                store: DeployerSecretStore::AwsParameterStore,
1069                name: "stack-deployer-secrets-database-password".to_string(),
1070                vault_name: None,
1071                console_url: None,
1072                cli_command: String::new(),
1073                delete_command: None,
1074            },
1075        }
1076    }
1077
1078    #[test]
1079    fn a_slot_is_read_from_the_vault_once_it_is_reported() {
1080        let inputs = vec![secret(
1081            "databasePassword",
1082            vec![StackInputProvider::Deployer],
1083        )];
1084        let no_values = HashMap::new();
1085
1086        assert!(deployer_secret_environment(
1087            &inputs,
1088            &no_values,
1089            Some(&[]),
1090            &[],
1091            Platform::Aws,
1092            &[]
1093        )
1094        .is_empty());
1095
1096        let env = deployer_secret_environment(
1097            &inputs,
1098            &no_values,
1099            Some(&[]),
1100            &[],
1101            Platform::Aws,
1102            &[report("databasePassword", DeployerSecretStatus::Missing)],
1103        );
1104        assert_eq!(env.len(), 1);
1105        let (variable, targets) = &env[0];
1106        assert_eq!(variable.name, "DATABASE_PASSWORD");
1107        assert_eq!(variable.vault_key, "database-password");
1108        assert_eq!(
1109            variable.secret_name,
1110            "stack-deployer-secrets-database-password"
1111        );
1112        assert!(variable.required);
1113        assert_eq!(variable.vault_name, None);
1114        assert_eq!(targets, &None);
1115    }
1116
1117    #[test]
1118    fn a_key_vault_slot_names_its_vault_for_the_workload() {
1119        let inputs = vec![secret(
1120            "databasePassword",
1121            vec![StackInputProvider::Deployer],
1122        )];
1123        let location = deployer_secret_location(
1124            &VaultBinding::key_vault("stacksecrets7f3a"),
1125            "database-password",
1126            &DeployerSecretLocationContext::default(),
1127        )
1128        .unwrap();
1129        let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
1130        azure_report.location = location;
1131
1132        let env = deployer_secret_environment(
1133            &inputs,
1134            &HashMap::new(),
1135            Some(&[]),
1136            &[],
1137            Platform::Azure,
1138            &[azure_report],
1139        );
1140
1141        assert_eq!(env.len(), 1);
1142        assert_eq!(env[0].0.secret_name, "database-password");
1143        assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
1144    }
1145
1146    #[test]
1147    fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
1148        let inputs = vec![secret(
1149            "databasePassword",
1150            vec![StackInputProvider::Deployer],
1151        )];
1152        let stored = HashMap::from([(
1153            "databasePassword".to_string(),
1154            serde_json::json!("from-before"),
1155        )]);
1156
1157        assert!(deployer_secret_environment(
1158            &inputs,
1159            &stored,
1160            Some(&[]),
1161            &[],
1162            Platform::Aws,
1163            &[report("databasePassword", DeployerSecretStatus::Missing)],
1164        )
1165        .is_empty());
1166        assert!(
1167            deployer_secret_environment(
1168                &inputs,
1169                &stored,
1170                Some(&[]),
1171                &[],
1172                Platform::Aws,
1173                &[report("databasePassword", DeployerSecretStatus::Present)],
1174            )
1175            .is_empty(),
1176            "a filled slot is not read while the stored value has no vault read grant"
1177        );
1178        assert_eq!(
1179            deployer_secret_environment(
1180                &inputs,
1181                &HashMap::new(),
1182                Some(&[]),
1183                &[],
1184                Platform::Aws,
1185                &[report("databasePassword", DeployerSecretStatus::Present)],
1186            )
1187            .len(),
1188            1
1189        );
1190    }
1191    #[test]
1192    fn stored_secret_ids_classify_presence_without_input_values() {
1193        for required in [true, false] {
1194            let mut input = secret(
1195                "apiKey",
1196                vec![StackInputProvider::Developer, StackInputProvider::Deployer],
1197            );
1198            input.required = required;
1199            let inputs = vec![input];
1200            let values = HashMap::new();
1201            let present = vec!["apiKey".to_string()];
1202            assert!(
1203                deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws)
1204                    .is_empty()
1205            );
1206            for ids in [vec![], vec!["removedInput".to_string()]] {
1207                let slots = deployer_secret_slots(&inputs, &values, Some(&ids), &[], Platform::Aws);
1208                assert_eq!(slots.len(), 1);
1209                assert!(!slots[0].has_stored_value);
1210                assert_eq!(slots[0].input.required, required);
1211            }
1212            let null_value = HashMap::from([("apiKey".to_string(), serde_json::Value::Null)]);
1213            assert!(deployer_secret_slots(
1214                &inputs,
1215                &null_value,
1216                Some(&present),
1217                &[],
1218                Platform::Aws
1219            )
1220            .is_empty());
1221            assert_eq!(
1222                deployer_secret_slots(&inputs, &null_value, Some(&[]), &[], Platform::Aws).len(),
1223                1
1224            );
1225            for status in [DeployerSecretStatus::Missing, DeployerSecretStatus::Present] {
1226                assert!(deployer_secret_environment(
1227                    &inputs,
1228                    &values,
1229                    Some(&present),
1230                    &[],
1231                    Platform::Aws,
1232                    &[report("apiKey", status)]
1233                )
1234                .is_empty());
1235            }
1236            assert!(
1237                values.is_empty(),
1238                "presence must not manufacture an input value"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn stored_secret_ids_keep_deployer_only_slots_on_stored_delivery() {
1245        let inputs = vec![secret("apiKey", vec![StackInputProvider::Deployer])];
1246        let values = HashMap::new();
1247        let present = vec!["apiKey".to_string()];
1248        let slots = deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws);
1249        assert_eq!(slots.len(), 1);
1250        assert!(slots[0].has_stored_value);
1251        assert!(deployer_secret_environment(
1252            &inputs,
1253            &values,
1254            Some(&present),
1255            &[],
1256            Platform::Aws,
1257            &[report("apiKey", DeployerSecretStatus::Missing)]
1258        )
1259        .is_empty());
1260        let environment = deployer_secret_environment(
1261            &inputs,
1262            &values,
1263            Some(&present),
1264            &[],
1265            Platform::Aws,
1266            &[report("apiKey", DeployerSecretStatus::Present)],
1267        );
1268        assert!(
1269            environment.is_empty(),
1270            "a filled native slot cannot replace stored delivery without its read grant"
1271        );
1272    }
1273
1274    #[test]
1275    fn stored_secret_ids_do_not_change_platform_or_kind_classification() {
1276        let mut input = secret("apiKey", vec![StackInputProvider::Deployer]);
1277        input.platforms = Some(vec![Platform::Aws]);
1278        let inputs = vec![input.clone()];
1279        let present = vec!["apiKey".to_string()];
1280        assert!(deployer_secret_slots(
1281            &inputs,
1282            &HashMap::new(),
1283            Some(&present),
1284            &[],
1285            Platform::Gcp
1286        )
1287        .is_empty());
1288        input.kind = StackInputKind::String;
1289        assert!(deployer_secret_slots(
1290            &[input],
1291            &HashMap::new(),
1292            Some(&present),
1293            &[],
1294            Platform::Aws
1295        )
1296        .is_empty());
1297    }
1298}