scratchstack_aspen/
policyset.rs

1use crate::{AspenError, Context, Decision, Policy};
2
3/// The source of a policy.
4#[derive(Clone, Debug, Eq, PartialEq, Hash)]
5pub enum PolicySource {
6    /// An inline policy directly attached to an IAM entity (user, role).
7    EntityInline {
8        /// The ARN of the entity.
9        entity_arn: String,
10
11        /// The IAM ID of the entity.
12        entity_id: String,
13
14        /// The name of the policy.
15        policy_name: String,
16    },
17
18    /// A managed policy that is attached to an IAM entity (user, role).
19    EntityAttachedPolicy {
20        /// The ARN of the of the policy.
21        policy_arn: String,
22
23        /// The IAM ID of the policy.
24        policy_id: String,
25
26        /// The version of the policy used.
27        version: String,
28    },
29
30    /// An inline policy directly attached to an IAM group that an IAM user ia a member of.
31    GroupInline {
32        /// The ARN of the IAM group.
33        group_arn: String,
34
35        /// The IAM ID of the group.
36        group_id: String,
37
38        /// The name of the policy.
39        policy_name: String,
40    },
41
42    /// A managed policy that is attached to an IAM group that an IAM user is a member of.
43    GroupAttachedPolicy {
44        /// The ARN of the of IAM group.
45        group_arn: String,
46
47        /// The IAM ID of the group.
48        group_id: String,
49
50        /// The ARN of the of the policy.
51        policy_arn: String,
52
53        /// The IAM ID of the policy.
54        policy_id: String,
55
56        /// The version of the policy used.
57        version: String,
58    },
59
60    /// A policy attached to a resource being accessed.
61    Resource {
62        /// The ARN of the resource being accessed.
63        resource_arn: String,
64
65        /// The name of the policy, if any.
66        policy_name: Option<String>,
67    },
68
69    /// A permissions boundary attached to an IAM entity (user, role).
70    PermissionBoundary {
71        /// The ARN of the the policy used as a permissions boundary.
72        policy_arn: String,
73
74        /// The IAM ID of the policy used as a permissions boundary.
75        policy_id: String,
76
77        /// The version of the policy used.
78        version: String,
79    },
80
81    /// An service control policy attached to an account or organizational unit.
82    OrgServiceControl {
83        /// The ARN of the the policy used as a service control policy.
84        policy_arn: String,
85
86        /// The name of the policy used as a service control policy.
87        policy_name: String,
88
89        /// The ARN of the account or organizational unit that the policy is attached to.
90        applied_arn: String,
91    },
92
93    /// A policy embedded in an assumed role session.
94    Session,
95}
96
97impl PolicySource {
98    /// Indicates whether the policy is being used permissions boundary.
99    ///
100    /// Permissions boundaries are used to limit the permissions in effect. Allow effects in a permissions boundary
101    /// do not grant permissions, but must be combined with an allow effect in a non-permissions boundary policy to
102    /// be effective. Absence of an allow effect in a permissions boundary is the same as a deny effect.
103    #[inline]
104    pub fn is_boundary(&self) -> bool {
105        matches!(
106            self,
107            PolicySource::PermissionBoundary { .. } | PolicySource::OrgServiceControl { .. } | PolicySource::Session
108        )
109    }
110
111    /// Create a new [PolicySource::EntityInline] object.
112    pub fn new_entity_inline<S1, S2, S3>(entity_arn: S1, entity_id: S2, policy_name: S3) -> Self
113    where
114        S1: Into<String>,
115        S2: Into<String>,
116        S3: Into<String>,
117    {
118        Self::EntityInline {
119            entity_arn: entity_arn.into(),
120            entity_id: entity_id.into(),
121            policy_name: policy_name.into(),
122        }
123    }
124
125    /// Create a new [PolicySource::EntityAttachedPolicy] object.
126    pub fn new_entity_attached_policy<S1, S2, S3>(policy_arn: S1, policy_id: S2, version: S3) -> Self
127    where
128        S1: Into<String>,
129        S2: Into<String>,
130        S3: Into<String>,
131    {
132        Self::EntityAttachedPolicy {
133            policy_arn: policy_arn.into(),
134            policy_id: policy_id.into(),
135            version: version.into(),
136        }
137    }
138
139    /// Create a new [PolicySource::GroupInline] object.
140    pub fn new_group_inline<S1, S2, S3>(group_arn: S1, group_id: S2, policy_name: S3) -> Self
141    where
142        S1: Into<String>,
143        S2: Into<String>,
144        S3: Into<String>,
145    {
146        Self::GroupInline {
147            group_arn: group_arn.into(),
148            group_id: group_id.into(),
149            policy_name: policy_name.into(),
150        }
151    }
152
153    /// Create a new [PolicySource::GroupAttachedPolicy] object.
154    pub fn new_group_attached_policy<S1, S2, S3, S4, S5>(
155        group_arn: S1,
156        group_id: S2,
157        policy_arn: S3,
158        policy_id: S4,
159        version: S5,
160    ) -> Self
161    where
162        S1: Into<String>,
163        S2: Into<String>,
164        S3: Into<String>,
165        S4: Into<String>,
166        S5: Into<String>,
167    {
168        Self::GroupAttachedPolicy {
169            group_arn: group_arn.into(),
170            group_id: group_id.into(),
171            policy_arn: policy_arn.into(),
172            policy_id: policy_id.into(),
173            version: version.into(),
174        }
175    }
176
177    /// Create a new [PolicySource::Resource] object.
178    pub fn new_resource<S1, S2>(resource_arn: S1, policy_name: Option<S2>) -> Self
179    where
180        S1: Into<String>,
181        S2: Into<String>,
182    {
183        Self::Resource {
184            resource_arn: resource_arn.into(),
185            policy_name: policy_name.map(|s| s.into()),
186        }
187    }
188
189    /// Create a new [PolicySource::PermissionBoundary] object.
190    pub fn new_permission_boundary<S1, S2, S3>(policy_arn: S1, policy_id: S2, version: S3) -> Self
191    where
192        S1: Into<String>,
193        S2: Into<String>,
194        S3: Into<String>,
195    {
196        Self::PermissionBoundary {
197            policy_arn: policy_arn.into(),
198            policy_id: policy_id.into(),
199            version: version.into(),
200        }
201    }
202
203    /// Create a new [PolicySource::OrgServiceControl] object.
204    pub fn new_org_service_control<S1, S2, S3>(policy_arn: S1, policy_name: S2, applied_arn: S3) -> Self
205    where
206        S1: Into<String>,
207        S2: Into<String>,
208        S3: Into<String>,
209    {
210        Self::OrgServiceControl {
211            policy_arn: policy_arn.into(),
212            policy_name: policy_name.into(),
213            applied_arn: applied_arn.into(),
214        }
215    }
216
217    /// Create a new [PolicySource::Session] object.
218    pub fn new_session() -> Self {
219        Self::Session
220    }
221}
222
223/// A set of policies being evaluated to determine the permissions in effect.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct PolicySet {
226    policies: Vec<(PolicySource, Policy)>,
227}
228
229impl PolicySet {
230    /// Create a new, empty policy set.
231    pub fn new() -> Self {
232        Self {
233            policies: vec![],
234        }
235    }
236
237    /// Add a policy to the set from the given source.
238    ///
239    /// # Example
240    ///
241    /// ```
242    /// # use scratchstack_aspen::{Policy, PolicySet, PolicySource};
243    /// # use std::str::FromStr;
244    /// let policy = Policy::from_str(r#"{"Statement": {"Effect": "Allow", "Action": "*", "Resource": "*"}}"#).unwrap();
245    /// let source = PolicySource::new_entity_inline("arn:aws:iam::123456789012:user/username", "AIDAEXAMPLEUSERID00", "PolicyName");
246    /// let mut policy_set = PolicySet::new();
247    /// policy_set.add_policy(source, policy);
248    ///
249    /// assert_eq!(policy_set.policies().len(), 1);
250    /// ```
251    pub fn add_policy(&mut self, source: PolicySource, policy: Policy) {
252        self.policies.push((source, policy));
253    }
254
255    /// Return the policies in the policy set.
256    pub fn policies(&self) -> &Vec<(PolicySource, Policy)> {
257        &self.policies
258    }
259
260    /// Evaluate the policy set. If a denial is found, return a Deny and the source immediately. Otherwise, if one or
261    /// more approvals are found, return Allow and the relevant sources. Otherwise, return a DefaultDeny with no
262    /// sources.
263    pub fn evaluate<'a>(&'a self, context: &'_ Context) -> Result<(Decision, Vec<&'a PolicySource>), AspenError> {
264        self.evaluate_core(context, false)
265    }
266
267    /// Evaluate all policies in the policy set. If one or more denials are found, return a Deny and the relevant
268    /// sources. Otherwise, if one or more approvals are found, return Allow and the relevant sources. Otherwise,
269    /// return a DefaultDeny with no sources.
270    pub fn evaluate_all<'a>(&'a self, context: &'_ Context) -> Result<(Decision, Vec<&'a PolicySource>), AspenError> {
271        self.evaluate_core(context, true)
272    }
273
274    fn evaluate_core<'a>(
275        &'a self,
276        context: &'_ Context,
277        eval_all: bool,
278    ) -> Result<(Decision, Vec<&'a PolicySource>), AspenError> {
279        let mut allowed_sources = Vec::with_capacity(self.policies.len());
280        let denied_len = if eval_all {
281            self.policies.len()
282        } else {
283            1
284        };
285        let mut denied_sources = Vec::with_capacity(denied_len);
286
287        for (source, policy) in &self.policies {
288            match policy.evaluate(context)? {
289                Decision::Allow => {
290                    if !source.is_boundary() {
291                        allowed_sources.push(source)
292                    }
293                }
294                Decision::Deny => {
295                    denied_sources.push(source);
296                    if !eval_all {
297                        return Ok((Decision::Deny, denied_sources));
298                    }
299                }
300                Decision::DefaultDeny => {
301                    if source.is_boundary() {
302                        denied_sources.push(source);
303                        if !eval_all {
304                            return Ok((Decision::Deny, denied_sources));
305                        }
306                    }
307                }
308            }
309        }
310
311        if !denied_sources.is_empty() {
312            Ok((Decision::Deny, denied_sources))
313        } else if !allowed_sources.is_empty() {
314            Ok((Decision::Allow, allowed_sources))
315        } else {
316            Ok((Decision::DefaultDeny, allowed_sources))
317        }
318    }
319}
320
321impl From<Vec<(PolicySource, Policy)>> for PolicySet {
322    fn from(policies: Vec<(PolicySource, Policy)>) -> Self {
323        Self {
324            policies,
325        }
326    }
327}
328
329impl Default for PolicySet {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use {
338        crate::{Context, Decision, Policy, PolicySet, PolicySource},
339        indoc::indoc,
340        pretty_assertions::{assert_eq, assert_ne},
341        scratchstack_arn::Arn,
342        scratchstack_aws_principal::{Principal, PrincipalIdentity, SessionData, SessionValue, User},
343        std::{
344            collections::hash_map::DefaultHasher,
345            hash::{Hash, Hasher},
346            str::FromStr,
347        },
348    };
349
350    #[test_log::test]
351    fn test_policy_source_derived() {
352        let policy_sources = vec![
353            PolicySource::new_entity_inline(
354                "arn:aws:iam::123456789012:user/MyUser",
355                "AIDAIXEXAMPLEID000000",
356                "MyPolicy",
357            ),
358            PolicySource::new_entity_attached_policy(
359                "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess",
360                "ANPAIXEXAMPLEID000000",
361                "v1",
362            ),
363            PolicySource::new_group_inline(
364                "arn:aws:iam::123456789012:group/MyGroup",
365                "AGPAIXEXAMPLEID000000",
366                "MyPolicy",
367            ),
368            PolicySource::new_group_attached_policy(
369                "arn:aws:iam::123456789012:group/MyGroup",
370                "AGPAIXEXAMPLEID000000",
371                "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess",
372                "AGPAIXEXAMPLEID000000",
373                "v1",
374            ),
375            PolicySource::new_resource("arn:aws:dynamodb:us-west-2:123456789012:table/MyTable", Some("MyTable")),
376            PolicySource::new_permission_boundary(
377                "arn:aws:iam::123456789012:policy/MyPermissionBoundary",
378                "APBAIXEXAMPLEID000000",
379                "v1",
380            ),
381            PolicySource::new_org_service_control(
382                "arn:aws:iam::123456789012:policy/MyOrgPolicy",
383                "ANPAIXEXAMPLEID000000",
384                "v1",
385            ),
386            PolicySource::new_session(),
387        ];
388
389        for i in 0..policy_sources.len() {
390            let mut h1 = DefaultHasher::new();
391            policy_sources[i].hash(&mut h1);
392            let h1 = h1.finish();
393
394            for j in 0..policy_sources.len() {
395                let mut h2 = DefaultHasher::new();
396                policy_sources[j].hash(&mut h2);
397                let h2 = h2.finish();
398
399                if i == j {
400                    assert_eq!(policy_sources[i], policy_sources[j]);
401                    assert_eq!(h1, h2);
402                    assert_eq!(format!("{:?}", policy_sources[i]), format!("{:?}", policy_sources[j]));
403                } else {
404                    assert_ne!(policy_sources[i], policy_sources[j]);
405                    assert_ne!(h1, h2);
406                    assert_ne!(format!("{:?}", policy_sources[i]), format!("{:?}", policy_sources[j]));
407                }
408            }
409        }
410    }
411
412    #[test_log::test]
413    #[allow(clippy::redundant_clone)]
414    fn test_eval() {
415        let mut ps = PolicySet::default();
416
417        let entity_inline_policy_source = PolicySource::new_entity_inline(
418            "arn:aws:iam::123456789012:user/MyUser",
419            "AIDAIXEXAMPLEID000000",
420            "MyPolicy",
421        );
422        let entity_inline_policy = Policy::from_str(indoc! {r#"
423        {
424            "Version": "2012-10-17",
425            "Statement": [
426                {
427                    "Effect": "Allow",
428                    "Action": "*",
429                    "Resource": "arn:aws:s3:::mybucket",
430                    "Condition": {
431                        "Bool": {
432                            "AllowBucketAccess": ["true"]
433                        }
434                    }
435                }
436            ]
437        }"#})
438        .unwrap();
439        ps.add_policy(entity_inline_policy_source.clone(), entity_inline_policy.clone());
440
441        let entity_attached_policy_source = PolicySource::new_entity_attached_policy(
442            "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess",
443            "ANPAIXEXAMPLEID000000",
444            "v1",
445        );
446        let entity_attached_policy = Policy::from_str(indoc! {r#"
447        {
448            "Version": "2012-10-17",
449            "Statement": [
450                {
451                    "Effect": "Allow",
452                    "Action": [
453                        "s3:GetObject",
454                        "s3:ListBucket",
455                        "s3:ListAllMyBuckets"
456                    ],
457                    "Resource": "arn:aws:s3:::*"
458                }
459            ]
460        }"#})
461        .unwrap();
462        ps.add_policy(entity_attached_policy_source.clone(), entity_attached_policy.clone());
463
464        let group_inline_policy_source = PolicySource::new_group_inline(
465            "arn:aws:iam::123456789012:group/MyGroup",
466            "AGPAIXEXAMPLEID000000",
467            "MyPolicy",
468        );
469        let group_inline_policy = Policy::from_str(indoc! {r#"
470        {
471            "Version": "2012-10-17",
472            "Statement": [
473                {
474                    "Effect": "Deny",
475                    "Action": "ec2:RunInstances",
476                    "Resource": "*"
477                },
478                {
479                    "Effect": "Deny",
480                    "Action": "ec2:RunInstances",
481                    "NotResource": "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
482                    "Principal": {
483                        "CanonicalUser": "9da4bcba2132ad952bba3c8ecb37e668d99b310ce313da30c98aba4cdf009a7d"
484                    }
485                }
486            ]
487        }"#})
488        .unwrap();
489        ps.add_policy(group_inline_policy_source.clone(), group_inline_policy.clone());
490
491        let group_attached_policy_source = PolicySource::new_group_attached_policy(
492            "arn:aws:iam::123456789012:group/MyGroup",
493            "AGPAIXEXAMPLEID000000",
494            "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess",
495            "AGPAIXEXAMPLEID000000",
496            "v1",
497        );
498        let group_attached_policy = Policy::from_str(indoc! {r#"
499        {
500            "Version": "2012-10-17",
501            "Statement": [
502                {
503                    "Effect": "Allow",
504                    "Action": [
505                        "ec2:Describe*"
506                    ],
507                    "Resource": "*",
508                    "Principal": "*"
509                }
510            ]
511        }"#})
512        .unwrap();
513        ps.add_policy(group_attached_policy_source.clone(), group_attached_policy.clone());
514
515        let resource_policy_source =
516            PolicySource::new_resource("arn:aws:dynamodb:us-west-2:123456789012:table/MyTable", Some("MyTable"));
517        let resource_policy = Policy::from_str(indoc! {r#"
518            {
519                "Version": "2012-10-17",
520                "Statement": [
521                    {
522                        "Effect": "Allow",
523                        "Action": [
524                            "dynamodb:DescribeTable",
525                            "dynamodb:ListTagsOfResource"
526                        ],
527                        "Resource": "arn:aws:dynamodb:us-west-2:123456789012:table/MyTable",
528                        "Principal": {
529                            "AWS": "arn:aws:iam::123456789012:user/MyUser"
530                        }
531                    }
532                ]
533            }
534        "#})
535        .unwrap();
536        ps.add_policy(resource_policy_source.clone(), resource_policy.clone());
537
538        let permission_boundary_policy_source = PolicySource::new_permission_boundary(
539            "arn:aws:iam::123456789012:policy/MyPermissionBoundary",
540            "APBAIXEXAMPLEID000000",
541            "v1",
542        );
543        let permission_boundary_policy = Policy::from_str(indoc! {r#"
544        {
545            "Version": "2012-10-17",
546            "Statement": [
547                {
548                    "Effect": "Allow",
549                    "NotAction": "iam:Create*",
550                    "Resource": "*"
551                }
552            ]
553        }"#})
554        .unwrap();
555        ps.add_policy(permission_boundary_policy_source.clone(), permission_boundary_policy.clone());
556
557        let org_service_control_policy_source = PolicySource::new_org_service_control(
558            "arn:aws:iam::123456789012:policy/MyOrgPolicy",
559            "ANPAIXEXAMPLEID000000",
560            "v1",
561        );
562        let org_service_control_policy = Policy::from_str(indoc! {r#"
563        {
564            "Version": "2012-10-17",
565            "Statement": [
566                {
567                    "Effect": "Deny",
568                    "Action": "iam:Delete*",
569                    "Resource": "*"
570                },
571                {
572                    "Effect": "Allow",
573                    "Action": "*",
574                    "Resource": "*"
575                }
576            ]
577        }"#})
578        .unwrap();
579        ps.add_policy(org_service_control_policy_source.clone(), org_service_control_policy.clone());
580
581        let session_source = PolicySource::new_session();
582        let session_policy = Policy::from_str(indoc! {r#"
583        {
584            "Version": "2012-10-17",
585            "Statement": [
586                {
587                    "Effect": "Allow",
588                    "Action": "*",
589                    "Resource": "*"
590                }
591            ]
592        }
593        "#})
594        .unwrap();
595        ps.add_policy(session_source.clone(), session_policy.clone());
596
597        assert_eq!(ps.policies().len(), 8);
598        let actor =
599            Principal::from(vec![PrincipalIdentity::from(User::new("aws", "123456789012", "/", "MyUser").unwrap())]);
600        let mut sd = SessionData::new();
601        sd.insert("aws:username", SessionValue::from("MyUser"));
602        let mut context_builder = Context::builder();
603        context_builder.api("DescribeSecurityGroups").actor(actor).session_data(sd).service("ec2");
604        let context = context_builder.build().unwrap();
605        let (decision, sources) = ps.evaluate_all(&context).unwrap();
606        assert_eq!(decision, Decision::Allow);
607        assert_eq!(sources.len(), 1);
608        assert_eq!(sources[0], &group_attached_policy_source);
609        assert_eq!(ps.evaluate(&context).unwrap().0, Decision::Allow);
610
611        context_builder.api("RunInstances");
612        let context = context_builder.build().unwrap();
613        let (decision, sources) = ps.evaluate_all(&context).unwrap();
614        assert_eq!(decision, Decision::Deny);
615        assert_eq!(sources.len(), 1);
616        assert_eq!(sources[0], &group_inline_policy_source);
617        assert_eq!(ps.evaluate(&context).unwrap().0, Decision::Deny);
618
619        context_builder
620            .api("DescribeTable")
621            .service("dynamodb")
622            .resources(vec![Arn::from_str("arn:aws:dynamodb:us-west-2:123456789012:table/MyTable").unwrap()]);
623        let context = context_builder.build().unwrap();
624        let (decision, sources) = ps.evaluate_all(&context).unwrap();
625        assert_eq!(decision, Decision::Allow);
626        assert_eq!(sources, vec![&resource_policy_source,]);
627        assert_eq!(ps.evaluate(&context).unwrap().0, Decision::Allow);
628
629        context_builder
630            .service("iam")
631            .api("CreateUser")
632            .resources(vec![Arn::from_str("arn:aws:iam::123456789012:user/MyUser").unwrap()]);
633        let context = context_builder.build().unwrap();
634        let (decision, sources) = ps.evaluate_all(&context).unwrap();
635        assert_eq!(decision, Decision::Deny);
636        assert_eq!(sources.len(), 1);
637        assert_eq!(sources[0], &permission_boundary_policy_source);
638        assert_eq!(ps.evaluate(&context).unwrap().0, Decision::Deny);
639
640        context_builder
641            .service("s3")
642            .api("DeleteBucket")
643            .resources(vec![Arn::from_str("arn:aws:s3:::notmybucket").unwrap()]);
644        let context = context_builder.build().unwrap();
645        let (decision, sources) = ps.evaluate_all(&context).unwrap();
646        assert_eq!(decision, Decision::DefaultDeny);
647        assert!(sources.is_empty());
648        assert_eq!(ps.evaluate(&context).unwrap().0, Decision::DefaultDeny);
649
650        let ps2 = PolicySet::from(vec![
651            (entity_inline_policy_source.clone(), entity_inline_policy.clone()),
652            (entity_attached_policy_source.clone(), entity_attached_policy.clone()),
653            (group_inline_policy_source.clone(), group_inline_policy.clone()),
654            (group_attached_policy_source.clone(), group_attached_policy.clone()),
655            (resource_policy_source.clone(), resource_policy.clone()),
656            (permission_boundary_policy_source.clone(), permission_boundary_policy.clone()),
657            (org_service_control_policy_source.clone(), org_service_control_policy.clone()),
658            (session_source.clone(), session_policy.clone()),
659        ]);
660
661        assert_eq!(ps, ps2);
662        assert_eq!(ps.clone(), ps);
663        assert_eq!(format!("{ps:?}"), format!("{ps2:?}"));
664    }
665}