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)]
151#[serde(deny_unknown_fields)]
152struct StatementJson {
153 #[serde(rename = "Sid")]
154 sid: Option<String>,
155 #[serde(rename = "Effect")]
156 effect: Effect,
157 #[serde(rename = "Action")]
158 action: StringOrVec,
159 #[serde(rename = "Resource")]
160 resource: StringOrVec,
161 #[serde(rename = "Principal", default)]
165 principal: Option<serde_json::Value>,
166 #[serde(rename = "Condition", default)]
169 condition: Option<HashMap<String, HashMap<String, StringOrVec>>>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173#[serde(deny_unknown_fields)]
174struct PolicyJson {
175 #[serde(rename = "Version")]
176 _version: Option<String>,
177 #[serde(rename = "Id", default)]
180 _id: Option<String>,
181 #[serde(rename = "Statement")]
182 statements: Vec<StatementJson>,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq)]
190pub enum ResourceArn {
191 Bucket(String),
193 Object { bucket: String, key_pattern: String },
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200enum ResourceKind {
201 ObjectOnly,
203 BucketOnly,
205 Either,
208}
209
210#[derive(Debug, thiserror::Error)]
214pub enum PolicyParseError {
215 #[error("policy JSON parse error: {0}")]
216 Json(#[from] serde_json::Error),
217 #[error("Resource ARN must start with \"arn:aws:s3:::\" — got {0:?}")]
218 InvalidResourceArn(String),
219 #[error("Resource ARN bucket name is empty: {0:?}")]
220 EmptyBucketInArn(String),
221 #[error("Principal wildcard must be exact \"*\" — got {0:?}")]
222 InvalidWildcard(String),
223 #[error(
224 "unsupported Principal type (only AWS principals are supported, no Service / Federated / CanonicalUser)"
225 )]
226 UnsupportedPrincipalType,
227 #[error("Principal AWS list must not be empty")]
228 EmptyPrincipalList,
229 #[error("Principal value must be the string \"*\" or a {{AWS: ...}} object")]
230 InvalidPrincipalShape,
231 #[error(
232 "unsupported policy Condition operator: {op:?}. v0.3 supports IpAddress / NotIpAddress / StringEquals / StringNotEquals / StringLike / StringNotLike / DateGreaterThan / DateLessThan / Bool."
233 )]
234 UnsupportedConditionOperator { op: String },
235}
236
237pub fn parse_resource_arn(s: &str) -> Result<ResourceArn, PolicyParseError> {
243 const PREFIX: &str = "arn:aws:s3:::";
244 let rest = s
245 .strip_prefix(PREFIX)
246 .ok_or_else(|| PolicyParseError::InvalidResourceArn(s.to_owned()))?;
247 match rest.split_once('/') {
248 None => {
249 if rest.is_empty() {
250 return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
251 }
252 Ok(ResourceArn::Bucket(rest.to_owned()))
253 }
254 Some((bucket, key_pattern)) => {
255 if bucket.is_empty() {
256 return Err(PolicyParseError::EmptyBucketInArn(s.to_owned()));
257 }
258 Ok(ResourceArn::Object {
259 bucket: bucket.to_owned(),
260 key_pattern: key_pattern.to_owned(),
261 })
262 }
263 }
264}
265
266fn action_resource_kind(action: &str) -> ResourceKind {
271 match action {
272 "s3:GetObject"
274 | "s3:PutObject"
275 | "s3:DeleteObject"
276 | "s3:GetObjectTagging"
277 | "s3:PutObjectTagging"
278 | "s3:DeleteObjectTagging"
279 | "s3:GetObjectAcl"
280 | "s3:PutObjectAcl"
281 | "s3:RestoreObject"
282 | "s3:GetObjectVersion"
283 | "s3:DeleteObjectVersion"
284 | "s3:GetObjectRetention"
285 | "s3:PutObjectRetention"
286 | "s3:GetObjectLegalHold"
287 | "s3:PutObjectLegalHold"
288 | "s3:BypassGovernanceRetention"
289 | "s3:AbortMultipartUpload" => ResourceKind::ObjectOnly,
290 "s3:ListBucket"
292 | "s3:GetBucketLocation"
293 | "s3:GetBucketAcl"
294 | "s3:GetBucketCors"
295 | "s3:PutBucketCors"
296 | "s3:DeleteBucketCors"
297 | "s3:GetBucketVersioning"
298 | "s3:PutBucketVersioning"
299 | "s3:GetBucketTagging"
300 | "s3:PutBucketTagging"
301 | "s3:DeleteBucketTagging"
302 | "s3:GetBucketReplication"
303 | "s3:PutBucketReplication"
304 | "s3:DeleteBucketReplication"
305 | "s3:GetBucketLifecycleConfiguration"
306 | "s3:PutBucketLifecycleConfiguration"
307 | "s3:GetBucketNotification"
308 | "s3:PutBucketNotification"
309 | "s3:GetInventoryConfiguration"
310 | "s3:PutInventoryConfiguration"
311 | "s3:GetObjectLockConfiguration"
312 | "s3:PutObjectLockConfiguration"
313 | "s3:CreateBucket"
314 | "s3:DeleteBucket"
315 | "s3:ListMultipartUploads" => ResourceKind::BucketOnly,
316 _ => ResourceKind::Either,
319 }
320}
321
322#[derive(Debug, Clone)]
324pub struct Policy {
325 statements: Vec<Statement>,
326}
327
328#[derive(Debug, Clone)]
329struct Statement {
330 sid: Option<String>,
331 effect: Effect,
332 actions: Vec<String>, resources: Vec<ResourceArn>,
337 principals: Option<PrincipalSet>,
343 conditions: Vec<Condition>,
346}
347
348#[derive(Debug, Clone, Default)]
352pub struct RequestContext {
353 pub source_ip: Option<IpAddr>,
354 pub user_agent: Option<String>,
355 pub request_time: Option<SystemTime>,
356 pub secure_transport: bool,
357 pub existing_object_tags: Option<crate::tagging::TagSet>,
364 pub request_object_tags: Option<crate::tagging::TagSet>,
369 pub extra: HashMap<String, String>,
373}
374
375#[derive(Debug, Clone)]
377struct Condition {
378 op: ConditionOp,
379 key: String, values: Vec<String>, }
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum ConditionOp {
385 IpAddress,
386 NotIpAddress,
387 StringEquals,
388 StringNotEquals,
389 StringLike,
390 StringNotLike,
391 DateGreaterThan,
392 DateLessThan,
393 Bool,
394}
395
396impl ConditionOp {
397 fn parse(s: &str) -> Option<Self> {
398 Some(match s {
399 "IpAddress" => Self::IpAddress,
400 "NotIpAddress" => Self::NotIpAddress,
401 "StringEquals" => Self::StringEquals,
402 "StringNotEquals" => Self::StringNotEquals,
403 "StringLike" => Self::StringLike,
404 "StringNotLike" => Self::StringNotLike,
405 "DateGreaterThan" => Self::DateGreaterThan,
406 "DateLessThan" => Self::DateLessThan,
407 "Bool" => Self::Bool,
408 _ => return None,
409 })
410 }
411}
412
413impl Policy {
414 pub fn from_json_str(s: &str) -> Result<Self, String> {
420 Self::from_json_str_typed(s).map_err(|e| e.to_string())
421 }
422
423 pub fn from_json_str_typed(s: &str) -> Result<Self, PolicyParseError> {
427 let raw: PolicyJson = serde_json::from_str(s)?;
428 let mut statements = Vec::with_capacity(raw.statements.len());
429 for stmt in raw.statements {
430 let mut conditions = Vec::new();
431 if let Some(cond_map) = stmt.condition {
432 for (op_name, key_map) in cond_map {
433 let op = ConditionOp::parse(&op_name).ok_or(
434 PolicyParseError::UnsupportedConditionOperator {
435 op: op_name.clone(),
436 },
437 )?;
438 for (key, values) in key_map {
439 conditions.push(Condition {
440 op,
441 key,
442 values: values.into_vec(),
443 });
444 }
445 }
446 }
447 let mut resources = Vec::with_capacity(stmt.resource.clone().into_vec().len());
451 for raw_arn in stmt.resource.into_vec() {
452 resources.push(parse_resource_arn(&raw_arn)?);
453 }
454 let principals = match stmt.principal {
456 None => None,
457 Some(value) => Some(PrincipalSet::parse(&value)?),
458 };
459 statements.push(Statement {
460 sid: stmt.sid,
461 effect: stmt.effect,
462 actions: stmt.action.into_vec(),
463 resources,
464 principals,
465 conditions,
466 });
467 }
468 Ok(Self { statements })
469 }
470
471 pub fn from_path(path: &Path) -> Result<Self, String> {
472 let txt = std::fs::read_to_string(path)
473 .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
474 Self::from_json_str(&txt)
475 }
476
477 pub fn evaluate(
486 &self,
487 action: &str,
488 bucket: &str,
489 key: Option<&str>,
490 principal_id: Option<&str>,
491 ) -> Decision {
492 self.evaluate_with(
493 action,
494 bucket,
495 key,
496 principal_id,
497 &RequestContext::default(),
498 )
499 }
500
501 pub fn evaluate_with(
505 &self,
506 action: &str,
507 bucket: &str,
508 key: Option<&str>,
509 principal_id: Option<&str>,
510 ctx: &RequestContext,
511 ) -> Decision {
512 let mut matched_allow: Option<Option<String>> = None;
513 let mut matched_deny: Option<Option<String>> = None;
514
515 for st in &self.statements {
516 if !st.actions.iter().any(|p| action_matches(p, action)) {
517 continue;
518 }
519 if !Self::statement_matches_resource(st, action, bucket, key) {
520 continue;
521 }
522 if !principal_matches(st.principals.as_ref(), principal_id) {
523 continue;
524 }
525 if !st.conditions.iter().all(|c| condition_matches(c, ctx)) {
529 continue;
530 }
531 match st.effect {
532 Effect::Deny => {
533 matched_deny = Some(st.sid.clone());
534 }
538 Effect::Allow => {
539 if matched_allow.is_none() {
540 matched_allow = Some(st.sid.clone());
541 }
542 }
543 }
544 }
545
546 if let Some(sid) = matched_deny {
547 Decision::deny(sid)
548 } else if let Some(sid) = matched_allow {
549 Decision::allow(sid)
550 } else {
551 Decision::implicit_deny()
552 }
553 }
554
555 fn statement_matches_resource(
572 stmt: &Statement,
573 action: &str,
574 bucket: &str,
575 key: Option<&str>,
576 ) -> bool {
577 let kind = action_resource_kind(action);
578 for parsed in &stmt.resources {
579 match (parsed, kind) {
580 (ResourceArn::Bucket(b), ResourceKind::BucketOnly) => {
582 if glob_match(b, bucket) {
583 return true;
584 }
585 }
586 (
588 ResourceArn::Object {
589 bucket: b,
590 key_pattern: kp,
591 },
592 ResourceKind::ObjectOnly,
593 ) => {
594 if !glob_match(b, bucket) {
595 continue;
596 }
597 if let Some(k) = key
598 && glob_match(kp, k)
599 {
600 return true;
601 }
602 }
603 (ResourceArn::Bucket(b), ResourceKind::Either) => {
605 if glob_match(b, bucket) {
606 return true;
607 }
608 }
609 (
610 ResourceArn::Object {
611 bucket: b,
612 key_pattern: kp,
613 },
614 ResourceKind::Either,
615 ) => {
616 if !glob_match(b, bucket) {
617 continue;
618 }
619 match key {
620 Some(k) => {
621 if glob_match(kp, k) {
622 return true;
623 }
624 }
625 None => {
626 if kp == "*" {
630 return true;
631 }
632 }
633 }
634 }
635 (ResourceArn::Bucket(_), ResourceKind::ObjectOnly)
637 | (ResourceArn::Object { .. }, ResourceKind::BucketOnly) => continue,
638 }
639 }
640 false
641 }
642}
643
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct Decision {
646 pub allow: bool,
647 pub matched_sid: Option<String>,
648 pub matched_effect: Option<Effect>,
651}
652
653impl Decision {
654 fn allow(sid: Option<String>) -> Self {
655 Self {
656 allow: true,
657 matched_sid: sid,
658 matched_effect: Some(Effect::Allow),
659 }
660 }
661 fn deny(sid: Option<String>) -> Self {
662 Self {
663 allow: false,
664 matched_sid: sid,
665 matched_effect: Some(Effect::Deny),
666 }
667 }
668 fn implicit_deny() -> Self {
669 Self {
670 allow: false,
671 matched_sid: None,
672 matched_effect: None,
673 }
674 }
675}
676
677fn action_matches(pattern: &str, action: &str) -> bool {
680 if pattern == "*" {
681 return true;
682 }
683 if let Some(prefix) = pattern.strip_suffix(":*") {
684 return action.starts_with(prefix) && action[prefix.len()..].starts_with(':');
685 }
686 pattern == action
687}
688
689fn glob_match(pattern: &str, s: &str) -> bool {
692 let p_bytes = pattern.as_bytes();
693 let s_bytes = s.as_bytes();
694 glob_match_bytes(p_bytes, s_bytes)
695}
696
697fn glob_match_bytes(p: &[u8], s: &[u8]) -> bool {
698 let mut pi = 0;
699 let mut si = 0;
700 let mut star: Option<(usize, usize)> = None;
701 while si < s.len() {
702 if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) {
703 pi += 1;
704 si += 1;
705 } else if pi < p.len() && p[pi] == b'*' {
706 star = Some((pi, si));
707 pi += 1;
708 } else if let Some((sp, ss)) = star {
709 pi = sp + 1;
710 si = ss + 1;
711 star = Some((sp, si));
712 } else {
713 return false;
714 }
715 }
716 while pi < p.len() && p[pi] == b'*' {
717 pi += 1;
718 }
719 pi == p.len()
720}
721
722fn principal_matches(allowed: Option<&PrincipalSet>, principal_id: Option<&str>) -> bool {
723 match allowed {
724 None => true,
726 Some(PrincipalSet::Wildcard) => true,
728 Some(PrincipalSet::Specific(list)) => match principal_id {
733 None => false,
734 Some(id) => list.iter().any(|p| p == "*" || p == id),
735 },
736 }
737}
738
739fn condition_matches(c: &Condition, ctx: &RequestContext) -> bool {
743 match c.op {
744 ConditionOp::IpAddress => match ctx.source_ip {
745 Some(ip) => c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
746 None => false,
747 },
748 ConditionOp::NotIpAddress => match ctx.source_ip {
749 Some(ip) => !c.values.iter().any(|cidr| ip_in_cidr(ip, cidr)),
750 None => false,
751 },
752 ConditionOp::StringEquals => match context_value(&c.key, ctx) {
753 Some(v) => c.values.iter().any(|x| x == &v),
754 None => false,
755 },
756 ConditionOp::StringNotEquals => match context_value(&c.key, ctx) {
757 Some(v) => !c.values.iter().any(|x| x == &v),
758 None => false,
759 },
760 ConditionOp::StringLike => match context_value(&c.key, ctx) {
761 Some(v) => c.values.iter().any(|pat| glob_match(pat, &v)),
762 None => false,
763 },
764 ConditionOp::StringNotLike => match context_value(&c.key, ctx) {
765 Some(v) => !c.values.iter().any(|pat| glob_match(pat, &v)),
766 None => false,
767 },
768 ConditionOp::DateGreaterThan | ConditionOp::DateLessThan => {
769 let now = ctx.request_time.unwrap_or_else(SystemTime::now);
771 let now_unix = match now.duration_since(SystemTime::UNIX_EPOCH) {
772 Ok(d) => d.as_secs() as i64,
773 Err(_) => 0,
774 };
775 c.values.iter().any(|s| match parse_iso8601(s) {
776 Some(t) => match c.op {
777 ConditionOp::DateGreaterThan => now_unix > t,
778 ConditionOp::DateLessThan => now_unix < t,
779 _ => unreachable!(),
780 },
781 None => false,
782 })
783 }
784 ConditionOp::Bool => match context_value(&c.key, ctx) {
785 Some(v) => c.values.iter().any(|x| x.eq_ignore_ascii_case(&v)),
786 None => false,
787 },
788 }
789}
790
791fn context_value(key: &str, ctx: &RequestContext) -> Option<String> {
797 match key {
798 "aws:UserAgent" | "aws:userAgent" => ctx.user_agent.clone(),
799 "aws:SourceIp" | "aws:sourceIp" => ctx.source_ip.map(|ip| ip.to_string()),
800 "aws:SecureTransport" => Some(ctx.secure_transport.to_string()),
801 other => {
802 if let Some(tag_key) = other.strip_prefix("s3:ExistingObjectTag/") {
809 return ctx
810 .existing_object_tags
811 .as_ref()
812 .and_then(|s| s.get(tag_key).map(str::to_owned));
813 }
814 if let Some(tag_key) = other.strip_prefix("s3:RequestObjectTag/") {
815 return ctx
816 .request_object_tags
817 .as_ref()
818 .and_then(|s| s.get(tag_key).map(str::to_owned));
819 }
820 ctx.extra.get(other).cloned()
821 }
822 }
823}
824
825fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
828 match cidr.split_once('/') {
829 None => cidr.parse::<IpAddr>().is_ok_and(|c| c == ip),
830 Some((net_str, mask_str)) => {
831 let Ok(net) = net_str.parse::<IpAddr>() else {
832 return false;
833 };
834 let Ok(mask_bits) = mask_str.parse::<u8>() else {
835 return false;
836 };
837 match (ip, net) {
838 (IpAddr::V4(ip4), IpAddr::V4(net4)) => {
839 if mask_bits > 32 {
840 return false;
841 }
842 if mask_bits == 0 {
843 return true;
844 }
845 let shift = 32 - mask_bits;
846 (u32::from(ip4) >> shift) == (u32::from(net4) >> shift)
847 }
848 (IpAddr::V6(ip6), IpAddr::V6(net6)) => {
849 if mask_bits > 128 {
850 return false;
851 }
852 if mask_bits == 0 {
853 return true;
854 }
855 let shift = 128 - mask_bits;
856 (u128::from(ip6) >> shift) == (u128::from(net6) >> shift)
857 }
858 _ => false, }
860 }
861 }
862}
863
864fn parse_iso8601(s: &str) -> Option<i64> {
882 let s = s.strip_suffix('Z')?;
884 let (date, time) = s.split_once('T')?;
885 let date_parts: Vec<&str> = date.split('-').collect();
886 if date_parts.len() != 3 {
887 return None;
888 }
889 let year: i64 = date_parts[0].parse().ok()?;
890 let month: i64 = date_parts[1].parse().ok()?;
891 let day: i64 = date_parts[2].parse().ok()?;
892 let time_parts: Vec<&str> = time.split(':').collect();
893 if time_parts.len() != 3 {
894 return None;
895 }
896 let h: i64 = time_parts[0].parse().ok()?;
897 let m: i64 = time_parts[1].parse().ok()?;
898 let s: i64 = time_parts[2].parse().ok()?;
899 if !(1970..=9999).contains(&year) {
904 return None;
905 }
906 if !(1..=12).contains(&month) {
907 return None;
908 }
909 if !(1..=31).contains(&day) {
910 return None;
911 }
912 if !(0..=23).contains(&h) || !(0..=59).contains(&m) || !(0..=60).contains(&s) {
913 return None;
914 }
915 let y = if month <= 2 { year - 1 } else { year };
918 let era = if y >= 0 { y } else { y - 399 } / 400;
919 let yoe = (y - era * 400) as u64;
920 let mp = if month > 2 { month - 3 } else { month + 9 };
921 let doy = (153 * mp + 2) / 5 + day - 1;
922 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
923 let days_from_epoch = era * 146097 + doe as i64 - 719468;
924 Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
925}
926
927pub type SharedPolicy = Arc<Policy>;
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 fn p(s: &str) -> Policy {
935 Policy::from_json_str(s).expect("policy")
936 }
937
938 #[test]
939 fn allow_then_deny_explicit_deny_wins() {
940 let pol = p(r#"{
941 "Version": "2012-10-17",
942 "Statement": [
943 {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
944 {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
945 ]
946 }"#);
947 let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
948 assert!(d.allow);
949 assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
950 let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
951 assert!(!d.allow);
952 assert_eq!(d.matched_effect, Some(Effect::Deny));
953 assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
954 }
955
956 #[test]
957 fn implicit_deny_when_no_statement_matches() {
958 let pol = p(r#"{
959 "Version": "2012-10-17",
960 "Statement": [
961 {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
962 ]
963 }"#);
964 let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
965 assert!(!d.allow);
966 assert_eq!(d.matched_effect, None);
967 }
968
969 #[test]
970 fn resource_glob_matches_prefix() {
971 let pol = p(r#"{
972 "Version": "2012-10-17",
973 "Statement": [{
974 "Effect": "Allow",
975 "Action": "s3:GetObject",
976 "Resource": "arn:aws:s3:::b/data/*.parquet"
977 }]
978 }"#);
979 assert!(
980 pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
981 .allow
982 );
983 assert!(
984 pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
985 .allow
986 );
987 assert!(
988 !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
989 .allow
990 );
991 }
992
993 #[test]
994 fn s3_action_wildcard() {
995 let pol = p(r#"{
1000 "Version": "2012-10-17",
1001 "Statement": [
1002 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"},
1003 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*/*"}
1004 ]
1005 }"#);
1006 assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
1007 assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
1008 assert!(pol.evaluate("s3:ListBucket", "any", None, None).allow);
1009 assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
1012 }
1013
1014 #[test]
1015 fn principal_match_by_access_key_id() {
1016 let pol = p(r#"{
1017 "Version": "2012-10-17",
1018 "Statement": [{
1019 "Effect": "Allow",
1020 "Action": "s3:*",
1021 "Resource": "arn:aws:s3:::b/*",
1022 "Principal": {"AWS": ["AKIATEST123"]}
1023 }]
1024 }"#);
1025 assert!(
1026 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
1027 .allow
1028 );
1029 assert!(
1030 !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
1031 .allow
1032 );
1033 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1034 }
1035
1036 #[test]
1037 fn principal_wildcard_matches_anyone() {
1038 let pol = p(r#"{
1039 "Version": "2012-10-17",
1040 "Statement": [{
1041 "Effect": "Allow",
1042 "Action": "s3:*",
1043 "Resource": "arn:aws:s3:::b/*",
1044 "Principal": "*"
1045 }]
1046 }"#);
1047 assert!(
1048 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
1049 .allow
1050 );
1051 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1052 }
1053
1054 #[test]
1055 fn resource_can_be_string_or_array() {
1056 let single = p(r#"{
1057 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1058 "Resource": "arn:aws:s3:::a/*"}]
1059 }"#);
1060 let multi = p(r#"{
1061 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1062 "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
1063 }"#);
1064 assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
1065 assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1066 assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1067 }
1068
1069 #[test]
1070 fn bucket_level_resource_for_listbucket() {
1071 let pol = p(r#"{
1072 "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
1073 "Resource": "arn:aws:s3:::b"}]
1074 }"#);
1075 assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
1077 assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
1078 }
1079
1080 #[test]
1081 fn glob_match_basics() {
1082 assert!(glob_match("foo", "foo"));
1083 assert!(!glob_match("foo", "bar"));
1084 assert!(glob_match("*", "anything"));
1085 assert!(glob_match("foo*", "foobar"));
1086 assert!(glob_match("*bar", "foobar"));
1087 assert!(glob_match("foo*bar", "fooXYZbar"));
1088 assert!(glob_match("a?c", "abc"));
1089 assert!(!glob_match("a?c", "abbc"));
1090 assert!(glob_match("a*b*c", "axxxbyyyc"));
1091 }
1092
1093 fn ctx_ip(ip: &str) -> RequestContext {
1096 RequestContext {
1097 source_ip: Some(ip.parse().unwrap()),
1098 ..Default::default()
1099 }
1100 }
1101
1102 #[test]
1103 fn condition_ip_address_cidr_match() {
1104 let pol = p(r#"{
1105 "Statement": [{
1106 "Effect": "Allow", "Action": "s3:GetObject",
1107 "Resource": "arn:aws:s3:::b/*",
1108 "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
1109 }]
1110 }"#);
1111 assert!(
1112 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
1113 .allow
1114 );
1115 assert!(
1116 pol.evaluate_with(
1117 "s3:GetObject",
1118 "b",
1119 Some("k"),
1120 None,
1121 &ctx_ip("192.168.1.50")
1122 )
1123 .allow
1124 );
1125 assert!(
1126 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
1127 .allow
1128 );
1129 assert!(
1131 !pol.evaluate_with(
1132 "s3:GetObject",
1133 "b",
1134 Some("k"),
1135 None,
1136 &RequestContext::default()
1137 )
1138 .allow
1139 );
1140 }
1141
1142 #[test]
1143 fn condition_not_ip_address_negates() {
1144 let pol = p(r#"{
1145 "Statement": [{
1146 "Effect": "Deny", "Action": "s3:DeleteObject",
1147 "Resource": "arn:aws:s3:::b/*",
1148 "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
1149 },
1150 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1151 }"#);
1152 assert!(
1154 !pol.evaluate_with(
1155 "s3:DeleteObject",
1156 "b",
1157 Some("k"),
1158 None,
1159 &ctx_ip("203.0.113.1")
1160 )
1161 .allow
1162 );
1163 assert!(
1165 pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
1166 .allow
1167 );
1168 }
1169
1170 #[test]
1171 fn condition_string_equals_user_agent() {
1172 let pol = p(r#"{
1173 "Statement": [{
1174 "Effect": "Allow", "Action": "s3:GetObject",
1175 "Resource": "arn:aws:s3:::b/*",
1176 "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
1177 }]
1178 }"#);
1179 let ua = |s: &str| RequestContext {
1180 user_agent: Some(s.into()),
1181 ..Default::default()
1182 };
1183 assert!(
1184 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
1185 .allow
1186 );
1187 assert!(
1188 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
1189 .allow
1190 );
1191 }
1192
1193 #[test]
1194 fn condition_string_like_glob() {
1195 let pol = p(r#"{
1196 "Statement": [{
1197 "Effect": "Allow", "Action": "s3:GetObject",
1198 "Resource": "arn:aws:s3:::b/*",
1199 "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
1200 }]
1201 }"#);
1202 let ua = |s: &str| RequestContext {
1203 user_agent: Some(s.into()),
1204 ..Default::default()
1205 };
1206 assert!(
1207 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
1208 .allow
1209 );
1210 assert!(
1211 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
1212 .allow
1213 );
1214 assert!(
1215 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
1216 .allow
1217 );
1218 }
1219
1220 #[test]
1221 fn condition_date_window() {
1222 let pol = p(r#"{
1224 "Statement": [{
1225 "Effect": "Allow", "Action": "s3:GetObject",
1226 "Resource": "arn:aws:s3:::b/*",
1227 "Condition": {
1228 "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
1229 "DateLessThan": {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
1230 }
1231 }]
1232 }"#);
1233 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 {
1236 request_time: Some(t),
1237 ..Default::default()
1238 };
1239 assert!(
1240 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
1241 .allow
1242 );
1243 assert!(
1244 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
1245 .allow
1246 );
1247 }
1248
1249 #[test]
1250 fn condition_bool_secure_transport() {
1251 let pol = p(r#"{
1252 "Statement": [{
1253 "Effect": "Deny", "Action": "s3:*",
1254 "Resource": "arn:aws:s3:::b/*",
1255 "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
1256 },
1257 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1258 }"#);
1259 let plain = RequestContext {
1260 secure_transport: false,
1261 ..Default::default()
1262 };
1263 let tls = RequestContext {
1264 secure_transport: true,
1265 ..Default::default()
1266 };
1267 assert!(
1269 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
1270 .allow
1271 );
1272 assert!(
1274 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
1275 .allow
1276 );
1277 }
1278
1279 #[test]
1280 fn condition_unknown_operator_rejected() {
1281 let err = Policy::from_json_str(
1282 r#"{
1283 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1284 "Resource": "arn:aws:s3:::b/*",
1285 "Condition": {"NumericGreaterThan": {"k": ["1"]}}
1286 }]
1287 }"#,
1288 )
1289 .expect_err("should reject unsupported operator");
1290 assert!(err.contains("unsupported policy Condition operator"));
1291 assert!(err.contains("NumericGreaterThan"));
1292 }
1293
1294 #[test]
1297 fn condition_existing_object_tag_matches_via_tagmanager_state() {
1298 let pol = p(r#"{
1299 "Statement": [{
1300 "Effect": "Allow", "Action": "s3:GetObject",
1301 "Resource": "arn:aws:s3:::b/*",
1302 "Condition": {
1303 "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
1304 }
1305 }]
1306 }"#);
1307 let with_tag = RequestContext {
1308 existing_object_tags: Some(
1309 crate::tagging::TagSet::from_pairs(vec![
1310 ("Project".into(), "Phoenix".into()),
1311 ("Env".into(), "prod".into()),
1312 ])
1313 .unwrap(),
1314 ),
1315 ..Default::default()
1316 };
1317 let other_tag = RequestContext {
1318 existing_object_tags: Some(
1319 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
1320 .unwrap(),
1321 ),
1322 ..Default::default()
1323 };
1324 assert!(
1326 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
1327 .allow
1328 );
1329 assert!(
1331 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
1332 .allow
1333 );
1334 }
1335
1336 #[test]
1337 fn condition_request_object_tag_matches_via_x_amz_tagging() {
1338 let pol = p(r#"{
1339 "Statement": [{
1340 "Effect": "Allow", "Action": "s3:PutObject",
1341 "Resource": "arn:aws:s3:::b/*",
1342 "Condition": {
1343 "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
1344 }
1345 }]
1346 }"#);
1347 let req_tags = |v: &str| RequestContext {
1348 request_object_tags: Some(
1349 crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
1350 ),
1351 ..Default::default()
1352 };
1353 assert!(
1354 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1355 .allow
1356 );
1357 assert!(
1358 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("staging"))
1359 .allow
1360 );
1361 assert!(
1362 !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1363 .allow
1364 );
1365 }
1366
1367 #[test]
1368 fn condition_tag_not_present_fails_closed() {
1369 let pol = p(r#"{
1373 "Statement": [{
1374 "Effect": "Allow", "Action": "s3:GetObject",
1375 "Resource": "arn:aws:s3:::b/*",
1376 "Condition": {
1377 "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1378 }
1379 }]
1380 }"#);
1381 let none_ctx = RequestContext::default();
1384 assert!(
1385 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1386 .allow
1387 );
1388 let other_only = RequestContext {
1390 existing_object_tags: Some(
1391 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())]).unwrap(),
1392 ),
1393 ..Default::default()
1394 };
1395 assert!(
1396 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1397 .allow
1398 );
1399 }
1400
1401 #[test]
1402 fn condition_legacy_evaluate_unchanged() {
1403 let pol = p(r#"{
1406 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1407 "Resource": "arn:aws:s3:::b/*"}]
1408 }"#);
1409 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1410 }
1411
1412 #[test]
1415 fn parse_resource_arn_bucket_form() {
1416 let arn = parse_resource_arn("arn:aws:s3:::mybucket").expect("parse");
1417 assert_eq!(arn, ResourceArn::Bucket("mybucket".into()));
1418 }
1419
1420 #[test]
1421 fn parse_resource_arn_object_form() {
1422 let arn = parse_resource_arn("arn:aws:s3:::mybucket/some/key").expect("parse");
1423 assert_eq!(
1424 arn,
1425 ResourceArn::Object {
1426 bucket: "mybucket".into(),
1427 key_pattern: "some/key".into(),
1428 }
1429 );
1430 }
1431
1432 #[test]
1433 fn parse_resource_arn_object_wildcard() {
1434 let arn = parse_resource_arn("arn:aws:s3:::mybucket/*").expect("parse");
1435 assert_eq!(
1436 arn,
1437 ResourceArn::Object {
1438 bucket: "mybucket".into(),
1439 key_pattern: "*".into(),
1440 }
1441 );
1442 let pre = parse_resource_arn("arn:aws:s3:::b/data/*.parquet").expect("parse");
1444 assert_eq!(
1445 pre,
1446 ResourceArn::Object {
1447 bucket: "b".into(),
1448 key_pattern: "data/*.parquet".into(),
1449 }
1450 );
1451 assert!(matches!(
1453 parse_resource_arn("not-an-arn"),
1454 Err(PolicyParseError::InvalidResourceArn(_))
1455 ));
1456 assert!(matches!(
1458 parse_resource_arn("arn:aws:s3:::"),
1459 Err(PolicyParseError::EmptyBucketInArn(_))
1460 ));
1461 assert!(matches!(
1462 parse_resource_arn("arn:aws:s3:::/key"),
1463 Err(PolicyParseError::EmptyBucketInArn(_))
1464 ));
1465 }
1466
1467 #[test]
1468 fn bucket_only_arn_does_not_grant_object_action() {
1469 let pol = p(r#"{
1473 "Statement": [{
1474 "Effect": "Allow",
1475 "Principal": "*",
1476 "Action": "s3:GetObject",
1477 "Resource": "arn:aws:s3:::mybucket"
1478 }]
1479 }"#);
1480 let d = pol.evaluate("s3:GetObject", "mybucket", Some("k"), None);
1481 assert!(!d.allow, "bucket-form ARN must not grant s3:GetObject");
1482 assert_eq!(d.matched_effect, None, "should be implicit deny");
1483 let pol_ok = p(r#"{
1485 "Statement": [{
1486 "Effect": "Allow",
1487 "Principal": "*",
1488 "Action": "s3:GetObject",
1489 "Resource": "arn:aws:s3:::mybucket/*"
1490 }]
1491 }"#);
1492 assert!(
1493 pol_ok
1494 .evaluate("s3:GetObject", "mybucket", Some("k"), None)
1495 .allow
1496 );
1497 }
1498
1499 #[test]
1500 fn object_arn_does_not_grant_bucket_action() {
1501 let pol = p(r#"{
1504 "Statement": [{
1505 "Effect": "Allow",
1506 "Principal": "*",
1507 "Action": "s3:ListBucket",
1508 "Resource": "arn:aws:s3:::b/k"
1509 }]
1510 }"#);
1511 let d = pol.evaluate("s3:ListBucket", "b", None, None);
1512 assert!(!d.allow, "object-form ARN must not grant s3:ListBucket");
1513 assert_eq!(d.matched_effect, None);
1514 }
1515
1516 #[test]
1517 fn principal_wildcard_only_accepts_literal_star() {
1518 let err = Policy::from_json_str_typed(
1523 r#"{"Statement": [{
1524 "Effect": "Allow", "Action": "s3:GetObject",
1525 "Resource": "arn:aws:s3:::b/*",
1526 "Principal": "AKIATESTNOTAWILDCARD"
1527 }]}"#,
1528 )
1529 .expect_err("non-* string principal must be rejected");
1530 assert!(
1531 matches!(err, PolicyParseError::InvalidWildcard(ref s) if s == "AKIATESTNOTAWILDCARD"),
1532 "expected InvalidWildcard, got {err:?}"
1533 );
1534 let ok = PrincipalSet::parse(&serde_json::Value::String("*".into())).expect("ok");
1536 assert_eq!(ok, PrincipalSet::Wildcard);
1537 }
1538
1539 #[test]
1540 fn principal_unsupported_service_type_rejected() {
1541 let err = Policy::from_json_str_typed(
1546 r#"{"Statement": [{
1547 "Effect": "Allow", "Action": "s3:GetObject",
1548 "Resource": "arn:aws:s3:::b/*",
1549 "Principal": {"Service": "lambda.amazonaws.com"}
1550 }]}"#,
1551 )
1552 .expect_err("Service principal must be rejected");
1553 assert!(
1554 matches!(err, PolicyParseError::UnsupportedPrincipalType),
1555 "expected UnsupportedPrincipalType, got {err:?}"
1556 );
1557 for shape in [
1559 r#"{"Federated": "cognito-identity.amazonaws.com"}"#,
1560 r#"{"CanonicalUser": "abcdef"}"#,
1561 r#"{"AWS": "AKIA", "Service": "x"}"#,
1562 ] {
1563 let v: serde_json::Value = serde_json::from_str(shape).unwrap();
1564 assert!(
1565 matches!(
1566 PrincipalSet::parse(&v),
1567 Err(PolicyParseError::UnsupportedPrincipalType)
1568 ),
1569 "expected UnsupportedPrincipalType for {shape}"
1570 );
1571 }
1572 }
1573
1574 #[test]
1575 fn principal_empty_aws_list_rejected() {
1576 let err = Policy::from_json_str_typed(
1580 r#"{"Statement": [{
1581 "Effect": "Allow", "Action": "s3:GetObject",
1582 "Resource": "arn:aws:s3:::b/*",
1583 "Principal": {"AWS": []}
1584 }]}"#,
1585 )
1586 .expect_err("empty AWS principal list must be rejected");
1587 assert!(
1588 matches!(err, PolicyParseError::EmptyPrincipalList),
1589 "expected EmptyPrincipalList, got {err:?}"
1590 );
1591 let v: serde_json::Value = serde_json::from_str(r#"{"AWS": "AKIAONE"}"#).unwrap();
1593 assert_eq!(
1594 PrincipalSet::parse(&v).unwrap(),
1595 PrincipalSet::Specific(vec!["AKIAONE".into()])
1596 );
1597 let pol = p(r#"{"Statement": [{
1599 "Effect": "Allow", "Action": "s3:GetObject",
1600 "Resource": "arn:aws:s3:::b/*",
1601 "Principal": {"AWS": ["AKIAONE"]}
1602 }]}"#);
1603 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1604 }
1605}