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