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