scratchstack_aspen/
statement.rs

1use {
2    crate::{
3        display_json, from_str_json, serutil::MapList, ActionList, AspenError, Condition, Context, Decision, Effect,
4        PolicyVersion, Principal, ResourceList,
5    },
6    derive_builder::Builder,
7    serde::{
8        de::{Deserializer, MapAccess, Visitor},
9        Deserialize, Serialize,
10    },
11    std::fmt::{Formatter, Result as FmtResult},
12};
13
14/// An Aspen policy statement.
15///
16/// Statement structs are immutable after creation. They can be created using the [StatementBuilder].
17#[derive(Builder, Clone, Debug, Eq, PartialEq, Serialize)]
18#[builder(build_fn(validate = "Self::validate"))]
19#[serde(deny_unknown_fields, rename_all = "PascalCase")]
20pub struct Statement {
21    /// The user-provided statement id.
22    #[builder(setter(into, strip_option), default)]
23    #[serde(skip_serializing_if = "Option::is_none")]
24    sid: Option<String>,
25
26    /// The effect of the statement (allow or deny).
27    effect: Effect,
28
29    /// The list of actions this statement applies to. Exactly one of `action` or `not_action` must be set.
30    #[builder(setter(into, strip_option), default)]
31    #[serde(skip_serializing_if = "Option::is_none")]
32    action: Option<ActionList>,
33
34    /// The list of actions this statement does not apply to. Exactly one of `action` or `not_action` must be set.
35    #[builder(setter(into, strip_option), default)]
36    #[serde(skip_serializing_if = "Option::is_none")]
37    not_action: Option<ActionList>,
38
39    /// The list of resources this statement applies to. This cannot be combined with `not_resource`.
40    #[builder(setter(into, strip_option), default)]
41    #[serde(skip_serializing_if = "Option::is_none")]
42    resource: Option<ResourceList>,
43
44    /// The list of resources this statement does not apply to. This cannot be combined with `resource`.
45    #[builder(setter(into, strip_option), default)]
46    #[serde(skip_serializing_if = "Option::is_none")]
47    not_resource: Option<ResourceList>,
48
49    /// The list of principals this statement applies to. This cannot be combined with `not_principal`.
50    #[builder(setter(into, strip_option), default)]
51    #[serde(skip_serializing_if = "Option::is_none")]
52    principal: Option<Principal>,
53
54    /// The list of principals this statement does not apply to. This cannot be combined with `principal`.
55    #[builder(setter(into, strip_option), default)]
56    #[serde(skip_serializing_if = "Option::is_none")]
57    not_principal: Option<Principal>,
58
59    /// Conditions that must be met for this statement to apply.
60    #[builder(setter(into, strip_option), default)]
61    #[serde(skip_serializing_if = "Option::is_none")]
62    condition: Option<Condition>,
63}
64
65impl Statement {
66    /// Create a new [StatementBuilder] for building a [Statement].
67    pub fn builder() -> StatementBuilder {
68        StatementBuilder::default()
69    }
70
71    /// Returns the user-provided statement id if provided, else `None`.
72    #[inline]
73    pub fn sid(&self) -> Option<&str> {
74        self.sid.as_deref()
75    }
76
77    /// Returns the effect of the statement (allow or deny).
78    #[inline]
79    pub fn effect(&self) -> &Effect {
80        &self.effect
81    }
82
83    /// Returns the list of actions this statement applies to if provided, else `None`.
84    #[inline]
85    pub fn action(&self) -> Option<&ActionList> {
86        self.action.as_ref()
87    }
88
89    /// Returns the list of actions this statement does not apply to if provided, else `None`.
90    #[inline]
91    pub fn not_action(&self) -> Option<&ActionList> {
92        self.not_action.as_ref()
93    }
94
95    /// Returns the list of resources this statement applies to if provided, else `None`.
96    #[inline]
97    pub fn resource(&self) -> Option<&ResourceList> {
98        self.resource.as_ref()
99    }
100
101    /// Returns the list of resources this statement does not apply to if provided, else `None`.
102    #[inline]
103    pub fn not_resource(&self) -> Option<&ResourceList> {
104        self.not_resource.as_ref()
105    }
106
107    /// Returns the list of principals this statement applies to if provided, else `None`.
108    #[inline]
109    pub fn principal(&self) -> Option<&Principal> {
110        self.principal.as_ref()
111    }
112
113    /// Returns the list of principals this statement does not apply to if provided, else `None`.
114    #[inline]
115    pub fn not_principal(&self) -> Option<&Principal> {
116        self.not_principal.as_ref()
117    }
118
119    /// Returns the conditions that must be met for this statement to apply if provided, else `None`.
120    #[inline]
121    pub fn condition(&self) -> Option<&Condition> {
122        self.condition.as_ref()
123    }
124
125    /// Evaluate this statement against the specified request [Context], using the [PolicyVersion] to perform
126    /// variable substitution.
127    ///
128    /// # Example
129    ///
130    /// ```
131    /// # use scratchstack_aspen::{Action, Context, Decision, Effect, PolicyVersion, Resource, Statement};
132    /// # use scratchstack_arn::Arn;
133    /// # use scratchstack_aws_principal::{Principal, User, SessionData, SessionValue};
134    /// # use std::str::FromStr;
135    /// let actor = Principal::from(vec![User::from_str("arn:aws:iam::123456789012:user/exampleuser").unwrap().into()]);
136    /// let s3_object_arn = Arn::from_str("arn:aws:s3:::examplebucket/exampleuser/my-object").unwrap();
137    /// let resources = vec![s3_object_arn.clone()];
138    /// let session_data = SessionData::from([("aws:username", SessionValue::from("exampleuser"))]);
139    /// let context = Context::builder()
140    ///     .service("s3").api("GetObject").actor(actor.clone()).resources(resources.clone())
141    ///     .session_data(session_data.clone()).build().unwrap();
142    /// let statement = Statement::builder().effect(Effect::Allow).action(vec![Action::new("s3", "Get*").unwrap()])
143    ///     .resource(Resource::Any).build().unwrap();
144    /// assert_eq!(statement.evaluate(&context, PolicyVersion::V2012_10_17).unwrap(), Decision::Allow);
145    ///
146    /// let context = Context::builder()
147    ///     .service("s3").api("PutObject").actor(actor).resources(resources)
148    ///     .session_data(session_data).build().unwrap();
149    /// assert_eq!(statement.evaluate(&context, PolicyVersion::V2012_10_17).unwrap(), Decision::DefaultDeny);
150    /// ```
151    pub fn evaluate(&self, context: &Context, pv: PolicyVersion) -> Result<Decision, AspenError> {
152        // Does the action match the context?
153        if let Some(actions) = self.action() {
154            let mut matched = false;
155            for action in actions.iter() {
156                if action.matches(context.service(), context.api()) {
157                    matched = true;
158                    break;
159                }
160            }
161
162            if !matched {
163                return Ok(Decision::DefaultDeny);
164            }
165        } else if let Some(actions) = self.not_action() {
166            let mut matched = false;
167            for action in actions.iter() {
168                if action.matches(context.service(), context.api()) {
169                    matched = true;
170                    break;
171                }
172            }
173
174            if matched {
175                return Ok(Decision::DefaultDeny);
176            }
177        } else {
178            unreachable!("Statement must have either an Action or NotAction");
179        }
180
181        // Does the resource match the context?
182        if let Some(resources) = self.resource() {
183            let candidates = context.resources();
184            if candidates.is_empty() {
185                // We need a resource statement that is a wildcard.
186                if !resources.iter().any(|r| r.is_any()) {
187                    return Ok(Decision::DefaultDeny);
188                }
189            } else {
190                for candidate in candidates {
191                    let mut candidate_matched = false;
192
193                    for resource in resources.iter() {
194                        if resource.matches(context, pv, candidate)? {
195                            candidate_matched = true;
196                            break;
197                        }
198                    }
199
200                    if !candidate_matched {
201                        return Ok(Decision::DefaultDeny);
202                    }
203                }
204            }
205        } else if let Some(resources) = self.not_resource() {
206            let candidates = context.resources();
207            log::trace!("NotResource: candidates = {:?}", candidates);
208            if candidates.is_empty() {
209                // We cannot have a resource statement that is a wildcard.
210                if resources.iter().any(|r| r.is_any()) {
211                    return Ok(Decision::DefaultDeny);
212                }
213            } else {
214                for candidate in candidates {
215                    log::trace!("NotResource: candidate = {:?}", candidate);
216                    for resource in resources.iter() {
217                        if resource.matches(context, pv, candidate)? {
218                            log::trace!("NotResource: candidate {:?} matched resource {:?}", candidate, resource);
219                            return Ok(Decision::DefaultDeny);
220                        }
221                    }
222                }
223
224                log::trace!("NotResource: no matches");
225            }
226        }
227        // We're allowed to not have a resource if this is a resource-based policy.
228
229        // Does the principal match the context?
230        if let Some(principal) = self.principal() {
231            if !principal.matches(context.actor()) {
232                return Ok(Decision::DefaultDeny);
233            }
234        } else if let Some(principal) = self.not_principal() {
235            if principal.matches(context.actor()) {
236                return Ok(Decision::DefaultDeny);
237            }
238        }
239        // We're allowed to not have a principal if this is a principal-based policy.
240
241        // Do the conditions match?
242        if let Some(conditions) = self.condition() {
243            for (key, values) in conditions.iter() {
244                if !key.matches(values, context, pv)? {
245                    return Ok(Decision::DefaultDeny);
246                }
247            }
248        }
249
250        // Everything matches here. Return the effect.
251        match self.effect() {
252            Effect::Allow => Ok(Decision::Allow),
253            Effect::Deny => Ok(Decision::Deny),
254        }
255    }
256}
257
258display_json!(Statement);
259from_str_json!(Statement);
260
261impl<'de> Deserialize<'de> for Statement {
262    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
263        deserializer.deserialize_map(StatementVisitor {})
264    }
265}
266
267struct StatementVisitor;
268impl<'de> Visitor<'de> for StatementVisitor {
269    type Value = Statement;
270
271    fn expecting(&self, formatter: &mut Formatter) -> FmtResult {
272        formatter.write_str("a map of statement properties")
273    }
274
275    fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Statement, A::Error> {
276        let mut builder = Statement::builder();
277        let mut sid_seen = false;
278        let mut effect_seen = false;
279        let mut action_seen = false;
280        let mut not_action_seen = false;
281        let mut resource_seen = false;
282        let mut not_resource_seen = false;
283        let mut principal_seen = false;
284        let mut not_principal_seen = false;
285        let mut condition_seen = false;
286
287        while let Some(key) = access.next_key::<&str>()? {
288            match key {
289                "Sid" => {
290                    if sid_seen {
291                        return Err(serde::de::Error::duplicate_field("Sid"));
292                    }
293
294                    sid_seen = true;
295                    builder.sid(access.next_value::<&str>()?);
296                }
297                "Effect" => {
298                    if effect_seen {
299                        return Err(serde::de::Error::duplicate_field("Effect"));
300                    }
301
302                    effect_seen = true;
303                    builder.effect(access.next_value::<Effect>()?);
304                }
305                "Action" => {
306                    if action_seen {
307                        return Err(serde::de::Error::duplicate_field("Action"));
308                    }
309
310                    action_seen = true;
311                    builder.action(access.next_value::<ActionList>()?);
312                }
313                "NotAction" => {
314                    if not_action_seen {
315                        return Err(serde::de::Error::duplicate_field("NotAction"));
316                    }
317
318                    not_action_seen = true;
319                    builder.not_action(access.next_value::<ActionList>()?);
320                }
321                "Resource" => {
322                    if resource_seen {
323                        return Err(serde::de::Error::duplicate_field("Resource"));
324                    }
325
326                    resource_seen = true;
327                    builder.resource(access.next_value::<ResourceList>()?);
328                }
329                "NotResource" => {
330                    if not_resource_seen {
331                        return Err(serde::de::Error::duplicate_field("NotResource"));
332                    }
333
334                    not_resource_seen = true;
335                    builder.not_resource(access.next_value::<ResourceList>()?);
336                }
337                "Principal" => {
338                    if principal_seen {
339                        return Err(serde::de::Error::duplicate_field("Principal"));
340                    }
341
342                    principal_seen = true;
343                    builder.principal(access.next_value::<Principal>()?);
344                }
345                "NotPrincipal" => {
346                    if not_principal_seen {
347                        return Err(serde::de::Error::duplicate_field("NotPrincipal"));
348                    }
349
350                    not_principal_seen = true;
351                    builder.not_principal(access.next_value::<Principal>()?);
352                }
353                "Condition" => {
354                    if condition_seen {
355                        return Err(serde::de::Error::duplicate_field("Condition"));
356                    }
357
358                    condition_seen = true;
359                    builder.condition(access.next_value::<Condition>()?);
360                }
361                _ => {
362                    return Err(serde::de::Error::unknown_field(
363                        key,
364                        &[
365                            "Sid",
366                            "Effect",
367                            "Action",
368                            "NotAction",
369                            "Resource",
370                            "NotResource",
371                            "Principal",
372                            "NotPrincipal",
373                            "Condition",
374                        ],
375                    ));
376                }
377            }
378        }
379
380        builder.build().map_err(|e| match e {
381            StatementBuilderError::ValidationError(s) => {
382                let msg2 = s.replace('.', ";").trim_end_matches(|c| c == ';').to_string();
383                serde::de::Error::custom(StatementBuilderError::ValidationError(msg2))
384            }
385            _ => serde::de::Error::custom(e),
386        })
387    }
388}
389
390impl StatementBuilder {
391    fn validate(&self) -> Result<(), StatementBuilderError> {
392        let mut errors = Vec::with_capacity(5);
393        if self.effect.is_none() {
394            errors.push("Effect must be set.");
395        }
396
397        match (&self.action, &self.not_action) {
398            (Some(_), Some(_)) => errors.push("Action and NotAction cannot both be set."),
399            (None, None) => errors.push("Either Action or NotAction must be set."),
400            _ => (),
401        }
402
403        match (&self.resource, &self.not_resource) {
404            (Some(_), Some(_)) => errors.push("Resource and NotResource cannot both be set."),
405            (None, None) => errors.push("Either Resource or NotResource must be set."),
406            _ => (),
407        }
408
409        if let (Some(_), Some(_)) = (&self.principal, &self.not_principal) {
410            errors.push("Principal and NotPrincipal cannot both be set.");
411        }
412
413        if errors.is_empty() {
414            Ok(())
415        } else {
416            Err(StatementBuilderError::ValidationError(errors.join(" ")))
417        }
418    }
419}
420
421/// A list of statements. In JSON, this may be an object or an array of objects.
422pub type StatementList = MapList<Statement>;
423
424#[cfg(test)]
425mod tests {
426    use {
427        crate::{
428            Action, AwsPrincipal, Context, Decision, Effect, Policy, PolicyVersion, Principal, Resource,
429            SpecifiedPrincipal, Statement,
430        },
431        indoc::indoc,
432        pretty_assertions::assert_eq,
433        scratchstack_aws_principal::{Principal as PrincipalActor, PrincipalIdentity, SessionData, User},
434        std::str::FromStr,
435    };
436
437    #[test_log::test]
438    fn test_blank_policy_import() {
439        let policy = Policy::from_str(indoc! { r#"
440            {
441                "Version": "2012-10-17",
442                "Statement": []
443            }"# })
444        .unwrap();
445        assert_eq!(policy.version(), PolicyVersion::V2012_10_17);
446        assert!(policy.id().is_none());
447
448        let policy_str = policy.to_string();
449        assert_eq!(
450            policy_str,
451            indoc! { r#"
452            {
453                "Version": "2012-10-17",
454                "Statement": []
455            }"#}
456        );
457    }
458
459    #[test_log::test]
460    fn test_builder() {
461        let err = Statement::builder().build().unwrap_err();
462        assert_eq!(
463            err.to_string(),
464            "Effect must be set. Either Action or NotAction must be set. Either Resource or NotResource must be set."
465        );
466
467        let err = Statement::builder().effect(Effect::Allow).build().unwrap_err();
468        assert_eq!(
469            err.to_string(),
470            "Either Action or NotAction must be set. Either Resource or NotResource must be set."
471        );
472
473        let err = Statement::builder()
474            .effect(Effect::Allow)
475            .action(Action::from_str("ec2:RunInstances").unwrap())
476            .build()
477            .unwrap_err();
478        assert_eq!(err.to_string(), "Either Resource or NotResource must be set.");
479
480        let err = Statement::builder()
481            .effect(Effect::Allow)
482            .action(Action::from_str("ec2:RunInstances").unwrap())
483            .resource(Resource::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-01234567890abcdef").unwrap())
484            .principal(
485                SpecifiedPrincipal::builder().aws(AwsPrincipal::from_str("123456789012").unwrap()).build().unwrap(),
486            )
487            .not_principal(
488                SpecifiedPrincipal::builder().aws(AwsPrincipal::from_str("123456789012").unwrap()).build().unwrap(),
489            )
490            .build()
491            .unwrap_err();
492        assert_eq!(err.to_string(), "Principal and NotPrincipal cannot both be set.");
493
494        let s = Statement::builder()
495            .sid("sid1")
496            .effect(Effect::Allow)
497            .action(Action::from_str("ec2:RunInstances").unwrap())
498            .resource(Resource::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-01234567890abcdef").unwrap())
499            .principal(
500                SpecifiedPrincipal::builder().aws(AwsPrincipal::from_str("123456789012").unwrap()).build().unwrap(),
501            )
502            .build()
503            .unwrap();
504
505        assert_eq!(s.sid(), Some("sid1"));
506        assert_eq!(s.effect(), &Effect::Allow);
507        assert_eq!(s.action().unwrap().len(), 1);
508        assert_eq!(s.action().unwrap()[0].to_string(), "ec2:RunInstances");
509
510        let s2 = s.clone();
511        assert_eq!(s, s2);
512
513        let s = Statement::builder()
514            .sid("sid1")
515            .effect(Effect::Allow)
516            .action(Action::from_str("ec2:RunInstances").unwrap())
517            .not_resource(vec![
518                Resource::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-01234567890abcdef").unwrap(),
519                Resource::from_str("arn:aws:ec2:us-west-1:123456789012:instance/i-01234567890abcdef").unwrap(),
520            ])
521            .not_principal(
522                SpecifiedPrincipal::builder().aws(AwsPrincipal::from_str("123456789012").unwrap()).build().unwrap(),
523            )
524            .build()
525            .unwrap();
526
527        assert_eq!(s.not_resource().unwrap().len(), 2);
528        assert_eq!(
529            s.not_resource().unwrap()[0].to_string(),
530            "arn:aws:ec2:us-east-1:123456789012:instance/i-01234567890abcdef"
531        );
532        let principal = s.not_principal().unwrap();
533        if let Principal::Specified(specified) = principal {
534            assert_eq!(specified.aws().unwrap().len(), 1);
535            assert_eq!(specified.aws().unwrap()[0].to_string(), "123456789012");
536        } else {
537            panic!("not_principal is not SpecifiedPrincipal");
538        }
539    }
540
541    #[test_log::test]
542    fn test_context_without_resources() {
543        let mut sb = Statement::builder();
544        sb.effect(Effect::Allow).action(Action::Any).resource(Resource::Any);
545
546        let s = sb.build().unwrap();
547        let actor = PrincipalActor::from(vec![PrincipalIdentity::from(
548            User::new("aws", "123456789012", "/", "MyUser").unwrap(),
549        )]);
550        let sd = SessionData::new();
551        let context =
552            Context::builder().api("DescribeInstances").actor(actor).service("ec2").session_data(sd).build().unwrap();
553
554        assert_eq!(s.evaluate(&context, PolicyVersion::None).unwrap(), Decision::Allow);
555
556        sb.resource(Resource::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-01234567890abcdef").unwrap());
557        let s = sb.build().unwrap();
558        assert_eq!(s.evaluate(&context, PolicyVersion::None).unwrap(), Decision::DefaultDeny);
559
560        let mut sb = Statement::builder();
561        sb.effect(Effect::Allow).action(Action::Any).not_resource(Resource::Any);
562        let s = sb.build().unwrap();
563        assert_eq!(s.evaluate(&context, PolicyVersion::None).unwrap(), Decision::DefaultDeny);
564    }
565
566    #[test_log::test]
567    fn test_bad_actions() {
568        let policy_str = indoc! { r#"
569            {
570                "Version": "2012-10-17",
571                "Statement": {
572                    "Effect": "Allow",
573                    "Action": ["ec2:"],
574                    "Resource": "*",
575                    "Principal": {
576                        "AWS": ["arn:aws:"]
577                    }
578                }
579            }"# };
580        let e = Policy::from_str(policy_str).unwrap_err();
581        assert_eq!(e.to_string(), r#"Invalid action: ec2: at line 5 column 26"#);
582    }
583
584    #[test_log::test]
585    fn test_bad_principals() {
586        let policy_str = indoc! { r#"
587            {
588                "Version": "2012-10-17",
589                "Statement": {
590                    "Effect": "Allow",
591                    "Action": "*",
592                    "Resource": "*",
593                    "Principal": {
594                        "AWS": ["arn:aws:"]
595                    }
596                }
597            }"# };
598        let e = Policy::from_str(policy_str).unwrap_err();
599        assert_eq!(e.to_string(), r#"Invalid principal: arn:aws: at line 8 column 31"#);
600    }
601
602    #[test_log::test]
603    fn test_bad_resources() {
604        let policy_str = indoc! { r#"
605            {
606                "Version": "2012-10-17",
607                "Statement": {
608                    "Effect": "Allow",
609                    "Action": "*",
610                    "Resource": [2],
611                    "Principal": "*"
612                }
613            }"# };
614        let e = Policy::from_str(policy_str).unwrap_err();
615        assert_eq!(
616            e.to_string(),
617            r#"invalid value: sequence, expected Resource or list of Resource at line 6 column 23"#
618        );
619
620        let policy_str = indoc! { r#"
621            {
622                "Version": "2012-10-17",
623                "Statement": {
624                    "Effect": "Allow",
625                    "Action": "*",
626                    "Resource": ["foo-bar-baz"],
627                    "Principal": "*"
628                }
629            }"# };
630        let e = Policy::from_str(policy_str).unwrap_err();
631        assert_eq!(e.to_string(), r#"Invalid resource: foo-bar-baz at line 6 column 35"#);
632    }
633}