1use 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
23pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
25
26pub 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
52pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
54 input.kind == StackInputKind::Secret
55 && input.provided_by.contains(&StackInputProvider::Deployer)
56}
57
58pub 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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct DeployerSecretSlot<'a> {
71 pub input: &'a StackInputDefinition,
73 pub vault_key: String,
75 pub has_stored_value: bool,
78}
79
80pub 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
117fn 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 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#[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 AwsParameterStore,
170 GcpSecretManager,
172 AzureKeyVault,
174 KubernetesSecret,
176 LocalVault,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183#[serde(rename_all = "camelCase")]
184pub struct DeployerSecretCommandVariants {
185 pub inline: String,
187 pub prompt: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
194#[serde(rename_all = "camelCase")]
195pub struct DeployerSecretCommands {
196 pub unix: DeployerSecretCommandVariants,
198 pub windows: DeployerSecretCommandVariants,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub target: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub check_command: Option<String>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
211#[serde(rename_all = "camelCase")]
212pub struct DeployerSecretLocation {
213 pub store: DeployerSecretStore,
215 pub name: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub vault_name: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub console_url: Option<String>,
224 pub cli_command: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub commands: Option<DeployerSecretCommands>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub delete_command: Option<String>,
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq)]
237pub struct DeployerSecretLocationContext {
238 pub secret_label: Option<String>,
240 pub aws_account_id: Option<String>,
242 pub aws_region: Option<String>,
244 pub gcp_project_id: Option<String>,
246 pub azure_subscription_id: Option<String>,
248 pub azure_resource_group: Option<String>,
250 pub deployment_name: Option<String>,
252}
253
254pub 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 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 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
453fn 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 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#[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 Present,
587 Missing,
589 Invalid,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
597#[serde(rename_all = "camelCase")]
598pub struct DeployerSecretReport {
599 pub input_id: String,
601 pub label: String,
603 pub required: bool,
605 pub status: DeployerSecretStatus,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub message: Option<String>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub version: Option<String>,
614 pub location: DeployerSecretLocation,
616}
617
618impl DeployerSecretReport {
619 pub fn blocks_start(&self) -> bool {
621 self.required && self.status != DeployerSecretStatus::Present
622 }
623
624 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
638pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647#[serde(rename_all = "camelCase")]
648pub struct DeployerSecretEnv {
649 pub name: String,
651 pub vault_key: String,
653 pub secret_name: String,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub vault_name: Option<String>,
659 pub label: String,
661 pub required: bool,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub version: Option<String>,
668}
669
670pub 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 .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 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}