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    /// Per-statement evaluation details, whether or not each statement matched.
74    /// Populated only when [`EvaluationOptions::collect_match_details`] is
75    /// enabled; otherwise empty.
76    pub statement_details: Vec<StatementMatch>,
77    /// Evaluation context used
78    pub context: IAMRequest,
79}
80
81/// Information about a statement's evaluation result (whether or not it matched)
82#[derive(Debug, Clone, PartialEq, Serialize)]
83#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
84pub struct StatementMatch {
85    /// Statement ID if available
86    pub sid: Option<String>,
87    /// Effect of the statement
88    pub effect: IAMEffect,
89    /// Whether all conditions were satisfied
90    pub conditions_satisfied: bool,
91    /// Reason for the match/non-match
92    pub reason: String,
93}
94
95/// Policy evaluation engine
96#[derive(Debug, Clone)]
97#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
98pub struct PolicyEvaluator {
99    /// Policies to evaluate
100    policies: Vec<IAMPolicy>,
101    /// Evaluation options
102    options: EvaluationOptions,
103}
104
105/// Options for policy evaluation
106#[derive(Debug, Clone)]
107#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
108pub struct EvaluationOptions {
109    /// Whether to continue evaluation after finding an explicit deny
110    pub stop_on_explicit_deny: bool,
111    /// Whether to collect detailed match information
112    pub collect_match_details: bool,
113    /// Maximum number of statements to evaluate (for safety)
114    pub max_statements: usize,
115    /// Whether to ignore resource constraints
116    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    /// Create a new policy evaluator
132    #[must_use]
133    pub fn new() -> Self {
134        Self {
135            policies: Vec::new(),
136            options: EvaluationOptions::default(),
137        }
138    }
139
140    /// Create evaluator with policies
141    #[must_use]
142    pub fn with_policies(policies: Vec<IAMPolicy>) -> Self {
143        Self {
144            policies,
145            options: EvaluationOptions::default(),
146        }
147    }
148
149    /// Add a policy to the evaluator
150    pub fn add_policy(&mut self, policy: IAMPolicy) {
151        self.policies.push(policy);
152    }
153
154    /// Set evaluation options
155    #[must_use]
156    pub fn with_options(mut self, options: EvaluationOptions) -> Self {
157        self.options = options;
158        self
159    }
160
161    /// Evaluate an authorization request against all policies
162    ///
163    /// # Errors
164    ///
165    /// Returns `EvaluationError` if:
166    /// - The request context is invalid
167    /// - ARN format errors occur during evaluation
168    /// - Variable interpolation fails
169    /// - Condition evaluation fails
170    /// - Maximum statement evaluation limit is exceeded
171    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        // Evaluate each policy
199        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                // Check if this statement applies to the request
215                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        // Apply IAM evaluation logic: Explicit deny overrides everything,
236        // then explicit allow, then implicit deny
237        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    /// Evaluate a single statement against a request
253    fn evaluate_statement(
254        statement: &IAMStatement,
255        request: &IAMRequest,
256        options: &EvaluationOptions,
257    ) -> Result<StatementMatch, EvaluationError> {
258        // Check if principal matches (for resource-based policies)
259        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        // Check if action matches
282        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        // Check if resource matches
305        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        // Check conditions
330        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        // All checks passed
342        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    /// Check if a principal matches the request principal
351    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            // If either is Wildcard, it matches
363            (Principal::Wildcard, _) | (_, Principal::Wildcard) => Ok(true),
364
365            //
366            // Check: AWS
367            //
368            (
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                // AWS principal can be an account ID, an ARN, or "*"
373                // See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html
374                // "*" matches any principal
375                if id == "*" || id == request_principal_id {
376                    return Ok(true);
377                }
378                // Account ID (e.g., "123456789012")
379                if id.len() == 12 && id.chars().all(|c| c.is_ascii_digit()) {
380                    // Accept either the raw account ID or the root ARN. The
381                    // root ARN's partition varies by region (aws, aws-cn,
382                    // aws-us-gov, ...), so parse the request principal instead
383                    // of hardcoding a single partition.
384                    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 it's an ARN, match directly or with wildcard
396                if id.starts_with("arn:") {
397                    return Self::principal_string_matches(id, request_principal_id);
398                }
399                Ok(false)
400            }),
401
402            //
403            // Check: Federated
404            //
405            (
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                // Federated principal can be a provider name or ARN
410                // e.g., "cognito-identity.amazonaws.com", "arn:aws:iam::account-id:oidc-provider/..."
411                if id == request_principal_id {
412                    return Ok(true);
413                }
414                // For OIDC/SAML, match by prefix
415                if request_principal_id.starts_with(id) {
416                    return Ok(true);
417                }
418                Ok(false)
419            }),
420
421            //
422            // Check: Service
423            //
424            (
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                // Service principal, e.g., "ec2.amazonaws.com"
429                // Can also be regionalized, e.g., "s3.ap-east-1.amazonaws.com"
430                if id == request_principal_id {
431                    return Ok(true);
432                }
433                Ok(false)
434            }),
435
436            //
437            // Check: CanonicalUser
438            //
439            (
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                // Canonical user ID, e.g., "79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be"
444                if id == request_principal_id {
445                    return Ok(true);
446                }
447                Ok(false)
448            }),
449            _ => {
450                // If principal types don't match, they can't match
451                Ok(false)
452            }
453        }
454    }
455
456    /// Helper function to handle `PrincipalId` enum matching
457    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                // If any ID in the array matches, return true
469                for id in ids {
470                    if matcher(id)? {
471                        return Ok(true);
472                    }
473                }
474                Ok(false)
475            }
476        }
477    }
478
479    /// Check if a principal string matches the request principal
480    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            // ARN-based principal matching
488            let matcher = ArnMatcher::from_pattern(principal_str)
489                .map_err(|e| EvaluationError::InvalidArn(e.to_string()))?;
490
491            // A raw 12-digit account ID is the account's root principal. Build
492            // the root ARN using the pattern's partition so the matcher can
493            // compare the service/account/resource components (the raw ID
494            // carries no partition, and account IDs are unique across
495            // partitions).
496            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            // A request principal that isn't a valid ARN can't match an ARN pattern
515            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    /// Check if an action matches the request action
527    ///
528    /// AWS treats action names as case-insensitive (e.g. `iam:ListAccessKeys`
529    /// is the same as `IAM:listaccesskeys`), so both the request action and the
530    /// policy pattern are compared lowercased.
531    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    /// Check if a resource matches the request resource
542    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                    // First, interpolate variables
553                    let interpolated = interpolate_variables(r, context)?;
554
555                    // Then use ARN matcher for pattern matching
556                    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    /// Evaluate condition block
579    fn evaluate_conditions(
580        condition_block: &ConditionBlock,
581        context: &Context,
582    ) -> Result<bool, EvaluationError> {
583        // All conditions in a block must be satisfied (AND logic)
584        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
601/// Convenience function for simple policy evaluation
602///
603/// # Errors
604///
605/// Returns `EvaluationError` if the policy evaluation fails due to:
606/// - Invalid request context
607/// - ARN format errors
608/// - Variable interpolation failures
609/// - Condition evaluation errors
610pub 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
619/// Convenience function for evaluating multiple policies
620///
621/// # Errors
622///
623/// Returns `EvaluationError` if the policy evaluation fails due to:
624/// - Invalid request context
625/// - ARN format errors
626/// - Variable interpolation failures
627/// - Condition evaluation errors
628pub 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        // AWS: action names are case-insensitive (iam:ListAccessKeys == IAM:listaccesskeys).
730        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", // different case
741            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        // AWS: a negated condition (StringNotEquals) with an absent context key
862        // is true, so this Deny statement applies and denies access.
863        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        // No context provided: aws:SecureTransport is absent.
875        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        // AWS: ForAllValues is true when every request value matches at least
890        // one policy value. Here each of the two TagKeys has a matching entry.
891        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        // Classic AWS pattern: deny if the requested region is not in the
924        // allowed set. A request to an allowed region must NOT be denied.
925        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        // Inside the allowed set -> not denied.
937        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        // Outside the allowed set -> denied.
954        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        // AWS IAM policies commonly use the string form "true"/"false".
974        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()), // key must exist
982                ),
983        );
984
985        // Key present -> condition true -> allowed.
986        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        // Key absent -> condition false -> not applicable.
1003        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        // Context value 500.0 < 1234.5 -> allowed.
1028        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        // Context value 2000.0 >= 1234.5 -> not applicable.
1045        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        // A plain operator against a multivalued context key matches if any
1065        // value matches (implicit ForAnyValue semantics).
1066        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        // "environment" is among the request's tag keys -> allowed.
1078        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        // No tag key matches -> not applicable.
1095        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        // AWS: context key names are case-insensitive.
1115        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        // Context uses a different case for the key name.
1127        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        // A request principal that is valid but not an ARN (e.g. a 12-digit
1176        // account ID) must not panic when matched against a wildcard ARN
1177        // principal. Previously this hit an unwrap on a failed ARN parse.
1178        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        // Principal doesn't match the ARN pattern -> statement not applicable.
1194        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        // A policy-side 12-digit account ID must match the account's root ARN
1201        // in any partition (aws, aws-cn, aws-us-gov, ...), not just "aws".
1202        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        // A root ARN for a different account must NOT match.
1230        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        // A request principal given as a raw 12-digit account ID is the
1244        // account's root principal, so ARN patterns that match the root must
1245        // match it.
1246        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        // A wildcard ARN that matches the root also matches the raw account ID.
1264        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        // An ARN pattern that targets IAM users (not the account root) must NOT
1276        // match the raw account ID.
1277        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        // List filenames in the tests/requests directory
1360        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        // Verify we actually found request files to test
1375        assert!(
1376            !request_files.is_empty(),
1377            "No request JSON files found in {request_dir}/"
1378        );
1379
1380        // Sort files by name for consistent test order
1381        // All files are called 1.json, 2.json, ..., 10.json, etc.
1382        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            // Read the JSON file
1404            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            // Parse the test case from JSON
1409            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            // Evaluate the policy against the request
1418            let result = evaluate_policy(&test.policy, &test.request).unwrap();
1419            assert_eq!(result, test.result);
1420        }
1421    }
1422}