1use std::net::IpAddr;
70
71use chrono::{DateTime, Utc};
72use serde_json::Value;
73
74pub use fakecloud_core::auth::ConditionContext;
79
80#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum Qualifier {
154 Single,
155 ForAnyValue,
156 ForAllValues,
157}
158
159#[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 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#[derive(Debug, Clone)]
200pub struct ParsedCondition {
201 pub operator: ParsedOperatorName,
202 pub key: String,
203 pub values: Vec<String>,
204}
205
206#[derive(Debug, Clone, Default)]
210pub struct CompiledCondition {
211 pub entries: Vec<ParsedCondition>,
212}
213
214impl CompiledCondition {
215 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 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 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
303pub fn evaluate_entry(entry: &ParsedCondition, ctx: &ConditionContext) -> bool {
306 if entry.operator.op == ConditionOperator::Null {
308 return evaluate_null(entry, ctx);
309 }
310
311 let context_values = ctx.lookup(&entry.key);
312
313 let context_values = match context_values {
315 Some(vs) if !vs.is_empty() => vs,
316 _ => {
317 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 context_values
339 .iter()
340 .any(|cv| match_values(entry.operator.op, &entry.values, cv))
341 }
342 Qualifier::ForAllValues => {
343 context_values
346 .iter()
347 .all(|cv| match_values(entry.operator.op, &entry.values, cv))
348 }
349 }
350}
351
352fn 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 entry.values.iter().any(|v| match v.as_str() {
362 "true" => !key_present,
363 "false" => key_present,
364 _ => false,
365 })
366}
367
368fn 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, }
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 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
502pub(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
541fn 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
573pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
812 fn null_true_requires_missing_key() {
813 let b = compile(json!({ "Null": { "aws:username": "true" } }));
814 assert!(!b.matches(&ctx_user("alice"))); let ctx = ConditionContext::default();
816 assert!(b.matches(&ctx)); }
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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 ctx.aws_mfa_age_seconds = Some(7200);
1201 assert!(!b.matches(&ctx));
1202 }
1203}