scratchstack_aspen/resource/
arn.rs

1use {
2    crate::{eval::regex_from_glob, AspenError, Context, PolicyVersion},
3    scratchstack_arn::Arn,
4    std::{
5        fmt::{Display, Formatter, Result as FmtResult},
6        str::FromStr,
7    },
8};
9
10const PARTITION_START: usize = 4;
11
12/// An Amazon Resource Name (ARN) statement in an IAM Aspen policy.
13///
14/// This is used to match [scratchstack_arn::Arn] objects from a resource statement in the IAM Aspen policy language. For example,
15/// an [ResourceArn] created from `arn:aws*:ec2:us-*-?:123456789012:instance/i-*` would match the following [Arn]
16/// objects:
17/// * `arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0`
18/// * `arn:aws-us-gov:ec2:us-west-2:123456789012:instance/i-1234567890abcdef0`
19///
20/// Patterns are similar to glob statements with a few differences:
21/// * The `*` character matches any number of characters, including none, within a single segment of the ARN.
22/// * The `?` character matches any single character within a single segment of the ARN.
23///
24/// [ResourceArn] objects are immutable.
25#[derive(Debug, Clone, Eq, Hash, PartialEq)]
26pub struct ResourceArn {
27    arn: String,
28    service_start: usize,
29    region_start: usize,
30    account_id_start: usize,
31    resource_start: usize,
32}
33
34impl ResourceArn {
35    /// Create a new ARN pattern from the specified components.
36    ///
37    /// * `partition` - The partition the resource is in.
38    /// * `service` - The service the resource belongs to.
39    /// * `region` - The region the resource is in.
40    /// * `account_id` - The account ID the resource belongs to.
41    /// * `resource` - The resource name.
42    pub fn new(partition: &str, service: &str, region: &str, account_id: &str, resource: &str) -> Self {
43        let arn = format!("arn:{partition}:{service}:{region}:{account_id}:{resource}");
44        let service_start = PARTITION_START + partition.len() + 1;
45        let region_start = service_start + service.len() + 1;
46        let account_id_start = region_start + region.len() + 1;
47        let resource_start = account_id_start + account_id.len() + 1;
48
49        Self {
50            arn,
51            service_start,
52            region_start,
53            account_id_start,
54            resource_start,
55        }
56    }
57
58    /// Retrieve the partition string pattern.
59    #[inline]
60    pub fn partition_pattern(&self) -> &str {
61        &self.arn[PARTITION_START..self.service_start - 1]
62    }
63
64    /// Retrieve the service string pattern.
65    #[inline]
66    pub fn service_pattern(&self) -> &str {
67        &self.arn[self.service_start..self.region_start - 1]
68    }
69
70    /// Retrieve the region string pattern.
71    #[inline]
72    pub fn region_pattern(&self) -> &str {
73        &self.arn[self.region_start..self.account_id_start - 1]
74    }
75
76    /// Retrieve the account ID string pattern.
77    #[inline]
78    pub fn account_id_pattern(&self) -> &str {
79        &self.arn[self.account_id_start..self.resource_start - 1]
80    }
81
82    /// Retrieve the resource name string pattern.
83    #[inline]
84    pub fn resource_pattern(&self) -> &str {
85        &self.arn[self.resource_start..]
86    }
87
88    /// Indicates whether this [ResourceArn] matches the candidate [Arn], given the request [Context] ad using variable
89    /// substitution rules according to the specified [PolicyVersion].
90    ///
91    /// # Example
92    /// ```
93    /// # use scratchstack_aspen::{Context, PolicyVersion, Resource, ResourceArn};
94    /// # use scratchstack_arn::Arn;
95    /// # use scratchstack_aws_principal::{Principal, User, SessionData, SessionValue};
96    /// # use std::str::FromStr;
97    /// let actor = Principal::from(vec![User::from_str("arn:aws:iam::123456789012:user/exampleuser").unwrap().into()]);
98    /// let s3_object_arn = Arn::from_str("arn:aws:s3:::examplebucket/exampleuser/my-object").unwrap();
99    /// let resources = vec![s3_object_arn.clone()];
100    /// let session_data = SessionData::from([("aws:username", SessionValue::from("exampleuser"))]);
101    /// let context = Context::builder()
102    ///     .service("s3").api("GetObject").actor(actor).resources(resources)
103    ///     .session_data(session_data).build().unwrap();
104    /// let resource_arn = ResourceArn::new("aws", "s3", "", "", "examplebucket/${aws:username}/*");
105    /// assert!(resource_arn.matches(&context, PolicyVersion::V2012_10_17, &s3_object_arn).unwrap());
106    ///
107    /// let bad_s3_object_arn = Arn::from_str("arn:aws:s3:::examplebucket/other-user/object").unwrap();
108    /// assert!(!resource_arn.matches(&context, PolicyVersion::V2012_10_17, &bad_s3_object_arn).unwrap());
109    /// ```
110    pub fn matches(&self, context: &Context, pv: PolicyVersion, candidate: &Arn) -> Result<bool, AspenError> {
111        let partition_pattern = self.partition_pattern();
112        let service_pattern = self.service_pattern();
113        let region_pattern = self.region_pattern();
114        let account_id_pattern = self.account_id_pattern();
115        let resource_pattern = self.resource_pattern();
116
117        let partition = regex_from_glob(partition_pattern, false);
118        let service = regex_from_glob(service_pattern, false);
119        let region = regex_from_glob(region_pattern, false);
120        let account_id = regex_from_glob(account_id_pattern, false);
121        let resource = context.matcher(resource_pattern, pv, false)?;
122
123        let partition_match = partition.is_match(candidate.partition());
124        let service_match = service.is_match(candidate.service());
125        let region_match = region.is_match(candidate.region());
126        let account_id_match = account_id.is_match(candidate.account_id());
127        let resource_match = resource.is_match(candidate.resource());
128        let result = partition_match && service_match && region_match && account_id_match && resource_match;
129
130        log::trace!("arn_pattern_matches: pattern={:?}, candidate={} -> partition={:?} ({}) service={:?} ({}) region={:?} ({}) account_id={:?} ({}) resource={:?} vs {:?} ({}) -> result={}", self, candidate, partition, partition_match, service, service_match, region, region_match, account_id, account_id_match, resource, candidate.resource(), resource_match, result);
131
132        Ok(result)
133    }
134}
135
136impl FromStr for ResourceArn {
137    type Err = AspenError;
138
139    /// Create an [ResourceArn] from a string.
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        let parts: Vec<&str> = s.splitn(6, ':').collect();
142        if parts.len() != 6 || parts[0] != "arn" {
143            return Err(AspenError::InvalidResource(s.to_string()));
144        }
145
146        let arn = s.to_string();
147        let service_start = PARTITION_START + parts[1].len() + 1;
148        let region_start = service_start + parts[2].len() + 1;
149        let account_id_start = region_start + parts[3].len() + 1;
150        let resource_start = account_id_start + parts[4].len() + 1;
151
152        Ok(Self {
153            arn,
154            service_start,
155            region_start,
156            account_id_start,
157            resource_start,
158        })
159    }
160}
161
162impl Display for ResourceArn {
163    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
164        f.write_str(&self.arn)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use {
171        super::ResourceArn,
172        crate::AspenError,
173        pretty_assertions::{assert_eq, assert_ne},
174        std::{collections::hash_map::DefaultHasher, hash::Hash, str::FromStr},
175    };
176
177    #[test_log::test]
178    fn check_arn_pattern_derived() {
179        let pat1a = ResourceArn::from_str("arn:*:ec2:us-*-1:123456789012:instance/*").unwrap();
180        let pat1b = ResourceArn::new("*", "ec2", "us-*-1", "123456789012", "instance/*");
181        let pat1c = pat1a.clone();
182        let pat2 = ResourceArn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/*").unwrap();
183        let pat3 = ResourceArn::from_str("arn:aws:ec*:us-*-1::*").unwrap();
184
185        assert_eq!(pat1a, pat1b);
186        assert_ne!(pat1a, pat2);
187        assert_eq!(pat1c, pat1b);
188
189        assert_eq!(pat1a.partition_pattern(), "*");
190        assert_eq!(pat1a.service_pattern(), "ec2");
191        assert_eq!(pat1a.region_pattern(), "us-*-1");
192        assert_eq!(pat1a.account_id_pattern(), "123456789012");
193        assert_eq!(pat1a.resource_pattern(), "instance/*");
194
195        // Ensure we can derive a hash for the arn.
196        let mut h2 = DefaultHasher::new();
197        pat3.hash(&mut h2);
198
199        // Ensure we can debug print the arn.
200        _ = format!("{pat3:?}");
201
202        // Ensure we can print the arn.
203        assert_eq!(pat3.to_string(), "arn:aws:ec*:us-*-1::*".to_string());
204    }
205
206    #[test_log::test]
207    fn check_arn_pattern_components() {
208        let pat = ResourceArn::from_str("arn:aws:ec*:us-*-1::*").unwrap();
209        assert_eq!(pat.partition_pattern(), "aws");
210        assert_eq!(pat.service_pattern(), "ec*");
211        assert_eq!(pat.region_pattern(), "us-*-1");
212        assert_eq!(pat.account_id_pattern(), "");
213        assert_eq!(pat.resource_pattern(), "*");
214    }
215
216    #[test_log::test]
217    fn check_malformed_patterns() {
218        let wrong_parts =
219            vec!["arn", "arn:aw*", "arn:aw*:e?2", "arn:aw*:e?2:us-*-1", "arn:aw*:e?2:us-*-1:123456789012"];
220        for wrong_part in wrong_parts {
221            assert_eq!(
222                ResourceArn::from_str(wrong_part).unwrap_err().to_string(),
223                format!("Invalid resource: {wrong_part}")
224            );
225        }
226
227        let err =
228            ResourceArn::from_str("https:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
229        assert_eq!(
230            err,
231            AspenError::InvalidResource(
232                "https:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0".to_string()
233            )
234        );
235    }
236}