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 existing_object_tags: Option<crate::tagging::TagSet>,
141 pub request_object_tags: Option<crate::tagging::TagSet>,
146 pub extra: HashMap<String, String>,
150}
151
152#[derive(Debug, Clone)]
154struct Condition {
155 op: ConditionOp,
156 key: String, values: Vec<String>, }
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161enum ConditionOp {
162 IpAddress,
163 NotIpAddress,
164 StringEquals,
165 StringNotEquals,
166 StringLike,
167 StringNotLike,
168 DateGreaterThan,
169 DateLessThan,
170 Bool,
171}
172
173impl ConditionOp {
174 fn parse(s: &str) -> Option<Self> {
175 Some(match s {
176 "IpAddress" => Self::IpAddress,
177 "NotIpAddress" => Self::NotIpAddress,
178 "StringEquals" => Self::StringEquals,
179 "StringNotEquals" => Self::StringNotEquals,
180 "StringLike" => Self::StringLike,
181 "StringNotLike" => Self::StringNotLike,
182 "DateGreaterThan" => Self::DateGreaterThan,
183 "DateLessThan" => Self::DateLessThan,
184 "Bool" => Self::Bool,
185 _ => return None,
186 })
187 }
188}
189
190impl Policy {
191 pub fn from_json_str(s: &str) -> Result<Self, String> {
192 let raw: PolicyJson =
193 serde_json::from_str(s).map_err(|e| format!("policy JSON parse error: {e}"))?;
194 let mut statements = Vec::with_capacity(raw.statements.len());
195 for s in raw.statements {
196 let mut conditions = Vec::new();
197 if let Some(cond_map) = s.condition {
198 for (op_name, key_map) in cond_map {
199 let op = ConditionOp::parse(&op_name).ok_or_else(|| {
200 format!(
201 "unsupported policy Condition operator: {op_name:?}. \
202 v0.3 supports IpAddress / NotIpAddress / StringEquals / \
203 StringNotEquals / StringLike / StringNotLike / \
204 DateGreaterThan / DateLessThan / Bool."
205 )
206 })?;
207 for (key, values) in key_map {
208 conditions.push(Condition {
209 op,
210 key,
211 values: values.into_vec(),
212 });
213 }
214 }
215 }
216 statements.push(Statement {
217 sid: s.sid,
218 effect: s.effect,
219 actions: s.action.into_vec(),
220 resources: s.resource.into_vec(),
221 principals: s.principal.map(|p| match p {
222 PrincipalSet::Wildcard(_) => Vec::new(),
223 PrincipalSet::Map { aws } => aws.map(|v| v.into_vec()).unwrap_or_default(),
224 }),
225 conditions,
226 });
227 }
228 Ok(Self { statements })
229 }
230
231 pub fn from_path(path: &Path) -> Result<Self, String> {
232 let txt = std::fs::read_to_string(path)
233 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
234 Self::from_json_str(&txt)
235 }
236
237 pub fn evaluate(
246 &self,
247 action: &str,
248 bucket: &str,
249 key: Option<&str>,
250 principal_id: Option<&str>,
251 ) -> Decision {
252 self.evaluate_with(
253 action,
254 bucket,
255 key,
256 principal_id,
257 &RequestContext::default(),
258 )
259 }
260
261 pub fn evaluate_with(
265 &self,
266 action: &str,
267 bucket: &str,
268 key: Option<&str>,
269 principal_id: Option<&str>,
270 ctx: &RequestContext,
271 ) -> Decision {
272 let object_resource = match key {
273 Some(k) => format!("arn:aws:s3:::{bucket}/{k}"),
274 None => format!("arn:aws:s3:::{bucket}"),
275 };
276 let bucket_resource = format!("arn:aws:s3:::{bucket}");
277
278 let mut matched_allow: Option<Option<String>> = None;
279 let mut matched_deny: Option<Option<String>> = None;
280
281 for st in &self.statements {
282 if !st.actions.iter().any(|p| action_matches(p, action)) {
283 continue;
284 }
285 let any_resource_matches = st.resources.iter().any(|p| {
286 resource_matches(p, &object_resource) || resource_matches(p, &bucket_resource)
287 });
288 if !any_resource_matches {
289 continue;
290 }
291 if !principal_matches(&st.principals, principal_id) {
292 continue;
293 }
294 if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
298 continue;
299 }
300 match st.effect {
301 Effect::Deny => {
302 matched_deny = Some(st.sid.clone());
303 }
307 Effect::Allow => {
308 if matched_allow.is_none() {
309 matched_allow = Some(st.sid.clone());
310 }
311 }
312 }
313 }
314
315 if let Some(sid) = matched_deny {
316 Decision::deny(sid)
317 } else if let Some(sid) = matched_allow {
318 Decision::allow(sid)
319 } else {
320 Decision::implicit_deny()
321 }
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct Decision {
327 pub allow: bool,
328 pub matched_sid: Option<String>,
329 pub matched_effect: Option<Effect>,
332}
333
334impl Decision {
335 fn allow(sid: Option<String>) -> Self {
336 Self {
337 allow: true,
338 matched_sid: sid,
339 matched_effect: Some(Effect::Allow),
340 }
341 }
342 fn deny(sid: Option<String>) -> Self {
343 Self {
344 allow: false,
345 matched_sid: sid,
346 matched_effect: Some(Effect::Deny),
347 }
348 }
349 fn implicit_deny() -> Self {
350 Self {
351 allow: false,
352 matched_sid: None,
353 matched_effect: None,
354 }
355 }
356}
357
358fn action_matches(pattern: &str, action: &str) -> bool {
361 if pattern == "*" {
362 return true;
363 }
364 if let Some(prefix) = pattern.strip_suffix(":*") {
365 return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
366 }
367 pattern == action
368}
369
370fn resource_matches(pattern: &str, resource: &str) -> bool {
373 glob_match(pattern, resource)
374}
375
376fn glob_match(pattern: &str, s: &str) -> bool {
379 let p_bytes = pattern.as_bytes();
380 let s_bytes = s.as_bytes();
381 glob_match_bytes(p_bytes, s_bytes)
382}
383
384fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
385 let mut pi = 0;
386 let mut si = 0;
387 let mut star: Option<(usize, usize)> = None;
388 while si < s.len() {
389 if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
390 pi += 1;
391 si += 1;
392 } else if pi < p.len() && p[pi] == b'*' {
393 star = Some((pi, si));
394 pi += 1;
395 } else if let Some((sp, ss)) = star {
396 pi = sp + 1;
397 si = ss + 1;
398 star = Some((sp, si));
399 } else {
400 return false;
401 }
402 }
403 while pi < p.len() && p[pi] == b'*' {
404 pi += 1;
405 }
406 pi == p.len()
407}
408
409fn principal_matches(allowed: &Option<Vec<String>>, principal_id: Option<&str>) -> bool {
410 match allowed {
411 None => true,
413 Some(list) if list.is_empty() => true,
414 Some(list) => match principal_id {
415 None => false,
416 Some(id) => list.iter().any(|p| p == "*" || p == id),
417 },
418 }
419}
420
421fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
425 match c.op {
426 ConditionOp::IpAddress => match ctx.source_ip {
427 Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
428 None => false,
429 },
430 ConditionOp::NotIpAddress => match ctx.source_ip {
431 Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
432 None => false,
433 },
434 ConditionOp::StringEquals => match context_value(&c.key, ctx) {
435 Some(v) => c.values.iter().any(|x| x == &v),
436 None => false,
437 },
438 ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
439 Some(v) => !c.values.iter().any(|x| x == &v),
440 None => false,
441 },
442 ConditionOp::StringLike => match context_value(&c.key, ctx) {
443 Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
444 None => false,
445 },
446 ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
447 Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
448 None => false,
449 },
450 ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
451 let now = ctx.request_time.unwrap_or_else(SystemTime::now);
453 let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
454 Ok(d) => d.as_secs() as i64,
455 Err(_) => 0,
456 };
457 c.values.iter().any(|s| match parse_iso8601(s) {
458 Some(t) => match c.op {
459 ConditionOp::DateGreaterThan => now_unix > t,
460 ConditionOp::DateLessThan => now_unix < t,
461 _ => unreachable!(),
462 },
463 None => false,
464 })
465 }
466 ConditionOp::Bool => match context_value(&c.key, ctx) {
467 Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
468 None => false,
469 },
470 }
471}
472
473fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
479 match key {
480 "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
481 "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
482 "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
483 other => {
484 if let Some(tag_key) = other.strip_prefix("s3:ExistingObjectTag/") {
491 return ctx
492 .existing_object_tags
493 .as_ref()
494 .and_then(|s| s.get(tag_key).map(str::to_owned));
495 }
496 if let Some(tag_key) = other.strip_prefix("s3:RequestObjectTag/") {
497 return ctx
498 .request_object_tags
499 .as_ref()
500 .and_then(|s| s.get(tag_key).map(str::to_owned));
501 }
502 ctx.extra.get(other).cloned()
503 }
504 }
505}
506
507fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
510 match cidr.split_once('/') {
511 None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
512 Some((net_str, mask_str)) => {
513 let Ok(net) = net_str.parse::<IpAddr>() else {
514 return false;
515 };
516 let Ok(mask_bits) = mask_str.parse::<u8>() else {
517 return false;
518 };
519 match (ip, net) {
520 (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
521 if mask_bits > 32 {
522 return false;
523 }
524 if mask_bits == 0 {
525 return true;
526 }
527 let shift = 32 - mask_bits;
528 (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
529 }
530 (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
531 if mask_bits > 128 {
532 return false;
533 }
534 if mask_bits == 0 {
535 return true;
536 }
537 let shift = 128 - mask_bits;
538 (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
539 }
540 _ => false, }
542 }
543 }
544}
545
546fn parse_iso8601(s: &str) -> Option<i64> {
552 let s = s.strip_suffix('Z')?;
554 let (date, time) = s.split_once('T')?;
555 let date_parts: Vec<&str> = date.split('-').collect();
556 if date_parts.len() != 3 {
557 return None;
558 }
559 let year: i64 = date_parts[0].parse().ok()?;
560 let month: i64 = date_parts[1].parse().ok()?;
561 let day: i64 = date_parts[2].parse().ok()?;
562 let time_parts: Vec<&str> = time.split(':').collect();
563 if time_parts.len() != 3 {
564 return None;
565 }
566 let h: i64 = time_parts[0].parse().ok()?;
567 let m: i64 = time_parts[1].parse().ok()?;
568 let s: i64 = time_parts[2].parse().ok()?;
569 let y = if month <= 2 { year - 1 } else { year };
572 let era = if y >= 0 { y } else { y - 399 } / 400;
573 let yoe = (y - era * 400) as u64;
574 let mp = if month > 2 { month - 3 } else { month + 9 };
575 let doy = (153 * mp + 2) / 5 + day - 1;
576 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
577 let days_from_epoch = era * 146097 + doe as i64 - 719468;
578 Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
579}
580
581pub type SharedPolicy = Arc<Policy>;
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 fn p(s: &str) -> Policy {
589 Policy::from_json_str(s).expect("policy")
590 }
591
592 #[test]
593 fn allow_then_deny_explicit_deny_wins() {
594 let pol = p(r#"{
595 "Version": "2012-10-17",
596 "Statement": [
597 {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
598 {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
599 ]
600 }"#);
601 let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
602 assert!(d.allow);
603 assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
604 let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
605 assert!(!d.allow);
606 assert_eq!(d.matched_effect, Some(Effect::Deny));
607 assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
608 }
609
610 #[test]
611 fn implicit_deny_when_no_statement_matches() {
612 let pol = p(r#"{
613 "Version": "2012-10-17",
614 "Statement": [
615 {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
616 ]
617 }"#);
618 let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
619 assert!(!d.allow);
620 assert_eq!(d.matched_effect, None);
621 }
622
623 #[test]
624 fn resource_glob_matches_prefix() {
625 let pol = p(r#"{
626 "Version": "2012-10-17",
627 "Statement": [{
628 "Effect": "Allow",
629 "Action": "s3:GetObject",
630 "Resource": "arn:aws:s3:::b/data/*.parquet"
631 }]
632 }"#);
633 assert!(
634 pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
635 .allow
636 );
637 assert!(
638 pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
639 .allow
640 );
641 assert!(
642 !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
643 .allow
644 );
645 }
646
647 #[test]
648 fn s3_action_wildcard() {
649 let pol = p(r#"{
650 "Version": "2012-10-17",
651 "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"}]
652 }"#);
653 assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
654 assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
655 assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
658 }
659
660 #[test]
661 fn principal_match_by_access_key_id() {
662 let pol = p(r#"{
663 "Version": "2012-10-17",
664 "Statement": [{
665 "Effect": "Allow",
666 "Action": "s3:*",
667 "Resource": "arn:aws:s3:::b/*",
668 "Principal": {"AWS": ["AKIATEST123"]}
669 }]
670 }"#);
671 assert!(
672 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
673 .allow
674 );
675 assert!(
676 !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
677 .allow
678 );
679 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
680 }
681
682 #[test]
683 fn principal_wildcard_matches_anyone() {
684 let pol = p(r#"{
685 "Version": "2012-10-17",
686 "Statement": [{
687 "Effect": "Allow",
688 "Action": "s3:*",
689 "Resource": "arn:aws:s3:::b/*",
690 "Principal": "*"
691 }]
692 }"#);
693 assert!(
694 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
695 .allow
696 );
697 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
698 }
699
700 #[test]
701 fn resource_can_be_string_or_array() {
702 let single = p(r#"{
703 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
704 "Resource": "arn:aws:s3:::a/*"}]
705 }"#);
706 let multi = p(r#"{
707 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
708 "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
709 }"#);
710 assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
711 assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
712 assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
713 }
714
715 #[test]
716 fn bucket_level_resource_for_listbucket() {
717 let pol = p(r#"{
718 "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
719 "Resource": "arn:aws:s3:::b"}]
720 }"#);
721 assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
723 assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
724 }
725
726 #[test]
727 fn glob_match_basics() {
728 assert!(glob_match("foo", "foo"));
729 assert!(!glob_match("foo", "bar"));
730 assert!(glob_match("*", "anything"));
731 assert!(glob_match("foo*", "foobar"));
732 assert!(glob_match("*bar", "foobar"));
733 assert!(glob_match("foo*bar", "fooXYZbar"));
734 assert!(glob_match("a?c", "abc"));
735 assert!(!glob_match("a?c", "abbc"));
736 assert!(glob_match("a*b*c", "axxxbyyyc"));
737 }
738
739 fn ctx_ip(ip: &str) -> RequestContext {
742 RequestContext {
743 source_ip: Some(ip.parse().unwrap()),
744 ..Default::default()
745 }
746 }
747
748 #[test]
749 fn condition_ip_address_cidr_match() {
750 let pol = p(r#"{
751 "Statement": [{
752 "Effect": "Allow", "Action": "s3:GetObject",
753 "Resource": "arn:aws:s3:::b/*",
754 "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
755 }]
756 }"#);
757 assert!(
758 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
759 .allow
760 );
761 assert!(
762 pol.evaluate_with(
763 "s3:GetObject",
764 "b",
765 Some("k"),
766 None,
767 &ctx_ip("192.168.1.50")
768 )
769 .allow
770 );
771 assert!(
772 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
773 .allow
774 );
775 assert!(
777 !pol.evaluate_with(
778 "s3:GetObject",
779 "b",
780 Some("k"),
781 None,
782 &RequestContext::default()
783 )
784 .allow
785 );
786 }
787
788 #[test]
789 fn condition_not_ip_address_negates() {
790 let pol = p(r#"{
791 "Statement": [{
792 "Effect": "Deny", "Action": "s3:DeleteObject",
793 "Resource": "arn:aws:s3:::b/*",
794 "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
795 },
796 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
797 }"#);
798 assert!(
800 !pol.evaluate_with(
801 "s3:DeleteObject",
802 "b",
803 Some("k"),
804 None,
805 &ctx_ip("203.0.113.1")
806 )
807 .allow
808 );
809 assert!(
811 pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
812 .allow
813 );
814 }
815
816 #[test]
817 fn condition_string_equals_user_agent() {
818 let pol = p(r#"{
819 "Statement": [{
820 "Effect": "Allow", "Action": "s3:GetObject",
821 "Resource": "arn:aws:s3:::b/*",
822 "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
823 }]
824 }"#);
825 let ua = |s: &str| RequestContext {
826 user_agent: Some(s.into()),
827 ..Default::default()
828 };
829 assert!(
830 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
831 .allow
832 );
833 assert!(
834 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
835 .allow
836 );
837 }
838
839 #[test]
840 fn condition_string_like_glob() {
841 let pol = p(r#"{
842 "Statement": [{
843 "Effect": "Allow", "Action": "s3:GetObject",
844 "Resource": "arn:aws:s3:::b/*",
845 "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
846 }]
847 }"#);
848 let ua = |s: &str| RequestContext {
849 user_agent: Some(s.into()),
850 ..Default::default()
851 };
852 assert!(
853 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
854 .allow
855 );
856 assert!(
857 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
858 .allow
859 );
860 assert!(
861 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
862 .allow
863 );
864 }
865
866 #[test]
867 fn condition_date_window() {
868 let pol = p(r#"{
870 "Statement": [{
871 "Effect": "Allow", "Action": "s3:GetObject",
872 "Resource": "arn:aws:s3:::b/*",
873 "Condition": {
874 "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
875 "DateLessThan": {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
876 }
877 }]
878 }"#);
879 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 {
882 request_time: Some(t),
883 ..Default::default()
884 };
885 assert!(
886 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
887 .allow
888 );
889 assert!(
890 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
891 .allow
892 );
893 }
894
895 #[test]
896 fn condition_bool_secure_transport() {
897 let pol = p(r#"{
898 "Statement": [{
899 "Effect": "Deny", "Action": "s3:*",
900 "Resource": "arn:aws:s3:::b/*",
901 "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
902 },
903 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
904 }"#);
905 let plain = RequestContext {
906 secure_transport: false,
907 ..Default::default()
908 };
909 let tls = RequestContext {
910 secure_transport: true,
911 ..Default::default()
912 };
913 assert!(
915 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
916 .allow
917 );
918 assert!(
920 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
921 .allow
922 );
923 }
924
925 #[test]
926 fn condition_unknown_operator_rejected() {
927 let err = Policy::from_json_str(
928 r#"{
929 "Statement": [{"Effect": "Allow", "Action": "s3:*",
930 "Resource": "arn:aws:s3:::b/*",
931 "Condition": {"NumericGreaterThan": {"k": ["1"]}}
932 }]
933 }"#,
934 )
935 .expect_err("should reject unsupported operator");
936 assert!(err.contains("unsupported policy Condition operator"));
937 assert!(err.contains("NumericGreaterThan"));
938 }
939
940 #[test]
943 fn condition_existing_object_tag_matches_via_tagmanager_state() {
944 let pol = p(r#"{
945 "Statement": [{
946 "Effect": "Allow", "Action": "s3:GetObject",
947 "Resource": "arn:aws:s3:::b/*",
948 "Condition": {
949 "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
950 }
951 }]
952 }"#);
953 let with_tag = RequestContext {
954 existing_object_tags: Some(
955 crate::tagging::TagSet::from_pairs(vec![
956 ("Project".into(), "Phoenix".into()),
957 ("Env".into(), "prod".into()),
958 ])
959 .unwrap(),
960 ),
961 ..Default::default()
962 };
963 let other_tag = RequestContext {
964 existing_object_tags: Some(
965 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
966 .unwrap(),
967 ),
968 ..Default::default()
969 };
970 assert!(
972 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
973 .allow
974 );
975 assert!(
977 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
978 .allow
979 );
980 }
981
982 #[test]
983 fn condition_request_object_tag_matches_via_x_amz_tagging() {
984 let pol = p(r#"{
985 "Statement": [{
986 "Effect": "Allow", "Action": "s3:PutObject",
987 "Resource": "arn:aws:s3:::b/*",
988 "Condition": {
989 "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
990 }
991 }]
992 }"#);
993 let req_tags = |v: &str| RequestContext {
994 request_object_tags: Some(
995 crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
996 ),
997 ..Default::default()
998 };
999 assert!(
1000 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1001 .allow
1002 );
1003 assert!(
1004 pol.evaluate_with(
1005 "s3:PutObject",
1006 "b",
1007 Some("k"),
1008 None,
1009 &req_tags("staging")
1010 )
1011 .allow
1012 );
1013 assert!(
1014 !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1015 .allow
1016 );
1017 }
1018
1019 #[test]
1020 fn condition_tag_not_present_fails_closed() {
1021 let pol = p(r#"{
1025 "Statement": [{
1026 "Effect": "Allow", "Action": "s3:GetObject",
1027 "Resource": "arn:aws:s3:::b/*",
1028 "Condition": {
1029 "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1030 }
1031 }]
1032 }"#);
1033 let none_ctx = RequestContext::default();
1036 assert!(
1037 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1038 .allow
1039 );
1040 let other_only = RequestContext {
1042 existing_object_tags: Some(
1043 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())])
1044 .unwrap(),
1045 ),
1046 ..Default::default()
1047 };
1048 assert!(
1049 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1050 .allow
1051 );
1052 }
1053
1054 #[test]
1055 fn condition_legacy_evaluate_unchanged() {
1056 let pol = p(r#"{
1059 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1060 "Resource": "arn:aws:s3:::b/*"}]
1061 }"#);
1062 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1063 }
1064}