Skip to main content

iam_rs/evaluation/
engine.rs

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/// Result of policy evaluation
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16pub enum Decision {
17    /// Access is explicitly allowed
18    Allow,
19    /// Access is explicitly denied
20    Deny,
21    /// No applicable policy found (implicit deny)
22    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/// Error types for policy evaluation
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
37pub enum EvaluationError {
38    /// Invalid request context
39    InvalidRequest(String),
40    /// Policy parsing or validation error
41    InvalidPolicy(String),
42    /// ARN format error during evaluation
43    InvalidArn(String),
44    /// Invalid variable reference
45    InvalidVariable(String),
46    /// Condition evaluation error
47    ConditionError(String),
48    /// Internal evaluation error
49    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/// Evaluation result with decision and metadata
68#[derive(Debug, Clone, PartialEq, Serialize)]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70pub struct EvaluationResult {
71    /// The final decision
72    pub decision: Decision,
73    /// Statements that matched (for debugging/auditing)
74    pub matched_statements: Vec<StatementMatch>,
75    /// Evaluation context used
76    pub context: IAMRequest,
77}
78
79/// Information about a statement that matched during evaluation
80#[derive(Debug, Clone, PartialEq, Serialize)]
81#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
82pub struct StatementMatch {
83    /// Statement ID if available
84    pub sid: Option<String>,
85    /// Effect of the statement
86    pub effect: IAMEffect,
87    /// Whether all conditions were satisfied
88    pub conditions_satisfied: bool,
89    /// Reason for the match/non-match
90    pub reason: String,
91}
92
93/// Policy evaluation engine
94#[derive(Debug, Clone)]
95#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
96pub struct PolicyEvaluator {
97    /// Policies to evaluate
98    policies: Vec<IAMPolicy>,
99    /// Evaluation options
100    options: EvaluationOptions,
101}
102
103/// Options for policy evaluation
104#[derive(Debug, Clone)]
105#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
106pub struct EvaluationOptions {
107    /// Whether to continue evaluation after finding an explicit deny
108    pub stop_on_explicit_deny: bool,
109    /// Whether to collect detailed match information
110    pub collect_match_details: bool,
111    /// Maximum number of statements to evaluate (for safety)
112    pub max_statements: usize,
113    /// Whether to ignore resource constraints
114    pub ignore_resource_constraints: bool,
115}
116
117impl Default for EvaluationOptions {
118    fn default() -> Self {
119        Self {
120            stop_on_explicit_deny: true,
121            collect_match_details: false,
122            max_statements: 1000,
123            ignore_resource_constraints: false,
124        }
125    }
126}
127
128impl PolicyEvaluator {
129    /// Create a new policy evaluator
130    #[must_use]
131    pub fn new() -> Self {
132        Self {
133            policies: Vec::new(),
134            options: EvaluationOptions::default(),
135        }
136    }
137
138    /// Create evaluator with policies
139    #[must_use]
140    pub fn with_policies(policies: Vec<IAMPolicy>) -> Self {
141        Self {
142            policies,
143            options: EvaluationOptions::default(),
144        }
145    }
146
147    /// Add a policy to the evaluator
148    pub fn add_policy(&mut self, policy: IAMPolicy) {
149        self.policies.push(policy);
150    }
151
152    /// Set evaluation options
153    #[must_use]
154    pub fn with_options(mut self, options: EvaluationOptions) -> Self {
155        self.options = options;
156        self
157    }
158
159    /// Evaluate an authorization request against all policies
160    ///
161    /// # Errors
162    ///
163    /// Returns `EvaluationError` if:
164    /// - The request context is invalid
165    /// - ARN format errors occur during evaluation
166    /// - Variable interpolation fails
167    /// - Condition evaluation fails
168    /// - Maximum statement evaluation limit is exceeded
169    pub fn evaluate(&self, request: &IAMRequest) -> Result<EvaluationResult, EvaluationError> {
170        if !request.principal.is_single() {
171            return Err(EvaluationError::InvalidRequest(
172                "Request principal must be a single entity".to_string(),
173            ));
174        }
175        if !request.principal.is_valid() {
176            return Err(EvaluationError::InvalidRequest(
177                "Invalid principal".to_string(),
178            ));
179        }
180        if request.action.is_empty() {
181            return Err(EvaluationError::InvalidRequest(
182                "Action cannot be empty".to_string(),
183            ));
184        }
185        if !request.resource.is_valid() && !self.options.ignore_resource_constraints {
186            return Err(EvaluationError::InvalidRequest(
187                "Invalid resource ARN".to_string(),
188            ));
189        }
190
191        let mut matched_statements = Vec::new();
192        let mut has_explicit_allow = false;
193        let mut has_explicit_deny = false;
194        let mut statement_count = 0;
195
196        // Evaluate each policy
197        for policy in &self.policies {
198            for statement in &policy.statement {
199                statement_count += 1;
200                if statement_count > self.options.max_statements {
201                    return Err(EvaluationError::InternalError(
202                        "Maximum statement evaluation limit exceeded".to_string(),
203                    ));
204                }
205
206                let statement_result = Self::evaluate_statement(statement, request, &self.options)?;
207
208                if self.options.collect_match_details {
209                    matched_statements.push(statement_result.clone());
210                }
211
212                // Check if this statement applies to the request
213                if statement_result.conditions_satisfied {
214                    match statement.effect {
215                        IAMEffect::Allow => {
216                            has_explicit_allow = true;
217                        }
218                        IAMEffect::Deny => {
219                            has_explicit_deny = true;
220                            if self.options.stop_on_explicit_deny {
221                                return Ok(EvaluationResult {
222                                    decision: Decision::Deny,
223                                    matched_statements,
224                                    context: request.clone(),
225                                });
226                            }
227                        }
228                    }
229                }
230            }
231        }
232
233        // Apply IAM evaluation logic: Explicit deny overrides everything,
234        // then explicit allow, then implicit deny
235        let decision = if has_explicit_deny {
236            Decision::Deny
237        } else if has_explicit_allow {
238            Decision::Allow
239        } else {
240            Decision::NotApplicable
241        };
242
243        Ok(EvaluationResult {
244            decision,
245            matched_statements,
246            context: request.clone(),
247        })
248    }
249
250    /// Evaluate a single statement against a request
251    fn evaluate_statement(
252        statement: &IAMStatement,
253        request: &IAMRequest,
254        options: &EvaluationOptions,
255    ) -> Result<StatementMatch, EvaluationError> {
256        // Check if principal matches (for resource-based policies)
257        if let Some(ref principal) = statement.principal
258            && !Self::principal_matches(principal, &request.principal)?
259        {
260            return Ok(StatementMatch {
261                sid: statement.sid.clone(),
262                effect: statement.effect,
263                conditions_satisfied: false,
264                reason: "Principal does not match".to_string(),
265            });
266        }
267
268        if let Some(ref not_principal) = statement.not_principal
269            && Self::principal_matches(not_principal, &request.principal)?
270        {
271            return Ok(StatementMatch {
272                sid: statement.sid.clone(),
273                effect: statement.effect,
274                conditions_satisfied: false,
275                reason: "Principal matches NotPrincipal exclusion".to_string(),
276            });
277        }
278
279        // Check if action matches
280        let action_matches = if let Some(ref action) = statement.action {
281            Self::action_matches(action, &request.action)
282        } else if let Some(ref not_action) = statement.not_action {
283            !Self::action_matches(not_action, &request.action)
284        } else {
285            return Ok(StatementMatch {
286                sid: statement.sid.clone(),
287                effect: statement.effect,
288                conditions_satisfied: false,
289                reason: "No action or not_action specified".to_string(),
290            });
291        };
292
293        if !action_matches {
294            return Ok(StatementMatch {
295                sid: statement.sid.clone(),
296                effect: statement.effect,
297                conditions_satisfied: false,
298                reason: "Action does not match".to_string(),
299            });
300        }
301
302        // Check if resource matches
303        let resource_matches = if options.ignore_resource_constraints {
304            true
305        } else if let Some(ref resource) = statement.resource {
306            Self::resource_matches(resource, &request.resource, &request.context)?
307        } else if let Some(ref not_resource) = statement.not_resource {
308            !Self::resource_matches(not_resource, &request.resource, &request.context)?
309        } else {
310            return Ok(StatementMatch {
311                sid: statement.sid.clone(),
312                effect: statement.effect,
313                conditions_satisfied: false,
314                reason: "No resource or not_resource specified".to_string(),
315            });
316        };
317
318        if !resource_matches {
319            return Ok(StatementMatch {
320                sid: statement.sid.clone(),
321                effect: statement.effect,
322                conditions_satisfied: false,
323                reason: "Resource does not match".to_string(),
324            });
325        }
326
327        // Check conditions
328        if let Some(ref condition_block) = statement.condition
329            && !Self::evaluate_conditions(condition_block, &request.context)?
330        {
331            return Ok(StatementMatch {
332                sid: statement.sid.clone(),
333                effect: statement.effect,
334                conditions_satisfied: false,
335                reason: "Conditions not satisfied".to_string(),
336            });
337        }
338
339        // All checks passed
340        Ok(StatementMatch {
341            sid: statement.sid.clone(),
342            effect: statement.effect,
343            conditions_satisfied: true,
344            reason: "Statement fully matched".to_string(),
345        })
346    }
347
348    /// Check if a principal matches the request principal
349    fn principal_matches(
350        principal: &Principal,
351        request_principal: &Principal,
352    ) -> Result<bool, EvaluationError> {
353        if !request_principal.is_single() {
354            return Err(EvaluationError::InvalidRequest(
355                "Request principal must be a single entity".to_string(),
356            ));
357        }
358
359        match (principal, request_principal) {
360            // If either is Wildcard, it matches
361            (Principal::Wildcard, _) | (_, Principal::Wildcard) => Ok(true),
362
363            //
364            // Check: AWS
365            //
366            (
367                Principal::Aws(principal_id),
368                Principal::Aws(PrincipalId::String(request_principal_id)),
369            ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
370                // AWS principal can be an account ID, an ARN, or "*"
371                // See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html
372                // "*" matches any principal
373                if id == "*" || id == request_principal_id {
374                    return Ok(true);
375                }
376                // Account ID (e.g., "123456789012")
377                if id.len() == 12 && id.chars().all(|c| c.is_ascii_digit()) {
378                    // Accept either the raw account ID or the root ARN
379                    let root_arn = format!("arn:aws:iam::{id}:root");
380                    if request_principal_id == id || request_principal_id.as_str() == root_arn {
381                        return Ok(true);
382                    }
383                }
384                // If it's an ARN, match directly or with wildcard
385                if id.starts_with("arn:") {
386                    return Self::principal_string_matches(id, request_principal_id);
387                }
388                Ok(false)
389            }),
390
391            //
392            // Check: Federated
393            //
394            (
395                Principal::Federated(principal_id),
396                Principal::Federated(PrincipalId::String(request_principal_id)),
397            ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
398                // Federated principal can be a provider name or ARN
399                // e.g., "cognito-identity.amazonaws.com", "arn:aws:iam::account-id:oidc-provider/..."
400                if id == request_principal_id {
401                    return Ok(true);
402                }
403                // For OIDC/SAML, match by prefix
404                if request_principal_id.starts_with(id) {
405                    return Ok(true);
406                }
407                Ok(false)
408            }),
409
410            //
411            // Check: Service
412            //
413            (
414                Principal::Service(principal_id),
415                Principal::Service(PrincipalId::String(request_principal_id)),
416            ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
417                // Service principal, e.g., "ec2.amazonaws.com"
418                // Can also be regionalized, e.g., "s3.ap-east-1.amazonaws.com"
419                if id == request_principal_id {
420                    return Ok(true);
421                }
422                Ok(false)
423            }),
424
425            //
426            // Check: CanonicalUser
427            //
428            (
429                Principal::CanonicalUser(principal_id),
430                Principal::CanonicalUser(PrincipalId::String(request_principal_id)),
431            ) => Self::principal_id_matches(principal_id, request_principal_id, |id| {
432                // Canonical user ID, e.g., "79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be"
433                if id == request_principal_id {
434                    return Ok(true);
435                }
436                Ok(false)
437            }),
438            _ => {
439                // If principal types don't match, they can't match
440                Ok(false)
441            }
442        }
443    }
444
445    /// Helper function to handle `PrincipalId` enum matching
446    fn principal_id_matches<F>(
447        principal_id: &PrincipalId,
448        _request_principal: &str,
449        matcher: F,
450    ) -> Result<bool, EvaluationError>
451    where
452        F: Fn(&str) -> Result<bool, EvaluationError>,
453    {
454        match principal_id {
455            PrincipalId::String(id) => matcher(id),
456            PrincipalId::Array(ids) => {
457                // If any ID in the array matches, return true
458                for id in ids {
459                    if matcher(id)? {
460                        return Ok(true);
461                    }
462                }
463                Ok(false)
464            }
465        }
466    }
467
468    /// Check if a principal string matches the request principal
469    fn principal_string_matches(
470        principal_str: &str,
471        request_principal: &str,
472    ) -> Result<bool, EvaluationError> {
473        if principal_str == "*" || principal_str == request_principal {
474            Ok(true)
475        } else if principal_str.starts_with("arn:") {
476            // ARN-based principal matching
477            let matcher = ArnMatcher::from_pattern(principal_str)
478                .map_err(|e| EvaluationError::InvalidArn(e.to_string()))?;
479            matcher
480                .matches(&Arn::parse(request_principal).unwrap())
481                .map_err(|e| EvaluationError::InvalidArn(e.to_string()))
482        } else {
483            Ok(false)
484        }
485    }
486
487    /// Check if an action matches the request action
488    fn action_matches(action: &IAMAction, request_action: &str) -> bool {
489        match action {
490            IAMAction::Single(a) => {
491                a == "*" || a == request_action || wildcard_match(request_action, a)
492            }
493            IAMAction::Multiple(actions) => {
494                for a in actions {
495                    if a == "*" || a == request_action || wildcard_match(request_action, a) {
496                        return true;
497                    }
498                }
499                false
500            }
501        }
502    }
503
504    /// Check if a resource matches the request resource
505    fn resource_matches(
506        resource: &IAMResource,
507        request_resource: &Arn,
508        context: &Context,
509    ) -> Result<bool, EvaluationError> {
510        match resource {
511            IAMResource::Single(r) => {
512                if r == "*" {
513                    Ok(true)
514                } else {
515                    // First, interpolate variables
516                    let interpolated = interpolate_variables(r, context)?;
517
518                    // Then use ARN matcher for pattern matching
519                    let matcher = ArnMatcher::from_pattern(&interpolated)
520                        .map_err(|e| EvaluationError::InvalidArn(e.to_string()))?;
521                    matcher
522                        .matches(request_resource)
523                        .map_err(|e| EvaluationError::InvalidArn(e.to_string()))
524                }
525            }
526            IAMResource::Multiple(resources) => {
527                for r in resources {
528                    if Self::resource_matches(
529                        &IAMResource::Single(r.clone()),
530                        request_resource,
531                        context,
532                    )? {
533                        return Ok(true);
534                    }
535                }
536                Ok(false)
537            }
538        }
539    }
540
541    /// Evaluate condition block
542    fn evaluate_conditions(
543        condition_block: &ConditionBlock,
544        context: &Context,
545    ) -> Result<bool, EvaluationError> {
546        // All conditions in a block must be satisfied (AND logic)
547        for (operator, condition_map) in &condition_block.conditions {
548            for (key, value) in condition_map {
549                if !evaluate_condition(context, operator, key, &value.to_json_value())? {
550                    return Ok(false);
551                }
552            }
553        }
554        Ok(true)
555    }
556}
557
558impl Default for PolicyEvaluator {
559    fn default() -> Self {
560        Self::new()
561    }
562}
563
564/// Convenience function for simple policy evaluation
565///
566/// # Errors
567///
568/// Returns `EvaluationError` if the policy evaluation fails due to:
569/// - Invalid request context
570/// - ARN format errors
571/// - Variable interpolation failures
572/// - Condition evaluation errors
573pub fn evaluate_policy(
574    policy: &IAMPolicy,
575    request: &IAMRequest,
576) -> Result<Decision, EvaluationError> {
577    let evaluator = PolicyEvaluator::with_policies(vec![policy.clone()]);
578    let result = evaluator.evaluate(request)?;
579    Ok(result.decision)
580}
581
582/// Convenience function for evaluating multiple policies
583///
584/// # Errors
585///
586/// Returns `EvaluationError` if the policy evaluation fails due to:
587/// - Invalid request context
588/// - ARN format errors
589/// - Variable interpolation failures
590/// - Condition evaluation errors
591pub fn evaluate_policies(
592    policies: &[IAMPolicy],
593    request: &IAMRequest,
594) -> Result<Decision, EvaluationError> {
595    let evaluator = PolicyEvaluator::with_policies(policies.to_vec());
596    let result = evaluator.evaluate(request)?;
597    Ok(result.decision)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::{
604        Arn, ConditionValue, ContextValue, IAMAction, IAMEffect, IAMOperator, IAMResource,
605        IAMStatement,
606    };
607
608    #[test]
609    fn test_simple_allow_policy() {
610        let policy = IAMPolicy::new().add_statement(
611            IAMStatement::new(IAMEffect::Allow)
612                .with_action(IAMAction::Single("s3:GetObject".to_string()))
613                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
614        );
615
616        let request = IAMRequest::new(
617            Principal::Aws(PrincipalId::String(
618                "arn:aws:iam::123456789012:user/test".into(),
619            )),
620            "s3:GetObject",
621            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
622        );
623
624        let result = evaluate_policy(&policy, &request).unwrap();
625        assert_eq!(result, Decision::Allow);
626    }
627
628    #[test]
629    fn test_simple_deny_policy() {
630        let policy = IAMPolicy::new().add_statement(
631            IAMStatement::new(IAMEffect::Deny)
632                .with_action(IAMAction::Single("s3:DeleteObject".to_string()))
633                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
634        );
635
636        let request = IAMRequest::new(
637            Principal::Aws(PrincipalId::String(
638                "arn:aws:iam::123456789012:user/test".into(),
639            )),
640            "s3:DeleteObject",
641            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
642        );
643
644        let result = evaluate_policy(&policy, &request).unwrap();
645        assert_eq!(result, Decision::Deny);
646    }
647
648    #[test]
649    fn test_not_applicable_policy() {
650        let policy = IAMPolicy::new().add_statement(
651            IAMStatement::new(IAMEffect::Allow)
652                .with_action(IAMAction::Single("s3:GetObject".to_string()))
653                .with_resource(IAMResource::Single(
654                    "arn:aws:s3:::other-bucket/*".to_string(),
655                )),
656        );
657
658        let request = IAMRequest::new(
659            Principal::Aws(PrincipalId::String(
660                "arn:aws:iam::123456789012:user/test".into(),
661            )),
662            "s3:GetObject",
663            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
664        );
665
666        let result = evaluate_policy(&policy, &request).unwrap();
667        assert_eq!(result, Decision::NotApplicable);
668    }
669
670    #[test]
671    fn test_wildcard_action_matching() {
672        let policy = IAMPolicy::new().add_statement(
673            IAMStatement::new(IAMEffect::Allow)
674                .with_action(IAMAction::Single("s3:*".to_string()))
675                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
676        );
677
678        let request = IAMRequest::new(
679            Principal::Aws(PrincipalId::String(
680                "arn:aws:iam::123456789012:user/test".into(),
681            )),
682            "s3:GetObject",
683            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
684        );
685
686        let result = evaluate_policy(&policy, &request).unwrap();
687        assert_eq!(result, Decision::Allow);
688    }
689
690    #[test]
691    fn test_condition_evaluation() {
692        use crate::IAMOperator;
693
694        let mut context = Context::new();
695        context.insert(
696            "aws:userid".to_string(),
697            ContextValue::String("test-user".to_string()),
698        );
699
700        let policy = IAMPolicy::new().add_statement(
701            IAMStatement::new(IAMEffect::Allow)
702                .with_action(IAMAction::Single("s3:GetObject".to_string()))
703                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string()))
704                .with_condition(
705                    IAMOperator::StringEquals,
706                    "aws:userid".to_string(),
707                    ConditionValue::String("test-user".to_string()),
708                ),
709        );
710
711        let request = IAMRequest::new_with_context(
712            Principal::Aws(PrincipalId::String(
713                "arn:aws:iam::123456789012:user/test".into(),
714            )),
715            "s3:GetObject",
716            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
717            context,
718        );
719
720        let result = evaluate_policy(&policy, &request).unwrap();
721        assert_eq!(result, Decision::Allow);
722    }
723
724    #[test]
725    fn test_condition_evaluation_failure() {
726        use crate::IAMOperator;
727
728        let mut context = Context::new();
729        context.insert(
730            "aws:userid".to_string(),
731            ContextValue::String("other-user".to_string()),
732        );
733
734        let policy = IAMPolicy::new().add_statement(
735            IAMStatement::new(IAMEffect::Allow)
736                .with_action(IAMAction::Single("s3:GetObject".to_string()))
737                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string()))
738                .with_condition(
739                    IAMOperator::StringEquals,
740                    "aws:userid".to_string(),
741                    ConditionValue::String("test-user".to_string()),
742                ),
743        );
744
745        let request = IAMRequest::new_with_context(
746            Principal::Aws(PrincipalId::String(
747                "arn:aws:iam::123456789012:user/test".into(),
748            )),
749            "s3:GetObject",
750            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
751            context,
752        );
753
754        let result = evaluate_policy(&policy, &request).unwrap();
755        assert_eq!(result, Decision::NotApplicable);
756    }
757
758    #[test]
759    fn test_explicit_deny_overrides_allow() {
760        let policies = vec![
761            IAMPolicy::new().add_statement(
762                IAMStatement::new(IAMEffect::Allow)
763                    .with_action(IAMAction::Single("s3:*".to_string()))
764                    .with_resource(IAMResource::Single("*".to_string())),
765            ),
766            IAMPolicy::new().add_statement(
767                IAMStatement::new(IAMEffect::Deny)
768                    .with_action(IAMAction::Single("s3:DeleteObject".to_string()))
769                    .with_resource(IAMResource::Single(
770                        "arn:aws:s3:::protected-bucket/*".to_string(),
771                    )),
772            ),
773        ];
774
775        let request = IAMRequest::new(
776            Principal::Aws(PrincipalId::String(
777                "arn:aws:iam::123456789012:user/test".into(),
778            )),
779            "s3:DeleteObject",
780            Arn::parse("arn:aws:s3:::protected-bucket/file.txt").unwrap(),
781        );
782
783        let result = evaluate_policies(&policies, &request).unwrap();
784        assert_eq!(result, Decision::Deny);
785    }
786
787    #[test]
788    fn test_numeric_condition() {
789        let mut context = Context::new();
790        context.insert("aws:RequestedRegion".to_string(), ContextValue::Number(5.0));
791
792        let policy = IAMPolicy::new().add_statement(
793            IAMStatement::new(IAMEffect::Allow)
794                .with_action(IAMAction::Single("s3:GetObject".to_string()))
795                .with_resource(IAMResource::Single("*".to_string()))
796                .with_condition(
797                    IAMOperator::NumericLessThan,
798                    "aws:RequestedRegion".to_string(),
799                    ConditionValue::Number(10),
800                ),
801        );
802
803        let request = IAMRequest::new_with_context(
804            Principal::Aws(PrincipalId::String(
805                "arn:aws:iam::123456789012:user/test".into(),
806            )),
807            "s3:GetObject",
808            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
809            context,
810        );
811
812        let result = evaluate_policy(&policy, &request).unwrap();
813        assert_eq!(result, Decision::Allow);
814    }
815
816    #[test]
817    fn test_evaluator_with_options() {
818        let policy = IAMPolicy::new().add_statement(
819            IAMStatement::new(IAMEffect::Allow)
820                .with_sid("AllowS3Read")
821                .with_action(IAMAction::Single("s3:GetObject".to_string()))
822                .with_resource(IAMResource::Single("arn:aws:s3:::my-bucket/*".to_string())),
823        );
824
825        let request = IAMRequest::new(
826            Principal::Aws(PrincipalId::String(
827                "arn:aws:iam::123456789012:user/test".into(),
828            )),
829            "s3:GetObject",
830            Arn::parse("arn:aws:s3:::my-bucket/file.txt").unwrap(),
831        );
832
833        let evaluator =
834            PolicyEvaluator::with_policies(vec![policy]).with_options(EvaluationOptions {
835                collect_match_details: true,
836                ..Default::default()
837            });
838
839        let result = evaluator.evaluate(&request).unwrap();
840        assert_eq!(result.decision, Decision::Allow);
841        assert!(!result.matched_statements.is_empty());
842        assert_eq!(
843            result.matched_statements[0].sid,
844            Some("AllowS3Read".to_string())
845        );
846    }
847
848    #[derive(Debug, Clone, Serialize, Deserialize)]
849    struct TestCase {
850        result: Decision,
851        request: IAMRequest,
852        policy: IAMPolicy,
853    }
854
855    #[test]
856    fn test_requests_testset() {
857        // List filenames in the tests/requests directory
858        let request_dir = "tests/requests";
859        let mut request_files = std::fs::read_dir(request_dir)
860            .unwrap_or_else(|e| panic!("Failed to read requests directory '{request_dir}': {e}"))
861            .filter_map(|entry| {
862                let entry = entry.ok()?;
863                let path = entry.path();
864                if path.extension()? == "json" {
865                    Some(path)
866                } else {
867                    None
868                }
869            })
870            .collect::<Vec<_>>();
871
872        // Verify we actually found request files to test
873        assert!(
874            !request_files.is_empty(),
875            "No request JSON files found in {request_dir}/"
876        );
877
878        // Sort files by name for consistent test order
879        // All files are called 1.json, 2.json, ..., 10.json, etc.
880        request_files.sort_by_key(|p| {
881            p.file_name()
882                .and_then(|n| n.to_str())
883                .map(|s| s.split('.').next().unwrap().parse::<u32>().unwrap())
884                .map(|n| format!("{n:010}"))
885        });
886
887        println!(
888            "Testing {} request files from {}/",
889            request_files.len(),
890            request_dir
891        );
892
893        for (index, request_file) in request_files.iter().enumerate() {
894            let filename = request_file
895                .file_name()
896                .and_then(|n| n.to_str())
897                .unwrap_or("unknown");
898
899            println!("Testing request #{}: {} ... ", index + 1, filename);
900
901            // Read the JSON file
902            let json_content = std::fs::read_to_string(request_file).unwrap_or_else(|e| {
903                panic!("Failed to read file '{}': {}", request_file.display(), e)
904            });
905
906            // Parse the test case from JSON
907            let test: TestCase = serde_json::from_str(&json_content).unwrap_or_else(|e| {
908                panic!(
909                    "Failed to parse JSON from file '{}': {:?}",
910                    request_file.display(),
911                    e
912                )
913            });
914
915            // Evaluate the policy against the request
916            let result = evaluate_policy(&test.policy, &test.request).unwrap();
917            assert_eq!(result, test.result);
918        }
919    }
920}