1use super::{context::Context, matcher::ArnMatcher, request::IAMRequest};
2use crate::{
3 Arn, Validate,
4 core::{IAMAction, IAMEffect, IAMResource, Principal, PrincipalId},
5 evaluation::{
6 operator_eval::{evaluate_condition, wildcard_match},
7 variable::interpolate_variables,
8 },
9 policy::{ConditionBlock, IAMPolicy, IAMStatement},
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16pub enum Decision {
17 Allow,
19 Deny,
21 NotApplicable,
23}
24
25impl std::fmt::Display for Decision {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 serde_json::to_string(self)
28 .map_err(|_| std::fmt::Error)?
29 .trim_matches('"')
30 .fmt(f)
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
37pub enum EvaluationError {
38 InvalidRequest(String),
40 InvalidPolicy(String),
42 InvalidArn(String),
44 InvalidVariable(String),
46 ConditionError(String),
48 InternalError(String),
50}
51
52impl std::fmt::Display for EvaluationError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 EvaluationError::InvalidRequest(msg) => write!(f, "Invalid request: {msg}"),
56 EvaluationError::InvalidPolicy(msg) => write!(f, "Invalid policy: {msg}"),
57 EvaluationError::InvalidArn(msg) => write!(f, "Invalid ARN: {msg}"),
58 EvaluationError::InvalidVariable(msg) => write!(f, "Invalid variable: {msg}"),
59 EvaluationError::ConditionError(msg) => write!(f, "Condition error: {msg}"),
60 EvaluationError::InternalError(msg) => write!(f, "Internal error: {msg}"),
61 }
62 }
63}
64
65impl std::error::Error for EvaluationError {}
66
67#[derive(Debug, Clone, PartialEq, Serialize)]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70pub struct EvaluationResult {
71 pub decision: Decision,
73 pub statement_details: Vec<StatementMatch>,
77 pub context: IAMRequest,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize)]
83#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
84pub struct StatementMatch {
85 pub sid: Option<String>,
87 pub effect: IAMEffect,
89 pub conditions_satisfied: bool,
91 pub reason: String,
93}
94
95#[derive(Debug, Clone)]
97#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
98pub struct PolicyEvaluator {
99 policies: Vec<IAMPolicy>,
101 options: EvaluationOptions,
103}
104
105#[derive(Debug, Clone)]
107#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
108pub struct EvaluationOptions {
109 pub stop_on_explicit_deny: bool,
111 pub collect_match_details: bool,
113 pub max_statements: usize,
115 pub ignore_resource_constraints: bool,
117}
118
119impl Default for EvaluationOptions {
120 fn default() -> Self {
121 Self {
122 stop_on_explicit_deny: true,
123 collect_match_details: false,
124 max_statements: 1000,
125 ignore_resource_constraints: false,
126 }
127 }
128}
129
130impl PolicyEvaluator {
131 #[must_use]
133 pub fn new() -> Self {
134 Self {
135 policies: Vec::new(),
136 options: EvaluationOptions::default(),
137 }
138 }
139
140 #[must_use]
142 pub fn with_policies(policies: Vec<IAMPolicy>) -> Self {
143 Self {
144 policies,
145 options: EvaluationOptions::default(),
146 }
147 }
148
149 pub fn add_policy(&mut self, policy: IAMPolicy) {
151 self.policies.push(policy);
152 }
153
154 #[must_use]
156 pub fn with_options(mut self, options: EvaluationOptions) -> Self {
157 self.options = options;
158 self
159 }
160
161 pub fn evaluate(&self, request: &IAMRequest) -> Result<EvaluationResult, EvaluationError> {
172 if !request.principal.is_single() {
173 return Err(EvaluationError::InvalidRequest(
174 "Request principal must be a single entity".to_string(),
175 ));
176 }
177 if !request.principal.is_valid() {
178 return Err(EvaluationError::InvalidRequest(
179 "Invalid principal".to_string(),
180 ));
181 }
182 if request.action.is_empty() {
183 return Err(EvaluationError::InvalidRequest(
184 "Action cannot be empty".to_string(),
185 ));
186 }
187 if !request.resource.is_valid() && !self.options.ignore_resource_constraints {
188 return Err(EvaluationError::InvalidRequest(
189 "Invalid resource ARN".to_string(),
190 ));
191 }
192
193 let mut statement_details = Vec::new();
194 let mut has_explicit_allow = false;
195 let mut has_explicit_deny = false;
196 let mut statement_count = 0;
197
198 for policy in &self.policies {
200 for statement in &policy.statement {
201 statement_count += 1;
202 if statement_count > self.options.max_statements {
203 return Err(EvaluationError::InternalError(
204 "Maximum statement evaluation limit exceeded".to_string(),
205 ));
206 }
207
208 let statement_result = Self::evaluate_statement(statement, request, &self.options)?;
209
210 if self.options.collect_match_details {
211 statement_details.push(statement_result.clone());
212 }
213
214 if statement_result.conditions_satisfied {
216 match statement.effect {
217 IAMEffect::Allow => {
218 has_explicit_allow = true;
219 }
220 IAMEffect::Deny => {
221 has_explicit_deny = true;
222 if self.options.stop_on_explicit_deny {
223 return Ok(EvaluationResult {
224 decision: Decision::Deny,
225 statement_details,
226 context: request.clone(),
227 });
228 }
229 }
230 }
231 }
232 }
233 }
234
235 let decision = if has_explicit_deny {
238 Decision::Deny
239 } else if has_explicit_allow {
240 Decision::Allow
241 } else {
242 Decision::NotApplicable
243 };
244
245 Ok(EvaluationResult {
246 decision,
247 statement_details,
248 context: request.clone(),
249 })
250 }
251
252 fn evaluate_statement(
254 statement: &IAMStatement,
255 request: &IAMRequest,
256 options: &EvaluationOptions,
257 ) -> Result<StatementMatch, EvaluationError> {
258 if let Some(ref principal) = statement.principal
260 && !Self::principal_matches(principal, &request.principal)?
261 {
262 return Ok(StatementMatch {
263 sid: statement.sid.clone(),
264 effect: statement.effect,
265 conditions_satisfied: false,
266 reason: "Principal does not match".to_string(),
267 });
268 }
269
270 if let Some(ref not_principal) = statement.not_principal
271 && Self::principal_matches(not_principal, &request.principal)?
272 {
273 return Ok(StatementMatch {
274 sid: statement.sid.clone(),
275 effect: statement.effect,
276 conditions_satisfied: false,
277 reason: "Principal matches NotPrincipal exclusion".to_string(),
278 });
279 }
280
281 let action_matches = if let Some(ref action) = statement.action {
283 Self::action_matches(action, &request.action)
284 } else if let Some(ref not_action) = statement.not_action {
285 !Self::action_matches(not_action, &request.action)
286 } else {
287 return Ok(StatementMatch {
288 sid: statement.sid.clone(),
289 effect: statement.effect,
290 conditions_satisfied: false,
291 reason: "No action or not_action specified".to_string(),
292 });
293 };
294
295 if !action_matches {
296 return Ok(StatementMatch {
297 sid: statement.sid.clone(),
298 effect: statement.effect,
299 conditions_satisfied: false,
300 reason: "Action does not match".to_string(),
301 });
302 }
303
304 let resource_matches = if options.ignore_resource_constraints {
306 true
307 } else if let Some(ref resource) = statement.resource {
308 Self::resource_matches(resource, &request.resource, &request.context)?
309 } else if let Some(ref not_resource) = statement.not_resource {
310 !Self::resource_matches(not_resource, &request.resource, &request.context)?
311 } else {
312 return Ok(StatementMatch {
313 sid: statement.sid.clone(),
314 effect: statement.effect,
315 conditions_satisfied: false,
316 reason: "No resource or not_resource specified".to_string(),
317 });
318 };
319
320 if !resource_matches {
321 return Ok(StatementMatch {
322 sid: statement.sid.clone(),
323 effect: statement.effect,
324 conditions_satisfied: false,
325 reason: "Resource does not match".to_string(),
326 });
327 }
328
329 if let Some(ref condition_block) = statement.condition
331 && !Self::evaluate_conditions(condition_block, &request.context)?
332 {
333 return Ok(StatementMatch {
334 sid: statement.sid.clone(),
335 effect: statement.effect,
336 conditions_satisfied: false,
337 reason: "Conditions not satisfied".to_string(),
338 });
339 }
340
341 Ok(StatementMatch {
343 sid: statement.sid.clone(),
344 effect: statement.effect,
345 conditions_satisfied: true,
346 reason: "Statement fully matched".to_string(),
347 })
348 }
349
350 fn principal_matches(
352 principal: &Principal,
353 request_principal: &Principal,
354 ) -> Result<bool, EvaluationError> {
355 if !request_principal.is_single() {
356 return Err(EvaluationError::InvalidRequest(
357 "Request principal must be a single entity".to_string(),
358 ));
359 }
360
361 match (principal, request_principal) {
362 (Principal::Wildcard, _) | (_, Principal::Wildcard) => Ok(true),
364
365 (
369 Principal::Aws(principal_id),
370 Principal::Aws(PrincipalId::String(request_principal_id)),
371 ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
372 if id == "*" || id == request_principal_id {
376 return Ok(true);
377 }
378 if id.len() == 12 && id.chars().all(|c| c.is_ascii_digit()) {
380 if request_principal_id == id {
385 return Ok(true);
386 }
387 if let Ok(request_arn) = Arn::parse(request_principal_id)
388 && request_arn.service == "iam"
389 && request_arn.account_id == id
390 && request_arn.resource == "root"
391 {
392 return Ok(true);
393 }
394 }
395 if id.starts_with("arn:") {
397 return Self::principal_string_matches(id, request_principal_id);
398 }
399 Ok(false)
400 }),
401
402 (
406 Principal::Federated(principal_id),
407 Principal::Federated(PrincipalId::String(request_principal_id)),
408 ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
409 if id == request_principal_id {
412 return Ok(true);
413 }
414 if request_principal_id.starts_with(id) {
416 return Ok(true);
417 }
418 Ok(false)
419 }),
420
421 (
425 Principal::Service(principal_id),
426 Principal::Service(PrincipalId::String(request_principal_id)),
427 ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
428 if id == request_principal_id {
431 return Ok(true);
432 }
433 Ok(false)
434 }),
435
436 (
440 Principal::CanonicalUser(principal_id),
441 Principal::CanonicalUser(PrincipalId::String(request_principal_id)),
442 ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
443 if id == request_principal_id {
445 return Ok(true);
446 }
447 Ok(false)
448 }),
449 _ => {
450 Ok(false)
452 }
453 }
454 }
455
456 fn principal_id_matches<F>(
458 principal_id: &PrincipalId,
459 _request_principal: &str,
460 matcher: F,
461 ) -> Result<bool, EvaluationError>
462 where
463 F: Fn(&str) -> Result<bool, EvaluationError>,
464 {
465 match principal_id {
466 PrincipalId::String(id) => matcher(id),
467 PrincipalId::Array(ids) => {
468 for id in ids {
470 if matcher(id)? {
471 return Ok(true);
472 }
473 }
474 Ok(false)
475 }
476 }
477 }
478
479 fn principal_string_matches(
481 principal_str: &str,
482 request_principal: &str,
483 ) -> Result<bool, EvaluationError> {
484 if principal_str == "*" || principal_str == request_principal {
485 Ok(true)
486 } else if principal_str.starts_with("arn:") {
487 let matcher = ArnMatcher::from_pattern(principal_str)
489 .map_err(|e| EvaluationError::InvalidArn(e.to_string()))?;
490
491 if request_principal.len() == 12
497 && request_principal.chars().all(|c| c.is_ascii_digit())
498 {
499 let Ok(pattern_arn) = Arn::parse(principal_str) else {
500 return Ok(false);
501 };
502 let root_arn = Arn {
503 partition: pattern_arn.partition.clone(),
504 service: "iam".to_string(),
505 region: String::new(),
506 account_id: request_principal.to_string(),
507 resource: "root".to_string(),
508 };
509 return matcher
510 .matches(&root_arn)
511 .map_err(|e| EvaluationError::InvalidArn(e.to_string()));
512 }
513
514 let Ok(request_arn) = Arn::parse(request_principal) else {
516 return Ok(false);
517 };
518 matcher
519 .matches(&request_arn)
520 .map_err(|e| EvaluationError::InvalidArn(e.to_string()))
521 } else {
522 Ok(false)
523 }
524 }
525
526 fn action_matches(action: &IAMAction, request_action: &str) -> bool {
532 let request_action = request_action.to_ascii_lowercase();
533 let pattern_matches =
534 |a: &str| a == "*" || wildcard_match(&request_action, &a.to_ascii_lowercase());
535 match action {
536 IAMAction::Single(a) => pattern_matches(a),
537 IAMAction::Multiple(actions) => actions.iter().any(|a| pattern_matches(a)),
538 }
539 }
540
541 fn resource_matches(
543 resource: &IAMResource,
544 request_resource: &Arn,
545 context: &Context,
546 ) -> Result<bool, EvaluationError> {
547 match resource {
548 IAMResource::Single(r) => {
549 if r == "*" {
550 Ok(true)
551 } else {
552 let interpolated = interpolate_variables(r, context)?;
554
555 let matcher = ArnMatcher::from_pattern(&interpolated)
557 .map_err(|e| EvaluationError::InvalidArn(e.to_string()))?;
558 matcher
559 .matches(request_resource)
560 .map_err(|e| EvaluationError::InvalidArn(e.to_string()))
561 }
562 }
563 IAMResource::Multiple(resources) => {
564 for r in resources {
565 if Self::resource_matches(
566 &IAMResource::Single(r.clone()),
567 request_resource,
568 context,
569 )? {
570 return Ok(true);
571 }
572 }
573 Ok(false)
574 }
575 }
576 }
577
578 fn evaluate_conditions(
580 condition_block: &ConditionBlock,
581 context: &Context,
582 ) -> Result<bool, EvaluationError> {
583 for (operator, condition_map) in &condition_block.conditions {
585 for (key, value) in condition_map {
586 if !evaluate_condition(context, operator, key, &value.to_json_value())? {
587 return Ok(false);
588 }
589 }
590 }
591 Ok(true)
592 }
593}
594
595impl Default for PolicyEvaluator {
596 fn default() -> Self {
597 Self::new()
598 }
599}
600
601pub fn evaluate_policy(
611 policy: &IAMPolicy,
612 request: &IAMRequest,
613) -> Result<Decision, EvaluationError> {
614 let evaluator = PolicyEvaluator::with_policies(vec![policy.clone()]);
615 let result = evaluator.evaluate(request)?;
616 Ok(result.decision)
617}
618
619pub fn evaluate_policies(
629 policies: &[IAMPolicy],
630 request: &IAMRequest,
631) -> Result<Decision, EvaluationError> {
632 let evaluator = PolicyEvaluator::with_policies(policies.to_vec());
633 let result = evaluator.evaluate(request)?;
634 Ok(result.decision)
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use crate::{
641 Arn, ConditionValue, ContextValue, IAMAction, IAMEffect, IAMOperator, IAMResource,
642 IAMStatement,
643 };
644
645 #[test]
646 fn test_simple_allow_policy() {
647 let policy = IAMPolicy::new().add_statement(
648 IAMStatement::new(IAMEffect::Allow)
649 .with_action(IAMAction::Single("s3:GetObject".to_string()))
650 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
651 );
652
653 let request = IAMRequest::new(
654 Principal::Aws(PrincipalId::String(
655 "arn:aws:iam::123456789012:user/test".into(),
656 )),
657 "s3:GetObject",
658 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
659 );
660
661 let result = evaluate_policy(&policy, &request).unwrap();
662 assert_eq!(result, Decision::Allow);
663 }
664
665 #[test]
666 fn test_simple_deny_policy() {
667 let policy = IAMPolicy::new().add_statement(
668 IAMStatement::new(IAMEffect::Deny)
669 .with_action(IAMAction::Single("s3:DeleteObject".to_string()))
670 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
671 );
672
673 let request = IAMRequest::new(
674 Principal::Aws(PrincipalId::String(
675 "arn:aws:iam::123456789012:user/test".into(),
676 )),
677 "s3:DeleteObject",
678 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
679 );
680
681 let result = evaluate_policy(&policy, &request).unwrap();
682 assert_eq!(result, Decision::Deny);
683 }
684
685 #[test]
686 fn test_not_applicable_policy() {
687 let policy = IAMPolicy::new().add_statement(
688 IAMStatement::new(IAMEffect::Allow)
689 .with_action(IAMAction::Single("s3:GetObject".to_string()))
690 .with_resource(IAMResource::Single(
691 "arn:aws:s3:::other-bucket/*".to_string(),
692 )),
693 );
694
695 let request = IAMRequest::new(
696 Principal::Aws(PrincipalId::String(
697 "arn:aws:iam::123456789012:user/test".into(),
698 )),
699 "s3:GetObject",
700 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
701 );
702
703 let result = evaluate_policy(&policy, &request).unwrap();
704 assert_eq!(result, Decision::NotApplicable);
705 }
706
707 #[test]
708 fn test_wildcard_action_matching() {
709 let policy = IAMPolicy::new().add_statement(
710 IAMStatement::new(IAMEffect::Allow)
711 .with_action(IAMAction::Single("s3:*".to_string()))
712 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
713 );
714
715 let request = IAMRequest::new(
716 Principal::Aws(PrincipalId::String(
717 "arn:aws:iam::123456789012:user/test".into(),
718 )),
719 "s3:GetObject",
720 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
721 );
722
723 let result = evaluate_policy(&policy, &request).unwrap();
724 assert_eq!(result, Decision::Allow);
725 }
726
727 #[test]
728 fn test_action_case_insensitive() {
729 let policy = IAMPolicy::new().add_statement(
731 IAMStatement::new(IAMEffect::Allow)
732 .with_action(IAMAction::Single("s3:GetObject".to_string()))
733 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
734 );
735
736 let request = IAMRequest::new(
737 Principal::Aws(PrincipalId::String(
738 "arn:aws:iam::123456789012:user/test".into(),
739 )),
740 "S3:getobject", Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
742 );
743
744 let result = evaluate_policy(&policy, &request).unwrap();
745 assert_eq!(result, Decision::Allow);
746 }
747
748 #[test]
749 fn test_action_multiple_case_insensitive() {
750 let policy = IAMPolicy::new().add_statement(
751 IAMStatement::new(IAMEffect::Allow)
752 .with_action(IAMAction::Multiple(vec![
753 "s3:GetObject".to_string(),
754 "s3:PutObject".to_string(),
755 ]))
756 .with_resource(IAMResource::Single("*".to_string())),
757 );
758
759 let request = IAMRequest::new(
760 Principal::Aws(PrincipalId::String(
761 "arn:aws:iam::123456789012:user/test".into(),
762 )),
763 "S3:PUTOBJECT",
764 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
765 );
766
767 let result = evaluate_policy(&policy, &request).unwrap();
768 assert_eq!(result, Decision::Allow);
769 }
770
771 #[test]
772 fn test_action_wildcard_case_insensitive() {
773 let policy = IAMPolicy::new().add_statement(
774 IAMStatement::new(IAMEffect::Allow)
775 .with_action(IAMAction::Single("iam:*AccessKey*".to_string()))
776 .with_resource(IAMResource::Single("*".to_string())),
777 );
778
779 let request = IAMRequest::new(
780 Principal::Aws(PrincipalId::String(
781 "arn:aws:iam::123456789012:user/test".into(),
782 )),
783 "IAM:listaccesskeys",
784 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
785 );
786
787 let result = evaluate_policy(&policy, &request).unwrap();
788 assert_eq!(result, Decision::Allow);
789 }
790
791 #[test]
792 fn test_condition_evaluation() {
793 use crate::IAMOperator;
794
795 let mut context = Context::new();
796 context.insert(
797 "aws:userid".to_string(),
798 ContextValue::String("test-user".to_string()),
799 );
800
801 let policy = IAMPolicy::new().add_statement(
802 IAMStatement::new(IAMEffect::Allow)
803 .with_action(IAMAction::Single("s3:GetObject".to_string()))
804 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string()))
805 .with_condition(
806 IAMOperator::StringEquals,
807 "aws:userid".to_string(),
808 ConditionValue::String("test-user".to_string()),
809 ),
810 );
811
812 let request = IAMRequest::new_with_context(
813 Principal::Aws(PrincipalId::String(
814 "arn:aws:iam::123456789012:user/test".into(),
815 )),
816 "s3:GetObject",
817 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
818 context,
819 );
820
821 let result = evaluate_policy(&policy, &request).unwrap();
822 assert_eq!(result, Decision::Allow);
823 }
824
825 #[test]
826 fn test_condition_evaluation_failure() {
827 use crate::IAMOperator;
828
829 let mut context = Context::new();
830 context.insert(
831 "aws:userid".to_string(),
832 ContextValue::String("other-user".to_string()),
833 );
834
835 let policy = IAMPolicy::new().add_statement(
836 IAMStatement::new(IAMEffect::Allow)
837 .with_action(IAMAction::Single("s3:GetObject".to_string()))
838 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string()))
839 .with_condition(
840 IAMOperator::StringEquals,
841 "aws:userid".to_string(),
842 ConditionValue::String("test-user".to_string()),
843 ),
844 );
845
846 let request = IAMRequest::new_with_context(
847 Principal::Aws(PrincipalId::String(
848 "arn:aws:iam::123456789012:user/test".into(),
849 )),
850 "s3:GetObject",
851 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
852 context,
853 );
854
855 let result = evaluate_policy(&policy, &request).unwrap();
856 assert_eq!(result, Decision::NotApplicable);
857 }
858
859 #[test]
860 fn test_deny_with_negated_condition_on_missing_key() {
861 let policy = IAMPolicy::new().add_statement(
864 IAMStatement::new(IAMEffect::Deny)
865 .with_action(IAMAction::Single("s3:GetObject".to_string()))
866 .with_resource(IAMResource::Single("*".to_string()))
867 .with_condition(
868 IAMOperator::StringNotEquals,
869 "aws:SecureTransport".to_string(),
870 ConditionValue::String("true".to_string()),
871 ),
872 );
873
874 let request = IAMRequest::new(
876 Principal::Aws(PrincipalId::String(
877 "arn:aws:iam::123456789012:user/test".into(),
878 )),
879 "s3:GetObject",
880 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
881 );
882
883 let result = evaluate_policy(&policy, &request).unwrap();
884 assert_eq!(result, Decision::Deny);
885 }
886
887 #[test]
888 fn test_for_all_values_allow() {
889 let mut ctx = Context::new();
892 ctx.insert(
893 "aws:TagKeys".to_string(),
894 ContextValue::StringList(vec!["environment".to_string(), "cost-center".to_string()]),
895 );
896
897 let policy = IAMPolicy::new().add_statement(
898 IAMStatement::new(IAMEffect::Allow)
899 .with_action(IAMAction::Single("s3:GetObject".to_string()))
900 .with_resource(IAMResource::Single("*".to_string()))
901 .with_condition(
902 IAMOperator::ForAllValuesStringEquals,
903 "aws:TagKeys".to_string(),
904 ConditionValue::StringList(vec!["environment".into(), "cost-center".into()]),
905 ),
906 );
907
908 let mut request = IAMRequest::new(
909 Principal::Aws(PrincipalId::String(
910 "arn:aws:iam::123456789012:user/test".into(),
911 )),
912 "s3:GetObject",
913 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
914 );
915 request.context = ctx;
916
917 let result = evaluate_policy(&policy, &request).unwrap();
918 assert_eq!(result, Decision::Allow);
919 }
920
921 #[test]
922 fn test_for_all_values_negated_deny_region() {
923 let policy = IAMPolicy::new().add_statement(
926 IAMStatement::new(IAMEffect::Deny)
927 .with_action(IAMAction::Single("*".to_string()))
928 .with_resource(IAMResource::Single("*".to_string()))
929 .with_condition(
930 IAMOperator::ForAllValuesStringNotEquals,
931 "aws:RequestedRegion".to_string(),
932 ConditionValue::StringList(vec!["eu-central-1".into(), "eu-west-1".into()]),
933 ),
934 );
935
936 let mut ctx = Context::new();
938 ctx.insert(
939 "aws:RequestedRegion".to_string(),
940 ContextValue::String("eu-central-1".to_string()),
941 );
942 let mut request = IAMRequest::new(
943 Principal::Aws(PrincipalId::String(
944 "arn:aws:iam::123456789012:user/test".into(),
945 )),
946 "s3:GetObject",
947 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
948 );
949 request.context = ctx;
950 let result = evaluate_policy(&policy, &request).unwrap();
951 assert_eq!(result, Decision::NotApplicable);
952
953 let mut ctx = Context::new();
955 ctx.insert(
956 "aws:RequestedRegion".to_string(),
957 ContextValue::String("us-east-1".to_string()),
958 );
959 let mut request = IAMRequest::new(
960 Principal::Aws(PrincipalId::String(
961 "arn:aws:iam::123456789012:user/test".into(),
962 )),
963 "s3:GetObject",
964 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
965 );
966 request.context = ctx;
967 let result = evaluate_policy(&policy, &request).unwrap();
968 assert_eq!(result, Decision::Deny);
969 }
970
971 #[test]
972 fn test_null_condition_string_form() {
973 let policy = IAMPolicy::new().add_statement(
975 IAMStatement::new(IAMEffect::Allow)
976 .with_action(IAMAction::Single("s3:GetObject".to_string()))
977 .with_resource(IAMResource::Single("*".to_string()))
978 .with_condition(
979 IAMOperator::Null,
980 "aws:TokenIssueTime".to_string(),
981 ConditionValue::String("false".to_string()), ),
983 );
984
985 let mut ctx = Context::new();
987 ctx.insert(
988 "aws:TokenIssueTime".to_string(),
989 ContextValue::String("2024-01-01T00:00:00Z".to_string()),
990 );
991 let mut request = IAMRequest::new(
992 Principal::Aws(PrincipalId::String(
993 "arn:aws:iam::123456789012:user/test".into(),
994 )),
995 "s3:GetObject",
996 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
997 );
998 request.context = ctx;
999 let result = evaluate_policy(&policy, &request).unwrap();
1000 assert_eq!(result, Decision::Allow);
1001
1002 let request = IAMRequest::new(
1004 Principal::Aws(PrincipalId::String(
1005 "arn:aws:iam::123456789012:user/test".into(),
1006 )),
1007 "s3:GetObject",
1008 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1009 );
1010 let result = evaluate_policy(&policy, &request).unwrap();
1011 assert_eq!(result, Decision::NotApplicable);
1012 }
1013
1014 #[test]
1015 fn test_float_numeric_condition() {
1016 let policy_json = r#"{
1017 "Version": "2012-10-17",
1018 "Statement": [{
1019 "Effect": "Allow",
1020 "Action": "s3:GetObject",
1021 "Resource": "*",
1022 "Condition": {"NumericLessThan": {"aws:multifactorAuthAge": 1234.5}}
1023 }]
1024 }"#;
1025 let policy = IAMPolicy::from_json(policy_json).unwrap();
1026
1027 let mut ctx = Context::new();
1029 ctx.insert(
1030 "aws:multifactorAuthAge".to_string(),
1031 ContextValue::Number(500.0),
1032 );
1033 let mut request = IAMRequest::new(
1034 Principal::Aws(PrincipalId::String(
1035 "arn:aws:iam::123456789012:user/test".into(),
1036 )),
1037 "s3:GetObject",
1038 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1039 );
1040 request.context = ctx;
1041 let result = evaluate_policy(&policy, &request).unwrap();
1042 assert_eq!(result, Decision::Allow);
1043
1044 let mut ctx = Context::new();
1046 ctx.insert(
1047 "aws:multifactorAuthAge".to_string(),
1048 ContextValue::Number(2000.0),
1049 );
1050 let mut request = IAMRequest::new(
1051 Principal::Aws(PrincipalId::String(
1052 "arn:aws:iam::123456789012:user/test".into(),
1053 )),
1054 "s3:GetObject",
1055 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1056 );
1057 request.context = ctx;
1058 let result = evaluate_policy(&policy, &request).unwrap();
1059 assert_eq!(result, Decision::NotApplicable);
1060 }
1061
1062 #[test]
1063 fn test_plain_operator_multivalued_context() {
1064 let policy = IAMPolicy::new().add_statement(
1067 IAMStatement::new(IAMEffect::Allow)
1068 .with_action(IAMAction::Single("ec2:DeleteTags".to_string()))
1069 .with_resource(IAMResource::Single("*".to_string()))
1070 .with_condition(
1071 IAMOperator::StringEquals,
1072 "aws:TagKeys".to_string(),
1073 ConditionValue::String("environment".to_string()),
1074 ),
1075 );
1076
1077 let mut ctx = Context::new();
1079 ctx.insert(
1080 "aws:TagKeys".to_string(),
1081 ContextValue::StringList(vec!["environment".to_string(), "dept".to_string()]),
1082 );
1083 let mut request = IAMRequest::new(
1084 Principal::Aws(PrincipalId::String(
1085 "arn:aws:iam::123456789012:user/test".into(),
1086 )),
1087 "ec2:DeleteTags",
1088 Arn::parse("arn:aws:ec2:us-east-1:123456789012:instance/i-123").unwrap(),
1089 );
1090 request.context = ctx;
1091 let result = evaluate_policy(&policy, &request).unwrap();
1092 assert_eq!(result, Decision::Allow);
1093
1094 let mut ctx = Context::new();
1096 ctx.insert(
1097 "aws:TagKeys".to_string(),
1098 ContextValue::StringList(vec!["dept".to_string()]),
1099 );
1100 let mut request = IAMRequest::new(
1101 Principal::Aws(PrincipalId::String(
1102 "arn:aws:iam::123456789012:user/test".into(),
1103 )),
1104 "ec2:DeleteTags",
1105 Arn::parse("arn:aws:ec2:us-east-1:123456789012:instance/i-123").unwrap(),
1106 );
1107 request.context = ctx;
1108 let result = evaluate_policy(&policy, &request).unwrap();
1109 assert_eq!(result, Decision::NotApplicable);
1110 }
1111
1112 #[test]
1113 fn test_context_key_case_insensitive() {
1114 let policy = IAMPolicy::new().add_statement(
1116 IAMStatement::new(IAMEffect::Allow)
1117 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1118 .with_resource(IAMResource::Single("*".to_string()))
1119 .with_condition(
1120 IAMOperator::StringEquals,
1121 "aws:username".to_string(),
1122 ConditionValue::String("alice".to_string()),
1123 ),
1124 );
1125
1126 let mut ctx = Context::new();
1128 ctx.insert(
1129 "AWS:UserName".to_string(),
1130 ContextValue::String("alice".to_string()),
1131 );
1132 let mut request = IAMRequest::new(
1133 Principal::Aws(PrincipalId::String(
1134 "arn:aws:iam::123456789012:user/test".into(),
1135 )),
1136 "s3:GetObject",
1137 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1138 );
1139 request.context = ctx;
1140 let result = evaluate_policy(&policy, &request).unwrap();
1141 assert_eq!(result, Decision::Allow);
1142 }
1143
1144 #[test]
1145 fn test_explicit_deny_overrides_allow() {
1146 let policies = vec![
1147 IAMPolicy::new().add_statement(
1148 IAMStatement::new(IAMEffect::Allow)
1149 .with_action(IAMAction::Single("s3:*".to_string()))
1150 .with_resource(IAMResource::Single("*".to_string())),
1151 ),
1152 IAMPolicy::new().add_statement(
1153 IAMStatement::new(IAMEffect::Deny)
1154 .with_action(IAMAction::Single("s3:DeleteObject".to_string()))
1155 .with_resource(IAMResource::Single(
1156 "arn:aws:s3:::protected-bucket/*".to_string(),
1157 )),
1158 ),
1159 ];
1160
1161 let request = IAMRequest::new(
1162 Principal::Aws(PrincipalId::String(
1163 "arn:aws:iam::123456789012:user/test".into(),
1164 )),
1165 "s3:DeleteObject",
1166 Arn::parse("arn:aws:s3:::protected-bucket/file.txt").unwrap(),
1167 );
1168
1169 let result = evaluate_policies(&policies, &request).unwrap();
1170 assert_eq!(result, Decision::Deny);
1171 }
1172
1173 #[test]
1174 fn test_non_arn_principal_does_not_panic() {
1175 let policy = IAMPolicy::new().add_statement(
1179 IAMStatement::new(IAMEffect::Allow)
1180 .with_principal(Principal::Aws(PrincipalId::String(
1181 "arn:aws:iam::123456789012:role/*".to_string(),
1182 )))
1183 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1184 .with_resource(IAMResource::Single("*".to_string())),
1185 );
1186
1187 let request = IAMRequest::new(
1188 Principal::Aws(PrincipalId::String("123456789012".to_string())),
1189 "s3:GetObject",
1190 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1191 );
1192
1193 let result = evaluate_policy(&policy, &request).unwrap();
1195 assert_eq!(result, Decision::NotApplicable);
1196 }
1197
1198 #[test]
1199 fn test_account_id_principal_matches_root_arn_any_partition() {
1200 let policy = IAMPolicy::new().add_statement(
1203 IAMStatement::new(IAMEffect::Allow)
1204 .with_principal(Principal::Aws(PrincipalId::String(
1205 "123456789012".to_string(),
1206 )))
1207 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1208 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
1209 );
1210
1211 for root in [
1212 "arn:aws:iam::123456789012:root",
1213 "arn:aws-cn:iam::123456789012:root",
1214 "arn:aws-us-gov:iam::123456789012:root",
1215 ] {
1216 let request = IAMRequest::new(
1217 Principal::Aws(PrincipalId::String(root.to_string())),
1218 "s3:GetObject",
1219 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1220 );
1221 let result = evaluate_policy(&policy, &request).unwrap();
1222 assert_eq!(
1223 result,
1224 Decision::Allow,
1225 "root principal {root} should match"
1226 );
1227 }
1228
1229 let request = IAMRequest::new(
1231 Principal::Aws(PrincipalId::String(
1232 "arn:aws:iam::999999999999:root".to_string(),
1233 )),
1234 "s3:GetObject",
1235 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1236 );
1237 let result = evaluate_policy(&policy, &request).unwrap();
1238 assert_eq!(result, Decision::NotApplicable);
1239 }
1240
1241 #[test]
1242 fn test_raw_account_id_request_principal_matches_root_arn() {
1243 let root_policy = IAMPolicy::new().add_statement(
1247 IAMStatement::new(IAMEffect::Allow)
1248 .with_principal(Principal::Aws(PrincipalId::String(
1249 "arn:aws:iam::123456789012:root".to_string(),
1250 )))
1251 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1252 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
1253 );
1254
1255 let request = IAMRequest::new(
1256 Principal::Aws(PrincipalId::String("123456789012".to_string())),
1257 "s3:GetObject",
1258 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1259 );
1260 let result = evaluate_policy(&root_policy, &request).unwrap();
1261 assert_eq!(result, Decision::Allow);
1262
1263 let wildcard_policy = IAMPolicy::new().add_statement(
1265 IAMStatement::new(IAMEffect::Allow)
1266 .with_principal(Principal::Aws(PrincipalId::String(
1267 "arn:aws:iam::123456789012:*".to_string(),
1268 )))
1269 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1270 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
1271 );
1272 let result = evaluate_policy(&wildcard_policy, &request).unwrap();
1273 assert_eq!(result, Decision::Allow);
1274
1275 let user_policy = IAMPolicy::new().add_statement(
1278 IAMStatement::new(IAMEffect::Allow)
1279 .with_principal(Principal::Aws(PrincipalId::String(
1280 "arn:aws:iam::123456789012:user/jo*".to_string(),
1281 )))
1282 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1283 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
1284 );
1285 let result = evaluate_policy(&user_policy, &request).unwrap();
1286 assert_eq!(result, Decision::NotApplicable);
1287 }
1288
1289 #[test]
1290 fn test_numeric_condition() {
1291 let mut context = Context::new();
1292 context.insert("aws:RequestedRegion".to_string(), ContextValue::Number(5.0));
1293
1294 let policy = IAMPolicy::new().add_statement(
1295 IAMStatement::new(IAMEffect::Allow)
1296 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1297 .with_resource(IAMResource::Single("*".to_string()))
1298 .with_condition(
1299 IAMOperator::NumericLessThan,
1300 "aws:RequestedRegion".to_string(),
1301 ConditionValue::Number(10.into()),
1302 ),
1303 );
1304
1305 let request = IAMRequest::new_with_context(
1306 Principal::Aws(PrincipalId::String(
1307 "arn:aws:iam::123456789012:user/test".into(),
1308 )),
1309 "s3:GetObject",
1310 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1311 context,
1312 );
1313
1314 let result = evaluate_policy(&policy, &request).unwrap();
1315 assert_eq!(result, Decision::Allow);
1316 }
1317
1318 #[test]
1319 fn test_evaluator_with_options() {
1320 let policy = IAMPolicy::new().add_statement(
1321 IAMStatement::new(IAMEffect::Allow)
1322 .with_sid("AllowS3Read")
1323 .with_action(IAMAction::Single("s3:GetObject".to_string()))
1324 .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
1325 );
1326
1327 let request = IAMRequest::new(
1328 Principal::Aws(PrincipalId::String(
1329 "arn:aws:iam::123456789012:user/test".into(),
1330 )),
1331 "s3:GetObject",
1332 Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
1333 );
1334
1335 let evaluator =
1336 PolicyEvaluator::with_policies(vec![policy]).with_options(EvaluationOptions {
1337 collect_match_details: true,
1338 ..Default::default()
1339 });
1340
1341 let result = evaluator.evaluate(&request).unwrap();
1342 assert_eq!(result.decision, Decision::Allow);
1343 assert!(!result.statement_details.is_empty());
1344 assert_eq!(
1345 result.statement_details[0].sid,
1346 Some("AllowS3Read".to_string())
1347 );
1348 }
1349
1350 #[derive(Debug, Clone, Serialize, Deserialize)]
1351 struct TestCase {
1352 result: Decision,
1353 request: IAMRequest,
1354 policy: IAMPolicy,
1355 }
1356
1357 #[test]
1358 fn test_requests_testset() {
1359 let request_dir = "tests/requests";
1361 let mut request_files = std::fs::read_dir(request_dir)
1362 .unwrap_or_else(|e| panic!("Failed to read requests directory '{request_dir}': {e}"))
1363 .filter_map(|entry| {
1364 let entry = entry.ok()?;
1365 let path = entry.path();
1366 if path.extension()? == "json" {
1367 Some(path)
1368 } else {
1369 None
1370 }
1371 })
1372 .collect::<Vec<_>>();
1373
1374 assert!(
1376 !request_files.is_empty(),
1377 "No request JSON files found in {request_dir}/"
1378 );
1379
1380 request_files.sort_by_key(|p| {
1383 p.file_name()
1384 .and_then(|n| n.to_str())
1385 .map(|s| s.split('.').next().unwrap().parse::<u32>().unwrap())
1386 .map(|n| format!("{n:010}"))
1387 });
1388
1389 println!(
1390 "Testing {} request files from {}/",
1391 request_files.len(),
1392 request_dir
1393 );
1394
1395 for (index, request_file) in request_files.iter().enumerate() {
1396 let filename = request_file
1397 .file_name()
1398 .and_then(|n| n.to_str())
1399 .unwrap_or("unknown");
1400
1401 println!("Testing request #{}: {} ... ", index + 1, filename);
1402
1403 let json_content = std::fs::read_to_string(request_file).unwrap_or_else(|e| {
1405 panic!("Failed to read file '{}': {}", request_file.display(), e)
1406 });
1407
1408 let test: TestCase = serde_json::from_str(&json_content).unwrap_or_else(|e| {
1410 panic!(
1411 "Failed to parse JSON from file '{}': {:?}",
1412 request_file.display(),
1413 e
1414 )
1415 });
1416
1417 let result = evaluate_policy(&test.policy, &test.request).unwrap();
1419 assert_eq!(result, test.result);
1420 }
1421 }
1422}