scratchstack_aws_principal/
assumed_role.rs

1use {
2    crate::{utils::validate_name, PrincipalError},
3    scratchstack_arn::{
4        utils::{validate_account_id, validate_partition},
5        Arn,
6    },
7    std::{
8        fmt::{Display, Formatter, Result as FmtResult},
9        str::FromStr,
10    },
11};
12
13/// Details about an AWS STS assumed role.
14///
15/// `AssumedRole` structs are immutable.
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct AssumedRole {
18    /// The partition this principal exists in.
19    partition: String,
20
21    /// The account id.
22    account_id: String,
23
24    /// Name of the role, case-insensitive.
25    role_name: String,
26
27    /// Session name for the assumed role.
28    session_name: String,
29}
30
31impl AssumedRole {
32    /// Create an [AssumedRole] object.
33    ///
34    /// # Arguments:
35    ///
36    /// * `partition`: The partition this principal exists in.
37    /// * `account_id`: The 12 digit account id. This must be composed of 12 ASCII digits or a
38    ///     [PrincipalError::InvalidAccountId] error will be returned.
39    /// * `role_name`: The name of the role being assumed. This must meet the following requirements or a
40    ///     [PrincipalError::InvalidRoleName] error will be returned:
41    ///     *   The name must contain between 1 and 64 characters.
42    ///     *   The name must be composed to ASCII alphanumeric characters or one of `, - . = @ _`.
43    /// * `session_name`: A name to assign to the session. This must meet the following requirements or a
44    ///     [PrincipalError::InvalidSessionName] error will be returned:
45    ///     *   The session name must contain between 2 and 64 characters.
46    ///     *   The session name must be composed to ASCII alphanumeric characters or one of `, - . = @ _`.
47    ///
48    /// # Return value
49    ///
50    /// If all of the requirements are met, an [AssumedRole] object is returned. Otherwise,
51    /// a [PrincipalError] error is returned.
52    ///
53    /// # Example
54    ///
55    /// ```
56    /// # use scratchstack_aws_principal::AssumedRole;
57    /// let assumed_role = AssumedRole::new("aws", "123456789012", "role-name", "session-name").unwrap();
58    /// assert_eq!(assumed_role.partition(), "aws");
59    /// assert_eq!(assumed_role.account_id(), "123456789012");
60    /// assert_eq!(assumed_role.role_name(), "role-name");
61    /// assert_eq!(assumed_role.session_name(), "session-name");
62    /// ```
63    pub fn new(partition: &str, account_id: &str, role_name: &str, session_name: &str) -> Result<Self, PrincipalError> {
64        validate_partition(partition)?;
65        validate_account_id(account_id)?;
66        validate_name(role_name, 64, PrincipalError::InvalidRoleName)?;
67        validate_name(session_name, 64, PrincipalError::InvalidSessionName)?;
68
69        if session_name.len() < 2 {
70            Err(PrincipalError::InvalidSessionName(session_name.into()))
71        } else {
72            Ok(Self {
73                partition: partition.into(),
74                account_id: account_id.into(),
75                role_name: role_name.into(),
76                session_name: session_name.into(),
77            })
78        }
79    }
80
81    /// The partition of the assumed role.
82    #[inline]
83    pub fn partition(&self) -> &str {
84        &self.partition
85    }
86
87    /// The account ID of the assumed role.
88    #[inline]
89    pub fn account_id(&self) -> &str {
90        &self.account_id
91    }
92
93    /// The name of the role being assumed.
94    #[inline]
95    pub fn role_name(&self) -> &str {
96        &self.role_name
97    }
98
99    /// The name of the session.
100    #[inline]
101    pub fn session_name(&self) -> &str {
102        &self.session_name
103    }
104}
105
106impl FromStr for AssumedRole {
107    type Err = PrincipalError;
108
109    /// Parse an ARN, returning an [AssumedRole] if the ARN is a valid assumed role ARN.
110    ///
111    /// # Example
112    ///
113    /// ```
114    /// # use scratchstack_aws_principal::AssumedRole;
115    /// # use std::str::FromStr;
116    /// let result = AssumedRole::from_str("arn:aws:sts::123456789012:assumed-role/role-name/session-name");
117    /// assert!(result.is_ok());
118    /// ```
119    fn from_str(arn: &str) -> Result<Self, PrincipalError> {
120        let parsed_arn = Arn::from_str(arn)?;
121        Self::try_from(&parsed_arn)
122    }
123}
124
125impl From<&AssumedRole> for Arn {
126    fn from(role: &AssumedRole) -> Arn {
127        Arn::new(
128            &role.partition,
129            "sts",
130            "",
131            &role.account_id,
132            &format!("assumed-role/{}/{}", role.role_name, role.session_name),
133        )
134        .unwrap()
135    }
136}
137
138impl Display for AssumedRole {
139    fn fmt(&self, f: &mut Formatter) -> FmtResult {
140        write!(
141            f,
142            "arn:{}:sts::{}:assumed-role/{}/{}",
143            self.partition, self.account_id, self.role_name, self.session_name
144        )
145    }
146}
147
148impl TryFrom<&Arn> for AssumedRole {
149    type Error = PrincipalError;
150
151    /// If an [Arn] represents a valid assumed role, convert it to an [AssumedRole]; otherwise, return a
152    /// [PrincipalError] indicating what is wrong with the ARN.
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// # use scratchstack_arn::Arn;
158    /// # use scratchstack_aws_principal::AssumedRole;
159    /// # use std::str::FromStr;
160    /// let arn = Arn::from_str("arn:aws:sts::123456789012:assumed-role/role-name/session-name").unwrap();
161    /// let assumed_role = AssumedRole::try_from(&arn).unwrap();
162    /// assert_eq!(assumed_role.role_name(), "role-name");
163    /// assert_eq!(assumed_role.session_name(), "session-name");
164    /// ```
165    fn try_from(arn: &Arn) -> Result<Self, Self::Error> {
166        let service = arn.service();
167        let region = arn.region();
168        let resource = arn.resource();
169
170        if service != "sts" {
171            return Err(PrincipalError::InvalidService(service.to_string()));
172        }
173
174        if !region.is_empty() {
175            return Err(PrincipalError::InvalidRegion(region.to_string()));
176        }
177
178        let resource_parts: Vec<&str> = resource.split('/').collect();
179        if resource_parts.len() != 3 || resource_parts[0] != "assumed-role" {
180            return Err(PrincipalError::InvalidResource(resource.to_string()));
181        }
182
183        Self::new(arn.partition(), arn.account_id(), resource_parts[1], resource_parts[2])
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use {
190        super::AssumedRole,
191        crate::{PrincipalIdentity, PrincipalSource},
192        scratchstack_arn::Arn,
193        std::{
194            collections::hash_map::DefaultHasher,
195            hash::{Hash, Hasher},
196            str::FromStr,
197        },
198    };
199
200    #[test]
201    fn check_components() {
202        let role = AssumedRole::new("aws", "123456789012", "role", "session").unwrap();
203        assert_eq!(role.partition(), "aws");
204        assert_eq!(role.account_id(), "123456789012");
205        assert_eq!(role.role_name(), "role");
206        assert_eq!(role.session_name(), "session");
207
208        let arn: Arn = (&role).into();
209        assert_eq!(arn.partition(), "aws");
210        assert_eq!(arn.service(), "sts");
211        assert_eq!(arn.region(), "");
212        assert_eq!(arn.account_id(), "123456789012");
213        assert_eq!(arn.resource(), "assumed-role/role/session");
214
215        let p = PrincipalIdentity::from(role);
216        let source = p.source();
217        assert_eq!(source, PrincipalSource::Aws);
218        assert_eq!(source.to_string(), "AWS".to_string());
219    }
220
221    #[test]
222    #[allow(clippy::redundant_clone)]
223    fn check_derived() {
224        let r1a = AssumedRole::new("aws", "123456789012", "role1", "session1").unwrap();
225        let r1b = AssumedRole::new("aws", "123456789012", "role1", "session1").unwrap();
226        let r2 = AssumedRole::new("aws", "123456789012", "role1", "session2").unwrap();
227        let r3 = AssumedRole::new("aws", "123456789012", "role2", "session2").unwrap();
228        let r4 = AssumedRole::new("aws", "123456789013", "role2", "session2").unwrap();
229        let r5 = AssumedRole::new("awt", "123456789013", "role2", "session2").unwrap();
230
231        assert_eq!(r1a, r1b);
232        assert_ne!(r1a, r2);
233        assert_eq!(r1a.clone(), r1a);
234
235        // Ensure we can hash an assumed role.
236        let mut h1a = DefaultHasher::new();
237        let mut h1b = DefaultHasher::new();
238        let mut h2 = DefaultHasher::new();
239        r1a.hash(&mut h1a);
240        r1b.hash(&mut h1b);
241        r2.hash(&mut h2);
242        let hash1a = h1a.finish();
243        let hash1b = h1b.finish();
244        let hash2 = h2.finish();
245        assert_eq!(hash1a, hash1b);
246        assert_ne!(hash1a, hash2);
247
248        // Ensure ordering is logical.
249        assert!(r1a <= r1b);
250        assert!(r1a < r2);
251        assert!(r2 < r3);
252        assert!(r3 > r2);
253        assert!(r3 > r1a);
254        assert!(r3 < r4);
255        assert!(r4 > r3);
256        assert!(r4 < r5);
257        assert!(r5 > r4);
258
259        assert_eq!(r1a.clone().max(r2.clone()), r2);
260        assert_eq!(r1a.clone().min(r2), r1a);
261
262        // Ensure formatting is correct to an ARN.
263        assert_eq!(r1a.to_string(), "arn:aws:sts::123456789012:assumed-role/role1/session1");
264
265        // Ensure we can debug print an assumed role.
266        let _ = format!("{r1a:?}");
267    }
268
269    #[test]
270    fn check_valid_assumed_roles() {
271        let r1a = AssumedRole::new("aws", "123456789012", "Role_name", "session_name").unwrap();
272        let r1b = AssumedRole::new("aws", "123456789012", "Role_name", "session_name").unwrap();
273        let r2 =
274            AssumedRole::new("aws2", "123456789012", "Role@Foo=bar,baz_=world-1234", "Session@1234,_=-,.OK").unwrap();
275
276        assert_eq!(r1a, r1b);
277        assert_ne!(r1a, r2);
278        assert!(r1a <= r1b);
279        assert!(r1a >= r1b);
280        assert_eq!(r1a.partition(), "aws");
281        assert_eq!(r1a.account_id(), "123456789012");
282        assert_eq!(r1a.role_name(), "Role_name");
283        assert_eq!(r1a.session_name(), "session_name");
284
285        assert!(r1a < r2);
286        assert!(r1a <= r2);
287        assert!(r2 > r1a);
288        assert!(r2 >= r1a);
289        assert!(r2 != r1a);
290
291        assert_eq!(r1a.to_string(), "arn:aws:sts::123456789012:assumed-role/Role_name/session_name");
292        assert_eq!(r1b.to_string(), "arn:aws:sts::123456789012:assumed-role/Role_name/session_name");
293        assert_eq!(
294            r2.to_string(),
295            "arn:aws2:sts::123456789012:assumed-role/Role@Foo=bar,baz_=world-1234/Session@1234,_=-,.OK"
296        );
297
298        let r1c = r1a.clone();
299        assert!(r1a == r1c);
300
301        AssumedRole::new("partition-with-32-characters1234", "123456789012", "role-name", "session_name").unwrap();
302
303        AssumedRole::new(
304            "aws",
305            "123456789012",
306            "role-name-with_64-characters====================================",
307            "session@1234",
308        )
309        .unwrap();
310
311        AssumedRole::new(
312            "aws",
313            "123456789012",
314            "role-name",
315            "session-name-with-64-characters=================================",
316        )
317        .unwrap();
318
319        // Make sure we can debug the assumed role.
320        let _ = format!("{r1a:?}");
321    }
322
323    #[test]
324    fn check_invalid_assumed_roles() {
325        let err = AssumedRole::new("", "123456789012", "role-name", "session-name").unwrap_err();
326        assert_eq!(err.to_string(), r#"Invalid partition: """#);
327
328        let err = AssumedRole::new("partition-with-33-characters12345", "123456789012", "role-name", "session_name")
329            .unwrap_err();
330        assert_eq!(err.to_string(), r#"Invalid partition: "partition-with-33-characters12345""#);
331
332        let err = AssumedRole::new("-aws", "123456789012", "role-name", "session-name").unwrap_err();
333        assert_eq!(err.to_string(), r#"Invalid partition: "-aws""#);
334        let err = AssumedRole::from_str("arn:-aws:sts::123456789012:assumed-role/role-name/session-name").unwrap_err();
335        assert_eq!(err.to_string(), r#"Invalid partition: "-aws""#);
336
337        let err = AssumedRole::new("aws-", "123456789012", "role-name", "session-name").unwrap_err();
338        assert_eq!(err.to_string(), r#"Invalid partition: "aws-""#);
339
340        let err = AssumedRole::new("aws--us", "123456789012", "role-name", "session-name").unwrap_err();
341        assert_eq!(err.to_string(), r#"Invalid partition: "aws--us""#);
342
343        let err = AssumedRole::new("aw!", "123456789012", "role-name", "session-name").unwrap_err();
344        assert_eq!(err.to_string(), r#"Invalid partition: "aw!""#);
345
346        let err = AssumedRole::new("aws", "", "role-name", "session-name").unwrap_err();
347        assert_eq!(err.to_string(), r#"Invalid account id: """#);
348
349        let err = AssumedRole::new("aws", "a23456789012", "role-name", "session-name").unwrap_err();
350        assert_eq!(err.to_string(), r#"Invalid account id: "a23456789012""#);
351
352        let err = AssumedRole::new("aws", "1234567890123", "role-name", "session-name").unwrap_err();
353        assert_eq!(err.to_string(), r#"Invalid account id: "1234567890123""#);
354
355        let err = AssumedRole::new("aws", "123456789012", "", "session-name").unwrap_err();
356        assert_eq!(err.to_string(), r#"Invalid role name: """#);
357
358        let err = AssumedRole::new(
359            "aws",
360            "123456789012",
361            "role-name-with-65-characters=====================================",
362            "session-name",
363        )
364        .unwrap_err();
365        assert_eq!(
366            err.to_string(),
367            r#"Invalid role name: "role-name-with-65-characters=====================================""#
368        );
369        let err = AssumedRole::from_str("arn:aws:sts::123456789012:assumed-role/role-name-with-65-characters=====================================/session-name")
370        .unwrap_err();
371        assert_eq!(
372            err.to_string(),
373            r#"Invalid role name: "role-name-with-65-characters=====================================""#
374        );
375
376        let err = AssumedRole::new("aws", "123456789012", "role+name", "session-name").unwrap_err();
377        assert_eq!(err.to_string(), r#"Invalid role name: "role+name""#);
378
379        let err = AssumedRole::new("aws", "123456789012", "role-name", "").unwrap_err();
380        assert_eq!(err.to_string(), r#"Invalid session name: """#);
381
382        let err = AssumedRole::new("aws", "123456789012", "role-name", "s").unwrap_err();
383        assert_eq!(err.to_string(), r#"Invalid session name: "s""#);
384
385        let err = AssumedRole::new(
386            "aws",
387            "123456789012",
388            "role-name",
389            "session-name-with-65-characters==================================",
390        )
391        .unwrap_err();
392
393        assert_eq!(
394            err.to_string(),
395            r#"Invalid session name: "session-name-with-65-characters==================================""#
396        );
397
398        let err = AssumedRole::new("aws", "123456789012", "role-name", "session+name").unwrap_err();
399        assert_eq!(err.to_string(), r#"Invalid session name: "session+name""#);
400
401        let err =
402            AssumedRole::from_str("arn:aws:iam::123456789012:assumed-role/role/role-name/session-name").unwrap_err();
403        assert_eq!(err.to_string(), r#"Invalid service name: "iam""#);
404
405        let err = AssumedRole::from_str("arn:aws:sts::123456789012:user/role/role-name/session-name").unwrap_err();
406        assert_eq!(err.to_string(), r#"Invalid resource: "user/role/role-name/session-name""#);
407    }
408}
409// end tests -- do not delete; needed for coverage.