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 `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 placeholder), 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/// Prefix of every vault key that holds a deployer secret, so these keys
24/// never collide with the env-var-named keys Alien syncs itself.
25pub const DEPLOYER_SECRET_KEY_PREFIX: &str = "input-";
26
27/// Placeholder for the secret value in the CLI commands Alien shows.
28pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
29
30/// The `secrets` vault key for a deployer secret input: `input-` plus the input
31/// id in lowercase kebab case (`databasePassword` → `input-database-password`).
32///
33/// Only `[a-z0-9-]` survive, so the key is valid unchanged in every backend
34/// (SSM, Secret Manager, Key Vault and Kubernetes object names).
35pub fn deployer_secret_vault_key(input_id: &str) -> String {
36    let mut key = String::from(DEPLOYER_SECRET_KEY_PREFIX);
37    let mut previous_lower_or_digit = false;
38    let mut pending_separator = false;
39    for character in input_id.chars() {
40        if character.is_ascii_alphanumeric() {
41            let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
42            if (pending_separator || starts_word) && !key.ends_with('-') {
43                key.push('-');
44            }
45            key.push(character.to_ascii_lowercase());
46            previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
47            pending_separator = false;
48        } else {
49            pending_separator = true;
50            previous_lower_or_digit = false;
51        }
52    }
53    key.trim_end_matches('-').to_string()
54}
55
56/// Whether the deployer may provide this secret input.
57pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
58    input.kind == StackInputKind::Secret
59        && input.provided_by.contains(&StackInputProvider::Deployer)
60}
61
62/// Why a setup path refuses a value for the deployer secret `name` (its label
63/// or id), with what to do instead.
64pub fn deployer_secret_value_refusal(name: &str) -> String {
65    format!(
66        "'{name}' is a deployer secret: its value goes into your own secret store and never \
67         through Alien. Do not pass it here; write it into the deployment's secrets vault \
68         instead (the deployment status shows the secret's name and the command that writes it)."
69    )
70}
71
72/// A deployer secret input whose value lives in the customer's secret store.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DeployerSecretSlot<'a> {
75    /// The stack input.
76    pub input: &'a StackInputDefinition,
77    /// Key of the value in the `secrets` vault.
78    pub vault_key: String,
79    /// The deployment still stores a value for this input from before slots
80    /// were vault-native. It is used until the control plane drops it.
81    pub has_stored_value: bool,
82}
83
84/// The vault-native deployer secret slots of a deployment on `platform`.
85///
86/// `values` are the deployment's stored input values. A secret input the
87/// developer may also provide is a slot only when it has no stored value: a
88/// developer value keeps today's path. `stored_secret_input_ids` carries trusted
89/// presence when stored secret values are intentionally absent from `values`.
90/// Presence never supplies a value for gates, defaults, or environment variables.
91/// Only omitted legacy metadata may use complete Secret delivery in the trusted
92/// target snapshot. Explicit empty metadata disables this compatibility path.
93pub fn deployer_secret_slots<'a>(
94    inputs: &'a [StackInputDefinition],
95    values: &HashMap<String, serde_json::Value>,
96    stored_secret_input_ids: Option<&[String]>,
97    environment: &[EnvironmentVariable],
98    platform: Platform,
99) -> Vec<DeployerSecretSlot<'a>> {
100    inputs
101        .iter()
102        .filter(|input| is_deployer_secret_input(input))
103        .filter(|input| {
104            input
105                .platforms
106                .as_ref()
107                .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
108        })
109        .filter_map(|input| {
110            let has_stored_value = stored_secret_input_ids
111                .is_some_and(|ids| ids.contains(&input.id))
112                || values.get(&input.id).is_some_and(|value| !value.is_null());
113            let developer_value = input.provided_by.contains(&StackInputProvider::Developer)
114                && (has_stored_value
115                    || (stored_secret_input_ids.is_none()
116                        && legacy_secret_delivery_complete(input, environment)));
117            (!developer_value).then(|| DeployerSecretSlot {
118                input,
119                vault_key: deployer_secret_vault_key(&input.id),
120                has_stored_value,
121            })
122        })
123        .collect()
124}
125
126// Only trusted legacy delivery can replace omitted presence metadata. No values
127// are read here, and scope coverage follows the same exact-id/prefix rules as delivery.
128fn legacy_secret_delivery_complete(
129    input: &StackInputDefinition,
130    environment: &[EnvironmentVariable],
131) -> bool {
132    !input.env.is_empty()
133        && input.env.iter().all(|mapping| {
134            let mut matches = environment
135                .iter()
136                .filter(|variable| variable.name == mapping.name);
137            let Some(variable) = matches.next() else {
138                return false;
139            };
140            // Duplicate names have no trusted precedence, even when their scopes differ.
141            if matches.next().is_some() {
142                return false;
143            }
144            !mapping.name.is_empty()
145                && mapping
146                    .target_resources
147                    .as_ref()
148                    .is_none_or(|targets| !targets.is_empty())
149                && !matches!(
150                    mapping.var_type,
151                    Some(StackInputEnvironmentVariableType::Plain)
152                )
153                && variable.var_type == EnvironmentVariableType::Secret
154                && match (&mapping.target_resources, &variable.target_resources) {
155                    (_, None) => true,
156                    (None, Some(delivered)) => delivered.iter().any(|pattern| pattern == "*"),
157                    (Some(required), Some(delivered)) => {
158                        !required.is_empty()
159                            && required.iter().all(|target| {
160                                delivered.iter().any(|pattern| {
161                                    pattern == target
162                                        || pattern
163                                            .strip_suffix('*')
164                                            .is_some_and(|prefix| target.starts_with(prefix))
165                                })
166                            })
167                    }
168                }
169        })
170}
171
172/// The secret store a deployer writes a vault-native secret into.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175#[serde(rename_all = "kebab-case")]
176pub enum DeployerSecretStore {
177    /// AWS Systems Manager Parameter Store, as a `SecureString` parameter.
178    AwsParameterStore,
179    /// GCP Secret Manager.
180    GcpSecretManager,
181    /// Azure Key Vault.
182    AzureKeyVault,
183    /// A Kubernetes Secret with the value under the `value` key.
184    KubernetesSecret,
185    /// The local development vault (`alien dev vault set`).
186    LocalVault,
187}
188
189/// Where a deployer writes a vault-native secret.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
192#[serde(rename_all = "camelCase")]
193pub struct DeployerSecretLocation {
194    /// The secret store.
195    pub store: DeployerSecretStore,
196    /// Full name of the secret in that store.
197    pub name: String,
198    /// The Azure Key Vault that holds the secret. Other stores resolve `name`
199    /// in the stack's own account or project.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub vault_name: Option<String>,
202    /// Cloud console page where the secret is created, when the store has one.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub console_url: Option<String>,
205    /// Command that writes the value, with `<VALUE>` in place of the secret.
206    pub cli_command: String,
207    /// Command that deletes the secret. Deleting a deployment keeps the
208    /// secrets the deployer wrote, since Alien never owned their values.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub delete_command: Option<String>,
211}
212
213/// What a location needs beyond the vault binding.
214#[derive(Debug, Clone, Default, PartialEq, Eq)]
215pub struct DeployerSecretLocationContext {
216    /// AWS region of the stack.
217    pub aws_region: Option<String>,
218    /// GCP project id of the stack.
219    pub gcp_project_id: Option<String>,
220    /// Azure subscription id of the stack.
221    pub azure_subscription_id: Option<String>,
222    /// Azure resource group that holds the Key Vault.
223    pub azure_resource_group: Option<String>,
224    /// Deployment name, which `alien dev vault set` selects with `--deployment`.
225    pub deployment_name: Option<String>,
226}
227
228/// Where the deployer writes the value for `vault_key` of the `secrets` vault
229/// described by `binding`.
230pub fn deployer_secret_location(
231    binding: &VaultBinding,
232    vault_key: &str,
233    context: &DeployerSecretLocationContext,
234) -> crate::Result<DeployerSecretLocation> {
235    let placeholder = DEPLOYER_SECRET_VALUE_PLACEHOLDER;
236    let location = match binding {
237        VaultBinding::ParameterStore(binding) => {
238            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
239            let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
240            let region = context.aws_region.as_deref();
241            let region_flag = region
242                .map(|region| format!(" --region {region}"))
243                .unwrap_or_default();
244            DeployerSecretLocation {
245                store: DeployerSecretStore::AwsParameterStore,
246                vault_name: None,
247                console_url: region.map(|region| {
248                    format!(
249                        "https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
250                    )
251                }),
252                cli_command: format!(
253                    "aws ssm put-parameter{region_flag} --name '{name}' --type SecureString --overwrite --value '{placeholder}'"
254                ),
255                delete_command: Some(format!(
256                    "aws ssm delete-parameter{region_flag} --name '{name}'"
257                )),
258                name,
259            }
260        }
261        VaultBinding::SecretManager(binding) => {
262            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
263            let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
264            let project = context.gcp_project_id.as_deref();
265            let project_flag = project
266                .map(|project| format!(" --project {project}"))
267                .unwrap_or_default();
268            DeployerSecretLocation {
269                store: DeployerSecretStore::GcpSecretManager,
270                vault_name: None,
271                console_url: project.map(|project| {
272                    format!(
273                        "https://console.cloud.google.com/security/secret-manager/create?project={project}"
274                    )
275                }),
276                // Creates the secret the first time; every run adds the value
277                // as a new version, so the same command rotates it.
278                cli_command: format!(
279                    "(gcloud secrets describe {name}{project_flag} >/dev/null 2>&1 || gcloud secrets create {name}{project_flag} --replication-policy=automatic) && printf '%s' '{placeholder}' | gcloud secrets versions add {name}{project_flag} --data-file=-"
280                ),
281                delete_command: Some(format!(
282                    "gcloud secrets delete {name}{project_flag} --quiet"
283                )),
284                name,
285            }
286        }
287        VaultBinding::KeyVault(binding) => {
288            let vault_name = concrete(&binding.vault_name, "vaultName")?;
289            let name = vault_naming::key_vault_secret_name(vault_key);
290            DeployerSecretLocation {
291                store: DeployerSecretStore::AzureKeyVault,
292                vault_name: Some(vault_name.clone()),
293                console_url: match (
294                    context.azure_subscription_id.as_deref(),
295                    context.azure_resource_group.as_deref(),
296                ) {
297                    (Some(subscription), Some(resource_group)) => Some(format!(
298                        "https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
299                    )),
300                    _ => None,
301                },
302                cli_command: format!(
303                    "az keyvault secret set --vault-name {vault_name} --name {name} --value '{placeholder}'"
304                ),
305                delete_command: Some(format!(
306                    "az keyvault secret delete --vault-name {vault_name} --name {name}"
307                )),
308                name,
309            }
310        }
311        VaultBinding::KubernetesSecret(binding) => {
312            let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
313            let namespace = concrete(&binding.namespace, "namespace")?;
314            let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
315            DeployerSecretLocation {
316                store: DeployerSecretStore::KubernetesSecret,
317                vault_name: None,
318                console_url: None,
319                cli_command: format!(
320                    "kubectl create secret generic {name} --namespace {namespace} --from-literal={}='{placeholder}'",
321                    vault_naming::KUBERNETES_SECRET_VALUE_KEY
322                ),
323                delete_command: Some(format!(
324                    "kubectl delete secret {name} --namespace {namespace}"
325                )),
326                name,
327            }
328        }
329        VaultBinding::Local(binding) => {
330            let deployment_flag = context
331                .deployment_name
332                .as_deref()
333                .map(|name| format!(" --deployment {name}"))
334                .unwrap_or_default();
335            DeployerSecretLocation {
336                store: DeployerSecretStore::LocalVault,
337                vault_name: None,
338                console_url: None,
339                cli_command: format!(
340                    "alien dev vault{deployment_flag} set {} {vault_key} '{placeholder}'",
341                    binding.vault_name
342                ),
343                delete_command: Some(format!(
344                    "alien dev vault{deployment_flag} delete {} {vault_key}",
345                    binding.vault_name
346                )),
347                name: vault_key.to_string(),
348            }
349        }
350    };
351    Ok(location)
352}
353
354fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
355    value.clone().into_value(crate::SECRETS_VAULT_ID, field)
356}
357
358/// Whether a deployer secret slot holds a usable value. Alien learns this from
359/// metadata only and never reads the value.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
362#[serde(rename_all = "kebab-case")]
363pub enum DeployerSecretStatus {
364    /// The secret exists and can be read by the workload.
365    Present,
366    /// The secret does not exist.
367    Missing,
368    /// The secret exists but cannot be used as is (wrong type, disabled, no
369    /// enabled version); the report's message says why.
370    Invalid,
371}
372
373/// The state of one deployer secret slot, reported with the deployment.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
376#[serde(rename_all = "camelCase")]
377pub struct DeployerSecretReport {
378    /// Stack input id.
379    pub input_id: String,
380    /// The input's label.
381    pub label: String,
382    /// Whether the workload cannot start without it.
383    pub required: bool,
384    /// Whether the slot is filled.
385    pub status: DeployerSecretStatus,
386    /// Why the slot is invalid.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub message: Option<String>,
389    /// The secret store's version of the present value (never the value or a
390    /// hash of it). A new version reaches workloads with the next update.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub version: Option<String>,
393    /// Where the deployer writes the value.
394    pub location: DeployerSecretLocation,
395}
396
397impl DeployerSecretReport {
398    /// Whether this slot keeps the workload from starting.
399    pub fn blocks_start(&self) -> bool {
400        self.required && self.status != DeployerSecretStatus::Present
401    }
402
403    /// One line for a person: `missing: Database password` or
404    /// `invalid: Database password (must be a SecureString)`.
405    pub fn summary(&self) -> String {
406        match self.status {
407            DeployerSecretStatus::Present => format!("present: {}", self.label),
408            DeployerSecretStatus::Missing => format!("missing: {}", self.label),
409            DeployerSecretStatus::Invalid => match &self.message {
410                Some(message) => format!("invalid: {} ({message})", self.label),
411                None => format!("invalid: {}", self.label),
412            },
413        }
414    }
415}
416
417/// Env var that carries a natively projected workload's
418/// [`DeployerSecretEnv`] list to its hosting controller, which resolves each
419/// entry before the process starts (a `secretKeyRef` on Kubernetes, a vault
420/// read on the local platform). It holds names only, never values.
421pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
422
423/// An environment variable a workload reads from a vault-native deployer
424/// secret when it starts.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "camelCase")]
427pub struct DeployerSecretEnv {
428    /// Environment variable name.
429    pub name: String,
430    /// Key in the `secrets` vault.
431    pub vault_key: String,
432    /// Full name in the secret store (the Kubernetes Secret on Kubernetes).
433    pub secret_name: String,
434    /// The Azure Key Vault that holds it. Other stores resolve `secret_name`
435    /// in the workload's own account or project.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub vault_name: Option<String>,
438    /// The input's label, for the "missing: <label>" a failed start reports.
439    pub label: String,
440    /// Whether the workload must not start without it.
441    pub required: bool,
442    /// The secret store's version of the value when the workload was
443    /// configured. Overwriting the secret changes it, so the next update
444    /// restarts the workload, which then reads the new value.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub version: Option<String>,
447}
448
449/// The environment variables workloads read from vault-native deployer
450/// secrets, each with the resources its mapping targets (`None` = all).
451///
452/// A slot is read from the vault once Alien has a report for it, unless the
453/// deployment still stores a value from before slots were vault-native: that
454/// value keeps today's path until the control plane drops it. The workload's
455/// profile gains the vault read grant at the same point, so a workload never
456/// reads a slot it may not read.
457pub fn deployer_secret_environment(
458    inputs: &[StackInputDefinition],
459    values: &HashMap<String, serde_json::Value>,
460    stored_secret_input_ids: Option<&[String]>,
461    environment: &[EnvironmentVariable],
462    platform: Platform,
463    reports: &[DeployerSecretReport],
464) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
465    deployer_secret_slots(
466        inputs,
467        values,
468        stored_secret_input_ids,
469        environment,
470        platform,
471    )
472    .into_iter()
473    .filter_map(|slot| {
474        let report = reports
475            .iter()
476            .find(|report| report.input_id == slot.input.id)?;
477        // Only a concrete legacy value supplies delivery. Presence IDs do
478        // not supply a value for a pure deployer slot.
479        (!values
480            .get(&slot.input.id)
481            .is_some_and(|value| !value.is_null())
482            && (!slot.has_stored_value || report.status == DeployerSecretStatus::Present))
483            .then_some((slot, report))
484    })
485    .flat_map(|(slot, report)| {
486        slot.input.env.iter().map(move |mapping| {
487            (
488                DeployerSecretEnv {
489                    name: mapping.name.clone(),
490                    vault_key: slot.vault_key.clone(),
491                    secret_name: report.location.name.clone(),
492                    vault_name: report.location.vault_name.clone(),
493                    label: slot.input.label.clone(),
494                    required: slot.input.required,
495                    version: report.version.clone(),
496                },
497                mapping.target_resources.clone(),
498            )
499        })
500    })
501    .collect()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::StackInputEnvironmentMapping;
508
509    fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
510        StackInputDefinition {
511            id: id.to_string(),
512            kind: StackInputKind::Secret,
513            provided_by,
514            required: true,
515            label: "Database password".to_string(),
516            description: String::new(),
517            placeholder: None,
518            default: None,
519            platforms: None,
520            validation: None,
521            generate: None,
522            env: vec![StackInputEnvironmentMapping {
523                name: "DATABASE_PASSWORD".to_string(),
524                target_resources: None,
525                var_type: None,
526            }],
527        }
528    }
529
530    #[test]
531    fn legacy_omitted_presence_uses_only_complete_scoped_secret_delivery() {
532        let base = serde_json::json!({
533            "environmentVariables": {
534                "variables": [{"name": "DATABASE_PASSWORD", "value": "fixture-value", "type": "secret"}],
535                "hash": "fixture", "createdAt": "2026-01-01T00:00:00Z"
536            }
537        });
538        let legacy: crate::DeploymentConfig = serde_json::from_value(base.clone()).unwrap();
539        assert_eq!(legacy.stored_secret_input_ids, None);
540        let input = secret(
541            "databasePassword",
542            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
543        );
544        for case in [
545            "legacy",
546            "null",
547            "empty",
548            "unrelated",
549            "missing",
550            "plain",
551            "wrong-name",
552            "partial",
553            "narrow",
554            "matching-scope",
555            "broader-scope",
556            "deployer-only",
557            "no-mappings",
558            "plain-mapping",
559            "duplicate-plain",
560            "duplicate-scoped-secret",
561        ] {
562            let mut wire = base.clone();
563            let mut input = input.clone();
564            match case {
565                "null" => wire["storedSecretInputIds"] = serde_json::Value::Null,
566                "empty" => wire["storedSecretInputIds"] = serde_json::json!([]),
567                "unrelated" => wire["storedSecretInputIds"] = serde_json::json!(["other"]),
568                "missing" => wire["environmentVariables"]["variables"] = serde_json::json!([]),
569                "plain" => {
570                    wire["environmentVariables"]["variables"][0]["type"] =
571                        serde_json::json!("plain")
572                }
573                "duplicate-plain" | "duplicate-scoped-secret" => {
574                    let mut duplicate = wire["environmentVariables"]["variables"][0].clone();
575                    if case == "duplicate-plain" {
576                        duplicate["type"] = serde_json::json!("plain");
577                    } else {
578                        input.env[0].target_resources = Some(vec!["app".to_string()]);
579                        wire["environmentVariables"]["variables"][0]["targetResources"] =
580                            serde_json::json!(["app"]);
581                        duplicate["targetResources"] = serde_json::json!(["other"]);
582                    }
583                    wire["environmentVariables"]["variables"]
584                        .as_array_mut()
585                        .unwrap()
586                        .push(duplicate);
587                }
588                "wrong-name" => {
589                    wire["environmentVariables"]["variables"][0]["name"] =
590                        serde_json::json!("OTHER")
591                }
592                "partial" => {
593                    let mut mapping = input.env[0].clone();
594                    mapping.name = "SECOND".to_string();
595                    input.env.push(mapping);
596                }
597                "narrow" => {
598                    wire["environmentVariables"]["variables"][0]["targetResources"] =
599                        serde_json::json!(["app"])
600                }
601                "matching-scope" => {
602                    input.env[0].target_resources = Some(vec!["app*".to_string()]);
603                    wire["environmentVariables"]["variables"][0]["targetResources"] =
604                        serde_json::json!(["app*"]);
605                }
606                "broader-scope" => input.env[0].target_resources = Some(vec!["app".to_string()]),
607                "deployer-only" => input.provided_by = vec![StackInputProvider::Deployer],
608                "no-mappings" => input.env.clear(),
609                "plain-mapping" => {
610                    input.env[0].var_type = Some(StackInputEnvironmentVariableType::Plain)
611                }
612                _ => {}
613            }
614            let config: crate::DeploymentConfig = serde_json::from_value(wire).unwrap();
615            let inputs = [input];
616            let delivered = matches!(case, "legacy" | "null" | "matching-scope" | "broader-scope");
617            let slots = deployer_secret_slots(
618                &inputs,
619                &config.input_values,
620                config.stored_secret_input_ids.as_deref(),
621                &config.environment_variables.variables,
622                Platform::Aws,
623            );
624            assert_eq!(slots.is_empty(), delivered, "{case}");
625            let reports = [report("databasePassword", DeployerSecretStatus::Present)];
626            let pointers = deployer_secret_environment(
627                &inputs,
628                &config.input_values,
629                config.stored_secret_input_ids.as_deref(),
630                &config.environment_variables.variables,
631                Platform::Aws,
632                &reports,
633            );
634            assert_eq!(
635                pointers.len(),
636                if delivered || case == "no-mappings" {
637                    0
638                } else {
639                    inputs[0].env.len()
640                },
641                "{case}"
642            );
643            assert!(config.input_values.is_empty());
644            assert!(is_deployer_secret_input(&inputs[0]));
645        }
646    }
647
648    #[test]
649    fn vault_keys_are_kebab_case_and_backend_safe() {
650        assert_eq!(
651            deployer_secret_vault_key("databasePassword"),
652            "input-database-password"
653        );
654        assert_eq!(deployer_secret_vault_key("api_key"), "input-api-key");
655        assert_eq!(
656            deployer_secret_vault_key("OAuth2Token"),
657            "input-oauth2-token"
658        );
659        assert_eq!(deployer_secret_vault_key("a--b__c"), "input-a-b-c");
660    }
661
662    #[test]
663    fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
664        let inputs = vec![secret(
665            "databasePassword",
666            vec![StackInputProvider::Deployer],
667        )];
668        let stored = HashMap::from([(
669            "databasePassword".to_string(),
670            serde_json::json!("from-before"),
671        )]);
672
673        let slots = deployer_secret_slots(&inputs, &stored, Some(&[]), &[], Platform::Aws);
674        assert_eq!(slots.len(), 1);
675        assert_eq!(slots[0].vault_key, "input-database-password");
676        assert!(slots[0].has_stored_value);
677    }
678
679    #[test]
680    fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
681        let inputs = vec![secret(
682            "databasePassword",
683            vec![StackInputProvider::Developer, StackInputProvider::Deployer],
684        )];
685        let with_developer_value =
686            HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
687
688        assert!(deployer_secret_slots(
689            &inputs,
690            &with_developer_value,
691            Some(&[]),
692            &[],
693            Platform::Aws
694        )
695        .is_empty());
696        assert_eq!(
697            deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Aws).len(),
698            1
699        );
700    }
701
702    #[test]
703    fn developer_secrets_and_other_platforms_are_not_slots() {
704        let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
705        aws_only.platforms = Some(vec![Platform::Aws]);
706        let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
707
708        assert!(
709            deployer_secret_slots(&inputs, &HashMap::new(), Some(&[]), &[], Platform::Gcp)
710                .is_empty()
711        );
712    }
713
714    #[test]
715    fn locations_name_the_same_secret_the_vault_reads() {
716        let context = DeployerSecretLocationContext {
717            aws_region: Some("us-east-1".to_string()),
718            gcp_project_id: Some("acme-prod".to_string()),
719            deployment_name: Some("default".to_string()),
720            ..Default::default()
721        };
722        let key = "input-database-password";
723
724        let aws = deployer_secret_location(
725            &VaultBinding::parameter_store("stack-secrets"),
726            key,
727            &context,
728        )
729        .unwrap();
730        assert_eq!(aws.name, "stack-secrets-input-database-password");
731        assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
732        assert!(aws.cli_command.contains("--type SecureString"));
733        assert!(aws.cli_command.contains("'<VALUE>'"));
734        assert_eq!(
735            aws.delete_command.as_deref(),
736            Some("aws ssm delete-parameter --region us-east-1 --name 'stack-secrets-input-database-password'")
737        );
738
739        let gcp = deployer_secret_location(
740            &VaultBinding::secret_manager("stack-secrets"),
741            key,
742            &context,
743        )
744        .unwrap();
745        assert_eq!(gcp.name, "stack-secrets-input-database-password");
746        assert_eq!(gcp.vault_name, None);
747        assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
748        assert_eq!(
749            gcp.delete_command.as_deref(),
750            Some("gcloud secrets delete stack-secrets-input-database-password --project acme-prod --quiet")
751        );
752
753        // A Key Vault secret name alone does not say which vault holds it, so
754        // the location carries the vault for whoever reads the slot.
755        let azure =
756            deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
757                .unwrap();
758        assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
759        assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
760        assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
761        assert_eq!(
762            azure.delete_command,
763            Some(format!(
764                "az keyvault secret delete --vault-name stacksecrets7f3a --name {}",
765                azure.name
766            ))
767        );
768
769        let kubernetes = deployer_secret_location(
770            &VaultBinding::kubernetes_secret("apps", "Stack-Secrets"),
771            key,
772            &context,
773        )
774        .unwrap();
775        assert_eq!(kubernetes.name, "stack-secrets-input-database-password");
776        assert!(kubernetes.cli_command.contains("--namespace apps"));
777        assert_eq!(
778            kubernetes.delete_command.as_deref(),
779            Some("kubectl delete secret stack-secrets-input-database-password --namespace apps")
780        );
781
782        let local =
783            deployer_secret_location(&VaultBinding::local("secrets", "/tmp/x"), key, &context)
784                .unwrap();
785        assert_eq!(
786            local.cli_command,
787            "alien dev vault --deployment default set secrets input-database-password '<VALUE>'"
788        );
789        assert_eq!(
790            local.delete_command.as_deref(),
791            Some("alien dev vault --deployment default delete secrets input-database-password")
792        );
793    }
794
795    #[test]
796    fn a_template_expression_has_no_location() {
797        let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
798            vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
799        });
800        assert!(deployer_secret_location(
801            &binding,
802            "input-x",
803            &DeployerSecretLocationContext::default()
804        )
805        .is_err());
806    }
807
808    #[test]
809    fn only_required_unfilled_slots_block_start() {
810        let location = deployer_secret_location(
811            &VaultBinding::local("secrets", "/tmp/x"),
812            "input-x",
813            &DeployerSecretLocationContext::default(),
814        )
815        .unwrap();
816        let mut report = DeployerSecretReport {
817            input_id: "x".to_string(),
818            label: "Database password".to_string(),
819            required: true,
820            status: DeployerSecretStatus::Missing,
821            message: None,
822            version: None,
823            location,
824        };
825        assert!(report.blocks_start());
826        assert_eq!(report.summary(), "missing: Database password");
827
828        report.required = false;
829        assert!(!report.blocks_start());
830
831        report.required = true;
832        report.status = DeployerSecretStatus::Present;
833        assert!(!report.blocks_start());
834    }
835
836    fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
837        DeployerSecretReport {
838            input_id: input_id.to_string(),
839            label: "Database password".to_string(),
840            required: true,
841            status,
842            message: None,
843            version: None,
844            location: DeployerSecretLocation {
845                store: DeployerSecretStore::AwsParameterStore,
846                name: "stack-secrets-input-database-password".to_string(),
847                vault_name: None,
848                console_url: None,
849                cli_command: String::new(),
850                delete_command: None,
851            },
852        }
853    }
854
855    #[test]
856    fn a_slot_is_read_from_the_vault_once_it_is_reported() {
857        let inputs = vec![secret(
858            "databasePassword",
859            vec![StackInputProvider::Deployer],
860        )];
861        let no_values = HashMap::new();
862
863        assert!(deployer_secret_environment(
864            &inputs,
865            &no_values,
866            Some(&[]),
867            &[],
868            Platform::Aws,
869            &[]
870        )
871        .is_empty());
872
873        let env = deployer_secret_environment(
874            &inputs,
875            &no_values,
876            Some(&[]),
877            &[],
878            Platform::Aws,
879            &[report("databasePassword", DeployerSecretStatus::Missing)],
880        );
881        assert_eq!(env.len(), 1);
882        let (variable, targets) = &env[0];
883        assert_eq!(variable.name, "DATABASE_PASSWORD");
884        assert_eq!(variable.vault_key, "input-database-password");
885        assert_eq!(
886            variable.secret_name,
887            "stack-secrets-input-database-password"
888        );
889        assert!(variable.required);
890        assert_eq!(variable.vault_name, None);
891        assert_eq!(targets, &None);
892    }
893
894    #[test]
895    fn a_key_vault_slot_names_its_vault_for_the_workload() {
896        let inputs = vec![secret(
897            "databasePassword",
898            vec![StackInputProvider::Deployer],
899        )];
900        let location = deployer_secret_location(
901            &VaultBinding::key_vault("stacksecrets7f3a"),
902            "input-database-password",
903            &DeployerSecretLocationContext::default(),
904        )
905        .unwrap();
906        let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
907        azure_report.location = location;
908
909        let env = deployer_secret_environment(
910            &inputs,
911            &HashMap::new(),
912            Some(&[]),
913            &[],
914            Platform::Azure,
915            &[azure_report],
916        );
917
918        assert_eq!(env.len(), 1);
919        assert_eq!(env[0].0.secret_name, "input-database-password");
920        assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
921    }
922
923    #[test]
924    fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
925        let inputs = vec![secret(
926            "databasePassword",
927            vec![StackInputProvider::Deployer],
928        )];
929        let stored = HashMap::from([(
930            "databasePassword".to_string(),
931            serde_json::json!("from-before"),
932        )]);
933
934        assert!(deployer_secret_environment(
935            &inputs,
936            &stored,
937            Some(&[]),
938            &[],
939            Platform::Aws,
940            &[report("databasePassword", DeployerSecretStatus::Missing)],
941        )
942        .is_empty());
943        assert!(
944            deployer_secret_environment(
945                &inputs,
946                &stored,
947                Some(&[]),
948                &[],
949                Platform::Aws,
950                &[report("databasePassword", DeployerSecretStatus::Present)],
951            )
952            .is_empty(),
953            "a filled slot is not read while the stored value has no vault read grant"
954        );
955        assert_eq!(
956            deployer_secret_environment(
957                &inputs,
958                &HashMap::new(),
959                Some(&[]),
960                &[],
961                Platform::Aws,
962                &[report("databasePassword", DeployerSecretStatus::Present)],
963            )
964            .len(),
965            1
966        );
967    }
968    #[test]
969    fn stored_secret_ids_classify_presence_without_input_values() {
970        for required in [true, false] {
971            let mut input = secret(
972                "apiKey",
973                vec![StackInputProvider::Developer, StackInputProvider::Deployer],
974            );
975            input.required = required;
976            let inputs = vec![input];
977            let values = HashMap::new();
978            let present = vec!["apiKey".to_string()];
979            assert!(
980                deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws)
981                    .is_empty()
982            );
983            for ids in [vec![], vec!["removedInput".to_string()]] {
984                let slots = deployer_secret_slots(&inputs, &values, Some(&ids), &[], Platform::Aws);
985                assert_eq!(slots.len(), 1);
986                assert!(!slots[0].has_stored_value);
987                assert_eq!(slots[0].input.required, required);
988            }
989            let null_value = HashMap::from([("apiKey".to_string(), serde_json::Value::Null)]);
990            assert!(deployer_secret_slots(
991                &inputs,
992                &null_value,
993                Some(&present),
994                &[],
995                Platform::Aws
996            )
997            .is_empty());
998            assert_eq!(
999                deployer_secret_slots(&inputs, &null_value, Some(&[]), &[], Platform::Aws).len(),
1000                1
1001            );
1002            for status in [DeployerSecretStatus::Missing, DeployerSecretStatus::Present] {
1003                assert!(deployer_secret_environment(
1004                    &inputs,
1005                    &values,
1006                    Some(&present),
1007                    &[],
1008                    Platform::Aws,
1009                    &[report("apiKey", status)]
1010                )
1011                .is_empty());
1012            }
1013            assert!(
1014                values.is_empty(),
1015                "presence must not manufacture an input value"
1016            );
1017        }
1018    }
1019
1020    #[test]
1021    fn stored_secret_ids_keep_deployer_only_slots_and_legacy_fallback() {
1022        let inputs = vec![secret("apiKey", vec![StackInputProvider::Deployer])];
1023        let values = HashMap::new();
1024        let present = vec!["apiKey".to_string()];
1025        let slots = deployer_secret_slots(&inputs, &values, Some(&present), &[], Platform::Aws);
1026        assert_eq!(slots.len(), 1);
1027        assert!(slots[0].has_stored_value);
1028        assert!(deployer_secret_environment(
1029            &inputs,
1030            &values,
1031            Some(&present),
1032            &[],
1033            Platform::Aws,
1034            &[report("apiKey", DeployerSecretStatus::Missing)]
1035        )
1036        .is_empty());
1037        let environment = deployer_secret_environment(
1038            &inputs,
1039            &values,
1040            Some(&present),
1041            &[],
1042            Platform::Aws,
1043            &[report("apiKey", DeployerSecretStatus::Present)],
1044        );
1045        assert_eq!(environment.len(), 1);
1046        assert_eq!(environment[0].0.vault_key, "input-api-key");
1047    }
1048
1049    #[test]
1050    fn stored_secret_ids_do_not_change_platform_or_kind_classification() {
1051        let mut input = secret("apiKey", vec![StackInputProvider::Deployer]);
1052        input.platforms = Some(vec![Platform::Aws]);
1053        let inputs = vec![input.clone()];
1054        let present = vec!["apiKey".to_string()];
1055        assert!(deployer_secret_slots(
1056            &inputs,
1057            &HashMap::new(),
1058            Some(&present),
1059            &[],
1060            Platform::Gcp
1061        )
1062        .is_empty());
1063        input.kind = StackInputKind::String;
1064        assert!(deployer_secret_slots(
1065            &[input],
1066            &HashMap::new(),
1067            Some(&present),
1068            &[],
1069            Platform::Aws
1070        )
1071        .is_empty());
1072    }
1073}