From 3fabe507ef22050c05a33e1b9a8b41ef3ba2b3a3 Mon Sep 17 00:00:00 2001 From: Nugine Date: Fri, 4 Oct 2024 01:14:59 +0800 Subject: [PATCH] feat(s3s-policy): add SimpleEvaluator --- crates/s3s-policy/Cargo.toml | 2 + crates/s3s-policy/src/eval.rs | 206 +++++++++++++++++++++++++++++++ crates/s3s-policy/src/lib.rs | 9 ++ crates/s3s-policy/src/model.rs | 101 ++++++--------- crates/s3s-policy/src/pattern.rs | 124 +++++++++++++++++++ crates/s3s-policy/src/tests.rs | 68 ++++++++++ 6 files changed, 447 insertions(+), 63 deletions(-) create mode 100644 crates/s3s-policy/src/eval.rs create mode 100644 crates/s3s-policy/src/pattern.rs create mode 100644 crates/s3s-policy/src/tests.rs diff --git a/crates/s3s-policy/Cargo.toml b/crates/s3s-policy/Cargo.toml index 43b58be..30e2612 100644 --- a/crates/s3s-policy/Cargo.toml +++ b/crates/s3s-policy/Cargo.toml @@ -11,5 +11,7 @@ license.workspace = true [dependencies] indexmap = { version = "2.5.0", features = ["serde"] } +s3s = { version = "0.11.0-dev", path = "../s3s" } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" +thiserror = "1.0.64" diff --git a/crates/s3s-policy/src/eval.rs b/crates/s3s-policy/src/eval.rs new file mode 100644 index 0000000..d3ac31f --- /dev/null +++ b/crates/s3s-policy/src/eval.rs @@ -0,0 +1,206 @@ +use crate::model::WildcardOneOrMore; +use crate::model::{ActionRule, ResourceRule}; +use crate::model::{Effect, Policy, Statement}; +use crate::pattern::{PatternError, PatternSet}; + +use s3s::auth::S3AuthContext; +use s3s::path::S3Path; + +pub struct SimpleEvaluator { + rules: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum EvalError { + #[error("Not supported")] + NotSupported, + + #[error("Invalid pattern")] + InvalidPattern, +} + +struct ParsedRule { + effect: Effect, + action: Option, + not_action: Option, + resource: Option, + not_resource: Option, +} + +impl SimpleEvaluator { + /// Create a new evaluator from a policy. + /// + /// # Errors + /// Returns an error if the policy contains unsupported features. + pub fn new(policy: &Policy) -> Result { + // TODO + for statement in policy.statement.as_slice() { + if statement.principal.is_some() { + return Err(EvalError::NotSupported); + } + if statement.condition.is_some() { + return Err(EvalError::NotSupported); + } + } + + let rules = policy + .statement + .as_slice() + .iter() + .map(Self::parse_rule) + .collect::>()?; + Ok(SimpleEvaluator { rules }) + } + + fn parse_rule(stmt: &Statement) -> Result { + let mut action = None; + let mut not_action = None; + let mut resource = None; + let mut not_resource = None; + + let handle_error = |e: PatternError| match e { + PatternError::InvalidPattern => EvalError::InvalidPattern, + }; + + let build = |w: &WildcardOneOrMore| match w.as_slice() { + Some(w) => PatternSet::new(w.iter().map(String::as_str)).map_err(handle_error), + None => PatternSet::new(["*"]).map_err(handle_error), + }; + + match &stmt.action { + ActionRule::Action(w) => action = Some(build(w)?), + ActionRule::NotAction(w) => not_action = Some(build(w)?), + } + + match &stmt.resource { + ResourceRule::Resource(w) => resource = Some(build(w)?), + ResourceRule::NotResource(w) => not_resource = Some(build(w)?), + } + + Ok(ParsedRule { + effect: stmt.effect.clone(), + action, + not_action, + resource, + not_resource, + }) + } + + fn s3_op_to_action(op_name: &str) -> String { + // FIXME: some op names are not exactly the same as the action names + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-with-s3-policy-actions.html + + // TODO: replace this with a static table + if op_name == "ListBuckets" { + return "s3:ListAllMyBuckets".to_owned(); + } + + format!("s3:{op_name}") + } + + fn s3_path_to_resource(path: &S3Path) -> String { + match path { + S3Path::Root => "arn:aws:s3:::".to_owned(), + S3Path::Bucket { bucket } => format!("arn:aws:s3:::{bucket}"), + S3Path::Object { bucket, key } => format!("arn:aws:s3:::{bucket}/{key}"), + } + } + + /// Check if the request is allowed by the policy. + /// + /// # Errors + /// + pub fn check_access(&self, cx: &mut S3AuthContext<'_>) -> Result { + Ok(self.check(cx.s3_op().name(), cx.s3_path())) + } + + fn check(&self, op_name: &str, path: &S3Path) -> Effect { + let action = Self::s3_op_to_action(op_name); + let resource = Self::s3_path_to_resource(path); + + let mut allow_count = 0; + for rule in &self.rules { + let mut action_matched = false; + if let Some(ps) = &rule.action { + if ps.is_match(&action) { + action_matched = true; + } + } else if let Some(ps) = &rule.not_action { + if !ps.is_match(&action) { + action_matched = true; + } + } + + let mut resource_matched = false; + if let Some(ps) = &rule.resource { + if ps.is_match(&resource) { + resource_matched = true; + } + } else if let Some(ps) = &rule.not_resource { + if !ps.is_match(&resource) { + resource_matched = true; + } + } + + let matched = action_matched && resource_matched; + + if matched { + match rule.effect { + Effect::Allow => { + allow_count += 1; + } + Effect::Deny => { + return Effect::Deny; + } + } + } + } + + if allow_count > 0 { + return Effect::Allow; + } + + Effect::Deny + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn example1() { + let json = crate::tests::example1_json(); + let mut policy: Policy = serde_json::from_str(json).unwrap(); + + // TODO + for stmt in policy.statement.as_mut_slice() { + stmt.principal = None; + stmt.condition = None; + } + + let evaluator = SimpleEvaluator::new(&policy).unwrap(); + + { + let ans = evaluator.check("ListBuckets", &S3Path::root()); + assert_eq!(ans, Effect::Allow); + } + { + let ans = evaluator.check("ListObjectsV2", &S3Path::bucket("confidential-data")); + assert_eq!(ans, Effect::Allow); + + let ans = evaluator.check("GetObject", &S3Path::object("confidential-data", "file.txt")); + assert_eq!(ans, Effect::Allow); + + let ans = evaluator.check("PutObject", &S3Path::bucket("confidential-data")); + assert_eq!(ans, Effect::Deny); + } + { + let ans = evaluator.check("ListObjectsV2", &S3Path::bucket("another-bucket")); + assert_eq!(ans, Effect::Deny); + + let ans = evaluator.check("GetObject", &S3Path::object("another-bucket", "file.txt")); + assert_eq!(ans, Effect::Deny); + } + } +} diff --git a/crates/s3s-policy/src/lib.rs b/crates/s3s-policy/src/lib.rs index 14b6f5d..48b2937 100644 --- a/crates/s3s-policy/src/lib.rs +++ b/crates/s3s-policy/src/lib.rs @@ -8,5 +8,14 @@ #![warn( clippy::dbg_macro, // )] +#![allow( + clippy::module_name_repetitions, // +)] + +mod pattern; +pub mod eval; pub mod model; + +#[cfg(test)] +mod tests; diff --git a/crates/s3s-policy/src/model.rs b/crates/s3s-policy/src/model.rs index 9a11a6d..bbb17f5 100644 --- a/crates/s3s-policy/src/model.rs +++ b/crates/s3s-policy/src/model.rs @@ -1,6 +1,7 @@ //! use std::marker::PhantomData; +use std::slice; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -280,6 +281,40 @@ where } } +impl OneOrMore { + pub fn as_slice(&self) -> &[T] { + match self { + OneOrMore::One(t) => slice::from_ref(t), + OneOrMore::More(ts) => ts, + } + } + + pub fn as_mut_slice(&mut self) -> &mut [T] { + match self { + OneOrMore::One(t) => slice::from_mut(t), + OneOrMore::More(ts) => ts, + } + } +} + +impl WildcardOneOrMore { + pub fn as_slice(&self) -> Option<&[T]> { + match self { + WildcardOneOrMore::Wildcard => None, + WildcardOneOrMore::One(t) => Some(slice::from_ref(t)), + WildcardOneOrMore::More(ts) => Some(ts), + } + } + + pub fn as_mut_slice(&mut self) -> Option<&mut [T]> { + match self { + WildcardOneOrMore::Wildcard => None, + WildcardOneOrMore::One(t) => Some(slice::from_mut(t)), + WildcardOneOrMore::More(ts) => Some(ts), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -457,42 +492,9 @@ mod tests { } } - /// #[test] fn example1() { - let json = r#" -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "FirstStatement", - "Effect": "Allow", - "Action": ["iam:ChangePassword"], - "Resource": "*" - }, - { - "Sid": "SecondStatement", - "Effect": "Allow", - "Action": "s3:ListAllMyBuckets", - "Resource": "*" - }, - { - "Sid": "ThirdStatement", - "Effect": "Allow", - "Action": [ - "s3:List*", - "s3:Get*" - ], - "Resource": [ - "arn:aws:s3:::confidential-data", - "arn:aws:s3:::confidential-data/*" - ], - "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}} - } - ] -} -"#; - + let json = crate::tests::example1_json(); let policy: Policy = serde_json::from_str(json).unwrap(); let expected = Policy { @@ -541,20 +543,9 @@ mod tests { } } - /// #[test] fn example2() { - let json = r#" -{ - "Version": "2012-10-17", - "Statement": { - "Effect": "Allow", - "Action": "s3:ListBucket", - "Resource": "arn:aws:s3:::example_bucket" - } -} -"#; - + let json = crate::tests::example2_json(); let policy: Policy = serde_json::from_str(json).unwrap(); let expected = Policy { @@ -579,25 +570,9 @@ mod tests { } } - /// #[test] fn example3() { - let json = r#" -{ - "Version": "2012-10-17", - "Statement": [{ - "Sid": "1", - "Effect": "Allow", - "Principal": {"AWS": ["arn:aws:iam::account-id:root"]}, - "Action": "s3:*", - "Resource": [ - "arn:aws:s3:::mybucket", - "arn:aws:s3:::mybucket/*" - ] - }] -} -"#; - + let json = crate::tests::example3_json(); let policy: Policy = serde_json::from_str(json).unwrap(); let expected = Policy { diff --git a/crates/s3s-policy/src/pattern.rs b/crates/s3s-policy/src/pattern.rs new file mode 100644 index 0000000..80af696 --- /dev/null +++ b/crates/s3s-policy/src/pattern.rs @@ -0,0 +1,124 @@ +pub(crate) struct PatternSet { + // TODO: rewrite the naive implementation with something like Aho-Corasick + patterns: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PatternError { + #[error("Invalid pattern")] + InvalidPattern, +} + +#[derive(Debug)] +struct Pattern { + bytes: Vec, +} + +impl PatternSet { + /// Create a new matcher from a list of patterns. + /// + /// Patterns can contain + /// + `*` to match any sequence of characters (including empty sequence) + /// + `?` to match any single character + /// + any other character to match itself + /// + /// # Errors + /// Returns an error if any pattern is invalid. + pub fn new<'a>(patterns: impl IntoIterator) -> Result { + let patterns = patterns.into_iter().map(Self::parse_pattern).collect::>()?; + Ok(PatternSet { patterns }) + } + + fn parse_pattern(pattern: &str) -> Result { + if pattern.is_empty() { + return Err(PatternError::InvalidPattern); + } + Ok(Pattern { + bytes: pattern.as_bytes().to_owned(), + }) + } + + /// Check if the input matches any of the patterns. + #[must_use] + pub fn is_match(&self, input: &str) -> bool { + for pattern in &self.patterns { + if Self::match_pattern(&pattern.bytes, input.as_bytes()) { + return true; + } + } + false + } + + /// + fn match_pattern(pattern: &[u8], input: &[u8]) -> bool { + let mut p_idx = 0; + let mut s_idx = 0; + + let mut p_back = usize::MAX - 1; + let mut s_back = usize::MAX - 1; + + loop { + if p_idx < pattern.len() { + let p = pattern[p_idx]; + if p == b'*' { + p_idx += 1; + p_back = p_idx; + s_back = s_idx; + continue; + } + + if s_idx < input.len() { + let c = input[s_idx]; + if p == c || p == b'?' { + p_idx += 1; + s_idx += 1; + continue; + } + } + } else if s_idx == input.len() { + return true; + } + + if p_back == pattern.len() { + return true; + } + + if s_back + 1 < input.len() { + s_back += 1; + p_idx = p_back; + s_idx = s_back; + continue; + } + + return false; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_match() { + let cases = &[ + ("*", "", true), + ("**", "", true), + ("***", "abc", true), + ("a", "aa", false), + ("***a", "aaaa", true), + ("*abc???def", "abcdefabc123def", true), + ("a*c?b", "acdcb", false), + ("*a*b*c*", "abc", true), + ("a*b*c*", "abc", true), + ("*a*b*c", "abc", true), + ("a*b*c", "abc", true), + ]; + + for &(pattern, input, expected) in cases { + let pattern = PatternSet::parse_pattern(pattern).unwrap(); + let ans = PatternSet::match_pattern(&pattern.bytes, input.as_bytes()); + assert_eq!(ans, expected, "pattern: {pattern:?}, input: {input:?}"); + } + } +} diff --git a/crates/s3s-policy/src/tests.rs b/crates/s3s-policy/src/tests.rs new file mode 100644 index 0000000..37d3698 --- /dev/null +++ b/crates/s3s-policy/src/tests.rs @@ -0,0 +1,68 @@ +/// +pub(crate) fn example1_json() -> &'static str { + r#" + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "FirstStatement", + "Effect": "Allow", + "Action": ["iam:ChangePassword"], + "Resource": "*" + }, + { + "Sid": "SecondStatement", + "Effect": "Allow", + "Action": "s3:ListAllMyBuckets", + "Resource": "*" + }, + { + "Sid": "ThirdStatement", + "Effect": "Allow", + "Action": [ + "s3:List*", + "s3:Get*" + ], + "Resource": [ + "arn:aws:s3:::confidential-data", + "arn:aws:s3:::confidential-data/*" + ], + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}} + } + ] + } + "# +} + +/// +pub(crate) fn example2_json() -> &'static str { + r#" + { + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": "arn:aws:s3:::example_bucket" + } + } + "# +} + +/// +pub(crate) fn example3_json() -> &'static str { + r#" + { + "Version": "2012-10-17", + "Statement": [{ + "Sid": "1", + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::account-id:root"]}, + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::mybucket", + "arn:aws:s3:::mybucket/*" + ] + }] + } + "# +}