1use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18 vault_naming, BindingValue, EnvironmentVariablesSnapshot, Platform, StackInputDefinition,
19 StackInputKind, StackInputProvider, VaultBinding,
20};
21
22pub const DEPLOYER_SECRET_KEY_PREFIX: &str = "input-";
25
26pub const DEPLOYER_SECRET_VALUE_PLACEHOLDER: &str = "<VALUE>";
28
29pub fn deployer_secret_vault_key(input_id: &str) -> String {
35 let mut key = String::from(DEPLOYER_SECRET_KEY_PREFIX);
36 let mut previous_lower_or_digit = false;
37 let mut pending_separator = false;
38 for character in input_id.chars() {
39 if character.is_ascii_alphanumeric() {
40 let starts_word = character.is_ascii_uppercase() && previous_lower_or_digit;
41 if (pending_separator || starts_word) && !key.ends_with('-') {
42 key.push('-');
43 }
44 key.push(character.to_ascii_lowercase());
45 previous_lower_or_digit = character.is_ascii_lowercase() || character.is_ascii_digit();
46 pending_separator = false;
47 } else {
48 pending_separator = true;
49 previous_lower_or_digit = false;
50 }
51 }
52 key.trim_end_matches('-').to_string()
53}
54
55pub fn is_deployer_secret_input(input: &StackInputDefinition) -> bool {
57 input.kind == StackInputKind::Secret
58 && input.provided_by.contains(&StackInputProvider::Deployer)
59}
60
61pub fn deployer_secret_value_refusal(name: &str) -> String {
64 format!(
65 "'{name}' is a deployer secret: its value goes into your own secret store and never \
66 through Alien. Do not pass it here; write it into the deployment's secrets vault \
67 instead (the deployment status shows the secret's name and the command that writes it)."
68 )
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DeployerSecretSlot<'a> {
74 pub input: &'a StackInputDefinition,
76 pub vault_key: String,
78 pub has_stored_value: bool,
81}
82
83pub fn stored_input_values(
92 inputs: &[StackInputDefinition],
93 values: &HashMap<String, serde_json::Value>,
94 environment: &EnvironmentVariablesSnapshot,
95) -> HashMap<String, serde_json::Value> {
96 let mut stored = values.clone();
97 for input in inputs {
98 if input.kind != StackInputKind::Secret
99 || input.env.is_empty()
100 || stored.get(&input.id).is_some_and(|value| !value.is_null())
101 {
102 continue;
103 }
104 let delivered = input.env.iter().all(|mapping| {
105 environment
106 .variables
107 .iter()
108 .any(|variable| variable.name == mapping.name)
109 });
110 if delivered {
111 stored.insert(input.id.clone(), serde_json::Value::Bool(true));
112 }
113 }
114 stored
115}
116
117pub fn deployer_secret_slots<'a>(
123 inputs: &'a [StackInputDefinition],
124 values: &HashMap<String, serde_json::Value>,
125 platform: Platform,
126) -> Vec<DeployerSecretSlot<'a>> {
127 inputs
128 .iter()
129 .filter(|input| is_deployer_secret_input(input))
130 .filter(|input| {
131 input
132 .platforms
133 .as_ref()
134 .is_none_or(|platforms| platforms.is_empty() || platforms.contains(&platform))
135 })
136 .filter_map(|input| {
137 let has_stored_value = values.get(&input.id).is_some_and(|value| !value.is_null());
138 let developer_value =
139 has_stored_value && input.provided_by.contains(&StackInputProvider::Developer);
140 (!developer_value).then(|| DeployerSecretSlot {
141 input,
142 vault_key: deployer_secret_vault_key(&input.id),
143 has_stored_value,
144 })
145 })
146 .collect()
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
152#[serde(rename_all = "kebab-case")]
153pub enum DeployerSecretStore {
154 AwsParameterStore,
156 GcpSecretManager,
158 AzureKeyVault,
160 KubernetesSecret,
162 LocalVault,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[serde(rename_all = "camelCase")]
170pub struct DeployerSecretLocation {
171 pub store: DeployerSecretStore,
173 pub name: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub vault_name: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub console_url: Option<String>,
182 pub cli_command: String,
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct DeployerSecretLocationContext {
189 pub aws_region: Option<String>,
191 pub gcp_project_id: Option<String>,
193 pub azure_subscription_id: Option<String>,
195 pub azure_resource_group: Option<String>,
197 pub deployment_name: Option<String>,
199}
200
201pub fn deployer_secret_location(
204 binding: &VaultBinding,
205 vault_key: &str,
206 context: &DeployerSecretLocationContext,
207) -> crate::Result<DeployerSecretLocation> {
208 let placeholder = DEPLOYER_SECRET_VALUE_PLACEHOLDER;
209 let location = match binding {
210 VaultBinding::ParameterStore(binding) => {
211 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
212 let name = vault_naming::parameter_store_parameter_name(&prefix, vault_key);
213 let region = context.aws_region.as_deref();
214 let region_flag = region
215 .map(|region| format!(" --region {region}"))
216 .unwrap_or_default();
217 DeployerSecretLocation {
218 store: DeployerSecretStore::AwsParameterStore,
219 vault_name: None,
220 console_url: region.map(|region| {
221 format!(
222 "https://{region}.console.aws.amazon.com/systems-manager/parameters/create?region={region}"
223 )
224 }),
225 cli_command: format!(
226 "aws ssm put-parameter{region_flag} --name '{name}' --type SecureString --overwrite --value '{placeholder}'"
227 ),
228 name,
229 }
230 }
231 VaultBinding::SecretManager(binding) => {
232 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
233 let name = vault_naming::secret_manager_secret_id(&prefix, vault_key);
234 let project = context.gcp_project_id.as_deref();
235 let project_flag = project
236 .map(|project| format!(" --project {project}"))
237 .unwrap_or_default();
238 DeployerSecretLocation {
239 store: DeployerSecretStore::GcpSecretManager,
240 vault_name: None,
241 console_url: project.map(|project| {
242 format!(
243 "https://console.cloud.google.com/security/secret-manager/create?project={project}"
244 )
245 }),
246 cli_command: format!(
249 "(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=-"
250 ),
251 name,
252 }
253 }
254 VaultBinding::KeyVault(binding) => {
255 let vault_name = concrete(&binding.vault_name, "vaultName")?;
256 let name = vault_naming::key_vault_secret_name(vault_key);
257 DeployerSecretLocation {
258 store: DeployerSecretStore::AzureKeyVault,
259 vault_name: Some(vault_name.clone()),
260 console_url: match (
261 context.azure_subscription_id.as_deref(),
262 context.azure_resource_group.as_deref(),
263 ) {
264 (Some(subscription), Some(resource_group)) => Some(format!(
265 "https://portal.azure.com/#@/resource/subscriptions/{subscription}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}/secrets"
266 )),
267 _ => None,
268 },
269 cli_command: format!(
270 "az keyvault secret set --vault-name {vault_name} --name {name} --value '{placeholder}'"
271 ),
272 name,
273 }
274 }
275 VaultBinding::KubernetesSecret(binding) => {
276 let prefix = concrete(&binding.vault_prefix, "vaultPrefix")?;
277 let namespace = concrete(&binding.namespace, "namespace")?;
278 let name = vault_naming::kubernetes_secret_name(&prefix, vault_key);
279 DeployerSecretLocation {
280 store: DeployerSecretStore::KubernetesSecret,
281 vault_name: None,
282 console_url: None,
283 cli_command: format!(
284 "kubectl create secret generic {name} --namespace {namespace} --from-literal={}='{placeholder}'",
285 vault_naming::KUBERNETES_SECRET_VALUE_KEY
286 ),
287 name,
288 }
289 }
290 VaultBinding::Local(binding) => {
291 let deployment_flag = context
292 .deployment_name
293 .as_deref()
294 .map(|name| format!(" --deployment {name}"))
295 .unwrap_or_default();
296 DeployerSecretLocation {
297 store: DeployerSecretStore::LocalVault,
298 vault_name: None,
299 console_url: None,
300 cli_command: format!(
301 "alien dev vault{deployment_flag} set {} {vault_key} '{placeholder}'",
302 binding.vault_name
303 ),
304 name: vault_key.to_string(),
305 }
306 }
307 };
308 Ok(location)
309}
310
311fn concrete(value: &BindingValue<String>, field: &str) -> crate::Result<String> {
312 value.clone().into_value(crate::SECRETS_VAULT_ID, field)
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
319#[serde(rename_all = "kebab-case")]
320pub enum DeployerSecretStatus {
321 Present,
323 Missing,
325 Invalid,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
333#[serde(rename_all = "camelCase")]
334pub struct DeployerSecretReport {
335 pub input_id: String,
337 pub label: String,
339 pub required: bool,
341 pub status: DeployerSecretStatus,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub message: Option<String>,
346 pub location: DeployerSecretLocation,
348}
349
350impl DeployerSecretReport {
351 pub fn blocks_start(&self) -> bool {
353 self.required && self.status != DeployerSecretStatus::Present
354 }
355
356 pub fn summary(&self) -> String {
359 match self.status {
360 DeployerSecretStatus::Present => format!("present: {}", self.label),
361 DeployerSecretStatus::Missing => format!("missing: {}", self.label),
362 DeployerSecretStatus::Invalid => match &self.message {
363 Some(message) => format!("invalid: {} ({message})", self.label),
364 None => format!("invalid: {}", self.label),
365 },
366 }
367 }
368}
369
370pub const ENV_ALIEN_DEPLOYER_SECRETS: &str = "ALIEN_DEPLOYER_SECRETS";
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase")]
380pub struct DeployerSecretEnv {
381 pub name: String,
383 pub vault_key: String,
385 pub secret_name: String,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub vault_name: Option<String>,
391 pub label: String,
393 pub required: bool,
395}
396
397pub fn deployer_secret_environment(
406 inputs: &[StackInputDefinition],
407 values: &HashMap<String, serde_json::Value>,
408 platform: Platform,
409 reports: &[DeployerSecretReport],
410) -> Vec<(DeployerSecretEnv, Option<Vec<String>>)> {
411 deployer_secret_slots(inputs, values, platform)
412 .into_iter()
413 .filter_map(|slot| {
414 let report = reports
415 .iter()
416 .find(|report| report.input_id == slot.input.id)?;
417 (!slot.has_stored_value).then_some((slot, report))
418 })
419 .flat_map(|(slot, report)| {
420 slot.input.env.iter().map(move |mapping| {
421 (
422 DeployerSecretEnv {
423 name: mapping.name.clone(),
424 vault_key: slot.vault_key.clone(),
425 secret_name: report.location.name.clone(),
426 vault_name: report.location.vault_name.clone(),
427 label: slot.input.label.clone(),
428 required: slot.input.required,
429 },
430 mapping.target_resources.clone(),
431 )
432 })
433 })
434 .collect()
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::StackInputEnvironmentMapping;
441
442 fn secret(id: &str, provided_by: Vec<StackInputProvider>) -> StackInputDefinition {
443 StackInputDefinition {
444 id: id.to_string(),
445 kind: StackInputKind::Secret,
446 provided_by,
447 required: true,
448 label: "Database password".to_string(),
449 description: String::new(),
450 placeholder: None,
451 default: None,
452 platforms: None,
453 validation: None,
454 generate: None,
455 env: vec![StackInputEnvironmentMapping {
456 name: "DATABASE_PASSWORD".to_string(),
457 target_resources: None,
458 var_type: None,
459 }],
460 }
461 }
462
463 #[test]
464 fn vault_keys_are_kebab_case_and_backend_safe() {
465 assert_eq!(
466 deployer_secret_vault_key("databasePassword"),
467 "input-database-password"
468 );
469 assert_eq!(deployer_secret_vault_key("api_key"), "input-api-key");
470 assert_eq!(
471 deployer_secret_vault_key("OAuth2Token"),
472 "input-oauth2-token"
473 );
474 assert_eq!(deployer_secret_vault_key("a--b__c"), "input-a-b-c");
475 }
476
477 #[test]
478 fn deployer_only_secrets_are_slots_even_with_a_stored_value() {
479 let inputs = vec![secret(
480 "databasePassword",
481 vec![StackInputProvider::Deployer],
482 )];
483 let stored = HashMap::from([(
484 "databasePassword".to_string(),
485 serde_json::json!("from-before"),
486 )]);
487
488 let slots = deployer_secret_slots(&inputs, &stored, Platform::Aws);
489 assert_eq!(slots.len(), 1);
490 assert_eq!(slots[0].vault_key, "input-database-password");
491 assert!(slots[0].has_stored_value);
492 }
493
494 #[test]
495 fn a_developer_value_keeps_a_dual_provided_secret_off_the_vault_path() {
496 let inputs = vec![secret(
497 "databasePassword",
498 vec![StackInputProvider::Developer, StackInputProvider::Deployer],
499 )];
500 let with_developer_value =
501 HashMap::from([("databasePassword".to_string(), serde_json::json!("dev"))]);
502
503 assert!(deployer_secret_slots(&inputs, &with_developer_value, Platform::Aws).is_empty());
504 assert_eq!(
505 deployer_secret_slots(&inputs, &HashMap::new(), Platform::Aws).len(),
506 1
507 );
508 }
509
510 #[test]
515 fn a_secret_delivered_through_its_variables_counts_as_stored() {
516 let inputs = vec![secret(
517 "databasePassword",
518 vec![StackInputProvider::Developer, StackInputProvider::Deployer],
519 )];
520 let snapshot = |names: &[&str]| EnvironmentVariablesSnapshot {
521 variables: names
522 .iter()
523 .map(|name| crate::EnvironmentVariable {
524 name: name.to_string(),
525 value: "dev".to_string(),
526 var_type: crate::EnvironmentVariableType::Secret,
527 target_resources: None,
528 })
529 .collect(),
530 hash: String::new(),
531 created_at: String::new(),
532 };
533
534 let delivered =
535 stored_input_values(&inputs, &HashMap::new(), &snapshot(&["DATABASE_PASSWORD"]));
536 assert!(deployer_secret_slots(&inputs, &delivered, Platform::Aws).is_empty());
537
538 let not_delivered = stored_input_values(&inputs, &HashMap::new(), &snapshot(&["OTHER"]));
539 assert_eq!(
540 deployer_secret_slots(&inputs, ¬_delivered, Platform::Aws).len(),
541 1
542 );
543 }
544
545 #[test]
546 fn developer_secrets_and_other_platforms_are_not_slots() {
547 let mut aws_only = secret("token", vec![StackInputProvider::Deployer]);
548 aws_only.platforms = Some(vec![Platform::Aws]);
549 let inputs = vec![secret("key", vec![StackInputProvider::Developer]), aws_only];
550
551 assert!(deployer_secret_slots(&inputs, &HashMap::new(), Platform::Gcp).is_empty());
552 }
553
554 #[test]
555 fn locations_name_the_same_secret_the_vault_reads() {
556 let context = DeployerSecretLocationContext {
557 aws_region: Some("us-east-1".to_string()),
558 gcp_project_id: Some("acme-prod".to_string()),
559 deployment_name: Some("default".to_string()),
560 ..Default::default()
561 };
562 let key = "input-database-password";
563
564 let aws = deployer_secret_location(
565 &VaultBinding::parameter_store("stack-secrets"),
566 key,
567 &context,
568 )
569 .unwrap();
570 assert_eq!(aws.name, "stack-secrets-input-database-password");
571 assert_eq!(aws.store, DeployerSecretStore::AwsParameterStore);
572 assert!(aws.cli_command.contains("--type SecureString"));
573 assert!(aws.cli_command.contains("'<VALUE>'"));
574
575 let gcp = deployer_secret_location(
576 &VaultBinding::secret_manager("stack-secrets"),
577 key,
578 &context,
579 )
580 .unwrap();
581 assert_eq!(gcp.name, "stack-secrets-input-database-password");
582 assert_eq!(gcp.vault_name, None);
583 assert!(gcp.console_url.unwrap().contains("project=acme-prod"));
584
585 let azure =
588 deployer_secret_location(&VaultBinding::key_vault("stacksecrets7f3a"), key, &context)
589 .unwrap();
590 assert_eq!(azure.store, DeployerSecretStore::AzureKeyVault);
591 assert_eq!(azure.vault_name.as_deref(), Some("stacksecrets7f3a"));
592 assert!(azure.cli_command.contains("--vault-name stacksecrets7f3a"));
593
594 let kubernetes = deployer_secret_location(
595 &VaultBinding::kubernetes_secret("apps", "Stack-Secrets"),
596 key,
597 &context,
598 )
599 .unwrap();
600 assert_eq!(kubernetes.name, "stack-secrets-input-database-password");
601 assert!(kubernetes.cli_command.contains("--namespace apps"));
602
603 let local =
604 deployer_secret_location(&VaultBinding::local("secrets", "/tmp/x"), key, &context)
605 .unwrap();
606 assert_eq!(
607 local.cli_command,
608 "alien dev vault --deployment default set secrets input-database-password '<VALUE>'"
609 );
610 }
611
612 #[test]
613 fn a_template_expression_has_no_location() {
614 let binding = VaultBinding::ParameterStore(crate::ParameterStoreVaultBinding {
615 vault_prefix: BindingValue::expression(serde_json::json!({"Ref": "Prefix"})),
616 });
617 assert!(deployer_secret_location(
618 &binding,
619 "input-x",
620 &DeployerSecretLocationContext::default()
621 )
622 .is_err());
623 }
624
625 #[test]
626 fn only_required_unfilled_slots_block_start() {
627 let location = deployer_secret_location(
628 &VaultBinding::local("secrets", "/tmp/x"),
629 "input-x",
630 &DeployerSecretLocationContext::default(),
631 )
632 .unwrap();
633 let mut report = DeployerSecretReport {
634 input_id: "x".to_string(),
635 label: "Database password".to_string(),
636 required: true,
637 status: DeployerSecretStatus::Missing,
638 message: None,
639 location,
640 };
641 assert!(report.blocks_start());
642 assert_eq!(report.summary(), "missing: Database password");
643
644 report.required = false;
645 assert!(!report.blocks_start());
646
647 report.required = true;
648 report.status = DeployerSecretStatus::Present;
649 assert!(!report.blocks_start());
650 }
651
652 fn report(input_id: &str, status: DeployerSecretStatus) -> DeployerSecretReport {
653 DeployerSecretReport {
654 input_id: input_id.to_string(),
655 label: "Database password".to_string(),
656 required: true,
657 status,
658 message: None,
659 location: DeployerSecretLocation {
660 store: DeployerSecretStore::AwsParameterStore,
661 name: "stack-secrets-input-database-password".to_string(),
662 vault_name: None,
663 console_url: None,
664 cli_command: String::new(),
665 },
666 }
667 }
668
669 #[test]
670 fn a_slot_is_read_from_the_vault_once_it_is_reported() {
671 let inputs = vec![secret(
672 "databasePassword",
673 vec![StackInputProvider::Deployer],
674 )];
675 let no_values = HashMap::new();
676
677 assert!(deployer_secret_environment(&inputs, &no_values, Platform::Aws, &[]).is_empty());
678
679 let env = deployer_secret_environment(
680 &inputs,
681 &no_values,
682 Platform::Aws,
683 &[report("databasePassword", DeployerSecretStatus::Missing)],
684 );
685 assert_eq!(env.len(), 1);
686 let (variable, targets) = &env[0];
687 assert_eq!(variable.name, "DATABASE_PASSWORD");
688 assert_eq!(variable.vault_key, "input-database-password");
689 assert_eq!(
690 variable.secret_name,
691 "stack-secrets-input-database-password"
692 );
693 assert!(variable.required);
694 assert_eq!(variable.vault_name, None);
695 assert_eq!(targets, &None);
696 }
697
698 #[test]
699 fn a_key_vault_slot_names_its_vault_for_the_workload() {
700 let inputs = vec![secret(
701 "databasePassword",
702 vec![StackInputProvider::Deployer],
703 )];
704 let location = deployer_secret_location(
705 &VaultBinding::key_vault("stacksecrets7f3a"),
706 "input-database-password",
707 &DeployerSecretLocationContext::default(),
708 )
709 .unwrap();
710 let mut azure_report = report("databasePassword", DeployerSecretStatus::Present);
711 azure_report.location = location;
712
713 let env =
714 deployer_secret_environment(&inputs, &HashMap::new(), Platform::Azure, &[azure_report]);
715
716 assert_eq!(env.len(), 1);
717 assert_eq!(env[0].0.secret_name, "input-database-password");
718 assert_eq!(env[0].0.vault_name.as_deref(), Some("stacksecrets7f3a"));
719 }
720
721 #[test]
722 fn a_stored_value_keeps_today_s_path_until_it_is_dropped() {
723 let inputs = vec![secret(
724 "databasePassword",
725 vec![StackInputProvider::Deployer],
726 )];
727 let stored = HashMap::from([(
728 "databasePassword".to_string(),
729 serde_json::json!("from-before"),
730 )]);
731
732 assert!(deployer_secret_environment(
733 &inputs,
734 &stored,
735 Platform::Aws,
736 &[report("databasePassword", DeployerSecretStatus::Missing)],
737 )
738 .is_empty());
739 assert!(
740 deployer_secret_environment(
741 &inputs,
742 &stored,
743 Platform::Aws,
744 &[report("databasePassword", DeployerSecretStatus::Present)],
745 )
746 .is_empty(),
747 "a filled slot is not read while the stored value has no vault read grant"
748 );
749 assert_eq!(
750 deployer_secret_environment(
751 &inputs,
752 &HashMap::new(),
753 Platform::Aws,
754 &[report("databasePassword", DeployerSecretStatus::Present)],
755 )
756 .len(),
757 1
758 );
759 }
760}