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