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** (Phase 2 initial set):
31//!
32//! - `aws:username`
33//! - `aws:userid`
34//! - `aws:PrincipalArn`
35//! - `aws:PrincipalAccount`
36//! - `aws:PrincipalType`
37//! - `aws:SourceIp`
38//! - `aws:CurrentTime`
39//! - `aws:EpochTime`
40//! - `aws:SecureTransport`
41//! - `aws:RequestedRegion`
42//!
43//! Service-specific keys (`s3:prefix`, `sqs:MessageAttribute`, …) are
44//! deferred to a follow-up batch; the [`ConditionContext::service_keys`]
45//! map is pre-wired so they can land without a signature change.
46//!
47//! # Safe-fail semantics
48//!
49//! Any unimplemented operator, unknown key, or parse error emits a
50//! `tracing::debug!` on the `fakecloud::iam::audit` target and causes the
51//! operator to evaluate to `false` — i.e. the statement is treated as
52//! *not applicable*. Silently returning `true` would let real policies
53//! grant access we can't actually verify, which would defeat the whole
54//! opt-in enforcement story.
55
56use std::net::IpAddr;
57
58use chrono::{DateTime, Utc};
59use serde_json::Value;
60
61/// Re-export of the data type defined in `fakecloud-core::auth` — see
62/// [`fakecloud_core::auth::ConditionContext`] for field documentation.
63/// The condition operator framework in this module is implemented
64/// against this type.
65pub use fakecloud_core::auth::ConditionContext;
66
67/// Base condition operator name (without `IfExists` suffix or
68/// `ForAllValues:` / `ForAnyValue:` qualifier).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ConditionOperator {
71    StringEquals,
72    StringNotEquals,
73    StringEqualsIgnoreCase,
74    StringNotEqualsIgnoreCase,
75    StringLike,
76    StringNotLike,
77    NumericEquals,
78    NumericNotEquals,
79    NumericLessThan,
80    NumericLessThanEquals,
81    NumericGreaterThan,
82    NumericGreaterThanEquals,
83    DateEquals,
84    DateNotEquals,
85    DateLessThan,
86    DateLessThanEquals,
87    DateGreaterThan,
88    DateGreaterThanEquals,
89    Bool,
90    BinaryEquals,
91    IpAddress,
92    NotIpAddress,
93    ArnEquals,
94    ArnNotEquals,
95    ArnLike,
96    ArnNotLike,
97    Null,
98}
99
100impl ConditionOperator {
101    fn from_str(name: &str) -> Option<Self> {
102        Some(match name {
103            "StringEquals" => Self::StringEquals,
104            "StringNotEquals" => Self::StringNotEquals,
105            "StringEqualsIgnoreCase" => Self::StringEqualsIgnoreCase,
106            "StringNotEqualsIgnoreCase" => Self::StringNotEqualsIgnoreCase,
107            "StringLike" => Self::StringLike,
108            "StringNotLike" => Self::StringNotLike,
109            "NumericEquals" => Self::NumericEquals,
110            "NumericNotEquals" => Self::NumericNotEquals,
111            "NumericLessThan" => Self::NumericLessThan,
112            "NumericLessThanEquals" => Self::NumericLessThanEquals,
113            "NumericGreaterThan" => Self::NumericGreaterThan,
114            "NumericGreaterThanEquals" => Self::NumericGreaterThanEquals,
115            "DateEquals" => Self::DateEquals,
116            "DateNotEquals" => Self::DateNotEquals,
117            "DateLessThan" => Self::DateLessThan,
118            "DateLessThanEquals" => Self::DateLessThanEquals,
119            "DateGreaterThan" => Self::DateGreaterThan,
120            "DateGreaterThanEquals" => Self::DateGreaterThanEquals,
121            "Bool" => Self::Bool,
122            "BinaryEquals" => Self::BinaryEquals,
123            "IpAddress" => Self::IpAddress,
124            "NotIpAddress" => Self::NotIpAddress,
125            "ArnEquals" => Self::ArnEquals,
126            "ArnNotEquals" => Self::ArnNotEquals,
127            "ArnLike" => Self::ArnLike,
128            "ArnNotLike" => Self::ArnNotLike,
129            "Null" => Self::Null,
130            _ => return None,
131        })
132    }
133}
134
135/// `ForAllValues:` / `ForAnyValue:` qualifier applied to the operator.
136///
137/// `Single` is the default (no qualifier) and behaves like `ForAnyValue`
138/// for single-valued context keys, which is what AWS does.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Qualifier {
141    Single,
142    ForAnyValue,
143    ForAllValues,
144}
145
146/// Parsed operator name: the base [`ConditionOperator`], the
147/// `IfExists` flag, and the `ForAllValues` / `ForAnyValue` qualifier.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct ParsedOperatorName {
150    pub op: ConditionOperator,
151    pub if_exists: bool,
152    pub qualifier: Qualifier,
153}
154
155impl ParsedOperatorName {
156    /// Parse an operator name as it appears in a policy JSON key, e.g.
157    /// `"StringEqualsIfExists"`, `"ForAllValues:StringLike"`,
158    /// `"ForAnyValue:DateLessThanIfExists"`.
159    ///
160    /// Returns `None` if the base operator is not one of the 28 AWS
161    /// defines. The caller should safe-fail (statement does not apply)
162    /// on `None`.
163    pub fn parse(raw: &str) -> Option<Self> {
164        let (qualifier, rest) = if let Some(s) = raw.strip_prefix("ForAllValues:") {
165            (Qualifier::ForAllValues, s)
166        } else if let Some(s) = raw.strip_prefix("ForAnyValue:") {
167            (Qualifier::ForAnyValue, s)
168        } else {
169            (Qualifier::Single, raw)
170        };
171        let (base, if_exists) = if let Some(s) = rest.strip_suffix("IfExists") {
172            (s, true)
173        } else {
174            (rest, false)
175        };
176        ConditionOperator::from_str(base).map(|op| Self {
177            op,
178            if_exists,
179            qualifier,
180        })
181    }
182}
183
184/// One parsed entry from a statement's condition block: the operator, a
185/// key, and the policy-declared value list.
186#[derive(Debug, Clone)]
187pub struct ParsedCondition {
188    pub operator: ParsedOperatorName,
189    pub key: String,
190    pub values: Vec<String>,
191}
192
193/// A statement's fully-parsed `Condition` block. Multiple entries are
194/// combined with **AND**: every entry must evaluate to `true` for the
195/// statement to apply.
196#[derive(Debug, Clone, Default)]
197pub struct CompiledCondition {
198    pub entries: Vec<ParsedCondition>,
199}
200
201impl CompiledCondition {
202    /// Parse a `Condition` block JSON value into a [`CompiledCondition`].
203    ///
204    /// AWS's condition block shape is:
205    /// ```json
206    /// { "OperatorName": { "key1": "val", "key2": ["v1", "v2"] }, ... }
207    /// ```
208    ///
209    /// Operators that fail to parse (unknown base name) become
210    /// [`ParsedCondition`] entries with an `Unknown` marker via a
211    /// sentinel: we still record them so evaluation can safe-fail. We
212    /// model this as "if any entry has an unrecognized operator, the
213    /// whole condition block evaluates to `false`" — recorded via a
214    /// dedicated `unknown_operators` vec.
215    pub fn parse(value: &Value) -> Self {
216        let mut out = Self::default();
217        let Some(obj) = value.as_object() else {
218            return out;
219        };
220        for (op_name, key_map) in obj {
221            let Some(operator) = ParsedOperatorName::parse(op_name) else {
222                // Unknown operator — record a poisoned entry that will
223                // force the whole block to false on evaluation.
224                out.entries.push(ParsedCondition {
225                    operator: ParsedOperatorName {
226                        op: ConditionOperator::Null,
227                        if_exists: false,
228                        qualifier: Qualifier::Single,
229                    },
230                    key: format!("__unknown_operator__:{op_name}"),
231                    values: Vec::new(),
232                });
233                continue;
234            };
235            let Some(inner) = key_map.as_object() else {
236                continue;
237            };
238            for (key, values) in inner {
239                let values = coerce_value_list(values);
240                out.entries.push(ParsedCondition {
241                    operator,
242                    key: key.clone(),
243                    values,
244                });
245            }
246        }
247        out
248    }
249
250    /// Evaluate this condition block against a [`ConditionContext`].
251    /// Returns `true` iff every entry matches (AND semantics).
252    pub fn matches(&self, ctx: &ConditionContext) -> bool {
253        for entry in &self.entries {
254            if entry.key.starts_with("__unknown_operator__:") {
255                let op_name = entry.key.trim_start_matches("__unknown_operator__:");
256                tracing::debug!(
257                    target: "fakecloud::iam::audit",
258                    operator = %op_name,
259                    "unknown condition operator; treating statement as non-applicable"
260                );
261                return false;
262            }
263            if !evaluate_entry(entry, ctx) {
264                return false;
265            }
266        }
267        true
268    }
269}
270
271fn coerce_value_list(value: &Value) -> Vec<String> {
272    match value {
273        Value::String(s) => vec![s.clone()],
274        Value::Bool(b) => vec![b.to_string()],
275        Value::Number(n) => vec![n.to_string()],
276        Value::Array(arr) => arr.iter().filter_map(value_to_string).collect(),
277        _ => Vec::new(),
278    }
279}
280
281fn value_to_string(v: &Value) -> Option<String> {
282    match v {
283        Value::String(s) => Some(s.clone()),
284        Value::Bool(b) => Some(b.to_string()),
285        Value::Number(n) => Some(n.to_string()),
286        _ => None,
287    }
288}
289
290/// Evaluate a single condition entry. Convenience entry point used by
291/// the evaluator integration in Batch 2 and by unit tests.
292pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
293    // Special case: `Null` checks key existence, not value equality.
294    if entry.operator.op == ConditionOperator::Null {
295        return evaluate_null(entry, ctx);
296    }
297
298    let context_values = ctx.lookup(&entry.key);
299
300    // Missing key handling.
301    let context_values = match context_values {
302        Some(vs) if !vs.is_empty() => vs,
303        _ => {
304            // Key not populated. `IfExists` -> vacuously true. Otherwise
305            // this is a safe-fail to false.
306            if entry.operator.if_exists {
307                return true;
308            }
309            if ctx.lookup(&entry.key).is_none() {
310                tracing::debug!(
311                    target: "fakecloud::iam::audit",
312                    key = %entry.key,
313                    operator = ?entry.operator.op,
314                    "condition key not populated; treating statement as non-applicable"
315                );
316            }
317            return false;
318        }
319    };
320
321    match entry.operator.qualifier {
322        Qualifier::Single | Qualifier::ForAnyValue => {
323            // ANY context value satisfies the operator against the
324            // policy value list (which is itself an OR of values).
325            context_values
326                .iter()
327                .any(|cv| match_values(entry.operator.op, &entry.values, cv))
328        }
329        Qualifier::ForAllValues => {
330            // EVERY context value must match; empty context set is
331            // vacuously true (AWS semantics).
332            context_values
333                .iter()
334                .all(|cv| match_values(entry.operator.op, &entry.values, cv))
335        }
336    }
337}
338
339/// `Null` operator: `{ "Null": { "aws:username": "true" } }` -> passes
340/// iff the key is missing. `"false"` -> passes iff the key is present.
341fn evaluate_null(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
342    let key_present = ctx
343        .lookup(&entry.key)
344        .map(|v| !v.is_empty())
345        .unwrap_or(false);
346    // "true" means "key MUST be null (missing)"; "false" means "key MUST
347    // be present". Multiple values in the policy list are OR-combined.
348    entry.values.iter().any(|v| match v.as_str() {
349        "true" => !key_present,
350        "false" => key_present,
351        _ => false,
352    })
353}
354
355/// Match a single policy-value list against a single context value,
356/// dispatched by operator. The value-list is treated as OR for positive
357/// operators and AND-of-negatives for negative operators — i.e. for
358/// `StringNotEquals` every policy value must differ from the context
359/// value, matching how AWS evaluates.
360fn match_values(op: ConditionOperator, policy_values: &[String], context_value: &str) -> bool {
361    use ConditionOperator::*;
362    match op {
363        StringEquals => policy_values.iter().any(|pv| pv == context_value),
364        StringNotEquals => policy_values.iter().all(|pv| pv != context_value),
365        StringEqualsIgnoreCase => policy_values
366            .iter()
367            .any(|pv| pv.eq_ignore_ascii_case(context_value)),
368        StringNotEqualsIgnoreCase => policy_values
369            .iter()
370            .all(|pv| !pv.eq_ignore_ascii_case(context_value)),
371        StringLike => policy_values.iter().any(|pv| glob(pv, context_value)),
372        StringNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
373        NumericEquals => numeric_cmp(policy_values, context_value, |p, c| p == c),
374        NumericNotEquals => numeric_cmp_all(policy_values, context_value, |p, c| p != c),
375        NumericLessThan => numeric_cmp(policy_values, context_value, |p, c| c < p),
376        NumericLessThanEquals => numeric_cmp(policy_values, context_value, |p, c| c <= p),
377        NumericGreaterThan => numeric_cmp(policy_values, context_value, |p, c| c > p),
378        NumericGreaterThanEquals => numeric_cmp(policy_values, context_value, |p, c| c >= p),
379        DateEquals => date_cmp(policy_values, context_value, |p, c| p == c),
380        DateNotEquals => date_cmp_all(policy_values, context_value, |p, c| p != c),
381        DateLessThan => date_cmp(policy_values, context_value, |p, c| c < p),
382        DateLessThanEquals => date_cmp(policy_values, context_value, |p, c| c <= p),
383        DateGreaterThan => date_cmp(policy_values, context_value, |p, c| c > p),
384        DateGreaterThanEquals => date_cmp(policy_values, context_value, |p, c| c >= p),
385        Bool => bool_match(policy_values, context_value),
386        BinaryEquals => policy_values.iter().any(|pv| pv == context_value),
387        IpAddress => policy_values.iter().any(|pv| cidr_match(pv, context_value)),
388        NotIpAddress => policy_values
389            .iter()
390            .all(|pv| !cidr_match(pv, context_value)),
391        ArnEquals | ArnLike => policy_values.iter().any(|pv| glob(pv, context_value)),
392        ArnNotEquals | ArnNotLike => policy_values.iter().all(|pv| !glob(pv, context_value)),
393        Null => false, // handled separately in evaluate_null
394    }
395}
396
397fn numeric_cmp(
398    policy_values: &[String],
399    context_value: &str,
400    pred: impl Fn(f64, f64) -> bool,
401) -> bool {
402    let Ok(c) = context_value.parse::<f64>() else {
403        tracing::debug!(
404            target: "fakecloud::iam::audit",
405            context_value = %context_value,
406            "non-numeric context value for Numeric* operator; failing closed"
407        );
408        return false;
409    };
410    policy_values.iter().any(|pv| {
411        pv.parse::<f64>()
412            .map(|p| pred(p, c))
413            .ok()
414            .unwrap_or_else(|| {
415                tracing::debug!(
416                    target: "fakecloud::iam::audit",
417                    policy_value = %pv,
418                    "non-numeric policy value for Numeric* operator; failing closed"
419                );
420                false
421            })
422    })
423}
424
425fn numeric_cmp_all(
426    policy_values: &[String],
427    context_value: &str,
428    pred: impl Fn(f64, f64) -> bool,
429) -> bool {
430    let Ok(c) = context_value.parse::<f64>() else {
431        return false;
432    };
433    policy_values
434        .iter()
435        .all(|pv| pv.parse::<f64>().map(|p| pred(p, c)).unwrap_or(false))
436}
437
438fn date_cmp(
439    policy_values: &[String],
440    context_value: &str,
441    pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
442) -> bool {
443    let Some(c) = parse_date(context_value) else {
444        tracing::debug!(
445            target: "fakecloud::iam::audit",
446            context_value = %context_value,
447            "unparseable context date for Date* operator; failing closed"
448        );
449        return false;
450    };
451    policy_values
452        .iter()
453        .any(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
454}
455
456fn date_cmp_all(
457    policy_values: &[String],
458    context_value: &str,
459    pred: impl Fn(DateTime<Utc>, DateTime<Utc>) -> bool,
460) -> bool {
461    let Some(c) = parse_date(context_value) else {
462        return false;
463    };
464    policy_values
465        .iter()
466        .all(|pv| parse_date(pv).map(|p| pred(p, c)).unwrap_or(false))
467}
468
469fn parse_date(s: &str) -> Option<DateTime<Utc>> {
470    // AWS accepts both RFC3339 timestamps and epoch seconds.
471    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
472        return Some(dt.with_timezone(&Utc));
473    }
474    if let Ok(secs) = s.parse::<i64>() {
475        return DateTime::from_timestamp(secs, 0);
476    }
477    None
478}
479
480fn bool_match(policy_values: &[String], context_value: &str) -> bool {
481    let cv = context_value.eq_ignore_ascii_case("true");
482    policy_values.iter().any(|pv| {
483        let pvb = pv.eq_ignore_ascii_case("true");
484        let pv_is_bool = pv.eq_ignore_ascii_case("true") || pv.eq_ignore_ascii_case("false");
485        pv_is_bool && pvb == cv
486    })
487}
488
489/// CIDR membership test. Accepts both bare addresses (treated as /32 or
490/// /128) and CIDR notation. Supports IPv4 and IPv6.
491pub(crate) fn cidr_match(pattern: &str, value: &str) -> bool {
492    let Ok(addr) = value.parse::<IpAddr>() else {
493        return false;
494    };
495    let (net_str, prefix_len) = match pattern.split_once('/') {
496        Some((n, p)) => {
497            let Ok(pl) = p.parse::<u8>() else {
498                return false;
499            };
500            (n, Some(pl))
501        }
502        None => (pattern, None),
503    };
504    let Ok(net) = net_str.parse::<IpAddr>() else {
505        return false;
506    };
507    match (net, addr) {
508        (IpAddr::V4(n), IpAddr::V4(a)) => {
509            let pl = prefix_len.unwrap_or(32);
510            if pl > 32 {
511                return false;
512            }
513            let mask: u32 = if pl == 0 { 0 } else { u32::MAX << (32 - pl) };
514            (u32::from(n) & mask) == (u32::from(a) & mask)
515        }
516        (IpAddr::V6(n), IpAddr::V6(a)) => {
517            let pl = prefix_len.unwrap_or(128);
518            if pl > 128 {
519                return false;
520            }
521            let mask: u128 = if pl == 0 { 0 } else { u128::MAX << (128 - pl) };
522            (u128::from(n) & mask) == (u128::from(a) & mask)
523        }
524        _ => false,
525    }
526}
527
528/// Glob match with `*` and `?`. Duplicated here rather than re-exported
529/// from `evaluator::glob_match` to avoid making that helper `pub(crate)`
530/// across modules — keeps the evaluator's public surface stable.
531fn glob(pattern: &str, value: &str) -> bool {
532    let p: Vec<char> = pattern.chars().collect();
533    let v: Vec<char> = value.chars().collect();
534    let mut pi = 0usize;
535    let mut vi = 0usize;
536    let mut star: Option<usize> = None;
537    let mut star_v = 0usize;
538    while vi < v.len() {
539        if pi < p.len() && (p[pi] == '?' || p[pi] == v[vi]) {
540            pi += 1;
541            vi += 1;
542        } else if pi < p.len() && p[pi] == '*' {
543            star = Some(pi);
544            star_v = vi;
545            pi += 1;
546        } else if let Some(s) = star {
547            pi = s + 1;
548            star_v += 1;
549            vi = star_v;
550        } else {
551            return false;
552        }
553    }
554    while pi < p.len() && p[pi] == '*' {
555        pi += 1;
556    }
557    pi == p.len()
558}
559
560/// Top-level evaluation helper used by the evaluator integration in
561/// Batch 2. Returns `true` iff the block is empty or every entry matches.
562pub fn evaluate_condition_block(block: &CompiledCondition, ctx: &ConditionContext) -> bool {
563    block.matches(ctx)
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use serde_json::json;
570
571    fn ctx_user(name: &str) -> ConditionContext {
572        ConditionContext {
573            aws_username: Some(name.to_string()),
574            aws_principal_arn: Some(format!("arn:aws:iam::123456789012:user/{name}")),
575            aws_principal_account: Some("123456789012".to_string()),
576            aws_principal_type: Some("User".to_string()),
577            aws_userid: Some("AIDAEXAMPLE".to_string()),
578            ..Default::default()
579        }
580    }
581
582    fn compile(v: serde_json::Value) -> CompiledCondition {
583        CompiledCondition::parse(&v)
584    }
585
586    // ---- Operator name parsing ----
587
588    #[test]
589    fn parse_plain_operator() {
590        let p = ParsedOperatorName::parse("StringEquals").unwrap();
591        assert_eq!(p.op, ConditionOperator::StringEquals);
592        assert!(!p.if_exists);
593        assert_eq!(p.qualifier, Qualifier::Single);
594    }
595
596    #[test]
597    fn parse_if_exists_suffix() {
598        let p = ParsedOperatorName::parse("StringEqualsIfExists").unwrap();
599        assert_eq!(p.op, ConditionOperator::StringEquals);
600        assert!(p.if_exists);
601    }
602
603    #[test]
604    fn parse_for_all_values_qualifier() {
605        let p = ParsedOperatorName::parse("ForAllValues:StringLike").unwrap();
606        assert_eq!(p.op, ConditionOperator::StringLike);
607        assert_eq!(p.qualifier, Qualifier::ForAllValues);
608    }
609
610    #[test]
611    fn parse_for_any_value_with_if_exists() {
612        let p = ParsedOperatorName::parse("ForAnyValue:DateLessThanIfExists").unwrap();
613        assert_eq!(p.op, ConditionOperator::DateLessThan);
614        assert!(p.if_exists);
615        assert_eq!(p.qualifier, Qualifier::ForAnyValue);
616    }
617
618    #[test]
619    fn parse_unknown_operator_returns_none() {
620        assert!(ParsedOperatorName::parse("NotARealOp").is_none());
621    }
622
623    // ---- String operators ----
624
625    #[test]
626    fn string_equals_matches_exact() {
627        let b = compile(json!({ "StringEquals": { "aws:username": "alice" } }));
628        assert!(b.matches(&ctx_user("alice")));
629        assert!(!b.matches(&ctx_user("bob")));
630    }
631
632    #[test]
633    fn string_not_equals_denies_match() {
634        let b = compile(json!({ "StringNotEquals": { "aws:username": "alice" } }));
635        assert!(!b.matches(&ctx_user("alice")));
636        assert!(b.matches(&ctx_user("bob")));
637    }
638
639    #[test]
640    fn string_equals_ignore_case() {
641        let b = compile(json!({ "StringEqualsIgnoreCase": { "aws:username": "ALICE" } }));
642        assert!(b.matches(&ctx_user("alice")));
643    }
644
645    #[test]
646    fn string_like_wildcard() {
647        let b = compile(json!({ "StringLike": { "aws:username": "al*" } }));
648        assert!(b.matches(&ctx_user("alice")));
649        assert!(!b.matches(&ctx_user("bob")));
650    }
651
652    #[test]
653    fn string_not_like_wildcard() {
654        let b = compile(json!({ "StringNotLike": { "aws:username": "al*" } }));
655        assert!(!b.matches(&ctx_user("alice")));
656        assert!(b.matches(&ctx_user("bob")));
657    }
658
659    #[test]
660    fn string_equals_list_is_or() {
661        let b = compile(json!({
662            "StringEquals": { "aws:username": ["alice", "carol"] }
663        }));
664        assert!(b.matches(&ctx_user("alice")));
665        assert!(b.matches(&ctx_user("carol")));
666        assert!(!b.matches(&ctx_user("bob")));
667    }
668
669    // ---- Numeric ----
670
671    #[test]
672    fn numeric_equals() {
673        let mut ctx = ctx_user("alice");
674        ctx.service_keys
675            .insert("s3:maxkeys".to_string(), vec!["42".to_string()]);
676        let b = compile(json!({ "NumericEquals": { "s3:maxkeys": "42" } }));
677        assert!(b.matches(&ctx));
678    }
679
680    #[test]
681    fn numeric_less_than_epoch() {
682        let mut ctx = ctx_user("alice");
683        ctx.aws_epoch_time = Some(1_000);
684        let b = compile(json!({ "NumericLessThan": { "aws:epochtime": "2000" } }));
685        assert!(b.matches(&ctx));
686        ctx.aws_epoch_time = Some(3_000);
687        assert!(!b.matches(&ctx));
688    }
689
690    // ---- Date ----
691
692    #[test]
693    fn date_less_than_current_time() {
694        let mut ctx = ctx_user("alice");
695        ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
696            .ok()
697            .map(|d| d.with_timezone(&Utc));
698        let b = compile(json!({
699            "DateLessThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
700        }));
701        assert!(b.matches(&ctx));
702    }
703
704    #[test]
705    fn date_greater_than_blocks_past() {
706        let mut ctx = ctx_user("alice");
707        ctx.aws_current_time = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
708            .ok()
709            .map(|d| d.with_timezone(&Utc));
710        let b = compile(json!({
711            "DateGreaterThan": { "aws:CurrentTime": "2025-01-01T00:00:00Z" }
712        }));
713        assert!(!b.matches(&ctx));
714    }
715
716    // ---- Bool ----
717
718    #[test]
719    fn bool_secure_transport() {
720        let mut ctx = ctx_user("alice");
721        ctx.aws_secure_transport = Some(false);
722        let b = compile(json!({
723            "Bool": { "aws:SecureTransport": "false" }
724        }));
725        assert!(b.matches(&ctx));
726        ctx.aws_secure_transport = Some(true);
727        assert!(!b.matches(&ctx));
728    }
729
730    // ---- IP address ----
731
732    #[test]
733    fn ip_address_cidr_match() {
734        let mut ctx = ctx_user("alice");
735        ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
736        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
737        assert!(b.matches(&ctx));
738    }
739
740    #[test]
741    fn ip_address_cidr_outside() {
742        let mut ctx = ctx_user("alice");
743        ctx.aws_source_ip = Some("192.168.1.5".parse().unwrap());
744        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
745        assert!(!b.matches(&ctx));
746    }
747
748    #[test]
749    fn not_ip_address_blocks_cidr() {
750        let mut ctx = ctx_user("alice");
751        ctx.aws_source_ip = Some("10.0.0.5".parse().unwrap());
752        let b = compile(json!({ "NotIpAddress": { "aws:SourceIp": "10.0.0.0/24" } }));
753        assert!(!b.matches(&ctx));
754    }
755
756    #[test]
757    fn ip_address_bare_v4() {
758        let mut ctx = ctx_user("alice");
759        ctx.aws_source_ip = Some("127.0.0.1".parse().unwrap());
760        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "127.0.0.1" } }));
761        assert!(b.matches(&ctx));
762    }
763
764    #[test]
765    fn ip_address_v6_cidr() {
766        let mut ctx = ctx_user("alice");
767        ctx.aws_source_ip = Some("2001:db8::1".parse().unwrap());
768        let b = compile(json!({ "IpAddress": { "aws:SourceIp": "2001:db8::/32" } }));
769        assert!(b.matches(&ctx));
770    }
771
772    // ---- ARN ----
773
774    #[test]
775    fn arn_like_wildcard() {
776        let b = compile(json!({
777            "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::*:user/*" }
778        }));
779        assert!(b.matches(&ctx_user("alice")));
780    }
781
782    #[test]
783    fn arn_not_equals_rejects_exact() {
784        let b = compile(json!({
785            "ArnNotEquals": {
786                "aws:PrincipalArn": "arn:aws:iam::123456789012:user/alice"
787            }
788        }));
789        assert!(!b.matches(&ctx_user("alice")));
790        assert!(b.matches(&ctx_user("bob")));
791    }
792
793    // ---- Null (existence) ----
794
795    #[test]
796    fn null_true_requires_missing_key() {
797        let b = compile(json!({ "Null": { "aws:username": "true" } }));
798        assert!(!b.matches(&ctx_user("alice"))); // key present
799        let ctx = ConditionContext::default();
800        assert!(b.matches(&ctx)); // key absent
801    }
802
803    #[test]
804    fn null_false_requires_present_key() {
805        let b = compile(json!({ "Null": { "aws:username": "false" } }));
806        assert!(b.matches(&ctx_user("alice")));
807        let ctx = ConditionContext::default();
808        assert!(!b.matches(&ctx));
809    }
810
811    // ---- IfExists ----
812
813    #[test]
814    fn if_exists_passes_on_missing_key() {
815        let b = compile(json!({
816            "StringEqualsIfExists": { "aws:username": "alice" }
817        }));
818        let ctx = ConditionContext::default();
819        assert!(b.matches(&ctx));
820    }
821
822    #[test]
823    fn if_exists_still_checks_present_key() {
824        let b = compile(json!({
825            "StringEqualsIfExists": { "aws:username": "alice" }
826        }));
827        assert!(b.matches(&ctx_user("alice")));
828        assert!(!b.matches(&ctx_user("bob")));
829    }
830
831    // ---- ForAllValues / ForAnyValue ----
832
833    #[test]
834    fn for_all_values_every_context_must_match() {
835        let mut ctx = ctx_user("alice");
836        ctx.request_tags = Some(
837            [("env", "dev"), ("team", "platform")]
838                .iter()
839                .map(|(k, v)| (k.to_string(), v.to_string()))
840                .collect(),
841        );
842        let b = compile(json!({
843            "ForAllValues:StringEquals": {
844                "aws:TagKeys": ["env", "team", "owner"]
845            }
846        }));
847        assert!(b.matches(&ctx));
848        ctx.request_tags = Some(
849            [("env", "dev"), ("rogue", "x")]
850                .iter()
851                .map(|(k, v)| (k.to_string(), v.to_string()))
852                .collect(),
853        );
854        assert!(!b.matches(&ctx));
855    }
856
857    #[test]
858    fn for_any_value_some_context_matches() {
859        let mut ctx = ctx_user("alice");
860        ctx.request_tags = Some(
861            [("env", "dev"), ("rogue", "x")]
862                .iter()
863                .map(|(k, v)| (k.to_string(), v.to_string()))
864                .collect(),
865        );
866        let b = compile(json!({
867            "ForAnyValue:StringEquals": { "aws:TagKeys": "env" }
868        }));
869        assert!(b.matches(&ctx));
870    }
871
872    // ---- Multi-operator AND semantics ----
873
874    #[test]
875    fn multiple_operators_must_all_match() {
876        let mut ctx = ctx_user("alice");
877        ctx.aws_source_ip = Some("10.0.0.1".parse().unwrap());
878        let b = compile(json!({
879            "StringEquals": { "aws:username": "alice" },
880            "IpAddress":    { "aws:SourceIp": "10.0.0.0/24" }
881        }));
882        assert!(b.matches(&ctx));
883
884        let mut wrong_ip = ctx.clone();
885        wrong_ip.aws_source_ip = Some("192.168.1.1".parse().unwrap());
886        assert!(!b.matches(&wrong_ip));
887
888        let wrong_user = ctx_user("bob");
889        let mut wu = wrong_user;
890        wu.aws_source_ip = Some("10.0.0.1".parse().unwrap());
891        assert!(!b.matches(&wu));
892    }
893
894    // ---- Safe-fail on unknown operator / key ----
895
896    #[test]
897    fn unknown_operator_fails_closed() {
898        let b = compile(json!({ "NotARealOp": { "aws:username": "alice" } }));
899        assert!(!b.matches(&ctx_user("alice")));
900    }
901
902    #[test]
903    fn unknown_key_fails_closed() {
904        let b = compile(json!({
905            "StringEquals": { "aws:madeupkey": "whatever" }
906        }));
907        assert!(!b.matches(&ctx_user("alice")));
908    }
909
910    #[test]
911    fn context_lookup_case_insensitive() {
912        let ctx = ctx_user("alice");
913        assert_eq!(ctx.lookup("AWS:UserName"), Some(vec!["alice".to_string()]));
914        assert_eq!(ctx.lookup("aws:username"), Some(vec!["alice".to_string()]));
915    }
916
917    #[test]
918    fn cidr_match_helper() {
919        assert!(cidr_match("10.0.0.0/8", "10.1.2.3"));
920        assert!(!cidr_match("10.0.0.0/8", "11.0.0.1"));
921        assert!(cidr_match("0.0.0.0/0", "1.2.3.4"));
922        assert!(!cidr_match("invalid", "1.2.3.4"));
923    }
924}