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/// CloudFormation `AllowedPattern` for the setup parameter when a template
23/// requires it: empty is refused, so every install and update sets the trust
24/// condition.
25pub const MANAGEMENT_EXTERNAL_ID_REQUIRED_PARAMETER_PATTERN: &str = "^[A-Za-z0-9]{32,128}$";
26
27/// A validated management ExternalId.
28///
29/// `Debug` never prints the value and there is no `Display`, so it cannot end
30/// up in logs or error messages by accident. Read it with [`Self::expose`] at
31/// the AssumeRole call.
32#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(try_from = "String", into = "String")]
34pub struct ManagementExternalId(String);
35
36impl ManagementExternalId {
37    /// Validate a value: 32 to 128 ASCII letters and digits.
38    pub fn new(value: impl Into<String>) -> Result<Self> {
39        let value = value.into();
40        let length_ok = (MANAGEMENT_EXTERNAL_ID_MIN_LENGTH..=MANAGEMENT_EXTERNAL_ID_MAX_LENGTH)
41            .contains(&value.len());
42        if !length_ok || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
43            return Err(AlienError::new(ErrorData::ManagementExternalIdInvalid {
44                length: value.len(),
45            }));
46        }
47        Ok(Self(value))
48    }
49
50    /// The raw value, for the AssumeRole request only.
51    pub fn expose(&self) -> &str {
52        &self.0
53    }
54}
55
56impl std::fmt::Debug for ManagementExternalId {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str("ManagementExternalId([REDACTED])")
59    }
60}
61
62impl TryFrom<String> for ManagementExternalId {
63    type Error = AlienError<ErrorData>;
64
65    fn try_from(value: String) -> Result<Self> {
66        Self::new(value)
67    }
68}
69
70impl From<ManagementExternalId> for String {
71    fn from(value: ManagementExternalId) -> Self {
72        value.0
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    const VALUE: &str = "0123456789abcdef0123456789ABCDEF";
81
82    #[test]
83    fn accepts_the_values_the_template_parameter_accepts() {
84        let pattern = regex::Regex::new(MANAGEMENT_EXTERNAL_ID_PARAMETER_PATTERN).unwrap();
85        for candidate in [
86            VALUE.to_string(),
87            "a".repeat(128),
88            "a".repeat(31),
89            "a".repeat(129),
90            format!("{}-", &VALUE[..31]),
91            format!("{VALUE} "),
92        ] {
93            assert_eq!(
94                ManagementExternalId::new(candidate.clone()).is_ok(),
95                pattern.is_match(&candidate),
96                "validation and the CloudFormation pattern disagree on {} characters",
97                candidate.len()
98            );
99        }
100    }
101
102    #[test]
103    fn the_required_pattern_accepts_exactly_the_valid_values() {
104        let pattern = regex::Regex::new(MANAGEMENT_EXTERNAL_ID_REQUIRED_PARAMETER_PATTERN).unwrap();
105        for candidate in [
106            String::new(),
107            VALUE.to_string(),
108            "a".repeat(128),
109            "a".repeat(31),
110            "a".repeat(129),
111            format!("{}-", &VALUE[..31]),
112            format!("{VALUE} "),
113        ] {
114            assert_eq!(
115                ManagementExternalId::new(candidate.clone()).is_ok(),
116                pattern.is_match(&candidate),
117                "validation and the required CloudFormation pattern disagree on {} characters",
118                candidate.len()
119            );
120        }
121    }
122
123    #[test]
124    fn debug_and_errors_never_contain_the_value() {
125        let id = ManagementExternalId::new(VALUE).unwrap();
126        assert!(!format!("{id:?}").contains(VALUE));
127        assert!(!format!("{:?}", Some(&id)).contains(VALUE));
128
129        let rejected = format!("{VALUE}!");
130        let error = ManagementExternalId::new(rejected.clone()).unwrap_err();
131        assert!(!error.to_string().contains(&rejected));
132        assert!(!format!("{error:?}").contains(VALUE));
133    }
134
135    #[test]
136    fn round_trips_as_a_plain_string_and_validates_on_read() {
137        let id = ManagementExternalId::new(VALUE).unwrap();
138        assert_eq!(serde_json::to_value(&id).unwrap(), serde_json::json!(VALUE));
139        assert_eq!(
140            serde_json::from_value::<ManagementExternalId>(serde_json::json!(VALUE)).unwrap(),
141            id
142        );
143        assert!(
144            serde_json::from_value::<ManagementExternalId>(serde_json::json!("short")).is_err()
145        );
146    }
147}