scratchstack_aws_principal/
user.rs

1use {
2    crate::{
3        utils::{validate_name, validate_path},
4        PrincipalError,
5    },
6    scratchstack_arn::{
7        utils::{validate_account_id, validate_partition},
8        Arn,
9    },
10    std::{
11        fmt::{Display, Formatter, Result as FmtResult},
12        str::FromStr,
13    },
14};
15
16/// Details about an AWS IAM user.
17///
18/// User structs are immutable.
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct User {
21    /// The partition this principal exists in.
22    partition: String,
23
24    /// The account id.
25    account_id: String,
26
27    /// Path, starting with a `/`.
28    path: String,
29
30    /// Name of the principal, case-insensitive.
31    user_name: String,
32}
33
34impl User {
35    /// Create a [User] object.
36    ///
37    /// # Arguments
38    ///
39    /// * `account_id`: The 12 digit account id. This must be composed of 12 ASCII digits or a
40    ///     [PrincipalError::InvalidAccountId] error will be returned.
41    /// * `path`: The IAM path the group is under. This must meet the following requirements or a
42    ///     [PrincipalError::InvalidPath] error will be returned:
43    ///     *   The path must contain between 1 and 512 characters.
44    ///     *   The path must start and end with `/`.
45    ///     *   All characters in the path must be in the ASCII range 0x21 (`!`) through 0x7E (`~`). The AWS documentation
46    ///         erroneously indicates that 0x7F (DEL) is acceptable; however, the IAM APIs reject this character.
47    /// * `user_name`: The name of the user. This must meet the following requirements or a
48    ///     [PrincipalError::InvalidUserName] error will be returned:
49    ///     *   The name must contain between 1 and 64 characters.
50    ///     *   The name must be composed to ASCII alphanumeric characters or one of `, - . = @ _`.
51    ///
52    /// # Return value
53    ///
54    /// If all of the requirements are met, a [User] object is returned. Otherwise, a [PrincipalError] error
55    /// is returned.
56    pub fn new(partition: &str, account_id: &str, path: &str, user_name: &str) -> Result<Self, PrincipalError> {
57        validate_partition(partition)?;
58        validate_account_id(account_id)?;
59        validate_path(path)?;
60        validate_name(user_name, 64, PrincipalError::InvalidUserName)?;
61
62        Ok(Self {
63            partition: partition.into(),
64            account_id: account_id.into(),
65            path: path.into(),
66            user_name: user_name.into(),
67        })
68    }
69
70    /// The partition of the user.
71    #[inline]
72    pub fn partition(&self) -> &str {
73        &self.partition
74    }
75
76    /// The account ID of the user.
77    #[inline]
78    pub fn account_id(&self) -> &str {
79        &self.account_id
80    }
81
82    /// The path of the user.
83    #[inline]
84    pub fn path(&self) -> &str {
85        &self.path
86    }
87
88    /// The name of the user.
89    #[inline]
90    pub fn user_name(&self) -> &str {
91        &self.user_name
92    }
93}
94
95impl From<&User> for Arn {
96    fn from(user: &User) -> Arn {
97        Arn::new(&user.partition, "iam", "", &user.account_id, &format!("user{}{}", user.path, user.user_name)).unwrap()
98    }
99}
100
101impl FromStr for User {
102    type Err = PrincipalError;
103
104    /// Parse an ARN, returning a [User] if the ARN is a valid user ARN.
105    ///
106    /// # Example
107    ///
108    /// ```
109    /// # use scratchstack_aws_principal::User;
110    /// # use std::str::FromStr;
111    ///
112    /// let result = User::from_str("arn:aws:iam::123456789012:user/username");
113    /// assert!(result.is_ok());
114    /// ```
115    fn from_str(arn: &str) -> Result<Self, PrincipalError> {
116        let parsed_arn = Arn::from_str(arn)?;
117        Self::try_from(&parsed_arn)
118    }
119}
120
121impl TryFrom<&Arn> for User {
122    type Error = PrincipalError;
123
124    /// If an [Arn] represents a valid IAM user, convert it to a [User]; otherwise, return a
125    /// [PrincipalError] indicating what is wrong with the ARN.
126    ///
127    /// # Example
128    ///
129    /// ```
130    /// # use scratchstack_arn::Arn;
131    /// # use scratchstack_aws_principal::User;
132    /// # use std::str::FromStr;
133    ///
134    /// let arn = Arn::from_str("arn:aws:iam::123456789012:user/path/user-name").unwrap();
135    /// let user = User::try_from(&arn).unwrap();
136    /// assert_eq!(user.path(), "/path/");
137    /// assert_eq!(user.user_name(), "user-name");
138    /// ```
139    fn try_from(arn: &Arn) -> Result<Self, Self::Error> {
140        let service = arn.service();
141        let region = arn.region();
142        let resource = arn.resource();
143
144        if service != "iam" {
145            return Err(PrincipalError::InvalidService(service.to_string()));
146        }
147
148        if !region.is_empty() {
149            return Err(PrincipalError::InvalidRegion(region.to_string()));
150        }
151
152        if !resource.starts_with("user/") {
153            return Err(PrincipalError::InvalidResource(resource.to_string()));
154        }
155
156        let path_and_username = &resource[4..];
157        let last_slash = path_and_username.rfind('/').unwrap(); // Safe because we know the string starts with "/".
158        let path = &path_and_username[..=last_slash];
159        let user_name = &path_and_username[last_slash + 1..];
160
161        Self::new(arn.partition(), arn.account_id(), path, user_name)
162    }
163}
164
165impl Display for User {
166    fn fmt(&self, f: &mut Formatter) -> FmtResult {
167        write!(f, "arn:{}:iam::{}:user{}{}", self.partition, self.account_id, self.path, self.user_name)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use {
174        super::User,
175        crate::{PrincipalIdentity, PrincipalSource},
176        scratchstack_arn::Arn,
177        std::{
178            collections::hash_map::DefaultHasher,
179            hash::{Hash, Hasher},
180            str::FromStr,
181        },
182    };
183
184    #[test]
185    fn check_components() {
186        let user = User::new("aws", "123456789012", "/my/path/", "user-name").unwrap();
187        assert_eq!(user.partition(), "aws");
188        assert_eq!(user.account_id(), "123456789012");
189        assert_eq!(user.path(), "/my/path/");
190        assert_eq!(user.user_name(), "user-name");
191
192        let arn: Arn = (&user).into();
193        assert_eq!(arn.partition(), "aws");
194        assert_eq!(arn.service(), "iam");
195        assert_eq!(arn.region(), "");
196        assert_eq!(arn.account_id(), "123456789012");
197        assert_eq!(arn.resource(), "user/my/path/user-name");
198
199        let p = PrincipalIdentity::from(user);
200        let source = p.source();
201        assert_eq!(source, PrincipalSource::Aws);
202        assert_eq!(source.to_string(), "AWS".to_string());
203    }
204
205    #[test]
206    fn check_derived() {
207        let u1a = User::new("aws", "123456789012", "/", "user1").unwrap();
208        let u1b = User::new("aws", "123456789012", "/", "user1").unwrap();
209        let u2 = User::new("aws", "123456789012", "/", "user2").unwrap();
210        let u3 = User::new("aws", "123456789012", "/path/", "user2").unwrap();
211        let u4 = User::new("aws", "123456789013", "/path/", "user2").unwrap();
212        let u5 = User::new("awt", "123456789013", "/path/", "user2").unwrap();
213
214        assert_eq!(u1a, u1b);
215        assert_ne!(u1a, u2);
216        assert_eq!(u1a, u1a.clone());
217
218        // Ensure we can hash a user.
219        let mut h1a = DefaultHasher::new();
220        let mut h1b = DefaultHasher::new();
221        let mut h2 = DefaultHasher::new();
222        u1a.hash(&mut h1a);
223        u1b.hash(&mut h1b);
224        u2.hash(&mut h2);
225        let hash1a = h1a.finish();
226        let hash1b = h1b.finish();
227        let hash2 = h2.finish();
228        assert_eq!(hash1a, hash1b);
229        assert_ne!(hash1a, hash2);
230
231        // Ensure ordering is logical.
232        assert!(u1a <= u1b);
233        assert!(u1a < u2);
234        assert!(u2 > u1a);
235        assert!(u2 < u3);
236        assert!(u3 > u2);
237        assert!(u3 > u1a);
238        assert!(u3 < u4);
239        assert!(u4 > u3);
240        assert!(u4 < u5);
241        assert!(u5 > u4);
242
243        assert!(u1a.clone().max(u2.clone()) == u2);
244        assert!(u1a.clone().min(u2.clone()) == u1a);
245
246        // Ensure formatting is correct to an ARN.
247        assert_eq!(u3.to_string(), "arn:aws:iam::123456789012:user/path/user2");
248
249        // Ensure we can debug print a user.
250        let _ = format!("{u1a:?}");
251    }
252
253    #[test]
254    fn check_valid_users() {
255        let u1a = User::new("aws", "123456789012", "/", "user-name").unwrap();
256        let u1b = User::new("aws", "123456789012", "/", "user-name").unwrap();
257        let u2 = User::new("aws", "123456789012", "/", "user-name_is@ok.with,accepted=symbols").unwrap();
258        let u3 = User::new(
259            "aws",
260            "123456789012",
261            "/!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~/",
262            "user-name",
263        )
264        .unwrap();
265        let u4 =
266            User::new("aws", "123456789012", "/", "user-name-with-64-characters====================================")
267                .unwrap();
268
269        assert_eq!(u1a, u1b);
270        assert_ne!(u1a, u2);
271        assert_eq!(u1a, u1a.clone());
272        assert_ne!(u3, u4);
273        assert_eq!(u3, u3.clone());
274
275        assert_eq!(u1a.partition(), "aws");
276        assert_eq!(u1a.account_id(), "123456789012");
277        assert_eq!(u1a.path(), "/");
278        assert_eq!(u1a.user_name(), "user-name");
279
280        assert_eq!(u1a.to_string(), "arn:aws:iam::123456789012:user/user-name");
281        assert_eq!(u2.to_string(), "arn:aws:iam::123456789012:user/user-name_is@ok.with,accepted=symbols");
282
283        User::new("aws", "123456789012", "/path/test/", "user-name").unwrap();
284        User::new("aws", "123456789012", "/path///multi-slash/test/", "user-name").unwrap();
285        User::new("aws", "123456789012", "/", "user-name").unwrap();
286
287        // Make sure we can debug a user.
288        let _ = format!("{u3:?}");
289    }
290
291    #[test]
292    fn check_invalid_users() {
293        let err = User::new("", "123456789012", "/", "user-name").unwrap_err();
294        assert_eq!(err.to_string(), r#"Invalid partition: """#);
295        let err = User::from_str("arn::iam::123456789012:user/user-name").unwrap_err();
296        assert_eq!(err.to_string(), r#"Invalid partition: """#);
297
298        let err = User::new("aws", "", "/", "user-name").unwrap_err();
299        assert_eq!(err.to_string(), r#"Invalid account id: """#);
300
301        let err = User::new("aws", "123456789012", "", "user-name").unwrap_err();
302        assert_eq!(err.to_string(), r#"Invalid path: """#);
303
304        let err = User::new("aws", "123456789012", "/", "").unwrap_err();
305        assert_eq!(err.to_string(), r#"Invalid user name: """#);
306
307        let err =
308            User::new("aws", "123456789012", "/", "user-name-with-65-characters=====================================")
309                .unwrap_err();
310        assert_eq!(
311            err.to_string(),
312            r#"Invalid user name: "user-name-with-65-characters=====================================""#
313        );
314
315        let err = User::new("aws", "123456789012", "/", "user!name").unwrap_err();
316        assert_eq!(err.to_string(), r#"Invalid user name: "user!name""#);
317
318        let err = User::new("aws", "123456789012", "path/test/", "user-name").unwrap_err();
319        assert_eq!(err.to_string(), r#"Invalid path: "path/test/""#);
320
321        let err = User::new("aws", "123456789012", "/path/test", "user-name").unwrap_err();
322        assert_eq!(err.to_string(), r#"Invalid path: "/path/test""#);
323
324        let err = User::new("aws", "123456789012", "/path test/", "user-name").unwrap_err();
325        assert_eq!(err.to_string(), r#"Invalid path: "/path test/""#);
326
327        let err = User::from_str("arn:aws:sts::123456789012:user/user-name").unwrap_err();
328        assert_eq!(err.to_string(), r#"Invalid service name: "sts""#);
329
330        let err = User::from_str("arn:aws:iam:us-east-1:123456789012:user/user-name").unwrap_err();
331        assert_eq!(err.to_string(), r#"Invalid region: "us-east-1""#);
332
333        let err = User::from_str("arn:aws:iam::123456789012:role/user-name").unwrap_err();
334        assert_eq!(err.to_string(), r#"Invalid resource: "role/user-name""#);
335    }
336}
337// end tests -- do not delete; needed for coverage.