1use std::collections::HashMap;
32use std::net::IpAddr;
33use std::path::Path;
34use std::sync::Arc;
35use std::time::SystemTime;
36
37use serde::Deserialize;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
40#[serde(rename_all = "PascalCase")]
41pub enum Effect {
42 Allow,
43 Deny,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47#[serde(untagged)]
48enum StringOrVec {
49 Single(String),
50 Many(Vec<String>),
51}
52
53impl StringOrVec {
54 fn into_vec(self) -> Vec<String> {
55 match self {
56 Self::Single(s) => vec![s],
57 Self::Many(v) => v,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Deserialize)]
63#[serde(untagged)]
64enum PrincipalSet {
65 Wildcard(#[allow(dead_code)] String),
69 Map {
70 #[serde(rename = "AWS", default)]
71 aws: Option<StringOrVec>,
72 },
73}
74
75#[derive(Debug, Clone, Deserialize)]
76struct StatementJson {
77 #[serde(rename = "Sid")]
78 sid: Option<String>,
79 #[serde(rename = "Effect")]
80 effect: Effect,
81 #[serde(rename = "Action")]
82 action: StringOrVec,
83 #[serde(rename = "Resource")]
84 resource: StringOrVec,
85 #[serde(rename = "Principal", default)]
86 principal: Option<PrincipalSet>,
87 #[serde(rename = "Condition", default)]
90 condition: Option<HashMap<String, HashMap<String, StringOrVec>>>,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94struct PolicyJson {
95 #[serde(rename = "Version")]
96 _version: Option<String>,
97 #[serde(rename = "Statement")]
98 statements: Vec<StatementJson>,
99}
100
101#[derive(Debug, Clone)]
103pub struct Policy {
104 statements: Vec<Statement>,
105}
106
107#[derive(Debug, Clone)]
108struct Statement {
109 sid: Option<String>,
110 effect: Effect,
111 actions: Vec<String>, resources: Vec<String>, principals: Option<Vec<String>>,
120 conditions: Vec<Condition>,
123}
124
125#[derive(Debug, Clone, Default)]
129pub struct RequestContext {
130 pub source_ip: Option<IpAddr>,
131 pub user_agent: Option<String>,
132 pub request_time: Option<SystemTime>,
133 pub secure_transport: bool,
134 pub extra: HashMap<String, String>,
138}
139
140#[derive(Debug, Clone)]
142struct Condition {
143 op: ConditionOp,
144 key: String, values: Vec<String>, }
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149enum ConditionOp {
150 IpAddress,
151 NotIpAddress,
152 StringEquals,
153 StringNotEquals,
154 StringLike,
155 StringNotLike,
156 DateGreaterThan,
157 DateLessThan,
158 Bool,
159}
160
161impl ConditionOp {
162 fn parse(s: &str) -> Option<Self> {
163 Some(match s {
164 "IpAddress" => Self::IpAddress,
165 "NotIpAddress" => Self::NotIpAddress,
166 "StringEquals" => Self::StringEquals,
167 "StringNotEquals" => Self::StringNotEquals,
168 "StringLike" => Self::StringLike,
169 "StringNotLike" => Self::StringNotLike,
170 "DateGreaterThan" => Self::DateGreaterThan,
171 "DateLessThan" => Self::DateLessThan,
172 "Bool" => Self::Bool,
173 _ => return None,
174 })
175 }
176}
177
178impl Policy {
179 pub fn from_json_str(s: &str) -> Result<Self, String> {
180 let raw: PolicyJson =
181 serde_json::from_str(s).map_err(|e| format!("policy JSON parse error: {e}"))?;
182 let mut statements = Vec::with_capacity(raw.statements.len());
183 for s in raw.statements {
184 let mut conditions = Vec::new();
185 if let Some(cond_map) = s.condition {
186 for (op_name, key_map) in cond_map {
187 let op = ConditionOp::parse(&op_name).ok_or_else(|| {
188 format!(
189 "unsupported policy Condition operator: {op_name:?}. \
190 v0.3 supports IpAddress / NotIpAddress / StringEquals / \
191 StringNotEquals / StringLike / StringNotLike / \
192 DateGreaterThan / DateLessThan / Bool."
193 )
194 })?;
195 for (key, values) in key_map {
196 conditions.push(Condition {
197 op,
198 key,
199 values: values.into_vec(),
200 });
201 }
202 }
203 }
204 statements.push(Statement {
205 sid: s.sid,
206 effect: s.effect,
207 actions: s.action.into_vec(),
208 resources: s.resource.into_vec(),
209 principals: s.principal.map(|p| match p {
210 PrincipalSet::Wildcard(_) => Vec::new(),
211 PrincipalSet::Map { aws } => aws.map(|v| v.into_vec()).unwrap_or_default(),
212 }),
213 conditions,
214 });
215 }
216 Ok(Self { statements })
217 }
218
219 pub fn from_path(path: &Path) -> Result<Self, String> {
220 let txt = std::fs::read_to_string(path)
221 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
222 Self::from_json_str(&txt)
223 }
224
225 pub fn evaluate(
234 &self,
235 action: &str,
236 bucket: &str,
237 key: Option<&str>,
238 principal_id: Option<&str>,
239 ) -> Decision {
240 self.evaluate_with(
241 action,
242 bucket,
243 key,
244 principal_id,
245 &RequestContext::default(),
246 )
247 }
248
249 pub fn evaluate_with(
253 &self,
254 action: &str,
255 bucket: &str,
256 key: Option<&str>,
257 principal_id: Option<&str>,
258 ctx: &RequestContext,
259 ) -> Decision {
260 let object_resource = match key {
261 Some(k) => format!("arn:aws:s3:::{bucket}/{k}"),
262 None => format!("arn:aws:s3:::{bucket}"),
263 };
264 let bucket_resource = format!("arn:aws:s3:::{bucket}");
265
266 let mut matched_allow: Option<Option<String>> = None;
267 let mut matched_deny: Option<Option<String>> = None;
268
269 for st in &self.statements {
270 if !st.actions.iter().any(|p| action_matches(p, action)) {
271 continue;
272 }
273 let any_resource_matches = st.resources.iter().any(|p| {
274 resource_matches(p, &object_resource) || resource_matches(p, &bucket_resource)
275 });
276 if !any_resource_matches {
277 continue;
278 }
279 if !principal_matches(&st.principals, principal_id) {
280 continue;
281 }
282 if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
286 continue;
287 }
288 match st.effect {
289 Effect::Deny => {
290 matched_deny = Some(st.sid.clone());
291 }
295 Effect::Allow => {
296 if matched_allow.is_none() {
297 matched_allow = Some(st.sid.clone());
298 }
299 }
300 }
301 }
302
303 if let Some(sid) = matched_deny {
304 Decision::deny(sid)
305 } else if let Some(sid) = matched_allow {
306 Decision::allow(sid)
307 } else {
308 Decision::implicit_deny()
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct Decision {
315 pub allow: bool,
316 pub matched_sid: Option<String>,
317 pub matched_effect: Option<Effect>,
320}
321
322impl Decision {
323 fn allow(sid: Option<String>) -> Self {
324 Self {
325 allow: true,
326 matched_sid: sid,
327 matched_effect: Some(Effect::Allow),
328 }
329 }
330 fn deny(sid: Option<String>) -> Self {
331 Self {
332 allow: false,
333 matched_sid: sid,
334 matched_effect: Some(Effect::Deny),
335 }
336 }
337 fn implicit_deny() -> Self {
338 Self {
339 allow: false,
340 matched_sid: None,
341 matched_effect: None,
342 }
343 }
344}
345
346fn action_matches(pattern: &str, action: &str) -> bool {
349 if pattern == "*" {
350 return true;
351 }
352 if let Some(prefix) = pattern.strip_suffix(":*") {
353 return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
354 }
355 pattern == action
356}
357
358fn resource_matches(pattern: &str, resource: &str) -> bool {
361 glob_match(pattern, resource)
362}
363
364fn glob_match(pattern: &str, s: &str) -> bool {
367 let p_bytes = pattern.as_bytes();
368 let s_bytes = s.as_bytes();
369 glob_match_bytes(p_bytes, s_bytes)
370}
371
372fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
373 let mut pi = 0;
374 let mut si = 0;
375 let mut star: Option<(usize, usize)> = None;
376 while si < s.len() {
377 if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
378 pi += 1;
379 si += 1;
380 } else if pi < p.len() && p[pi] == b'*' {
381 star = Some((pi, si));
382 pi += 1;
383 } else if let Some((sp, ss)) = star {
384 pi = sp + 1;
385 si = ss + 1;
386 star = Some((sp, si));
387 } else {
388 return false;
389 }
390 }
391 while pi < p.len() && p[pi] == b'*' {
392 pi += 1;
393 }
394 pi == p.len()
395}
396
397fn principal_matches(allowed: &Option<Vec<String>>, principal_id: Option<&str>) -> bool {
398 match allowed {
399 None => true,
401 Some(list) if list.is_empty() => true,
402 Some(list) => match principal_id {
403 None => false,
404 Some(id) => list.iter().any(|p| p == "*" || p == id),
405 },
406 }
407}
408
409fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
413 match c.op {
414 ConditionOp::IpAddress => match ctx.source_ip {
415 Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
416 None => false,
417 },
418 ConditionOp::NotIpAddress => match ctx.source_ip {
419 Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
420 None => false,
421 },
422 ConditionOp::StringEquals => match context_value(&c.key, ctx) {
423 Some(v) => c.values.iter().any(|x| x == &v),
424 None => false,
425 },
426 ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
427 Some(v) => !c.values.iter().any(|x| x == &v),
428 None => false,
429 },
430 ConditionOp::StringLike => match context_value(&c.key, ctx) {
431 Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
432 None => false,
433 },
434 ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
435 Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
436 None => false,
437 },
438 ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
439 let now = ctx.request_time.unwrap_or_else(SystemTime::now);
441 let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
442 Ok(d) => d.as_secs() as i64,
443 Err(_) => 0,
444 };
445 c.values.iter().any(|s| match parse_iso8601(s) {
446 Some(t) => match c.op {
447 ConditionOp::DateGreaterThan => now_unix > t,
448 ConditionOp::DateLessThan => now_unix < t,
449 _ => unreachable!(),
450 },
451 None => false,
452 })
453 }
454 ConditionOp::Bool => match context_value(&c.key, ctx) {
455 Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
456 None => false,
457 },
458 }
459}
460
461fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
466 match key {
467 "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
468 "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
469 "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
470 other => ctx.extra.get(other).cloned(),
471 }
472}
473
474fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
477 match cidr.split_once('/') {
478 None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
479 Some((net_str, mask_str)) => {
480 let Ok(net) = net_str.parse::<IpAddr>() else {
481 return false;
482 };
483 let Ok(mask_bits) = mask_str.parse::<u8>() else {
484 return false;
485 };
486 match (ip, net) {
487 (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
488 if mask_bits > 32 {
489 return false;
490 }
491 if mask_bits == 0 {
492 return true;
493 }
494 let shift = 32 - mask_bits;
495 (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
496 }
497 (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
498 if mask_bits > 128 {
499 return false;
500 }
501 if mask_bits == 0 {
502 return true;
503 }
504 let shift = 128 - mask_bits;
505 (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
506 }
507 _ => false, }
509 }
510 }
511}
512
513fn parse_iso8601(s: &str) -> Option<i64> {
519 let s = s.strip_suffix('Z')?;
521 let (date, time) = s.split_once('T')?;
522 let date_parts: Vec<&str> = date.split('-').collect();
523 if date_parts.len() != 3 {
524 return None;
525 }
526 let year: i64 = date_parts[0].parse().ok()?;
527 let month: i64 = date_parts[1].parse().ok()?;
528 let day: i64 = date_parts[2].parse().ok()?;
529 let time_parts: Vec<&str> = time.split(':').collect();
530 if time_parts.len() != 3 {
531 return None;
532 }
533 let h: i64 = time_parts[0].parse().ok()?;
534 let m: i64 = time_parts[1].parse().ok()?;
535 let s: i64 = time_parts[2].parse().ok()?;
536 let y = if month <= 2 { year - 1 } else { year };
539 let era = if y >= 0 { y } else { y - 399 } / 400;
540 let yoe = (y - era * 400) as u64;
541 let mp = if month > 2 { month - 3 } else { month + 9 };
542 let doy = (153 * mp + 2) / 5 + day - 1;
543 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
544 let days_from_epoch = era * 146097 + doe as i64 - 719468;
545 Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
546}
547
548pub type SharedPolicy = Arc<Policy>;
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 fn p(s: &str) -> Policy {
556 Policy::from_json_str(s).expect("policy")
557 }
558
559 #[test]
560 fn allow_then_deny_explicit_deny_wins() {
561 let pol = p(r#"{
562 "Version": "2012-10-17",
563 "Statement": [
564 {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
565 {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
566 ]
567 }"#);
568 let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
569 assert!(d.allow);
570 assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
571 let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
572 assert!(!d.allow);
573 assert_eq!(d.matched_effect, Some(Effect::Deny));
574 assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
575 }
576
577 #[test]
578 fn implicit_deny_when_no_statement_matches() {
579 let pol = p(r#"{
580 "Version": "2012-10-17",
581 "Statement": [
582 {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
583 ]
584 }"#);
585 let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
586 assert!(!d.allow);
587 assert_eq!(d.matched_effect, None);
588 }
589
590 #[test]
591 fn resource_glob_matches_prefix() {
592 let pol = p(r#"{
593 "Version": "2012-10-17",
594 "Statement": [{
595 "Effect": "Allow",
596 "Action": "s3:GetObject",
597 "Resource": "arn:aws:s3:::b/data/*.parquet"
598 }]
599 }"#);
600 assert!(
601 pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
602 .allow
603 );
604 assert!(
605 pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
606 .allow
607 );
608 assert!(
609 !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
610 .allow
611 );
612 }
613
614 #[test]
615 fn s3_action_wildcard() {
616 let pol = p(r#"{
617 "Version": "2012-10-17",
618 "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"}]
619 }"#);
620 assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
621 assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
622 assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
625 }
626
627 #[test]
628 fn principal_match_by_access_key_id() {
629 let pol = p(r#"{
630 "Version": "2012-10-17",
631 "Statement": [{
632 "Effect": "Allow",
633 "Action": "s3:*",
634 "Resource": "arn:aws:s3:::b/*",
635 "Principal": {"AWS": ["AKIATEST123"]}
636 }]
637 }"#);
638 assert!(
639 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
640 .allow
641 );
642 assert!(
643 !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
644 .allow
645 );
646 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
647 }
648
649 #[test]
650 fn principal_wildcard_matches_anyone() {
651 let pol = p(r#"{
652 "Version": "2012-10-17",
653 "Statement": [{
654 "Effect": "Allow",
655 "Action": "s3:*",
656 "Resource": "arn:aws:s3:::b/*",
657 "Principal": "*"
658 }]
659 }"#);
660 assert!(
661 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
662 .allow
663 );
664 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
665 }
666
667 #[test]
668 fn resource_can_be_string_or_array() {
669 let single = p(r#"{
670 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
671 "Resource": "arn:aws:s3:::a/*"}]
672 }"#);
673 let multi = p(r#"{
674 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
675 "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
676 }"#);
677 assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
678 assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
679 assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
680 }
681
682 #[test]
683 fn bucket_level_resource_for_listbucket() {
684 let pol = p(r#"{
685 "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
686 "Resource": "arn:aws:s3:::b"}]
687 }"#);
688 assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
690 assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
691 }
692
693 #[test]
694 fn glob_match_basics() {
695 assert!(glob_match("foo", "foo"));
696 assert!(!glob_match("foo", "bar"));
697 assert!(glob_match("*", "anything"));
698 assert!(glob_match("foo*", "foobar"));
699 assert!(glob_match("*bar", "foobar"));
700 assert!(glob_match("foo*bar", "fooXYZbar"));
701 assert!(glob_match("a?c", "abc"));
702 assert!(!glob_match("a?c", "abbc"));
703 assert!(glob_match("a*b*c", "axxxbyyyc"));
704 }
705
706 fn ctx_ip(ip: &str) -> RequestContext {
709 RequestContext {
710 source_ip: Some(ip.parse().unwrap()),
711 ..Default::default()
712 }
713 }
714
715 #[test]
716 fn condition_ip_address_cidr_match() {
717 let pol = p(r#"{
718 "Statement": [{
719 "Effect": "Allow", "Action": "s3:GetObject",
720 "Resource": "arn:aws:s3:::b/*",
721 "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
722 }]
723 }"#);
724 assert!(
725 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
726 .allow
727 );
728 assert!(
729 pol.evaluate_with(
730 "s3:GetObject",
731 "b",
732 Some("k"),
733 None,
734 &ctx_ip("192.168.1.50")
735 )
736 .allow
737 );
738 assert!(
739 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
740 .allow
741 );
742 assert!(
744 !pol.evaluate_with(
745 "s3:GetObject",
746 "b",
747 Some("k"),
748 None,
749 &RequestContext::default()
750 )
751 .allow
752 );
753 }
754
755 #[test]
756 fn condition_not_ip_address_negates() {
757 let pol = p(r#"{
758 "Statement": [{
759 "Effect": "Deny", "Action": "s3:DeleteObject",
760 "Resource": "arn:aws:s3:::b/*",
761 "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
762 },
763 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
764 }"#);
765 assert!(
767 !pol.evaluate_with(
768 "s3:DeleteObject",
769 "b",
770 Some("k"),
771 None,
772 &ctx_ip("203.0.113.1")
773 )
774 .allow
775 );
776 assert!(
778 pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
779 .allow
780 );
781 }
782
783 #[test]
784 fn condition_string_equals_user_agent() {
785 let pol = p(r#"{
786 "Statement": [{
787 "Effect": "Allow", "Action": "s3:GetObject",
788 "Resource": "arn:aws:s3:::b/*",
789 "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
790 }]
791 }"#);
792 let ua = |s: &str| RequestContext {
793 user_agent: Some(s.into()),
794 ..Default::default()
795 };
796 assert!(
797 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
798 .allow
799 );
800 assert!(
801 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
802 .allow
803 );
804 }
805
806 #[test]
807 fn condition_string_like_glob() {
808 let pol = p(r#"{
809 "Statement": [{
810 "Effect": "Allow", "Action": "s3:GetObject",
811 "Resource": "arn:aws:s3:::b/*",
812 "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
813 }]
814 }"#);
815 let ua = |s: &str| RequestContext {
816 user_agent: Some(s.into()),
817 ..Default::default()
818 };
819 assert!(
820 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
821 .allow
822 );
823 assert!(
824 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
825 .allow
826 );
827 assert!(
828 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
829 .allow
830 );
831 }
832
833 #[test]
834 fn condition_date_window() {
835 let pol = p(r#"{
837 "Statement": [{
838 "Effect": "Allow", "Action": "s3:GetObject",
839 "Resource": "arn:aws:s3:::b/*",
840 "Condition": {
841 "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
842 "DateLessThan": {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
843 }
844 }]
845 }"#);
846 let mid_year = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_780_000_000); let after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000); let ctx_at = |t: SystemTime| RequestContext {
849 request_time: Some(t),
850 ..Default::default()
851 };
852 assert!(
853 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
854 .allow
855 );
856 assert!(
857 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
858 .allow
859 );
860 }
861
862 #[test]
863 fn condition_bool_secure_transport() {
864 let pol = p(r#"{
865 "Statement": [{
866 "Effect": "Deny", "Action": "s3:*",
867 "Resource": "arn:aws:s3:::b/*",
868 "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
869 },
870 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
871 }"#);
872 let plain = RequestContext {
873 secure_transport: false,
874 ..Default::default()
875 };
876 let tls = RequestContext {
877 secure_transport: true,
878 ..Default::default()
879 };
880 assert!(
882 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
883 .allow
884 );
885 assert!(
887 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
888 .allow
889 );
890 }
891
892 #[test]
893 fn condition_unknown_operator_rejected() {
894 let err = Policy::from_json_str(
895 r#"{
896 "Statement": [{"Effect": "Allow", "Action": "s3:*",
897 "Resource": "arn:aws:s3:::b/*",
898 "Condition": {"NumericGreaterThan": {"k": ["1"]}}
899 }]
900 }"#,
901 )
902 .expect_err("should reject unsupported operator");
903 assert!(err.contains("unsupported policy Condition operator"));
904 assert!(err.contains("NumericGreaterThan"));
905 }
906
907 #[test]
908 fn condition_legacy_evaluate_unchanged() {
909 let pol = p(r#"{
912 "Statement": [{"Effect": "Allow", "Action": "s3:*",
913 "Resource": "arn:aws:s3:::b/*"}]
914 }"#);
915 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
916 }
917}