scratchstack_aws_principal/
utils.rs

1use {
2    crate::PrincipalError,
3    std::fmt::{Display, Formatter, Result as FmtResult},
4};
5
6/// IamIdPrefix represents the four character prefix used to identify IAM resources.
7/// See [the unique identifiers section of the IAM identifiers documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html).
8#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum IamIdPrefix {
10    /// The prefix for static IAM access keys: `AKIA`.
11    AccessKey,
12
13    /// The prefix for IAM bearer tokens: `ABIA`.
14    BearerToken,
15
16    /// The prefix for IAM certificates: `ASCA`.
17    Certificate,
18
19    /// The prefix for IAM context-specific credentials: `ACCA`.
20    ContextSpecificCredential,
21
22    /// The prefix for IAM groups: `AGPA`.
23    Group,
24
25    /// The prefix for IAM instance profiles: `AIPA`.
26    InstanceProfile,
27
28    /// The prefix for IAM managed policies: `ANPA`.
29    ManagedPolicy,
30
31    /// The prefix for IAM managed policy versions: `ANVA`.
32    ///
33    /// This does not appear to be used within IAM.
34    ManagedPolicyVersion,
35
36    /// The prefix for IAM public keys: `APKA`.
37    PublicKey,
38
39    /// The prefix for IAM roles: `AROA`.
40    Role,
41
42    /// The prefix for IAM temporary access keys: `ASIA`.
43    TemporaryAccessKey,
44
45    /// The prefix for IAM users: `AIDA`.
46    User,
47}
48
49impl Display for IamIdPrefix {
50    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
51        match self {
52            Self::AccessKey => f.write_str("AKIA"),
53            Self::BearerToken => f.write_str("ABIA"),
54            Self::Certificate => f.write_str("ASCA"),
55            Self::ContextSpecificCredential => f.write_str("ACCA"),
56            Self::Group => f.write_str("AGPA"),
57            Self::InstanceProfile => f.write_str("AIPA"),
58            Self::ManagedPolicy => f.write_str("ANPA"),
59            Self::ManagedPolicyVersion => f.write_str("ANVA"),
60            Self::PublicKey => f.write_str("APKA"),
61            Self::Role => f.write_str("AROA"),
62            Self::TemporaryAccessKey => f.write_str("ASIA"),
63            Self::User => f.write_str("AIDA"),
64        }
65    }
66}
67
68impl AsRef<str> for IamIdPrefix {
69    fn as_ref(&self) -> &str {
70        match self {
71            Self::AccessKey => "AKIA",
72            Self::BearerToken => "ABIA",
73            Self::Certificate => "ASCA",
74            Self::ContextSpecificCredential => "ACCA",
75            Self::Group => "AGPA",
76            Self::InstanceProfile => "AIPA",
77            Self::ManagedPolicy => "ANPA",
78            Self::ManagedPolicyVersion => "ANVA",
79            Self::PublicKey => "APKA",
80            Self::Role => "AROA",
81            Self::TemporaryAccessKey => "ASIA",
82            Self::User => "AIDA",
83        }
84    }
85}
86
87impl IamIdPrefix {
88    /// Returns the IAM ID prefix as a string.
89    pub fn as_str(&self) -> &str {
90        self.as_ref()
91    }
92}
93
94/// Verify that an instance profile, group, role, or user name meets AWS requirements.
95///
96/// The [AWS requirements](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html) are similar for
97/// these names:
98/// *   The name must contain between 1 and `max_length` characters.
99/// *   The name must be composed to ASCII alphanumeric characters or one of `, - . = @ _`.
100///
101/// The `max_length` argument is specified as an argument to this function, but should be
102/// [128 for instance profiles](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateInstanceProfile.html),
103/// [128 for IAM groups](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateGroup.html),
104/// [64 for IAM roles](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html), and
105/// [64 for IAM users](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateUser.html).
106///
107/// If `name` meets these requirements, `Ok(())` is returned. Otherwise, `Err(map_err(name.to_string()))` is returned.
108pub fn validate_name<F: FnOnce(String) -> PrincipalError>(
109    name: &str,
110    max_length: usize,
111    map_err: F,
112) -> Result<(), PrincipalError> {
113    let n_bytes = name.as_bytes();
114    let n_len = n_bytes.len();
115
116    if n_len == 0 || n_len > max_length {
117        return Err(map_err(name.to_string()));
118    }
119
120    // Check that all characters are alphanumeric or , - . = @ _
121    for c in n_bytes {
122        if !(c.is_ascii_alphanumeric()
123            || *c == b','
124            || *c == b'-'
125            || *c == b'.'
126            || *c == b'='
127            || *c == b'@'
128            || *c == b'_')
129        {
130            return Err(map_err(name.to_string()));
131        }
132    }
133
134    Ok(())
135}
136
137/// Verify that an instance profile id, group id, role id, or user id meets AWS requirements.
138///
139/// AWS only stipulates the first four characters of the ID as a type identifier; however, all IDs follow a common
140/// convention of being 20+ character base-32 strings. We enforce the prefix, length, and base-32 requirements here.
141///
142/// If `identifier` meets these requirements, Ok is returned. Otherwise, Err(map_err(id.to_string())) is returned.
143pub fn validate_identifier<F: FnOnce(String) -> PrincipalError>(
144    id: &str,
145    prefix: &str,
146    map_err: F,
147) -> Result<(), PrincipalError> {
148    if !id.starts_with(prefix) || id.len() < 20 {
149        Err(map_err(id.to_string()))
150    } else {
151        for c in id.as_bytes() {
152            // Must be base-32 encoded.
153            if !(c.is_ascii_alphabetic() || (b'2'..=b'7').contains(c)) {
154                return Err(map_err(id.to_string()));
155            }
156        }
157
158        Ok(())
159    }
160}
161
162/// Verify that a path meets AWS requirements.
163///
164/// The [AWS requirements for a path](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html) specify:
165/// *   The path must contain between 1 and 512 characters.
166/// *   The path must start and end with `/`.
167/// *   All characters in the path must be in the ASCII range 0x21 (`!`) through 0x7E (`~`). The AWS documentation
168///     erroneously indicates that 0x7F (DEL) is acceptable; however, the IAM APIs reject this character.
169///
170/// If `path` meets these requirements, Ok. Otherwise, a [PrincipalError::InvalidPath] error is returned.
171pub fn validate_path(path: &str) -> Result<(), PrincipalError> {
172    let p_bytes = path.as_bytes();
173    let p_len = p_bytes.len();
174
175    if p_len == 0 || p_len > 512 {
176        return Err(PrincipalError::InvalidPath(path.to_string()));
177    }
178
179    // Must begin and end with a slash
180    if p_bytes[0] != b'/' || p_bytes[p_len - 1] != b'/' {
181        return Err(PrincipalError::InvalidPath(path.to_string()));
182    }
183
184    // Check that all characters fall in the fange u+0021 - u+007e
185    for c in p_bytes {
186        if *c < 0x21 || *c > 0x7e {
187            return Err(PrincipalError::InvalidPath(path.to_string()));
188        }
189    }
190
191    Ok(())
192}
193
194/// Verify that a DNS name meets Scratchstack requirements.
195///
196/// DNS names may have multiple components separated by a dot (`.`). Each component must be between 1 and 63 characters.
197/// The total length of the name must be less than the `max_length` argument.
198///
199/// Components may contain ASCII alphanumeric characters, hyphens (`-`), and underscores (`_`). A component may not
200/// begin or end with a hyphen, and may not contain two consecutive hyphens.
201pub fn validate_dns<F: FnOnce(String) -> PrincipalError>(
202    name: &str,
203    max_length: usize,
204    map_err: F,
205) -> Result<(), PrincipalError> {
206    let name_bytes = name.as_bytes();
207    if name_bytes.is_empty() || name_bytes.len() > max_length {
208        return Err(map_err(name.to_string()));
209    }
210
211    let components = name_bytes.split(|c| *c == b'.');
212
213    for component in components {
214        if component.is_empty() || component.len() > 63 {
215            return Err(map_err(name.to_string()));
216        }
217
218        let mut last = b'-';
219
220        for c in component.iter() {
221            if *c == b'-' {
222                if last == b'-' {
223                    return Err(map_err(name.to_string()));
224                }
225            } else if !c.is_ascii_alphanumeric() && *c != b'_' {
226                return Err(map_err(name.to_string()));
227            }
228
229            last = *c;
230        }
231
232        if last == b'-' {
233            return Err(map_err(name.to_string()));
234        }
235    }
236
237    Ok(())
238}
239
240#[cfg(test)]
241mod test {
242    use {
243        super::{validate_dns, validate_identifier, validate_name, IamIdPrefix},
244        crate::PrincipalError,
245        std::{
246            collections::hash_map::DefaultHasher,
247            hash::{Hash, Hasher},
248        },
249    };
250
251    #[test]
252    fn check_names() {
253        validate_name("test", 32, PrincipalError::InvalidRoleName).unwrap();
254        validate_name("test,name-.with=exactly@32_chars", 32, PrincipalError::InvalidRoleName).unwrap();
255        assert_eq!(
256            validate_name("bad!name", 32, PrincipalError::InvalidRoleName).unwrap_err().to_string(),
257            r#"Invalid role name: "bad!name""#
258        );
259    }
260
261    fn validate_group_id(id: &str) -> Result<(), PrincipalError> {
262        validate_identifier(id, IamIdPrefix::Group.as_str(), PrincipalError::InvalidGroupId)
263    }
264
265    fn validate_instance_profile_id(id: &str) -> Result<(), PrincipalError> {
266        validate_identifier(id, IamIdPrefix::InstanceProfile.as_str(), PrincipalError::InvalidInstanceProfileId)
267    }
268
269    fn validate_role_id(id: &str) -> Result<(), PrincipalError> {
270        validate_identifier(id, IamIdPrefix::Role.as_str(), PrincipalError::InvalidRoleId)
271    }
272
273    fn validate_user_id(id: &str) -> Result<(), PrincipalError> {
274        validate_identifier(id, IamIdPrefix::User.as_str(), PrincipalError::InvalidUserId)
275    }
276
277    #[test]
278    fn check_identifiers() {
279        validate_group_id("AGPA234567ABCDEFGHIJ").unwrap();
280        let err = validate_group_id("AIDA234567ABCDEFGHIJ").unwrap_err();
281        assert_eq!(err.to_string(), r#"Invalid group id: "AIDA234567ABCDEFGHIJ""#);
282        let err = validate_group_id("AGPA234567ABCDEFGHI!").unwrap_err();
283        assert_eq!(err.to_string(), r#"Invalid group id: "AGPA234567ABCDEFGHI!""#);
284        let err = validate_group_id("AGPA234567ABCDEFGHI").unwrap_err();
285        assert_eq!(err.to_string(), r#"Invalid group id: "AGPA234567ABCDEFGHI""#);
286
287        validate_instance_profile_id("AIPAKLMNOPQRSTUVWXYZ").unwrap();
288        let err = validate_instance_profile_id("AKIAKLMNOPQRSTUVWXYZ").unwrap_err();
289        assert_eq!(err.to_string(), r#"Invalid instance profile id: "AKIAKLMNOPQRSTUVWXYZ""#);
290        let err = validate_instance_profile_id("AIPAKLMNOPQRSTUVWXY!").unwrap_err();
291        assert_eq!(err.to_string(), r#"Invalid instance profile id: "AIPAKLMNOPQRSTUVWXY!""#);
292        let err = validate_instance_profile_id("AIPAKLMNOPQRSTUVWXY").unwrap_err();
293        assert_eq!(err.to_string(), r#"Invalid instance profile id: "AIPAKLMNOPQRSTUVWXY""#);
294
295        validate_role_id("AROAKLMNOPQRSTUVWXYZ").unwrap();
296        let err = validate_role_id("AKIAKLMNOPQRSTUVWXYZ").unwrap_err();
297        assert_eq!(err.to_string(), r#"Invalid role id: "AKIAKLMNOPQRSTUVWXYZ""#);
298        let err = validate_role_id("AROAKLMNOPQRSTUVWXY!").unwrap_err();
299        assert_eq!(err.to_string(), r#"Invalid role id: "AROAKLMNOPQRSTUVWXY!""#);
300        let err = validate_role_id("AROAKLMNOPQRSTUVWXY").unwrap_err();
301        assert_eq!(err.to_string(), r#"Invalid role id: "AROAKLMNOPQRSTUVWXY""#);
302
303        validate_user_id("AIDAKLMNOPQRSTUVWXYZ").unwrap();
304        let err = validate_user_id("AKIAKLMNOPQRSTUVWXYZ").unwrap_err();
305        assert_eq!(err.to_string(), r#"Invalid user id: "AKIAKLMNOPQRSTUVWXYZ""#);
306        let err = validate_user_id("AIDAKLMNOPQRSTUVWXY!").unwrap_err();
307        assert_eq!(err.to_string(), r#"Invalid user id: "AIDAKLMNOPQRSTUVWXY!""#);
308        let err = validate_user_id("AIDAKLMNOPQRSTUVWXY").unwrap_err();
309        assert_eq!(err.to_string(), r#"Invalid user id: "AIDAKLMNOPQRSTUVWXY""#);
310    }
311
312    #[test]
313    fn check_id_prefix_derived() {
314        let prefixes = [
315            IamIdPrefix::AccessKey,
316            IamIdPrefix::BearerToken,
317            IamIdPrefix::Certificate,
318            IamIdPrefix::ContextSpecificCredential,
319            IamIdPrefix::Group,
320            IamIdPrefix::InstanceProfile,
321            IamIdPrefix::ManagedPolicy,
322            IamIdPrefix::ManagedPolicyVersion,
323            IamIdPrefix::PublicKey,
324            IamIdPrefix::Role,
325            IamIdPrefix::TemporaryAccessKey,
326            IamIdPrefix::User,
327        ];
328        let p1a = IamIdPrefix::AccessKey;
329        let p1b = p1a;
330        let p2 = IamIdPrefix::BearerToken;
331        assert_eq!(p1a, p1b);
332        assert_eq!(p1a, p1a.clone());
333        assert_ne!(p1a, p2);
334
335        // Ensure we can hash the enum.
336        let mut h1a = DefaultHasher::new();
337        let mut h1b = DefaultHasher::new();
338        let mut h2 = DefaultHasher::new();
339        p1a.hash(&mut h1a);
340        p1b.hash(&mut h1b);
341        p2.hash(&mut h2);
342        let hash1a = h1a.finish();
343        let hash1b = h1b.finish();
344        let hash2 = h2.finish();
345        assert_eq!(hash1a, hash1b);
346        assert_ne!(hash1a, hash2);
347
348        // Ensure the ordering is logical and we can print each one.
349        for i in 0..prefixes.len() {
350            for j in i + 1..prefixes.len() {
351                assert!(prefixes[i] < prefixes[j]);
352                assert!(prefixes[j] > prefixes[i]);
353                assert_eq!(prefixes[i].max(prefixes[j]), prefixes[j]);
354            }
355
356            let _ = format!("{:?}", prefixes[i]);
357            assert_eq!(prefixes[i].to_string().as_str(), prefixes[i].as_ref());
358        }
359    }
360
361    #[test]
362    fn check_access_key() {
363        // Miscellaneous bits for AKIA/access key.
364        assert_eq!(IamIdPrefix::AccessKey.as_ref(), "AKIA");
365        assert_eq!(format!("{}", IamIdPrefix::AccessKey).as_str(), "AKIA");
366    }
367
368    #[test]
369    fn check_dns() {
370        validate_dns("exa_mple.com", 256, PrincipalError::InvalidService).unwrap();
371        let e = validate_dns("exa_mple.com.", 256, PrincipalError::InvalidService).unwrap_err();
372        assert_eq!(e.to_string(), r#"Invalid service name: "exa_mple.com.""#);
373        let e = validate_dns("example.com", 5, PrincipalError::InvalidService).unwrap_err();
374        assert_eq!(e.to_string(), r#"Invalid service name: "example.com""#);
375        validate_dns("exam-ple.com", 256, PrincipalError::InvalidService).unwrap();
376        validate_dns("exam--ple.com", 256, PrincipalError::InvalidService).unwrap_err();
377        validate_dns("-example.com", 256, PrincipalError::InvalidService).unwrap_err();
378        validate_dns("example-.com", 256, PrincipalError::InvalidService).unwrap_err();
379    }
380}
381// end tests -- do not delete; needed for coverage.