Skip to main content

fakecloud_iam/
condition.rs

1//! IAM policy `Condition` block evaluation (Phase 2).
2//!
3//! Phase 1 of the opt-in IAM enforcement skipped any statement carrying a
4//! `Condition` block. This module implements real evaluation of the 28
5//! operators AWS defines, with `IfExists` suffix handling and
6//! `ForAllValues` / `ForAnyValue` qualifiers, against a
7//! [`ConditionContext`] populated at dispatch time.
8//!
9//! # Scope
10//!
11//! **Implemented operators** (authoritative list mirrored from
12//! [`crate::policy_validation`]):
13//!
14//! - String: `StringEquals`, `StringNotEquals`, `StringEqualsIgnoreCase`,
15//!   `StringNotEqualsIgnoreCase`, `StringLike`, `StringNotLike`
16//! - Numeric: `NumericEquals`, `NumericNotEquals`, `NumericLessThan`,
17//!   `NumericLessThanEquals`, `NumericGreaterThan`,
18//!   `NumericGreaterThanEquals`
19//! - Date: `DateEquals`, `DateNotEquals`, `DateLessThan`,
20//!   `DateLessThanEquals`, `DateGreaterThan`, `DateGreaterThanEquals`
21//! - Boolean: `Bool`
22//! - Binary: `BinaryEquals`
23//! - IP: `IpAddress`, `NotIpAddress`
24//! - ARN: `ArnEquals`, `ArnNotEquals`, `ArnLike`, `ArnNotLike`
25//! - Existence: `Null`
26//!
27//! Plus the `...IfExists` suffix and the `ForAllValues:` / `ForAnyValue:`
28//! qualifiers on every operator.
29//!
30//! **Implemented global keys**:
31//!
32//! - `aws:username` — populated from the IAM user ARN at dispatch time;
33//!   absent for assumed-role / federated-user / root principals,
34//!   matching AWS.
35//! - `aws:userid` — `<role-id>:<RoleSessionName>` for assumed-role
36//!   sessions, the IAM user's `AIDA...` id for IAM users.
37//! - `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:PrincipalType`
38//! - `aws:SourceIp` — remote-address of the HTTP connection.
39//! - `aws:CurrentTime`, `aws:EpochTime`
40//! - `aws:SecureTransport`, `aws:RequestedRegion`
41//! - `aws:MultiFactorAuthPresent` — `true` iff the underlying STS
42//!   session was minted with `SerialNumber` + `TokenCode`.
43//! - `aws:MultiFactorAuthAge` — seconds since the MFA-asserted session
44//!   was minted; only present when MFA was supplied at mint time.
45//! - `aws:CalledVia` — multi-value chain of service principals that
46//!   re-invoked downstream services on the caller's behalf.
47//! - `aws:SourceVpc`, `aws:SourceVpce`, `aws:VpcSourceIp` — populated
48//!   when the request transited a VPC interface endpoint.
49//! - `aws:FederatedProvider` — SAML provider ARN for
50//!   `AssumeRoleWithSAML`, OIDC provider ARN (or `ProviderId` host)
51//!   for `AssumeRoleWithWebIdentity`. Absent for IAM user keys, plain
52//!   `AssumeRole`, `GetSessionToken`, `GetFederationToken`.
53//! - `aws:TokenIssueTime` — wall-clock time at which the underlying
54//!   STS credential was issued. Absent for IAM user access keys.
55//!
56//! Service-specific keys (`s3:prefix`, `sqs:MessageAttribute`, …) are
57//! deferred to a follow-up batch; the [`ConditionContext::service_keys`]
58//! map is pre-wired so they can land without a signature change.
59//!
60//! # Safe-fail semantics
61//!
62//! Any unimplemented operator, unknown key, or parse error emits a
63//! `tracing::debug!` on the `fakecloud::iam::audit` target and causes the
64//! operator to evaluate to `false` — i.e. the statement is treated as
65//! *not applicable*. Silently returning `true` would let real policies
66//! grant access we can't actually verify, which would defeat the whole
67//! opt-in enforcement story.
68
69use std::net::IpAddr;
70
71use chrono::{DateTime, Utc};
72use serde_json::Value;
73
74/// Re-export of the data type defined in `fakecloud-core::auth` — see
75/// [`fakecloud_core::auth::ConditionContext`] for field documentation.
76/// The condition operator framework in this module is implemented
77/// against this type.
78pub use fakecloud_core::auth::ConditionContext;
79
80/// Base condition operator name (without `IfExists` suffix or
81/// `ForAllValues:` / `ForAnyValue:` qualifier).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ConditionOperator {
84    StringEquals,
85    StringNotEquals,
86    StringEqualsIgnoreCase,
87    StringNotEqualsIgnoreCase,
88    StringLike,
89    StringNotLike,
90    NumericEquals,
91    NumericNotEquals,
92    NumericLessThan,
93    NumericLessThanEquals,
94    NumericGreaterThan,
95    NumericGreaterThanEquals,
96    DateEquals,
97    DateNotEquals,
98    DateLessThan,
99    DateLessThanEquals,
100    DateGreaterThan,
101    DateGreaterThanEquals,
102    Bool,
103    BinaryEquals,
104    IpAddress,
105    NotIpAddress,
106    ArnEquals,
107    ArnNotEquals,
108    ArnLike,
109    ArnNotLike,
110    Null,
111}
112
113impl ConditionOperator {
114    fn from_str(name: &str) -> Option<Self> {
115        Some(match name {
116            "StringEquals" => Self::StringEquals,
117            "StringNotEquals" => Self::StringNotEquals,
118            "StringEqualsIgnoreCase" => Self::StringEqualsIgnoreCase,
119            "StringNotEqualsIgnoreCase" => Self::StringNotEqualsIgnoreCase,
120            "StringLike" => Self::StringLike,
121            "StringNotLike" => Self::StringNotLike,
122            "NumericEquals" => Self::NumericEquals,
123            "NumericNotEquals" => Self::NumericNotEquals,
124            "NumericLessThan" => Self::NumericLessThan,
125            "NumericLessThanEquals" => Self::NumericLessThanEquals,
126            "NumericGreaterThan" => Self::NumericGreaterThan,
127            "NumericGreaterThanEquals" => Self::NumericGreaterThanEquals,
128            "DateEquals" => Self::DateEquals,
129            "DateNotEquals" => Self::DateNotEquals,
130            "DateLessThan" => Self::DateLessThan,
131            "DateLessThanEquals" => Self::DateLessThanEquals,
132            "DateGreaterThan" => Self::DateGreaterThan,
133            "DateGreaterThanEquals" => Self::DateGreaterThanEquals,
134            "Bool" => Self::Bool,
135            "BinaryEquals" => Self::BinaryEquals,
136            "IpAddress" => Self::IpAddress,
137            "NotIpAddress" => Self::NotIpAddress,
138            "ArnEquals" => Self::ArnEquals,
139            "ArnNotEquals" => Self::ArnNotEquals,
140            "ArnLike" => Self::ArnLike,
141            "ArnNotLike" => Self::ArnNotLike,
142            "Null" => Self::Null,
143            _ => return None,
144        })
145    }
146}
147
148/// `ForAllValues:` / `ForAnyValue:` qualifier applied to the operator.
149///
150/// `Single` is the default (no qualifier) and behaves like `ForAnyValue`
151/// for single-valued context keys, which is what AWS does.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Qualifier {
154    Single,
155    ForAnyValue,
156    ForAllValues,
157}
158
159/// Parsed operator name: the base [`ConditionOperator`], the
160/// `IfExists` flag, and the `ForAllValues` / `ForAnyValue` qualifier.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct ParsedOperatorName {
163    pub op: ConditionOperator,
164    pub if_exists: bool,
165    pub qualifier: Qualifier,
166}
167
168impl ParsedOperatorName {
169    /// Parse an operator name as it appears in a policy JSON key, e.g.
170    /// `"StringEqualsIfExists"`, `"ForAllValues:StringLike"`,
171    /// `"ForAnyValue:DateLessThanIfExists"`.
172    ///
173    /// Returns `None` if the base operator is not one of the 28 AWS
174    /// defines. The caller should safe-fail (statement does not apply)
175    /// on `None`.
176    pub fn parse(raw: &str) -> Option<Self> {
177        let (qualifier, rest) = if let Some(s) = raw.strip_prefix("ForAllValues:") {
178            (Qualifier::ForAllValues, s)
179        } else if let Some(s) = raw.strip_prefix("ForAnyValue:") {
180            (Qualifier::ForAnyValue, s)
181        } else {
182            (Qualifier::Single, raw)
183        };
184        let (base, if_exists) = if let Some(s) = rest.strip_suffix("IfExists") {
185            (s, true)
186        } else {
187            (rest, false)
188        };
189        ConditionOperator::from_str(base).map(|op| Self {
190            op,
191            if_exists,
192            qualifier,
193        })
194    }
195}
196
197/// One parsed entry from a statement's condition block: the operator, a
198/// key, and the policy-declared value list.
199#[derive(Debug, Clone)]
200pub struct ParsedCondition {
201    pub operator: ParsedOperatorName,
202    pub key: String,
203    pub values: Vec<String>,
204}
205
206/// A statement's fully-parsed `Condition` block. Multiple entries are
207/// combined with **AND**: every entry must evaluate to `true` for the
208/// statement to apply.
209#[derive(Debug, Clone, Default)]
210pub struct CompiledCondition {
211    pub entries: Vec<ParsedCondition>,
212}
213
214impl CompiledCondition {
215    /// Parse a `Condition` block JSON value into a [`CompiledCondition`].
216    ///
217    /// AWS's condition block shape is:
218    /// ```json
219    /// { "OperatorName": { "key1": "val", "key2": ["v1", "v2"] }, ... }
220    /// ```
221    ///
222    /// Operators that fail to parse (unknown base name) become
223    /// [`ParsedCondition`] entries with an `Unknown` marker via a
224    /// sentinel: we still record them so evaluation can safe-fail. We
225    /// model this as "if any entry has an unrecognized operator, the
226    /// whole condition block evaluates to `false`" — recorded via a
227    /// dedicated `unknown_operators` vec.
228    pub fn parse(value: &Value) -> Self {
229        let mut out = Self::default();
230        let Some(obj) = value.as_object() else {
231            return out;
232        };
233        for (op_name, key_map) in obj {
234            let Some(operator) = ParsedOperatorName::parse(op_name) else {
235                // Unknown operator — record a poisoned entry that will
236                // force the whole block to false on evaluation.
237                out.entries.push(ParsedCondition {
238                    operator: ParsedOperatorName {
239                        op: ConditionOperator::Null,
240                        if_exists: false,
241                        qualifier: Qualifier::Single,
242                    },
243                    key: format!("__unknown_operator__:{op_name}"),
244                    values: Vec::new(),
245                });
246                continue;
247            };
248            let Some(inner) = key_map.as_object() else {
249                continue;
250            };
251            for (key, values) in inner {
252                let values = coerce_value_list(values);
253                out.entries.push(ParsedCondition {
254                    operator,
255                    key: key.clone(),
256                    values,
257                });
258            }
259        }
260        out
261    }
262
263    /// Evaluate this condition block against a [`ConditionContext`].
264    /// Returns `true` iff every entry matches (AND semantics).
265    pub fn matches(&self, ctx: &ConditionContext) -> bool {
266        for entry in &self.entries {
267            if entry.key.starts_with("__unknown_operator__:") {
268                let op_name = entry.key.trim_start_matches("__unknown_operator__:");
269                tracing::debug!(
270                    target: "fakecloud::iam::audit",
271                    operator = %op_name,
272                    "unknown condition operator; treating statement as non-applicable"
273                );
274                return false;
275            }
276            if !evaluate_entry(entry, ctx) {
277                return false;
278            }
279        }
280        true
281    }
282}
283
284fn coerce_value_list(value: &Value) -> Vec<String> {
285    match value {
286        Value::String(s) => vec![s.clone()],
287        Value::Bool(b) => vec![b.to_string()],
288        Value::Number(n) => vec![n.to_string()],
289        Value::Array(arr) => arr.iter().filter_map(value_to_string).collect(),
290        _ => Vec::new(),
291    }
292}
293
294fn value_to_string(v: &Value) -> Option<String> {
295    match v {
296        Value::String(s) => Some(s.clone()),
297        Value::Bool(b) => Some(b.to_string()),
298        Value::Number(n) => Some(n.to_string()),
299        _ => None,
300    }
301}
302
303/// Evaluate a single condition entry. Convenience entry point used by
304/// the evaluator integration in Batch 2 and by unit tests.
305pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
306    // Special case: `Null` checks key existence, not value equality.
307    if entry.operator.op == ConditionOperator::Null {
308        return evaluate_null(entry, ctx);
309    }
310
311    let context_values = ctx.lookup(&entry.key);
312
313    // Missing key handling.
314    let context_values = match context_values {
315        Some(vs) if !vs.is_empty() => vs,
316        _ => {
317            // Key not populated. `IfExists` -> vacuously true. Otherwise
318            // this is a safe-fail to false.
319            if entry.operator.if_exists {
320                return true;
321            }
322            if ctx.lookup(&entry.key).is_none() {
323                tracing::debug!(
324                    target: "fakecloud::iam::audit",
325                    key = %entry.key,
326                    operator = ?entry.operator.op,
327                    "condition key not populated; treating statement as non-applicable"
328                );
329            }
330            return false;
331        }
332    };
333
334    match entry.operator.qualifier {
335        Qualifier::Single | Qualifier::ForAnyValue => {
336            // ANY context value satisfies the operator against the
337            // policy value list (which is itself an OR of values).
338            context_values
339                .iter()
340                .any(|cv| match_values(entry.operator.op, &entry.values, cv))
341        }
342        Qualifier::ForAllValues => {
343            // EVERY context value must match; empty context set is
344            // vacuously true (AWS semantics).
345            context_values
346                .iter()
347                .all(|cv| match_values(entry.operator.op, &entry.values, cv))
348        }
349    }
350}
351
352/// `Null` operator: `{ "Null": { "aws:username": "true" } }` -> passes
353/// iff the key is missing. `"false"` -> passes iff the key is present.
354fn evaluate_null(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
355    let key_present = ctx
356        .lookup(&entry.key)
357        .map(|v| !v.is_empty())
358        .unwrap_or(false);
359    // "true" means "key MUST be null (missing)"; "false" means "key MUST
360    // be present". Multiple values in the policy list are OR-combined.
361    entry.values.iter().any(|v| match v.as_str() {
362        "true" => !key_present,
363        "false" => key_present,
364        _ => false,
365    })
366}
367
368/// Match a single policy-value list against a single context value,
369/// dispatched by operator. The value-list is treated as OR for positive
370/// operators and AND-of-negatives for negative operators — i.e. for
371/// `StringNotEquals` every policy value must differ from the context
372/// value, matching how AWS evaluates.
373fn match_values(op: ConditionOperator, policy_values: &[String], context_value: &str) -> bool {
374    use ConditionOperator::*;
375    match op {
376        StringEquals => policy_values.iter().any(|pv| pv == context_value),
377        StringNotEquals => policy_values.iter().all(|pv| pv != context_value),
378        StringEqualsIgnoreCase => policy_values
379            .iter()
380            .any(|pv| pv.eq_ignore_ascii_case(context_value)),
381        StringNotEqualsIgnoreCase => policy_values
382            .iter()
383            .all(|pv| !pv.eq_ignore_ascii_case(context_value)),
384        StringLike => policy_values.iter().any(|pv| glob(pv, context_value)),
385        StringNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
386        NumericEquals => numeric_cmp(policy_values, context_value, |p, c| p == c),
387        NumericNotEquals => numeric_cmp_all(policy_values, context_value, |p, c| p != c),
388        NumericLessThan => numeric_cmp(policy_values, context_value, |p, c| c < p),
389        NumericLessThanEquals => numeric_cmp(policy_values, context_value, |p, c| c <= p),
390        NumericGreaterThan => numeric_cmp(policy_values, context_value, |p, c| c > p),
391        NumericGreaterThanEquals => numeric_cmp(policy_values, context_value, |p, c| c >= p),
392        DateEquals => date_cmp(policy_values, context_value, |p, c| p == c),
393        DateNotEquals => date_cmp_all(policy_values, context_value, |p, c| p != c),
394        DateLessThan => date_cmp(policy_values, context_value, |p, c| c < p),
395        DateLessThanEquals => date_cmp(policy_values, context_value, |p, c| c <= p),
396        DateGreaterThan => date_cmp(policy_values, context_value, |p, c| c > p),
397        DateGreaterThanEquals => date_cmp(policy_values, context_value, |p, c| c >= p),
398        Bool => bool_match(policy_values, context_value),
399        BinaryEquals => policy_values.iter().any(|pv| pv == context_value),
400        IpAddress => policy_values.iter().any(|pv| cidr_match(pv, context_value)),
401        NotIpAddress => policy_values
402            .iter()
403            .all(|pv| !cidr_match(pv, context_value)),
404        ArnEquals | ArnLike => policy_values.iter().any(|pv| glob(pv, context_value)),
405        ArnNotEquals | ArnNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
406        Null => false, // handled separately in evaluate_null
407    }
408}
409
410fn numeric_cmp(
411    policy_values: &[String],
412    context_value: &str,
413    pred: impl Fn(f64, f64) -> bool,
414) -> bool {
415    let Ok(c) = context_value.parse::<f64>() else {
416        tracing::debug!(
417            target: "fakecloud::iam::audit",
418            context_value = %context_value,
419            "non-numeric context value for Numeric* operator; failing closed"
420        );
421        return false;
422    };
423    policy_values.iter().any(|pv| {
424        pv.parse::<f64>()
425            .map(|p| pred(p, c))
426            .ok()
427            .unwrap_or_else(|| {
428                tracing::debug!(
429                    target: "fakecloud::iam::audit",
430                    policy_value = %pv,
431                    "non-numeric policy value for Numeric* operator; failing closed"
432                );
433                false
434            })
435    })
436}
437
438fn numeric_cmp_all(
439    policy_values: &[String],
440    context_value: &str,
441    pred: impl Fn(f64, f64) -> bool,
442) -> bool {
443    let Ok(c) = context_value.parse::<f64>() else {
444        return false;
445    };
446    policy_values
447        .iter()
448        .all(|pv| pv.parse::<f64>().map(|p| pred(p, c)).unwrap_or(false))
449}
450
451fn date_cmp(
452    policy_values: &[String],
453    context_value: &str,
454    pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
455) -> bool {
456    let Some(c) = parse_date(context_value) else {
457        tracing::debug!(
458            target: "fakecloud::iam::audit",
459            context_value = %context_value,
460            "unparseable context date for Date* operator; failing closed"
461        );
462        return false;
463    };
464    policy_values
465        .iter()
466        .any(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
467}
468
469fn date_cmp_all(
470    policy_values: &[String],
471    context_value: &str,
472    pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
473) -> bool {
474    let Some(c) = parse_date(context_value) else {
475        return false;
476    };
477    policy_values
478        .iter()
479        .all(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
480}
481
482fn parse_date(s: &str) -> Option<DateTime<Utc>> {
483    // AWS accepts both RFC3339 timestamps and epoch seconds.
484    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
485        return Some(dt.with_timezone(&Utc));
486    }
487    if let Ok(secs) = s.parse::<i64>() {
488        return DateTime::from_timestamp(secs, 0);
489    }
490    None
491}
492
493fn bool_match(policy_values: &[String], context_value: &str) -> bool {
494    let cv = context_value.eq_ignore_ascii_case("true");
495    policy_values.iter().any(|pv| {
496        let pvb = pv.eq_ignore_ascii_case("true");
497        let pv_is_bool = pv.eq_ignore_ascii_case("true") || pv.eq_ignore_ascii_case("false");
498        pv_is_bool && pvb == cv
499    })
500}
501
502/// CIDR membership test. Accepts both bare addresses (treated as /32 or
503/// /128) and CIDR notation. Supports IPv4 and IPv6.
504pub(crate) fn cidr_match(pattern: &str, value: &str) -> bool {
505    let Ok(addr) = value.parse::<IpAddr>() else {
506        return false;
507    };
508    let (net_str, prefix_len) = match pattern.split_once('/') {
509        Some((n, p)) => {
510            let Ok(pl) = p.parse::<u8>() else {
511                return false;
512            };
513            (n, Some(pl))
514        }
515        None => (pattern, None),
516    };
517    let Ok(net) = net_str.parse::<IpAddr>() else {
518        return false;
519    };
520    match (net, addr) {
521        (IpAddr::V4(n), IpAddr::V4(a)) => {
522            let pl = prefix_len.unwrap_or(32);
523            if pl > 32 {
524                return false;
525            }
526            let mask: u32 = if pl == 0 { 0 } else { u32::MAX << (32 - pl) };
527            (u32::from(n) & mask) == (u32::from(a) & mask)
528        }
529        (IpAddr::V6(n), IpAddr::V6(a)) => {
530            let pl = prefix_len.unwrap_or(128);
531            if pl > 128 {
532                return false;
533            }
534            let mask: u128 = if pl == 0 { 0 } else { u128::MAX << (128 - pl) };
535            (u128::from(n) & mask) == (u128::from(a) & mask)
536        }
537        _ => false,
538    }
539}
540
541/// Glob match with `*` and `?`. Duplicated here rather than re-exported
542/// from `evaluator::glob_match` to avoid making that helper `pub(crate)`
543/// across modules — keeps the evaluator's public surface stable.
544fn glob(pattern: &str, value: &str) -> bool {
545    let p: Vec<char> = pattern.chars().collect();
546    let v: Vec<char> = value.chars().collect();
547    let mut pi = 0usize;
548    let mut vi = 0usize;
549    let mut star: Option<usize> = None;
550    let mut star_v = 0usize;
551    while vi < v.len() {
552        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
553            pi += 1;
554            vi += 1;
555        } else if pi < p.len() && p[pi] == '*' {
556            star = Some(pi);
557            star_v = vi;
558            pi += 1;
559        } else if let Some(s) = star {
560            pi = s + 1;
561            star_v += 1;
562            vi = star_v;
563        } else {
564            return false;
565        }
566    }
567    while pi < p.len() && p[pi] == '*' {
568        pi += 1;
569    }
570    pi == p.len()
571}
572
573/// Top-level evaluation helper used by the evaluator integration in
574/// Batch 2. Returns `true` iff the block is empty or every entry matches.
575pub fn evaluate_condition_block(block: &CompiledCondition, ctx: &ConditionContext) -> bool {
576    block.matches(ctx)
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use fakecloud_aws::arn::Arn;
583    use serde_json::json;
584
585    fn ctx_user(name: &str) -> ConditionContext {
586        ConditionContext {
587            aws_username: Some(name.to_string()),
588            aws_principal_arn: Some(
589                Arn::global("iam", "123456789012", &format!("user/{name}")).to_string(),
590            ),
591            aws_principal_account: Some("123456789012".to_string()),
592            aws_principal_type: Some("User".to_string()),
593            aws_userid: Some("AIDAEXAMPLE".to_string()),
594            ..Default::default()
595        }
596    }
597
598    fn compile(v: serde_json::Value) -> CompiledCondition {
599        CompiledCondition::parse(&v)
600    }
601
602    // ---- Operator name parsing ----
603
604    #[test]
605    fn parse_plain_operator() {
606        let p = ParsedOperatorName::parse("StringEquals").unwrap();
607        assert_eq!(p.op, ConditionOperator::StringEquals);
608        assert!(!p.if_exists);
609        assert_eq!(p.qualifier, Qualifier::Single);
610    }
611
612    #[test]
613    fn parse_if_exists_suffix() {
614        let p = ParsedOperatorName::parse("StringEqualsIfExists").unwrap();
615        assert_eq!(p.op, ConditionOperator::StringEquals);
616        assert!(p.if_exists);
617    }
618
619    #[test]
620    fn parse_for_all_values_qualifier() {
621        let p = ParsedOperatorName::parse("ForAllValues:StringLike").unwrap();
622        assert_eq!(p.op, ConditionOperator::StringLike);
623        assert_eq!(p.qualifier, Qualifier::ForAllValues);
624    }
625
626    #[test]
627    fn parse_for_any_value_with_if_exists() {
628        let p = ParsedOperatorName::parse("ForAnyValue:DateLessThanIfExists").unwrap();
629        assert_eq!(p.op, ConditionOperator::DateLessThan);
630        assert!(p.if_exists);
631        assert_eq!(p.qualifier, Qualifier::ForAnyValue);
632    }
633
634    #[test]
635    fn parse_unknown_operator_returns_none() {
636        assert!(ParsedOperatorName::parse("NotARealOp").is_none());
637    }
638
639    // ---- String operators ----
640
641    #[test]
642    fn string_equals_matches_exact() {
643        let b = compile(json!({ "StringEquals": { "aws:username": "alice" } }));
644        assert!(b.matches(&ctx_user("alice")));
645        assert!(!b.matches(&ctx_user("bob")));
646    }
647
648    #[test]
649    fn string_not_equals_denies_match() {
650        let b = compile(json!({ "StringNotEquals": { "aws:username": "alice" } }));
651        assert!(!b.matches(&ctx_user("alice")));
652        assert!(b.matches(&ctx_user("bob")));
653    }
654
655    #[test]
656    fn string_equals_ignore_case() {
657        let b = compile(json!({ "StringEqualsIgnoreCase": { "aws:username": "ALICE" } }));
658        assert!(b.matches(&ctx_user("alice")));
659    }
660
661    #[test]
662    fn string_like_wildcard() {
663        let b = compile(json!({ "StringLike": { "aws:username": "al*" } }));
664        assert!(b.matches(&ctx_user("alice")));
665        assert!(!b.matches(&ctx_user("bob")));
666    }
667
668    #[test]
669    fn string_not_like_wildcard() {
670        let b = compile(json!({ "StringNotLike": { "aws:username": "al*" } }));
671        assert!(!b.matches(&ctx_user("alice")));
672        assert!(b.matches(&ctx_user("bob")));
673    }
674
675    #[test]
676    fn string_equals_list_is_or() {
677        let b = compile(json!({
678            "StringEquals": { "aws:username": ["alice", "carol"] }
679        }));
680        assert!(b.matches(&ctx_user("alice")));
681        assert!(b.matches(&ctx_user("carol")));
682        assert!(!b.matches(&ctx_user("bob")));
683    }
684
685    // ---- Numeric ----
686
687    #[test]
688    fn numeric_equals() {
689        let mut ctx = ctx_user("alice");
690        ctx.service_keys
691            .insert("s3:maxkeys".to_string(), vec!["42".to_string()]);
692        let b = compile(json!({ "NumericEquals": { "s3:maxkeys": "42" } }));
693        assert!(b.matches(&ctx));
694    }
695
696    #[test]
697    fn numeric_less_than_epoch() {
698        let mut ctx = ctx_user("alice");
699        ctx.aws_epoch_time = Some(1_000);
700        let b = compile(json!({ "NumericLessThan": { "aws:epochtime": "2000" } }));
701        assert!(b.matches(&ctx));
702        ctx.aws_epoch_time = Some(3_000);
703        assert!(!b.matches(&ctx));
704    }
705
706    // ---- Date ----
707
708    #[test]
709    fn date_less_than_current_time() {
710        let mut ctx = ctx_user("alice");
711        ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
712            .ok()
713            .map(|d| d.with_timezone(&Utc));
714        let b = compile(json!({
715            "DateLessThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
716        }));
717        assert!(b.matches(&ctx));
718    }
719
720    #[test]
721    fn date_greater_than_blocks_past() {
722        let mut ctx = ctx_user("alice");
723        ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
724            .ok()
725            .map(|d| d.with_timezone(&Utc));
726        let b = compile(json!({
727            "DateGreaterThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
728        }));
729        assert!(!b.matches(&ctx));
730    }
731
732    // ---- Bool ----
733
734    #[test]
735    fn bool_secure_transport() {
736        let mut ctx = ctx_user("alice");
737        ctx.aws_secure_transport = Some(false);
738        let b = compile(json!({
739            "Bool": { "aws:SecureTransport": "false" }
740        }));
741        assert!(b.matches(&ctx));
742        ctx.aws_secure_transport = Some(true);
743        assert!(!b.matches(&ctx));
744    }
745
746    // ---- IP address ----
747
748    #[test]
749    fn ip_address_cidr_match() {
750        let mut ctx = ctx_user("alice");
751        ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
752        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
753        assert!(b.matches(&ctx));
754    }
755
756    #[test]
757    fn ip_address_cidr_outside() {
758        let mut ctx = ctx_user("alice");
759        ctx.aws_source_ip = Some("192.168.1.5".parse().unwrap());
760        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
761        assert!(!b.matches(&ctx));
762    }
763
764    #[test]
765    fn not_ip_address_blocks_cidr() {
766        let mut ctx = ctx_user("alice");
767        ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
768        let b = compile(json!({ "NotIpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
769        assert!(!b.matches(&ctx));
770    }
771
772    #[test]
773    fn ip_address_bare_v4() {
774        let mut ctx = ctx_user("alice");
775        ctx.aws_source_ip = Some("127.0.0.1".parse().unwrap());
776        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "127.0.0.1" } }));
777        assert!(b.matches(&ctx));
778    }
779
780    #[test]
781    fn ip_address_v6_cidr() {
782        let mut ctx = ctx_user("alice");
783        ctx.aws_source_ip = Some("2001:db8::1".parse().unwrap());
784        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "2001:db8::/32" } }));
785        assert!(b.matches(&ctx));
786    }
787
788    // ---- ARN ----
789
790    #[test]
791    fn arn_like_wildcard() {
792        let b = compile(json!({
793            "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::*:user/*" }
794        }));
795        assert!(b.matches(&ctx_user("alice")));
796    }
797
798    #[test]
799    fn arn_not_equals_rejects_exact() {
800        let b = compile(json!({
801            "ArnNotEquals": {
802                "aws:PrincipalArn": "arn:aws:iam::123456789012:user/alice"
803            }
804        }));
805        assert!(!b.matches(&ctx_user("alice")));
806        assert!(b.matches(&ctx_user("bob")));
807    }
808
809    // ---- Null (existence) ----
810
811    #[test]
812    fn null_true_requires_missing_key() {
813        let b = compile(json!({ "Null": { "aws:username": "true" } }));
814        assert!(!b.matches(&ctx_user("alice"))); // key present
815        let ctx = ConditionContext::default();
816        assert!(b.matches(&ctx)); // key absent
817    }
818
819    #[test]
820    fn null_false_requires_present_key() {
821        let b = compile(json!({ "Null": { "aws:username": "false" } }));
822        assert!(b.matches(&ctx_user("alice")));
823        let ctx = ConditionContext::default();
824        assert!(!b.matches(&ctx));
825    }
826
827    // ---- IfExists ----
828
829    #[test]
830    fn if_exists_passes_on_missing_key() {
831        let b = compile(json!({
832            "StringEqualsIfExists": { "aws:username": "alice" }
833        }));
834        let ctx = ConditionContext::default();
835        assert!(b.matches(&ctx));
836    }
837
838    #[test]
839    fn if_exists_still_checks_present_key() {
840        let b = compile(json!({
841            "StringEqualsIfExists": { "aws:username": "alice" }
842        }));
843        assert!(b.matches(&ctx_user("alice")));
844        assert!(!b.matches(&ctx_user("bob")));
845    }
846
847    // ---- ForAllValues / ForAnyValue ----
848
849    #[test]
850    fn for_all_values_every_context_must_match() {
851        let mut ctx = ctx_user("alice");
852        ctx.request_tags = Some(
853            [("env", "dev"), ("team", "platform")]
854                .iter()
855                .map(|(k, v)| (k.to_string(), v.to_string()))
856                .collect(),
857        );
858        let b = compile(json!({
859            "ForAllValues:StringEquals": {
860                "aws:TagKeys": ["env", "team", "owner"]
861            }
862        }));
863        assert!(b.matches(&ctx));
864        ctx.request_tags = Some(
865            [("env", "dev"), ("rogue", "x")]
866                .iter()
867                .map(|(k, v)| (k.to_string(), v.to_string()))
868                .collect(),
869        );
870        assert!(!b.matches(&ctx));
871    }
872
873    #[test]
874    fn for_any_value_some_context_matches() {
875        let mut ctx = ctx_user("alice");
876        ctx.request_tags = Some(
877            [("env", "dev"), ("rogue", "x")]
878                .iter()
879                .map(|(k, v)| (k.to_string(), v.to_string()))
880                .collect(),
881        );
882        let b = compile(json!({
883            "ForAnyValue:StringEquals": { "aws:TagKeys": "env" }
884        }));
885        assert!(b.matches(&ctx));
886    }
887
888    // ---- Multi-operator AND semantics ----
889
890    #[test]
891    fn multiple_operators_must_all_match() {
892        let mut ctx = ctx_user("alice");
893        ctx.aws_source_ip = Some("10.0.0.1".parse().unwrap());
894        let b = compile(json!({
895            "StringEquals": { "aws:username": "alice" },
896            "IpAddress":    { "aws:SourceIp": "10.0.0.0/24" }
897        }));
898        assert!(b.matches(&ctx));
899
900        let mut wrong_ip = ctx.clone();
901        wrong_ip.aws_source_ip = Some("192.168.1.1".parse().unwrap());
902        assert!(!b.matches(&wrong_ip));
903
904        let wrong_user = ctx_user("bob");
905        let mut wu = wrong_user;
906        wu.aws_source_ip = Some("10.0.0.1".parse().unwrap());
907        assert!(!b.matches(&wu));
908    }
909
910    // ---- Safe-fail on unknown operator / key ----
911
912    #[test]
913    fn unknown_operator_fails_closed() {
914        let b = compile(json!({ "NotARealOp": { "aws:username": "alice" } }));
915        assert!(!b.matches(&ctx_user("alice")));
916    }
917
918    #[test]
919    fn unknown_key_fails_closed() {
920        let b = compile(json!({
921            "StringEquals": { "aws:madeupkey": "whatever" }
922        }));
923        assert!(!b.matches(&ctx_user("alice")));
924    }
925
926    #[test]
927    fn context_lookup_case_insensitive() {
928        let ctx = ctx_user("alice");
929        assert_eq!(ctx.lookup("AWS:UserName"), Some(vec!["alice".to_string()]));
930        assert_eq!(ctx.lookup("aws:username"), Some(vec!["alice".to_string()]));
931    }
932
933    #[test]
934    fn cidr_match_helper() {
935        assert!(cidr_match("10.0.0.0/8", "10.1.2.3"));
936        assert!(!cidr_match("10.0.0.0/8", "11.0.0.1"));
937        assert!(cidr_match("0.0.0.0/0", "1.2.3.4"));
938        assert!(!cidr_match("invalid", "1.2.3.4"));
939    }
940
941    // ---- F3 global condition keys ----
942
943    #[test]
944    fn mfa_present_bool_true() {
945        let mut ctx = ctx_user("alice");
946        ctx.aws_mfa_present = Some(true);
947        let b = compile(json!({
948            "Bool": { "aws:MultiFactorAuthPresent": "true" }
949        }));
950        assert!(b.matches(&ctx));
951    }
952
953    #[test]
954    fn mfa_present_bool_false_when_absent_session() {
955        let mut ctx = ctx_user("alice");
956        ctx.aws_mfa_present = Some(false);
957        let b = compile(json!({
958            "Bool": { "aws:MultiFactorAuthPresent": "true" }
959        }));
960        assert!(!b.matches(&ctx));
961    }
962
963    #[test]
964    fn mfa_present_missing_key_safe_fails_without_if_exists() {
965        // Long-lived IAM user keys never have MFA; a policy that
966        // requires it must safe-fail.
967        let ctx = ctx_user("alice");
968        let b = compile(json!({
969            "Bool": { "aws:MultiFactorAuthPresent": "true" }
970        }));
971        assert!(!b.matches(&ctx));
972    }
973
974    #[test]
975    fn mfa_present_if_exists_passes_when_absent() {
976        let ctx = ctx_user("alice");
977        let b = compile(json!({
978            "BoolIfExists": { "aws:MultiFactorAuthPresent": "true" }
979        }));
980        assert!(b.matches(&ctx));
981    }
982
983    #[test]
984    fn mfa_age_numeric_less_than() {
985        // Session minted 60 seconds ago — policy requires age < 3600.
986        let mut ctx = ctx_user("alice");
987        ctx.aws_mfa_age_seconds = Some(60);
988        let b = compile(json!({
989            "NumericLessThan": { "aws:MultiFactorAuthAge": "3600" }
990        }));
991        assert!(b.matches(&ctx));
992    }
993
994    #[test]
995    fn mfa_age_numeric_greater_than_blocks_old_sessions() {
996        let mut ctx = ctx_user("alice");
997        ctx.aws_mfa_age_seconds = Some(7200);
998        let b = compile(json!({
999            "NumericLessThan": { "aws:MultiFactorAuthAge": "3600" }
1000        }));
1001        assert!(!b.matches(&ctx));
1002    }
1003
1004    #[test]
1005    fn called_via_string_equals_matches_first_hop() {
1006        let mut ctx = ctx_user("alice");
1007        ctx.aws_called_via = vec!["cloudformation.amazonaws.com".into()];
1008        let b = compile(json!({
1009            "StringEquals": { "aws:CalledVia": "cloudformation.amazonaws.com" }
1010        }));
1011        assert!(b.matches(&ctx));
1012    }
1013
1014    #[test]
1015    fn called_via_for_any_value_matches_in_chain() {
1016        let mut ctx = ctx_user("alice");
1017        ctx.aws_called_via = vec![
1018            "cloudformation.amazonaws.com".into(),
1019            "athena.amazonaws.com".into(),
1020        ];
1021        let b = compile(json!({
1022            "ForAnyValue:StringEquals": {
1023                "aws:CalledVia": ["athena.amazonaws.com", "lambda.amazonaws.com"]
1024            }
1025        }));
1026        assert!(b.matches(&ctx));
1027    }
1028
1029    #[test]
1030    fn called_via_for_all_values_requires_every_hop_in_set() {
1031        let mut ctx = ctx_user("alice");
1032        ctx.aws_called_via = vec![
1033            "cloudformation.amazonaws.com".into(),
1034            "athena.amazonaws.com".into(),
1035        ];
1036        let b = compile(json!({
1037            "ForAllValues:StringEquals": {
1038                "aws:CalledVia": ["cloudformation.amazonaws.com", "athena.amazonaws.com", "lambda.amazonaws.com"]
1039            }
1040        }));
1041        assert!(b.matches(&ctx));
1042        // Add a hop not in the policy set; ForAllValues now fails.
1043        ctx.aws_called_via.push("ec2.amazonaws.com".into());
1044        assert!(!b.matches(&ctx));
1045    }
1046
1047    #[test]
1048    fn source_vpce_string_equals() {
1049        let mut ctx = ctx_user("alice");
1050        ctx.aws_source_vpce = Some("vpce-0abcd1234".into());
1051        let b = compile(json!({
1052            "StringEquals": { "aws:SourceVpce": "vpce-0abcd1234" }
1053        }));
1054        assert!(b.matches(&ctx));
1055    }
1056
1057    #[test]
1058    fn source_vpce_string_not_equals_blocks_other() {
1059        let mut ctx = ctx_user("alice");
1060        ctx.aws_source_vpce = Some("vpce-bad".into());
1061        let b = compile(json!({
1062            "StringEquals": { "aws:SourceVpce": "vpce-good" }
1063        }));
1064        assert!(!b.matches(&ctx));
1065    }
1066
1067    #[test]
1068    fn source_vpc_string_equals() {
1069        let mut ctx = ctx_user("alice");
1070        ctx.aws_source_vpc = Some("vpc-1a2b3c".into());
1071        let b = compile(json!({
1072            "StringEquals": { "aws:SourceVpc": "vpc-1a2b3c" }
1073        }));
1074        assert!(b.matches(&ctx));
1075    }
1076
1077    #[test]
1078    fn vpc_source_ip_cidr_match() {
1079        let mut ctx = ctx_user("alice");
1080        ctx.aws_vpc_source_ip = Some("172.31.5.10".parse().unwrap());
1081        let b = compile(json!({
1082            "IpAddress": { "aws:VpcSourceIp": "172.31.0.0/16" }
1083        }));
1084        assert!(b.matches(&ctx));
1085    }
1086
1087    #[test]
1088    fn vpc_source_ip_cidr_outside() {
1089        let mut ctx = ctx_user("alice");
1090        ctx.aws_vpc_source_ip = Some("10.0.0.5".parse().unwrap());
1091        let b = compile(json!({
1092            "IpAddress": { "aws:VpcSourceIp": "172.31.0.0/16" }
1093        }));
1094        assert!(!b.matches(&ctx));
1095    }
1096
1097    #[test]
1098    fn federated_provider_string_equals_saml_arn() {
1099        let mut ctx = ctx_user("alice");
1100        ctx.aws_federated_provider = Some("arn:aws:iam::123456789012:saml-provider/idp".into());
1101        let b = compile(json!({
1102            "StringEquals": {
1103                "aws:FederatedProvider": "arn:aws:iam::123456789012:saml-provider/idp"
1104            }
1105        }));
1106        assert!(b.matches(&ctx));
1107    }
1108
1109    #[test]
1110    fn federated_provider_string_like_oidc_host() {
1111        let mut ctx = ctx_user("alice");
1112        ctx.aws_federated_provider = Some("accounts.google.com".into());
1113        let b = compile(json!({
1114            "StringLike": { "aws:FederatedProvider": "accounts.*" }
1115        }));
1116        assert!(b.matches(&ctx));
1117    }
1118
1119    #[test]
1120    fn token_issue_time_date_less_than() {
1121        let mut ctx = ctx_user("alice");
1122        ctx.aws_token_issue_time = DateTime::parse_from_rfc3339("2024-06-01T00:00:00Z")
1123            .ok()
1124            .map(|d| d.with_timezone(&Utc));
1125        let b = compile(json!({
1126            "DateLessThan": { "aws:TokenIssueTime": "2024-12-31T23:59:59Z" }
1127        }));
1128        assert!(b.matches(&ctx));
1129    }
1130
1131    #[test]
1132    fn token_issue_time_date_greater_than_blocks_old_token() {
1133        let mut ctx = ctx_user("alice");
1134        ctx.aws_token_issue_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1135            .ok()
1136            .map(|d| d.with_timezone(&Utc));
1137        let b = compile(json!({
1138            "DateGreaterThan": { "aws:TokenIssueTime": "2024-06-01T00:00:00Z" }
1139        }));
1140        assert!(!b.matches(&ctx));
1141    }
1142
1143    #[test]
1144    fn userid_string_equals_assumed_role_format() {
1145        // AWS userid format for assumed roles: <role-id>:<RoleSessionName>
1146        let mut ctx = ctx_user("alice");
1147        ctx.aws_userid = Some("AROAEXAMPLE:bob-session".into());
1148        let b = compile(json!({
1149            "StringEquals": { "aws:userid": "AROAEXAMPLE:bob-session" }
1150        }));
1151        assert!(b.matches(&ctx));
1152    }
1153
1154    #[test]
1155    fn userid_string_like_wildcard() {
1156        let mut ctx = ctx_user("alice");
1157        ctx.aws_userid = Some("AROAEXAMPLE:bob-session".into());
1158        let b = compile(json!({
1159            "StringLike": { "aws:userid": "AROAEXAMPLE:*" }
1160        }));
1161        assert!(b.matches(&ctx));
1162    }
1163
1164    #[test]
1165    fn username_lookup_case_insensitive_for_f3_keys() {
1166        // AWS treats every condition key case-insensitively. Spot-check
1167        // each F3 key's lookup with mixed case.
1168        let mut ctx = ctx_user("alice");
1169        ctx.aws_mfa_present = Some(true);
1170        ctx.aws_called_via = vec!["lambda.amazonaws.com".into()];
1171        ctx.aws_source_vpc = Some("vpc-x".into());
1172        ctx.aws_federated_provider = Some("accounts.google.com".into());
1173        ctx.aws_token_issue_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
1174            .ok()
1175            .map(|d| d.with_timezone(&Utc));
1176
1177        assert!(ctx.lookup("AWS:MULTIFACTORAUTHPRESENT").is_some());
1178        assert!(ctx.lookup("aws:multifactorauthpresent").is_some());
1179        assert!(ctx.lookup("aws:CalledVia").is_some());
1180        assert!(ctx.lookup("AWS:SOURCEVPC").is_some());
1181        assert!(ctx.lookup("aws:FederatedProvider").is_some());
1182        assert!(ctx.lookup("aws:tokenissuetime").is_some());
1183    }
1184
1185    #[test]
1186    fn combined_mfa_and_token_issue_time() {
1187        // Realistic policy: only allow when MFA is present AND the
1188        // session was minted in the last hour. Combined AND semantics.
1189        let mut ctx = ctx_user("alice");
1190        ctx.aws_mfa_present = Some(true);
1191        ctx.aws_mfa_age_seconds = Some(120);
1192        ctx.aws_token_issue_time = Some(Utc::now() - chrono::Duration::seconds(120));
1193        let b = compile(json!({
1194            "Bool": { "aws:MultiFactorAuthPresent": "true" },
1195            "NumericLessThan": { "aws:MultiFactorAuthAge": "3600" }
1196        }));
1197        assert!(b.matches(&ctx));
1198
1199        // Bump the age past the policy ceiling — denied.
1200        ctx.aws_mfa_age_seconds = Some(7200);
1201        assert!(!b.matches(&ctx));
1202    }
1203}