1use std::net::IpAddr;
57
58use chrono::{DateTime, Utc};
59use serde_json::Value;
60
61pub use fakecloud_core::auth::ConditionContext;
66
67#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum Qualifier {
141 Single,
142 ForAnyValue,
143 ForAllValues,
144}
145
146#[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 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#[derive(Debug, Clone)]
187pub struct ParsedCondition {
188 pub operator: ParsedOperatorName,
189 pub key: String,
190 pub values: Vec<String>,
191}
192
193#[derive(Debug, Clone, Default)]
197pub struct CompiledCondition {
198 pub entries: Vec<ParsedCondition>,
199}
200
201impl CompiledCondition {
202 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 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 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
290pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
293 if entry.operator.op == ConditionOperator::Null {
295 return evaluate_null(entry, ctx);
296 }
297
298 let context_values = ctx.lookup(&entry.key);
299
300 let context_values = match context_values {
302 Some(vs) if !vs.is_empty() => vs,
303 _ => {
304 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 context_values
326 .iter()
327 .any(|cv| match_values(entry.operator.op, &entry.values, cv))
328 }
329 Qualifier::ForAllValues => {
330 context_values
333 .iter()
334 .all(|cv| match_values(entry.operator.op, &entry.values, cv))
335 }
336 }
337}
338
339fn 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 entry.values.iter().any(|v| match v.as_str() {
349 "true" => !key_present,
350 "false" => key_present,
351 _ => false,
352 })
353}
354
355fn 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, }
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 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
489pub(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
528fn 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
560pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
796 fn null_true_requires_missing_key() {
797 let b = compile(json!({ "Null": { "aws:username": "true" } }));
798 assert!(!b.matches(&ctx_user("alice"))); let ctx = ConditionContext::default();
800 assert!(b.matches(&ctx)); }
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 #[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 #[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 #[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 #[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}