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 let max_day = match month {
915 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
916 4 | 6 | 9 | 11 => 30,
917 2 => {
918 let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
922 if leap { 29 } else { 28 }
923 }
924 _ => unreachable!("month bounds already checked"),
925 };
926 if !(1..=max_day).contains(&day) {
927 return None;
928 }
929 if !(0..=23).contains(&h) || !(0..=59).contains(&m) || !(0..=60).contains(&s) {
930 return None;
931 }
932 let y = if month <= 2 { year - 1 } else { year };
935 let era = if y >= 0 { y } else { y - 399 } / 400;
936 let yoe = (y - era * 400) as u64;
937 let mp = if month > 2 { month - 3 } else { month + 9 };
938 let doy = (153 * mp + 2) / 5 + day - 1;
939 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u64;
940 let days_from_epoch = era * 146097 + doe as i64 - 719468;
941 Some(days_from_epoch * 86_400 + h * 3600 + m * 60 + s)
942}
943
944pub type SharedPolicy = Arc<Policy>;
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950
951 fn p(s: &str) -> Policy {
952 Policy::from_json_str(s).expect("policy")
953 }
954
955 #[test]
956 fn allow_then_deny_explicit_deny_wins() {
957 let pol = p(r#"{
958 "Version": "2012-10-17",
959 "Statement": [
960 {"Sid": "AllowAll", "Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"},
961 {"Sid": "DenyDelete", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::b/*"}
962 ]
963 }"#);
964 let d = pol.evaluate("s3:GetObject", "b", Some("k"), None);
965 assert!(d.allow);
966 assert_eq!(d.matched_sid.as_deref(), Some("AllowAll"));
967 let d = pol.evaluate("s3:DeleteObject", "b", Some("k"), None);
968 assert!(!d.allow);
969 assert_eq!(d.matched_effect, Some(Effect::Deny));
970 assert_eq!(d.matched_sid.as_deref(), Some("DenyDelete"));
971 }
972
973 #[test]
974 fn implicit_deny_when_no_statement_matches() {
975 let pol = p(r#"{
976 "Version": "2012-10-17",
977 "Statement": [
978 {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::other/*"}
979 ]
980 }"#);
981 let d = pol.evaluate("s3:GetObject", "mine", Some("k"), None);
982 assert!(!d.allow);
983 assert_eq!(d.matched_effect, None);
984 }
985
986 #[test]
987 fn resource_glob_matches_prefix() {
988 let pol = p(r#"{
989 "Version": "2012-10-17",
990 "Statement": [{
991 "Effect": "Allow",
992 "Action": "s3:GetObject",
993 "Resource": "arn:aws:s3:::b/data/*.parquet"
994 }]
995 }"#);
996 assert!(
997 pol.evaluate("s3:GetObject", "b", Some("data/foo.parquet"), None)
998 .allow
999 );
1000 assert!(
1001 pol.evaluate("s3:GetObject", "b", Some("data/sub/bar.parquet"), None)
1002 .allow
1003 );
1004 assert!(
1005 !pol.evaluate("s3:GetObject", "b", Some("data/foo.txt"), None)
1006 .allow
1007 );
1008 }
1009
1010 #[test]
1011 fn s3_action_wildcard() {
1012 let pol = p(r#"{
1017 "Version": "2012-10-17",
1018 "Statement": [
1019 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*"},
1020 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::*/*"}
1021 ]
1022 }"#);
1023 assert!(pol.evaluate("s3:GetObject", "any", Some("k"), None).allow);
1024 assert!(pol.evaluate("s3:PutObject", "any", Some("k"), None).allow);
1025 assert!(pol.evaluate("s3:ListBucket", "any", None, None).allow);
1026 assert!(!pol.evaluate("iam:ListUsers", "any", None, None).allow);
1029 }
1030
1031 #[test]
1032 fn principal_match_by_access_key_id() {
1033 let pol = p(r#"{
1034 "Version": "2012-10-17",
1035 "Statement": [{
1036 "Effect": "Allow",
1037 "Action": "s3:*",
1038 "Resource": "arn:aws:s3:::b/*",
1039 "Principal": {"AWS": ["AKIATEST123"]}
1040 }]
1041 }"#);
1042 assert!(
1043 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIATEST123"))
1044 .allow
1045 );
1046 assert!(
1047 !pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAOTHER"))
1048 .allow
1049 );
1050 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1051 }
1052
1053 #[test]
1054 fn principal_wildcard_matches_anyone() {
1055 let pol = p(r#"{
1056 "Version": "2012-10-17",
1057 "Statement": [{
1058 "Effect": "Allow",
1059 "Action": "s3:*",
1060 "Resource": "arn:aws:s3:::b/*",
1061 "Principal": "*"
1062 }]
1063 }"#);
1064 assert!(
1065 pol.evaluate("s3:GetObject", "b", Some("k"), Some("AKIAANY"))
1066 .allow
1067 );
1068 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1069 }
1070
1071 #[test]
1072 fn resource_can_be_string_or_array() {
1073 let single = p(r#"{
1074 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1075 "Resource": "arn:aws:s3:::a/*"}]
1076 }"#);
1077 let multi = p(r#"{
1078 "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
1079 "Resource": ["arn:aws:s3:::a/*", "arn:aws:s3:::b/*"]}]
1080 }"#);
1081 assert!(single.evaluate("s3:GetObject", "a", Some("k"), None).allow);
1082 assert!(!single.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1083 assert!(multi.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1084 }
1085
1086 #[test]
1087 fn bucket_level_resource_for_listbucket() {
1088 let pol = p(r#"{
1089 "Statement": [{"Effect": "Allow", "Action": "s3:ListBucket",
1090 "Resource": "arn:aws:s3:::b"}]
1091 }"#);
1092 assert!(pol.evaluate("s3:ListBucket", "b", None, None).allow);
1094 assert!(!pol.evaluate("s3:ListBucket", "other", None, None).allow);
1095 }
1096
1097 #[test]
1098 fn glob_match_basics() {
1099 assert!(glob_match("foo", "foo"));
1100 assert!(!glob_match("foo", "bar"));
1101 assert!(glob_match("*", "anything"));
1102 assert!(glob_match("foo*", "foobar"));
1103 assert!(glob_match("*bar", "foobar"));
1104 assert!(glob_match("foo*bar", "fooXYZbar"));
1105 assert!(glob_match("a?c", "abc"));
1106 assert!(!glob_match("a?c", "abbc"));
1107 assert!(glob_match("a*b*c", "axxxbyyyc"));
1108 }
1109
1110 fn ctx_ip(ip: &str) -> RequestContext {
1113 RequestContext {
1114 source_ip: Some(ip.parse().unwrap()),
1115 ..Default::default()
1116 }
1117 }
1118
1119 #[test]
1120 fn condition_ip_address_cidr_match() {
1121 let pol = p(r#"{
1122 "Statement": [{
1123 "Effect": "Allow", "Action": "s3:GetObject",
1124 "Resource": "arn:aws:s3:::b/*",
1125 "Condition": {"IpAddress": {"aws:SourceIp": ["10.0.0.0/8", "192.168.1.0/24"]}}
1126 }]
1127 }"#);
1128 assert!(
1129 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("10.5.6.7"))
1130 .allow
1131 );
1132 assert!(
1133 pol.evaluate_with(
1134 "s3:GetObject",
1135 "b",
1136 Some("k"),
1137 None,
1138 &ctx_ip("192.168.1.50")
1139 )
1140 .allow
1141 );
1142 assert!(
1143 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_ip("203.0.113.1"))
1144 .allow
1145 );
1146 assert!(
1148 !pol.evaluate_with(
1149 "s3:GetObject",
1150 "b",
1151 Some("k"),
1152 None,
1153 &RequestContext::default()
1154 )
1155 .allow
1156 );
1157 }
1158
1159 #[test]
1160 fn condition_not_ip_address_negates() {
1161 let pol = p(r#"{
1162 "Statement": [{
1163 "Effect": "Deny", "Action": "s3:DeleteObject",
1164 "Resource": "arn:aws:s3:::b/*",
1165 "Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}}
1166 },
1167 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1168 }"#);
1169 assert!(
1171 !pol.evaluate_with(
1172 "s3:DeleteObject",
1173 "b",
1174 Some("k"),
1175 None,
1176 &ctx_ip("203.0.113.1")
1177 )
1178 .allow
1179 );
1180 assert!(
1182 pol.evaluate_with("s3:DeleteObject", "b", Some("k"), None, &ctx_ip("10.0.0.7"))
1183 .allow
1184 );
1185 }
1186
1187 #[test]
1188 fn condition_string_equals_user_agent() {
1189 let pol = p(r#"{
1190 "Statement": [{
1191 "Effect": "Allow", "Action": "s3:GetObject",
1192 "Resource": "arn:aws:s3:::b/*",
1193 "Condition": {"StringEquals": {"aws:UserAgent": ["MyApp/1.0", "MyApp/2.0"]}}
1194 }]
1195 }"#);
1196 let ua = |s: &str| RequestContext {
1197 user_agent: Some(s.into()),
1198 ..Default::default()
1199 };
1200 assert!(
1201 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/1.0"))
1202 .allow
1203 );
1204 assert!(
1205 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("OtherApp/1.0"))
1206 .allow
1207 );
1208 }
1209
1210 #[test]
1211 fn condition_string_like_glob() {
1212 let pol = p(r#"{
1213 "Statement": [{
1214 "Effect": "Allow", "Action": "s3:GetObject",
1215 "Resource": "arn:aws:s3:::b/*",
1216 "Condition": {"StringLike": {"aws:UserAgent": ["MyApp/*", "boto3/*"]}}
1217 }]
1218 }"#);
1219 let ua = |s: &str| RequestContext {
1220 user_agent: Some(s.into()),
1221 ..Default::default()
1222 };
1223 assert!(
1224 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("MyApp/3.14"))
1225 .allow
1226 );
1227 assert!(
1228 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("boto3/1.34.5"))
1229 .allow
1230 );
1231 assert!(
1232 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ua("curl/8"))
1233 .allow
1234 );
1235 }
1236
1237 #[test]
1238 fn condition_date_window() {
1239 let pol = p(r#"{
1241 "Statement": [{
1242 "Effect": "Allow", "Action": "s3:GetObject",
1243 "Resource": "arn:aws:s3:::b/*",
1244 "Condition": {
1245 "DateGreaterThan": {"aws:CurrentTime": ["2026-01-01T00:00:00Z"]},
1246 "DateLessThan": {"aws:CurrentTime": ["2026-12-31T23:59:59Z"]}
1247 }
1248 }]
1249 }"#);
1250 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 {
1253 request_time: Some(t),
1254 ..Default::default()
1255 };
1256 assert!(
1257 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(mid_year))
1258 .allow
1259 );
1260 assert!(
1261 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &ctx_at(after))
1262 .allow
1263 );
1264 }
1265
1266 #[test]
1267 fn condition_bool_secure_transport() {
1268 let pol = p(r#"{
1269 "Statement": [{
1270 "Effect": "Deny", "Action": "s3:*",
1271 "Resource": "arn:aws:s3:::b/*",
1272 "Condition": {"Bool": {"aws:SecureTransport": ["false"]}}
1273 },
1274 {"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"}]
1275 }"#);
1276 let plain = RequestContext {
1277 secure_transport: false,
1278 ..Default::default()
1279 };
1280 let tls = RequestContext {
1281 secure_transport: true,
1282 ..Default::default()
1283 };
1284 assert!(
1286 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &plain)
1287 .allow
1288 );
1289 assert!(
1291 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &tls)
1292 .allow
1293 );
1294 }
1295
1296 #[test]
1297 fn condition_unknown_operator_rejected() {
1298 let err = Policy::from_json_str(
1299 r#"{
1300 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1301 "Resource": "arn:aws:s3:::b/*",
1302 "Condition": {"NumericGreaterThan": {"k": ["1"]}}
1303 }]
1304 }"#,
1305 )
1306 .expect_err("should reject unsupported operator");
1307 assert!(err.contains("unsupported policy Condition operator"));
1308 assert!(err.contains("NumericGreaterThan"));
1309 }
1310
1311 #[test]
1314 fn condition_existing_object_tag_matches_via_tagmanager_state() {
1315 let pol = p(r#"{
1316 "Statement": [{
1317 "Effect": "Allow", "Action": "s3:GetObject",
1318 "Resource": "arn:aws:s3:::b/*",
1319 "Condition": {
1320 "StringEquals": {"s3:ExistingObjectTag/Project": ["Phoenix"]}
1321 }
1322 }]
1323 }"#);
1324 let with_tag = RequestContext {
1325 existing_object_tags: Some(
1326 crate::tagging::TagSet::from_pairs(vec![
1327 ("Project".into(), "Phoenix".into()),
1328 ("Env".into(), "prod".into()),
1329 ])
1330 .unwrap(),
1331 ),
1332 ..Default::default()
1333 };
1334 let other_tag = RequestContext {
1335 existing_object_tags: Some(
1336 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "Other".into())])
1337 .unwrap(),
1338 ),
1339 ..Default::default()
1340 };
1341 assert!(
1343 pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &with_tag)
1344 .allow
1345 );
1346 assert!(
1348 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_tag)
1349 .allow
1350 );
1351 }
1352
1353 #[test]
1354 fn condition_request_object_tag_matches_via_x_amz_tagging() {
1355 let pol = p(r#"{
1356 "Statement": [{
1357 "Effect": "Allow", "Action": "s3:PutObject",
1358 "Resource": "arn:aws:s3:::b/*",
1359 "Condition": {
1360 "StringEquals": {"s3:RequestObjectTag/Env": ["prod", "staging"]}
1361 }
1362 }]
1363 }"#);
1364 let req_tags = |v: &str| RequestContext {
1365 request_object_tags: Some(
1366 crate::tagging::TagSet::from_pairs(vec![("Env".into(), v.into())]).unwrap(),
1367 ),
1368 ..Default::default()
1369 };
1370 assert!(
1371 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("prod"))
1372 .allow
1373 );
1374 assert!(
1375 pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("staging"))
1376 .allow
1377 );
1378 assert!(
1379 !pol.evaluate_with("s3:PutObject", "b", Some("k"), None, &req_tags("dev"))
1380 .allow
1381 );
1382 }
1383
1384 #[test]
1385 fn condition_tag_not_present_fails_closed() {
1386 let pol = p(r#"{
1390 "Statement": [{
1391 "Effect": "Allow", "Action": "s3:GetObject",
1392 "Resource": "arn:aws:s3:::b/*",
1393 "Condition": {
1394 "StringEquals": {"s3:ExistingObjectTag/Owner": ["alice"]}
1395 }
1396 }]
1397 }"#);
1398 let none_ctx = RequestContext::default();
1401 assert!(
1402 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &none_ctx)
1403 .allow
1404 );
1405 let other_only = RequestContext {
1407 existing_object_tags: Some(
1408 crate::tagging::TagSet::from_pairs(vec![("Project".into(), "X".into())]).unwrap(),
1409 ),
1410 ..Default::default()
1411 };
1412 assert!(
1413 !pol.evaluate_with("s3:GetObject", "b", Some("k"), None, &other_only)
1414 .allow
1415 );
1416 }
1417
1418 #[test]
1419 fn condition_legacy_evaluate_unchanged() {
1420 let pol = p(r#"{
1423 "Statement": [{"Effect": "Allow", "Action": "s3:*",
1424 "Resource": "arn:aws:s3:::b/*"}]
1425 }"#);
1426 assert!(pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1427 }
1428
1429 #[test]
1432 fn parse_resource_arn_bucket_form() {
1433 let arn = parse_resource_arn("arn:aws:s3:::mybucket").expect("parse");
1434 assert_eq!(arn, ResourceArn::Bucket("mybucket".into()));
1435 }
1436
1437 #[test]
1438 fn parse_resource_arn_object_form() {
1439 let arn = parse_resource_arn("arn:aws:s3:::mybucket/some/key").expect("parse");
1440 assert_eq!(
1441 arn,
1442 ResourceArn::Object {
1443 bucket: "mybucket".into(),
1444 key_pattern: "some/key".into(),
1445 }
1446 );
1447 }
1448
1449 #[test]
1450 fn parse_resource_arn_object_wildcard() {
1451 let arn = parse_resource_arn("arn:aws:s3:::mybucket/*").expect("parse");
1452 assert_eq!(
1453 arn,
1454 ResourceArn::Object {
1455 bucket: "mybucket".into(),
1456 key_pattern: "*".into(),
1457 }
1458 );
1459 let pre = parse_resource_arn("arn:aws:s3:::b/data/*.parquet").expect("parse");
1461 assert_eq!(
1462 pre,
1463 ResourceArn::Object {
1464 bucket: "b".into(),
1465 key_pattern: "data/*.parquet".into(),
1466 }
1467 );
1468 assert!(matches!(
1470 parse_resource_arn("not-an-arn"),
1471 Err(PolicyParseError::InvalidResourceArn(_))
1472 ));
1473 assert!(matches!(
1475 parse_resource_arn("arn:aws:s3:::"),
1476 Err(PolicyParseError::EmptyBucketInArn(_))
1477 ));
1478 assert!(matches!(
1479 parse_resource_arn("arn:aws:s3:::/key"),
1480 Err(PolicyParseError::EmptyBucketInArn(_))
1481 ));
1482 }
1483
1484 #[test]
1485 fn bucket_only_arn_does_not_grant_object_action() {
1486 let pol = p(r#"{
1490 "Statement": [{
1491 "Effect": "Allow",
1492 "Principal": "*",
1493 "Action": "s3:GetObject",
1494 "Resource": "arn:aws:s3:::mybucket"
1495 }]
1496 }"#);
1497 let d = pol.evaluate("s3:GetObject", "mybucket", Some("k"), None);
1498 assert!(!d.allow, "bucket-form ARN must not grant s3:GetObject");
1499 assert_eq!(d.matched_effect, None, "should be implicit deny");
1500 let pol_ok = p(r#"{
1502 "Statement": [{
1503 "Effect": "Allow",
1504 "Principal": "*",
1505 "Action": "s3:GetObject",
1506 "Resource": "arn:aws:s3:::mybucket/*"
1507 }]
1508 }"#);
1509 assert!(
1510 pol_ok
1511 .evaluate("s3:GetObject", "mybucket", Some("k"), None)
1512 .allow
1513 );
1514 }
1515
1516 #[test]
1517 fn object_arn_does_not_grant_bucket_action() {
1518 let pol = p(r#"{
1521 "Statement": [{
1522 "Effect": "Allow",
1523 "Principal": "*",
1524 "Action": "s3:ListBucket",
1525 "Resource": "arn:aws:s3:::b/k"
1526 }]
1527 }"#);
1528 let d = pol.evaluate("s3:ListBucket", "b", None, None);
1529 assert!(!d.allow, "object-form ARN must not grant s3:ListBucket");
1530 assert_eq!(d.matched_effect, None);
1531 }
1532
1533 #[test]
1534 fn principal_wildcard_only_accepts_literal_star() {
1535 let err = Policy::from_json_str_typed(
1540 r#"{"Statement": [{
1541 "Effect": "Allow", "Action": "s3:GetObject",
1542 "Resource": "arn:aws:s3:::b/*",
1543 "Principal": "AKIATESTNOTAWILDCARD"
1544 }]}"#,
1545 )
1546 .expect_err("non-* string principal must be rejected");
1547 assert!(
1548 matches!(err, PolicyParseError::InvalidWildcard(ref s) if s == "AKIATESTNOTAWILDCARD"),
1549 "expected InvalidWildcard, got {err:?}"
1550 );
1551 let ok = PrincipalSet::parse(&serde_json::Value::String("*".into())).expect("ok");
1553 assert_eq!(ok, PrincipalSet::Wildcard);
1554 }
1555
1556 #[test]
1557 fn principal_unsupported_service_type_rejected() {
1558 let err = Policy::from_json_str_typed(
1563 r#"{"Statement": [{
1564 "Effect": "Allow", "Action": "s3:GetObject",
1565 "Resource": "arn:aws:s3:::b/*",
1566 "Principal": {"Service": "lambda.amazonaws.com"}
1567 }]}"#,
1568 )
1569 .expect_err("Service principal must be rejected");
1570 assert!(
1571 matches!(err, PolicyParseError::UnsupportedPrincipalType),
1572 "expected UnsupportedPrincipalType, got {err:?}"
1573 );
1574 for shape in [
1576 r#"{"Federated": "cognito-identity.amazonaws.com"}"#,
1577 r#"{"CanonicalUser": "abcdef"}"#,
1578 r#"{"AWS": "AKIA", "Service": "x"}"#,
1579 ] {
1580 let v: serde_json::Value = serde_json::from_str(shape).unwrap();
1581 assert!(
1582 matches!(
1583 PrincipalSet::parse(&v),
1584 Err(PolicyParseError::UnsupportedPrincipalType)
1585 ),
1586 "expected UnsupportedPrincipalType for {shape}"
1587 );
1588 }
1589 }
1590
1591 #[test]
1592 fn principal_empty_aws_list_rejected() {
1593 let err = Policy::from_json_str_typed(
1597 r#"{"Statement": [{
1598 "Effect": "Allow", "Action": "s3:GetObject",
1599 "Resource": "arn:aws:s3:::b/*",
1600 "Principal": {"AWS": []}
1601 }]}"#,
1602 )
1603 .expect_err("empty AWS principal list must be rejected");
1604 assert!(
1605 matches!(err, PolicyParseError::EmptyPrincipalList),
1606 "expected EmptyPrincipalList, got {err:?}"
1607 );
1608 let v: serde_json::Value = serde_json::from_str(r#"{"AWS": "AKIAONE"}"#).unwrap();
1610 assert_eq!(
1611 PrincipalSet::parse(&v).unwrap(),
1612 PrincipalSet::Specific(vec!["AKIAONE".into()])
1613 );
1614 let pol = p(r#"{"Statement": [{
1616 "Effect": "Allow", "Action": "s3:GetObject",
1617 "Resource": "arn:aws:s3:::b/*",
1618 "Principal": {"AWS": ["AKIAONE"]}
1619 }]}"#);
1620 assert!(!pol.evaluate("s3:GetObject", "b", Some("k"), None).allow);
1621 }
1622}