Skip to main content

alien_core/
management_external_id.rs

1//! The `sts:ExternalId` a control plane sends when it assumes a deployment's
2//! AWS management roles.
3//!
4//! A control plane that manages many customers through one IAM identity is a
5//! confused deputy unless each customer's role demands a value only that
6//! customer's installation knows. The control plane generates the value, the
7//! customer's setup template puts it in the role trust policy, and every
8//! AssumeRole of that role sends it.
9
10use crate::{ErrorData, Result};
11use alien_error::AlienError;
12use serde::{Deserialize, Serialize};
13
14/// Shortest accepted value. Long enough that a random value cannot be guessed.
15pub const MANAGEMENT_EXTERNAL_ID_MIN_LENGTH: usize = 32;
16/// Longest accepted value.
17pub const MANAGEMENT_EXTERNAL_ID_MAX_LENGTH: usize = 128;
18/// CloudFormation `AllowedPattern` for the setup parameter. Empty keeps the
19/// role trust unchanged, so stacks created before the parameter existed can be
20/// updated without one.
21pub const MANAGEMENT_EXTERNAL_ID_PARAMETER_PATTERN: &str = "^$|^[A-Za-z0-9]{32,128}$";
22
23/// A validated management ExternalId.
24///
25/// `Debug` never prints the value and there is no `Display`, so it cannot end
26/// up in logs or error messages by accident. Read it with [`Self::expose`] at
27/// the AssumeRole call.
28#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(try_from = "String", into = "String")]
30pub struct ManagementExternalId(String);
31
32impl ManagementExternalId {
33    /// Validate a value: 32 to 128 ASCII letters and digits.
34    pub fn new(value: impl Into<String>) -> Result<Self> {
35        let value = value.into();
36        let length_ok = (MANAGEMENT_EXTERNAL_ID_MIN_LENGTH..=MANAGEMENT_EXTERNAL_ID_MAX_LENGTH)
37            .contains(&value.len());
38        if !length_ok || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
39            return Err(AlienError::new(ErrorData::ManagementExternalIdInvalid {
40                length: value.len(),
41            }));
42        }
43        Ok(Self(value))
44    }
45
46    /// The raw value, for the AssumeRole request only.
47    pub fn expose(&self) -> &str {
48        &self.0
49    }
50}
51
52impl std::fmt::Debug for ManagementExternalId {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.write_str("ManagementExternalId([REDACTED])")
55    }
56}
57
58impl TryFrom<String> for ManagementExternalId {
59    type Error = AlienError<ErrorData>;
60
61    fn try_from(value: String) -> Result<Self> {
62        Self::new(value)
63    }
64}
65
66impl From<ManagementExternalId> for String {
67    fn from(value: ManagementExternalId) -> Self {
68        value.0
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    const VALUE: &str = "0123456789abcdef0123456789ABCDEF";
77
78    #[test]
79    fn accepts_the_values_the_template_parameter_accepts() {
80        let pattern = regex::Regex::new(MANAGEMENT_EXTERNAL_ID_PARAMETER_PATTERN).unwrap();
81        for candidate in [
82            VALUE.to_string(),
83            "a".repeat(128),
84            "a".repeat(31),
85            "a".repeat(129),
86            format!("{}-", &VALUE[..31]),
87            format!("{VALUE} "),
88        ] {
89            assert_eq!(
90                ManagementExternalId::new(candidate.clone()).is_ok(),
91                pattern.is_match(&candidate),
92                "validation and the CloudFormation pattern disagree on {} characters",
93                candidate.len()
94            );
95        }
96    }
97
98    #[test]
99    fn debug_and_errors_never_contain_the_value() {
100        let id = ManagementExternalId::new(VALUE).unwrap();
101        assert!(!format!("{id:?}").contains(VALUE));
102        assert!(!format!("{:?}", Some(&id)).contains(VALUE));
103
104        let rejected = format!("{VALUE}!");
105        let error = ManagementExternalId::new(rejected.clone()).unwrap_err();
106        assert!(!error.to_string().contains(&rejected));
107        assert!(!format!("{error:?}").contains(VALUE));
108    }
109
110    #[test]
111    fn round_trips_as_a_plain_string_and_validates_on_read() {
112        let id = ManagementExternalId::new(VALUE).unwrap();
113        assert_eq!(serde_json::to_value(&id).unwrap(), serde_json::json!(VALUE));
114        assert_eq!(
115            serde_json::from_value::<ManagementExternalId>(serde_json::json!(VALUE)).unwrap(),
116            id
117        );
118        assert!(
119            serde_json::from_value::<ManagementExternalId>(serde_json::json!("short")).is_err()
120        );
121    }
122}