1use std::collections::HashMap;
58use std::net::IpAddr;
59use std::path::Path;
60use std::sync::Arc;
61use std::time::SystemTime;
62
63use serde::Deserialize;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "PascalCase")]
67pub enum Effect {
68 Allow,
69 Deny,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73#[serde(untagged)]
74enum StringOrVec {
75 Single(String),
76 Many(Vec<String>),
77}
78
79impl StringOrVec {
80 fn into_vec(self) -> Vec<String> {
81 match self {
82 Self::Single(s) => vec![s],
83 Self::Many(v) => v,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum PrincipalSet {
95 Wildcard,
99 Specific(Vec<String>),
102}
103
104impl PrincipalSet {
105 pub fn parse(value: &serde_json::Value) -> Result<Self, PolicyParseError> {
107 match value {
108 serde_json::Value::String(s) if s == "*" => Ok(PrincipalSet::Wildcard),
109 serde_json::Value::String(other) => {
110 Err(PolicyParseError::InvalidWildcard(other.clone()))
111 }
112 serde_json::Value::Object(map) => {
113 if map.len() != 1 || !map.contains_key("AWS") {
114 return Err(PolicyParseError::UnsupportedPrincipalType);
115 }
116 let aws = &map["AWS"];
117 let principals: Vec<String> = match aws {
118 serde_json::Value::String(s) => vec![s.clone()],
119 serde_json::Value::Array(arr) => {
120 let mut out = Vec::with_capacity(arr.len());
121 for v in arr {
122 match v {
123 serde_json::Value::String(s) => out.push(s.clone()),
124 _ => return Err(PolicyParseError::InvalidPrincipalShape),
125 }
126 }
127 out
128 }
129 _ => return Err(PolicyParseError::InvalidPrincipalShape),
130 };
131 if principals.is_empty() {
132 return Err(PolicyParseError::EmptyPrincipalList);
133 }
134 Ok(PrincipalSet::Specific(principals))
135 }
136 _ => Err(PolicyParseError::InvalidPrincipalShape),
137 }
138 }
139}
140
141#[derive(Debug, Clone, Deserialize)]
142struct StatementJson {
143 #[serde(rename = "Sid")]
144 sid: Option<String>,
145 #[serde(rename = "Effect")]
146 effect: Effect,
147 #[serde(rename = "Action")]
148 action: StringOrVec,
149 #[serde(rename = "Resource")]
150 resource: StringOrVec,
151 #[serde(rename = "Principal", default)]
155 principal: Option<serde_json::Value>,
156 #[serde(rename = "Condition", default)]
159 condition: Option<HashMap<String, HashMap<String, StringOrVec>>>,
160}
161
162#[derive(Debug, Clone, Deserialize)]
163struct PolicyJson {
164 #[serde(rename = "Version")]
165 _version: Option<String>,
166 #[serde(rename = "Statement")]
167 statements: Vec<StatementJson>,
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
175pub enum ResourceArn {
176 Bucket(String),
178 Object { bucket: String, key_pattern: String },
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum ResourceKind {
186 ObjectOnly,
188 BucketOnly,
190 Either,
193}
194
195#[derive(Debug, thiserror::Error)]
199pub enum PolicyParseError {
200 #[error("policy JSON parse error: {0}")]
201 Json(#[from] serde_json::Error),
202 #[error("Resource ARN must start with \"arn:aws:s3:::\" — got {0:?}")]
203 InvalidResourceArn(String),
204 #[error("Resource ARN bucket name is empty: {0:?}")]
205 EmptyBucketInArn(String),
206 #[error("Principal wildcard must be exact \"*\" — got {0:?}")]
207 InvalidWildcard(String),
208 #[error(
209 "unsupported Principal type (only AWS principals are supported, no Service / Federated / CanonicalUser)"
210 )]
211 UnsupportedPrincipalType,
212 #[error("Principal AWS list must not be empty")]
213 EmptyPrincipalList,
214 #[error("Principal value must be the string \"*\" or a {{AWS: ...}} object")]
215 InvalidPrincipalShape,
216 #[error(
217 "unsupported policy Condition operator: {op:?}. v0.3 supports IpAddress / NotIpAddress / StringEquals / StringNotEquals / StringLike / StringNotLike / DateGreaterThan / DateLessThan / Bool."
218 )]
219 UnsupportedConditionOperator { op: String },
220}
221
222pub fn parse_resource_arn(s: &str) -> Result<ResourceArn, PolicyParseError> {
228 const PREFIX: &str = "arn:aws:s3:::";
229 let rest = s
230 .strip_prefix(PREFIX)
231 .ok_or_else(|| PolicyParseError::InvalidResourceArn(s.to_owned()))?;
232 match rest.split_once('/') {
233 None => {
234 if rest.is_empty() {
235 return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
236 }
237 Ok(ResourceArn::Bucket(rest.to_owned()))
238 }
239 Some((bucket, key_pattern)) => {
240 if bucket.is_empty() {
241 return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
242 }
243 Ok(ResourceArn::Object {
244 bucket: bucket.to_owned(),
245 key_pattern: key_pattern.to_owned(),
246 })
247 }
248 }
249}
250
251fn action_resource_kind(action: &str) -> ResourceKind {
256 match action {
257 "s3:GetObject"
259 | "s3:PutObject"
260 | "s3:DeleteObject"
261 | "s3:GetObjectTagging"
262 | "s3:PutObjectTagging"
263 | "s3:DeleteObjectTagging"
264 | "s3:GetObjectAcl"
265 | "s3:PutObjectAcl"
266 | "s3:RestoreObject"
267 | "s3:GetObjectVersion"
268 | "s3:DeleteObjectVersion"
269 | "s3:GetObjectRetention"
270 | "s3:PutObjectRetention"
271 | "s3:GetObjectLegalHold"
272 | "s3:PutObjectLegalHold"
273 | "s3:BypassGovernanceRetention"
274 | "s3:AbortMultipartUpload" => ResourceKind::ObjectOnly,
275 "s3:ListBucket"
277 | "s3:GetBucketLocation"
278 | "s3:GetBucketAcl"
279 | "s3:GetBucketCors"
280 | "s3:PutBucketCors"
281 | "s3:DeleteBucketCors"
282 | "s3:GetBucketVersioning"
283 | "s3:PutBucketVersioning"
284 | "s3:GetBucketTagging"
285 | "s3:PutBucketTagging"
286 | "s3:DeleteBucketTagging"
287 | "s3:GetBucketReplication"
288 | "s3:PutBucketReplication"
289 | "s3:DeleteBucketReplication"
290 | "s3:GetBucketLifecycleConfiguration"
291 | "s3:PutBucketLifecycleConfiguration"
292 | "s3:GetBucketNotification"
293 | "s3:PutBucketNotification"
294 | "s3:GetInventoryConfiguration"
295 | "s3:PutInventoryConfiguration"
296 | "s3:GetObjectLockConfiguration"
297 | "s3:PutObjectLockConfiguration"
298 | "s3:CreateBucket"
299 | "s3:DeleteBucket"
300 | "s3:ListMultipartUploads" => ResourceKind::BucketOnly,
301 _ => ResourceKind::Either,
304 }
305}
306
307#[derive(Debug, Clone)]
309pub struct Policy {
310 statements: Vec<Statement>,
311}
312
313#[derive(Debug, Clone)]
314struct Statement {
315 sid: Option<String>,
316 effect: Effect,
317 actions: Vec<String>, resources: Vec<ResourceArn>,
322 principals: Option<PrincipalSet>,
328 conditions: Vec<Condition>,
331}
332
333#[derive(Debug, Clone, Default)]
337pub struct RequestContext {
338 pub source_ip: Option<IpAddr>,
339 pub user_agent: Option<String>,
340 pub request_time: Option<SystemTime>,
341 pub secure_transport: bool,
342 pub existing_object_tags: Option<crate::tagging::TagSet>,
349 pub request_object_tags: Option<crate::tagging::TagSet>,
354 pub extra: HashMap<String, String>,
358}
359
360#[derive(Debug, Clone)]
362struct Condition {
363 op: ConditionOp,
364 key: String, values: Vec<String>, }
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369enum ConditionOp {
370 IpAddress,
371 NotIpAddress,
372 StringEquals,
373 StringNotEquals,
374 StringLike,
375 StringNotLike,
376 DateGreaterThan,
377 DateLessThan,
378 Bool,
379}
380
381impl ConditionOp {
382 fn parse(s: &str) -> Option<Self> {
383 Some(match s {
384 "IpAddress" => Self::IpAddress,
385 "NotIpAddress" => Self::NotIpAddress,
386 "StringEquals" => Self::StringEquals,
387 "StringNotEquals" => Self::StringNotEquals,
388 "StringLike" => Self::StringLike,
389 "StringNotLike" => Self::StringNotLike,
390 "DateGreaterThan" => Self::DateGreaterThan,
391 "DateLessThan" => Self::DateLessThan,
392 "Bool" => Self::Bool,
393 _ => return None,
394 })
395 }
396}
397
398impl Policy {
399 pub fn from_json_str(s: &str) -> Result<Self, String> {
405 Self::from_json_str_typed(s).map_err(|e| e.to_string())
406 }
407
408 pub fn from_json_str_typed(s: &str) -> Result<Self, PolicyParseError> {
412 let raw: PolicyJson = serde_json::from_str(s)?;
413 let mut statements = Vec::with_capacity(raw.statements.len());
414 for stmt in raw.statements {
415 let mut conditions = Vec::new();
416 if let Some(cond_map) = stmt.condition {
417 for (op_name, key_map) in cond_map {
418 let op = ConditionOp::parse(&op_name).ok_or(
419 PolicyParseError::UnsupportedConditionOperator {
420 op: op_name.clone(),
421 },
422 )?;
423 for (key, values) in key_map {
424 conditions.push(Condition {
425 op,
426 key,
427 values: values.into_vec(),
428 });
429 }
430 }
431 }
432 let mut resources = Vec::with_capacity(stmt.resource.clone().into_vec().len());
436 for raw_arn in stmt.resource.into_vec() {
437 resources.push(parse_resource_arn(&raw_arn)?);
438 }
439 let principals = match stmt.principal {
441 None => None,
442 Some(value) => Some(PrincipalSet::parse(&value)?),
443 };
444 statements.push(Statement {
445 sid: stmt.sid,
446 effect: stmt.effect,
447 actions: stmt.action.into_vec(),
448 resources,
449 principals,
450 conditions,
451 });
452 }
453 Ok(Self { statements })
454 }
455
456 pub fn from_path(path: &Path) -> Result<Self, String> {
457 let txt = std::fs::read_to_string(path)
458 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
459 Self::from_json_str(&txt)
460 }
461
462 pub fn evaluate(
471 &self,
472 action: &str,
473 bucket: &str,
474 key: Option<&str>,
475 principal_id: Option<&str>,
476 ) -> Decision {
477 self.evaluate_with(
478 action,
479 bucket,
480 key,
481 principal_id,
482 &RequestContext::default(),
483 )
484 }
485
486 pub fn evaluate_with(
490 &self,
491 action: &str,
492 bucket: &str,
493 key: Option<&str>,
494 principal_id: Option<&str>,
495 ctx: &RequestContext,
496 ) -> Decision {
497 let mut matched_allow: Option<Option<String>> = None;
498 let mut matched_deny: Option<Option<String>> = None;
499
500 for st in &self.statements {
501 if !st.actions.iter().any(|p| action_matches(p, action)) {
502 continue;
503 }
504 if !Self::statement_matches_resource(st, action, bucket, key) {
505 continue;
506 }
507 if !principal_matches(st.principals.as_ref(), principal_id) {
508 continue;
509 }
510 if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
514 continue;
515 }
516 match st.effect {
517 Effect::Deny => {
518 matched_deny = Some(st.sid.clone());
519 }
523 Effect::Allow => {
524 if matched_allow.is_none() {
525 matched_allow = Some(st.sid.clone());
526 }
527 }
528 }
529 }
530
531 if let Some(sid) = matched_deny {
532 Decision::deny(sid)
533 } else if let Some(sid) = matched_allow {
534 Decision::allow(sid)
535 } else {
536 Decision::implicit_deny()
537 }
538 }
539
540 fn statement_matches_resource(
557 stmt: &Statement,
558 action: &str,
559 bucket: &str,
560 key: Option<&str>,
561 ) -> bool {
562 let kind = action_resource_kind(action);
563 for parsed in &stmt.resources {
564 match (parsed, kind) {
565 (ResourceArn::Bucket(b), ResourceKind::BucketOnly) => {
567 if glob_match(b, bucket) {
568 return true;
569 }
570 }
571 (
573 ResourceArn::Object {
574 bucket: b,
575 key_pattern: kp,
576 },
577 ResourceKind::ObjectOnly,
578 ) => {
579 if !glob_match(b, bucket) {
580 continue;
581 }
582 if let Some(k) = key
583 && glob_match(kp, k)
584 {
585 return true;
586 }
587 }
588 (ResourceArn::Bucket(b), ResourceKind::Either) => {
590 if glob_match(b, bucket) {
591 return true;
592 }
593 }
594 (
595 ResourceArn::Object {
596 bucket: b,
597 key_pattern: kp,
598 },
599 ResourceKind::Either,
600 ) => {
601 if !glob_match(b, bucket) {
602 continue;
603 }
604 match key {
605 Some(k) => {
606 if glob_match(kp, k) {
607 return true;
608 }
609 }
610 None => {
611 if kp == "*" {
615 return true;
616 }
617 }
618 }
619 }
620 (ResourceArn::Bucket(_), ResourceKind::ObjectOnly)
622 | (ResourceArn::Object { .. }, ResourceKind::BucketOnly) => continue,
623 }
624 }
625 false
626 }
627}
628
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct Decision {
631 pub allow: bool,
632 pub matched_sid: Option<String>,
633 pub matched_effect: Option<Effect>,
636}
637
638impl Decision {
639 fn allow(sid: Option<String>) -> Self {
640 Self {
641 allow: true,
642 matched_sid: sid,
643 matched_effect: Some(Effect::Allow),
644 }
645 }
646 fn deny(sid: Option<String>) -> Self {
647 Self {
648 allow: false,
649 matched_sid: sid,
650 matched_effect: Some(Effect::Deny),
651 }
652 }
653 fn implicit_deny() -> Self {
654 Self {
655 allow: false,
656 matched_sid: None,
657 matched_effect: None,
658 }
659 }
660}
661
662fn action_matches(pattern: &str, action: &str) -> bool {
665 if pattern == "*" {
666 return true;
667 }
668 if let Some(prefix) = pattern.strip_suffix(":*") {
669 return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
670 }
671 pattern == action
672}
673
674fn glob_match(pattern: &str, s: &str) -> bool {
677 let p_bytes = pattern.as_bytes();
678 let s_bytes = s.as_bytes();
679 glob_match_bytes(p_bytes, s_bytes)
680}
681
682fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
683 let mut pi = 0;
684 let mut si = 0;
685 let mut star: Option<(usize, usize)> = None;
686 while si < s.len() {
687 if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
688 pi += 1;
689 si += 1;
690 } else if pi < p.len() && p[pi] == b'*' {
691 star = Some((pi, si));
692 pi += 1;
693 } else if let Some((sp, ss)) = star {
694 pi = sp + 1;
695 si = ss + 1;
696 star = Some((sp, si));
697 } else {
698 return false;
699 }
700 }
701 while pi < p.len() && p[pi] == b'*' {
702 pi += 1;
703 }
704 pi == p.len()
705}
706
707fn principal_matches(allowed: Option<&PrincipalSet>, principal_id: Option<&str>) -> bool {
708 match allowed {
709 None => true,
711 Some(PrincipalSet::Wildcard) => true,
713 Some(PrincipalSet::Specific(list)) => match principal_id {
718 None => false,
719 Some(id) => list.iter().any(|p| p == "*" || p == id),
720 },
721 }
722}
723
724fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
728 match c.op {
729 ConditionOp::IpAddress => match ctx.source_ip {
730 Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
731 None => false,
732 },
733 ConditionOp::NotIpAddress => match ctx.source_ip {
734 Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
735 None => false,
736 },
737 ConditionOp::StringEquals => match context_value(&c.key, ctx) {
738 Some(v) => c.values.iter().any(|x| x == &v),
739 None => false,
740 },
741 ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
742 Some(v) => !c.values.iter().any(|x| x == &v),
743 None => false,
744 },
745 ConditionOp::StringLike => match context_value(&c.key, ctx) {
746 Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
747 None => false,
748 },
749 ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
750 Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
751 None => false,
752 },
753 ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
754 let now = ctx.request_time.unwrap_or_else(SystemTime::now);
756 let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
757 Ok(d) => d.as_secs() as i64,
758 Err(_) => 0,
759 };
760 c.values.iter().any(|s| match parse_iso8601(s) {
761 Some(t) => match c.op {
762 ConditionOp::DateGreaterThan => now_unix > t,
763 ConditionOp::DateLessThan => now_unix < t,
764 _ => unreachable!(),
765 },
766 None => false,
767 })
768 }
769 ConditionOp::Bool => match context_value(&c.key, ctx) {
770 Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
771 None => false,
772 },
773 }
774}
775
776fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
782 match key {
783 "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
784 "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
785 "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
786 other => {
787 if let Some(tag_key) = other.strip_prefix("s3:ExistingObjectTag/") {
794 return ctx
795 .existing_object_tags
796 .as_ref()
797 .and_then(|s| s.get(tag_key).map(str::to_owned));
798 }
799 if let Some(tag_key) = other.strip_prefix("s3:RequestObjectTag/") {
800 return ctx
801 .request_object_tags
802 .as_ref()
803 .and_then(|s| s.get(tag_key).map(str::to_owned));
804 }
805 ctx.extra.get(other).cloned()
806 }
807 }
808}
809
810fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
813 match cidr.split_once('/') {
814 None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
815 Some((net_str, mask_str)) => {
816 let Ok(net) = net_str.parse::<IpAddr>() else {
817 return false;
818 };
819 let Ok(mask_bits) = mask_str.parse::<u8>() else {
820 return false;
821 };
822 match (ip, net) {
823 (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
824 if mask_bits > 32 {
825 return false;
826 }
827 if mask_bits == 0 {
828 return true;
829 }
830 let shift = 32 - mask_bits;
831 (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
832 }
833 (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
834 if mask_bits > 128 {
835 return false;
836 }
837 if mask_bits == 0 {
838 return true;
839 }
840 let shift = 128 - mask_bits;
841 (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
842 }
843 _ => false, }
845 }
846 }
847}
848
849fn parse_iso8601(s: &str) -> Option<i64> {
855 let s = s.strip_suffix('Z')?;
857 let (date, time) = s.split_once('T')?;
858 let date_parts: Vec<&str> = date.split('-').collect();
859 if date_parts.len() != 3 {
860 return None;
861 }
862 let year: i64 = date_parts[0].parse().ok()?;
863 let month: i64 = date_parts[1].parse().ok()?;
864 let day: i64 = date_parts[2].parse().ok()?;
865 let time_parts: Vec<&str> = time.split(':').collect();
866 if time_parts.len() != 3 {
867 return None;
868 }
869 let h: i64 = time_parts[0].parse().ok()?;
870 let m: i64 = time_parts[1].parse().ok()?;
871 let s: i64 = time_parts[2].parse().ok()?;
872 let y = if month <= 2 { year - 1 } else { year };
875 let era = if y >= 0 { y } else { y - 399 } / 400;
876 let yoe = (y - era * 400) as u64;
877 let mp = if month > 2 { month - 3 } else { month + 9 };
878 let doy = (153 * mp + 2) / 5 + day - 1;
879 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
880 let days_from_epoch = era * 146097 + doe as i64 - 719468;
881 Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
882}
883
884pub type SharedPolicy = Arc<Policy>;
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890
891 fn p(s: &str) -> Policy {
892 Policy::from_json_str(s).expect("policy")
893 }
894
895 #[test]
896 fn allow_then_deny_explicit_deny_wins() {
897 let pol = p(r#"{
898 "Version": "2012-10-17",
899 "Statement": [
900 {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
901 {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
902 ]
903 }"#);
904 let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
905 assert!(d.allow);
906 assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
907 let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
908 assert!(!d.allow);
909 assert_eq!(d.matched_effect, Some(Effect::Deny));
910 assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
911 }
912
913 #[test]
914 fn implicit_deny_when_no_statement_matches() {
915 let pol = p(r#"{
916 "Version": "2012-10-17",
917 "Statement": [
918 {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
919 ]
920 }"#);
921 let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
922 assert!(!d.allow);
923 assert_eq!(d.matched_effect, None);
924 }
925
926 #[test]
927 fn resource_glob_matches_prefix() {
928 let pol = p(r#"{
929 "Version": "2012-10-17",
930 "Statement": [{
931 "Effect": "Allow",
932 "Action": "s3:GetObject",
933 "Resource": "arn:aws:s3:::b/data/*.parquet"
934 }]
935 }"#);
936 assert!(
937 pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
938 .allow
939 );
940 assert!(
941 pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
942 .allow
943 );
944 assert!(
945 !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
946 .allow
947 );
948 }
949
950 #[test]
951 fn s3_action_wildcard() {
952 let pol = p(r#"{
957 "Version": "2012-10-17",
958 "Statement": [
959 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"},
960 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*/*"}
961 ]
962 }"#);
963 assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
964 assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
965 assert!(pol.evaluate("s3:ListBucket", "any", None, None).allow);
966 assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
969 }
970
971 #[test]
972 fn principal_match_by_access_key_id() {
973 let pol = p(r#"{
974 "Version": "2012-10-17",
975 "Statement": [{
976 "Effect": "Allow",
977 "Action": "s3:*",
978 "Resource": "arn:aws:s3:::b/*",
979 "Principal": {"AWS": ["AKIATEST123"]}
980 }]
981 }"#);
982 assert!(
983 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
984 .allow
985 );
986 assert!(
987 !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
988 .allow
989 );
990 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
991 }
992
993 #[test]
994 fn principal_wildcard_matches_anyone() {
995 let pol = p(r#"{
996 "Version": "2012-10-17",
997 "Statement": [{
998 "Effect": "Allow",
999 "Action": "s3:*",
1000 "Resource": "arn:aws:s3:::b/*",
1001 "Principal": "*"
1002 }]
1003 }"#);
1004 assert!(
1005 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
1006 .allow
1007 );
1008 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1009 }
1010
1011 #[test]
1012 fn resource_can_be_string_or_array() {
1013 let single = p(r#"{
1014 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1015 "Resource": "arn:aws:s3:::a/*"}]
1016 }"#);
1017 let multi = p(r#"{
1018 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1019 "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
1020 }"#);
1021 assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
1022 assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1023 assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1024 }
1025
1026 #[test]
1027 fn bucket_level_resource_for_listbucket() {
1028 let pol = p(r#"{
1029 "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
1030 "Resource": "arn:aws:s3:::b"}]
1031 }"#);
1032 assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
1034 assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
1035 }
1036
1037 #[test]
1038 fn glob_match_basics() {
1039 assert!(glob_match("foo", "foo"));
1040 assert!(!glob_match("foo", "bar"));
1041 assert!(glob_match("*", "anything"));
1042 assert!(glob_match("foo*", "foobar"));
1043 assert!(glob_match("*bar", "foobar"));
1044 assert!(glob_match("foo*bar", "fooXYZbar"));
1045 assert!(glob_match("a?c", "abc"));
1046 assert!(!glob_match("a?c", "abbc"));
1047 assert!(glob_match("a*b*c", "axxxbyyyc"));
1048 }
1049
1050 fn ctx_ip(ip: &str) -> RequestContext {
1053 RequestContext {
1054 source_ip: Some(ip.parse().unwrap()),
1055 ..Default::default()
1056 }
1057 }
1058
1059 #[test]
1060 fn condition_ip_address_cidr_match() {
1061 let pol = p(r#"{
1062 "Statement": [{
1063 "Effect": "Allow", "Action": "s3:GetObject",
1064 "Resource": "arn:aws:s3:::b/*",
1065 "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
1066 }]
1067 }"#);
1068 assert!(
1069 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
1070 .allow
1071 );
1072 assert!(
1073 pol.evaluate_with(
1074 "s3:GetObject",
1075 "b",
1076 Some("k"),
1077 None,
1078 &ctx_ip("192.168.1.50")
1079 )
1080 .allow
1081 );
1082 assert!(
1083 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
1084 .allow
1085 );
1086 assert!(
1088 !pol.evaluate_with(
1089 "s3:GetObject",
1090 "b",
1091 Some("k"),
1092 None,
1093 &RequestContext::default()
1094 )
1095 .allow
1096 );
1097 }
1098
1099 #[test]
1100 fn condition_not_ip_address_negates() {
1101 let pol = p(r#"{
1102 "Statement": [{
1103 "Effect": "Deny", "Action": "s3:DeleteObject",
1104 "Resource": "arn:aws:s3:::b/*",
1105 "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
1106 },
1107 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1108 }"#);
1109 assert!(
1111 !pol.evaluate_with(
1112 "s3:DeleteObject",
1113 "b",
1114 Some("k"),
1115 None,
1116 &ctx_ip("203.0.113.1")
1117 )
1118 .allow
1119 );
1120 assert!(
1122 pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
1123 .allow
1124 );
1125 }
1126
1127 #[test]
1128 fn condition_string_equals_user_agent() {
1129 let pol = p(r#"{
1130 "Statement": [{
1131 "Effect": "Allow", "Action": "s3:GetObject",
1132 "Resource": "arn:aws:s3:::b/*",
1133 "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
1134 }]
1135 }"#);
1136 let ua = |s: &str| RequestContext {
1137 user_agent: Some(s.into()),
1138 ..Default::default()
1139 };
1140 assert!(
1141 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
1142 .allow
1143 );
1144 assert!(
1145 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
1146 .allow
1147 );
1148 }
1149
1150 #[test]
1151 fn condition_string_like_glob() {
1152 let pol = p(r#"{
1153 "Statement": [{
1154 "Effect": "Allow", "Action": "s3:GetObject",
1155 "Resource": "arn:aws:s3:::b/*",
1156 "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
1157 }]
1158 }"#);
1159 let ua = |s: &str| RequestContext {
1160 user_agent: Some(s.into()),
1161 ..Default::default()
1162 };
1163 assert!(
1164 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
1165 .allow
1166 );
1167 assert!(
1168 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
1169 .allow
1170 );
1171 assert!(
1172 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
1173 .allow
1174 );
1175 }
1176
1177 #[test]
1178 fn condition_date_window() {
1179 let pol = p(r#"{
1181 "Statement": [{
1182 "Effect": "Allow", "Action": "s3:GetObject",
1183 "Resource": "arn:aws:s3:::b/*",
1184 "Condition": {
1185 "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
1186 "DateLessThan": {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
1187 }
1188 }]
1189 }"#);
1190 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 {
1193 request_time: Some(t),
1194 ..Default::default()
1195 };
1196 assert!(
1197 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
1198 .allow
1199 );
1200 assert!(
1201 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
1202 .allow
1203 );
1204 }
1205
1206 #[test]
1207 fn condition_bool_secure_transport() {
1208 let pol = p(r#"{
1209 "Statement": [{
1210 "Effect": "Deny", "Action": "s3:*",
1211 "Resource": "arn:aws:s3:::b/*",
1212 "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
1213 },
1214 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1215 }"#);
1216 let plain = RequestContext {
1217 secure_transport: false,
1218 ..Default::default()
1219 };
1220 let tls = RequestContext {
1221 secure_transport: true,
1222 ..Default::default()
1223 };
1224 assert!(
1226 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
1227 .allow
1228 );
1229 assert!(
1231 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
1232 .allow
1233 );
1234 }
1235
1236 #[test]
1237 fn condition_unknown_operator_rejected() {
1238 let err = Policy::from_json_str(
1239 r#"{
1240 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1241 "Resource": "arn:aws:s3:::b/*",
1242 "Condition": {"NumericGreaterThan": {"k": ["1"]}}
1243 }]
1244 }"#,
1245 )
1246 .expect_err("should reject unsupported operator");
1247 assert!(err.contains("unsupported policy Condition operator"));
1248 assert!(err.contains("NumericGreaterThan"));
1249 }
1250
1251 #[test]
1254 fn condition_existing_object_tag_matches_via_tagmanager_state() {
1255 let pol = p(r#"{
1256 "Statement": [{
1257 "Effect": "Allow", "Action": "s3:GetObject",
1258 "Resource": "arn:aws:s3:::b/*",
1259 "Condition": {
1260 "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
1261 }
1262 }]
1263 }"#);
1264 let with_tag = RequestContext {
1265 existing_object_tags: Some(
1266 crate::tagging::TagSet::from_pairs(vec![
1267 ("Project".into(), "Phoenix".into()),
1268 ("Env".into(), "prod".into()),
1269 ])
1270 .unwrap(),
1271 ),
1272 ..Default::default()
1273 };
1274 let other_tag = RequestContext {
1275 existing_object_tags: Some(
1276 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
1277 .unwrap(),
1278 ),
1279 ..Default::default()
1280 };
1281 assert!(
1283 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
1284 .allow
1285 );
1286 assert!(
1288 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
1289 .allow
1290 );
1291 }
1292
1293 #[test]
1294 fn condition_request_object_tag_matches_via_x_amz_tagging() {
1295 let pol = p(r#"{
1296 "Statement": [{
1297 "Effect": "Allow", "Action": "s3:PutObject",
1298 "Resource": "arn:aws:s3:::b/*",
1299 "Condition": {
1300 "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
1301 }
1302 }]
1303 }"#);
1304 let req_tags = |v: &str| RequestContext {
1305 request_object_tags: Some(
1306 crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
1307 ),
1308 ..Default::default()
1309 };
1310 assert!(
1311 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1312 .allow
1313 );
1314 assert!(
1315 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("staging"))
1316 .allow
1317 );
1318 assert!(
1319 !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1320 .allow
1321 );
1322 }
1323
1324 #[test]
1325 fn condition_tag_not_present_fails_closed() {
1326 let pol = p(r#"{
1330 "Statement": [{
1331 "Effect": "Allow", "Action": "s3:GetObject",
1332 "Resource": "arn:aws:s3:::b/*",
1333 "Condition": {
1334 "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1335 }
1336 }]
1337 }"#);
1338 let none_ctx = RequestContext::default();
1341 assert!(
1342 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1343 .allow
1344 );
1345 let other_only = RequestContext {
1347 existing_object_tags: Some(
1348 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())]).unwrap(),
1349 ),
1350 ..Default::default()
1351 };
1352 assert!(
1353 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1354 .allow
1355 );
1356 }
1357
1358 #[test]
1359 fn condition_legacy_evaluate_unchanged() {
1360 let pol = p(r#"{
1363 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1364 "Resource": "arn:aws:s3:::b/*"}]
1365 }"#);
1366 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1367 }
1368
1369 #[test]
1372 fn parse_resource_arn_bucket_form() {
1373 let arn = parse_resource_arn("arn:aws:s3:::mybucket").expect("parse");
1374 assert_eq!(arn, ResourceArn::Bucket("mybucket".into()));
1375 }
1376
1377 #[test]
1378 fn parse_resource_arn_object_form() {
1379 let arn = parse_resource_arn("arn:aws:s3:::mybucket/some/key").expect("parse");
1380 assert_eq!(
1381 arn,
1382 ResourceArn::Object {
1383 bucket: "mybucket".into(),
1384 key_pattern: "some/key".into(),
1385 }
1386 );
1387 }
1388
1389 #[test]
1390 fn parse_resource_arn_object_wildcard() {
1391 let arn = parse_resource_arn("arn:aws:s3:::mybucket/*").expect("parse");
1392 assert_eq!(
1393 arn,
1394 ResourceArn::Object {
1395 bucket: "mybucket".into(),
1396 key_pattern: "*".into(),
1397 }
1398 );
1399 let pre = parse_resource_arn("arn:aws:s3:::b/data/*.parquet").expect("parse");
1401 assert_eq!(
1402 pre,
1403 ResourceArn::Object {
1404 bucket: "b".into(),
1405 key_pattern: "data/*.parquet".into(),
1406 }
1407 );
1408 assert!(matches!(
1410 parse_resource_arn("not-an-arn"),
1411 Err(PolicyParseError::InvalidResourceArn(_))
1412 ));
1413 assert!(matches!(
1415 parse_resource_arn("arn:aws:s3:::"),
1416 Err(PolicyParseError::EmptyBucketInArn(_))
1417 ));
1418 assert!(matches!(
1419 parse_resource_arn("arn:aws:s3:::/key"),
1420 Err(PolicyParseError::EmptyBucketInArn(_))
1421 ));
1422 }
1423
1424 #[test]
1425 fn bucket_only_arn_does_not_grant_object_action() {
1426 let pol = p(r#"{
1430 "Statement": [{
1431 "Effect": "Allow",
1432 "Principal": "*",
1433 "Action": "s3:GetObject",
1434 "Resource": "arn:aws:s3:::mybucket"
1435 }]
1436 }"#);
1437 let d = pol.evaluate("s3:GetObject", "mybucket", Some("k"), None);
1438 assert!(!d.allow, "bucket-form ARN must not grant s3:GetObject");
1439 assert_eq!(d.matched_effect, None, "should be implicit deny");
1440 let pol_ok = p(r#"{
1442 "Statement": [{
1443 "Effect": "Allow",
1444 "Principal": "*",
1445 "Action": "s3:GetObject",
1446 "Resource": "arn:aws:s3:::mybucket/*"
1447 }]
1448 }"#);
1449 assert!(
1450 pol_ok
1451 .evaluate("s3:GetObject", "mybucket", Some("k"), None)
1452 .allow
1453 );
1454 }
1455
1456 #[test]
1457 fn object_arn_does_not_grant_bucket_action() {
1458 let pol = p(r#"{
1461 "Statement": [{
1462 "Effect": "Allow",
1463 "Principal": "*",
1464 "Action": "s3:ListBucket",
1465 "Resource": "arn:aws:s3:::b/k"
1466 }]
1467 }"#);
1468 let d = pol.evaluate("s3:ListBucket", "b", None, None);
1469 assert!(!d.allow, "object-form ARN must not grant s3:ListBucket");
1470 assert_eq!(d.matched_effect, None);
1471 }
1472
1473 #[test]
1474 fn principal_wildcard_only_accepts_literal_star() {
1475 let err = Policy::from_json_str_typed(
1480 r#"{"Statement": [{
1481 "Effect": "Allow", "Action": "s3:GetObject",
1482 "Resource": "arn:aws:s3:::b/*",
1483 "Principal": "AKIATESTNOTAWILDCARD"
1484 }]}"#,
1485 )
1486 .expect_err("non-* string principal must be rejected");
1487 assert!(
1488 matches!(err, PolicyParseError::InvalidWildcard(ref s) if s == "AKIATESTNOTAWILDCARD"),
1489 "expected InvalidWildcard, got {err:?}"
1490 );
1491 let ok = PrincipalSet::parse(&serde_json::Value::String("*".into())).expect("ok");
1493 assert_eq!(ok, PrincipalSet::Wildcard);
1494 }
1495
1496 #[test]
1497 fn principal_unsupported_service_type_rejected() {
1498 let err = Policy::from_json_str_typed(
1503 r#"{"Statement": [{
1504 "Effect": "Allow", "Action": "s3:GetObject",
1505 "Resource": "arn:aws:s3:::b/*",
1506 "Principal": {"Service": "lambda.amazonaws.com"}
1507 }]}"#,
1508 )
1509 .expect_err("Service principal must be rejected");
1510 assert!(
1511 matches!(err, PolicyParseError::UnsupportedPrincipalType),
1512 "expected UnsupportedPrincipalType, got {err:?}"
1513 );
1514 for shape in [
1516 r#"{"Federated": "cognito-identity.amazonaws.com"}"#,
1517 r#"{"CanonicalUser": "abcdef"}"#,
1518 r#"{"AWS": "AKIA", "Service": "x"}"#,
1519 ] {
1520 let v: serde_json::Value = serde_json::from_str(shape).unwrap();
1521 assert!(
1522 matches!(
1523 PrincipalSet::parse(&v),
1524 Err(PolicyParseError::UnsupportedPrincipalType)
1525 ),
1526 "expected UnsupportedPrincipalType for {shape}"
1527 );
1528 }
1529 }
1530
1531 #[test]
1532 fn principal_empty_aws_list_rejected() {
1533 let err = Policy::from_json_str_typed(
1537 r#"{"Statement": [{
1538 "Effect": "Allow", "Action": "s3:GetObject",
1539 "Resource": "arn:aws:s3:::b/*",
1540 "Principal": {"AWS": []}
1541 }]}"#,
1542 )
1543 .expect_err("empty AWS principal list must be rejected");
1544 assert!(
1545 matches!(err, PolicyParseError::EmptyPrincipalList),
1546 "expected EmptyPrincipalList, got {err:?}"
1547 );
1548 let v: serde_json::Value = serde_json::from_str(r#"{"AWS": "AKIAONE"}"#).unwrap();
1550 assert_eq!(
1551 PrincipalSet::parse(&v).unwrap(),
1552 PrincipalSet::Specific(vec!["AKIAONE".into()])
1553 );
1554 let pol = p(r#"{"Statement": [{
1556 "Effect": "Allow", "Action": "s3:GetObject",
1557 "Resource": "arn:aws:s3:::b/*",
1558 "Principal": {"AWS": ["AKIAONE"]}
1559 }]}"#);
1560 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1561 }
1562}